commit 21b562852885f883be43032e03c709241e8e6d4f Author: Robin Ward Date: Tue Feb 5 14:16:51 2013 -0500 Initial release of Discourse diff --git a/.autotest b/.autotest new file mode 100644 index 00000000000..5d775fd5643 --- /dev/null +++ b/.autotest @@ -0,0 +1,5 @@ +Autotest.add_hook :initialize do |autotest| + %w{.git .svn .hg .DS_Store db log tmp vendor ._*}.each do |exception| + autotest.add_exception(exception) + end +end diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..176a458f94e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000000..5a7d5419a1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile ~/.gitignore_global + +tags + +.DS_Store +._.DS_Store +dump.rdb + +.sass-cache/* + +# Ignore bundler config +/.bundle +/.vagrant +/.vagrantfile +/cache +/coverage/* + +# Ignore the default SQLite database and db dumps +/db/*.sqlite3 +/dbs/*.sql +/dbs/*.sql.gz + +# Ignore all logfiles and tempfiles. +/log/*.log +/tmp + +# Ignore Eclipse .project file +/.project + +# Ignore Eclipse .buildpath file +/.buildpath + +# Ignore RubyMine settings +/.idea + +# Ignore gem that is copied in +MiniProfiler/Ruby/rack-mini-profiler-2.0.1a.gem + +sublime-project.sublime-workspace + +# Vim temp files +*~ +*.swp +*.swo + +# don't check in multisite config +config/multisite.yml +# don't check in my renamed multisite config as well :) +config/multisite1.yml +config/fog_credentials.yml + +/public/uploads +/public/stylesheet-cache/* + +# Scripts used for downloading/refshing db +script/download_db +script/refresh_db diff --git a/.rspec b/.rspec new file mode 100644 index 00000000000..53607ea52b7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--colour diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 00000000000..7a9c8fec28a --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,19 @@ +# The Discourse Team + +* Jeff Atwood - Founder, Principal Overlord, Lead Systems Design + +* Robin Ward - Co-Founder, Ruby developer + +* Sam Saffron - Co-Founder, Ruby developer + +* Neil Lalonde - Ruby Developer + +* Ryan Mudryk - UI Implementation, supplemental + +### Specials Thanks To + +* Nick Sahler - UI Implementation, supplemental + +* Don Petersen - Ruby developmer, installation scripts + +*For a more detailed list of the many individuals that contributed to the design and development of Discourse outside of GitHub, please refer to the official Discourse website.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..e70e32f424a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,114 @@ +# Contributing to Discourse + +## Before You Start + +Anyone wishing to contribute to the **[Discourse/Core](https://github.com/discourse/core)** project **MUST read & sign the [Discourse Forums Contribution License Agreement](https://github.com/discourse/core-cla)**. The Discourse team is legally prevented from accepting any pull requests from users who have not signed the CLA first. + +## Reporting Bugs + +1. Update to the most recent master release; the bug may already be resolved. + +2. Search for similar issues on the Discourse development forums; it may already be an identified bug. + +3. On GitHub, provide the details of the issue, with any included workflows, screenshots, or links to examples on jsfiddle.net. If possible, submit a Pull Request with a failing test. If you'd rather take matters into your own hands, fix the bug yourself (jump down to the "Contributing (Step-by-step)" section). + +4. The Discourse team will work with you until your issue can be verified. Once verified, a team member will flag the issue appropriately, lock it, and create a new topic discussing the bug on the Discourse forums. + +5. Continue to monitor the progress/discussion surrounding the bug by reading the topic assigned to your bug on the Discourse forums. + +6. When the bug is fixed, the Discourse topic will be frozen, and the bug will be marked as fixed in the repo, with the appropriate commit assigned to the fix for tracking purposes. + +## Requesting New Features + +1. Do not submit a feature request on GitHub; all feature requests on GitHub will be closed. Instead, visit the Discourse development forums, and search for the "Feature Request" category, which will filter a list of outstanding requests. Review this list for similar feature requests. It's possible somebody has already asked for this feature or provided a pull request that we're still discussing. + +2. Provide a clear and detailed explanation of the feature you want and why it's important to add. The feature must apply to a wide array of users of Discourse; for smaller, more targeted "one-off" features, you might consider writing a plugin for Discourse. You may also want to provide us with some advance documentation on the feature, which will help the community to better understand where it will fit. + +3. If you're a Rock Star programmer, build the feature yourself (refer to the "Contributing (Step-by-step)" section below). + +## Contributing (Step-by-step) + +1. Clone the Repo: + + ``` + git clone git://github.com/discourse/core.git + ``` + +2. Create a new Branch: + + ``` + cd core + git checkout -b new_discourse_branch + ``` + +3. Code + + Make some magic happen! Remember to: + * Adhere to conventions. + * Update CHANGELOG with a description of your work. + * Include tests, and ensure they pass. + * Remember to check to see if your new functionality has an impact on our Documentation, and include updates as appropriate. + + Completing these steps will increase the chances of your code making it into **[Discourse/Core](https://github.com/discourse/core)**. + +4. Commit + + ``` + git commit -a + ``` + + **Do not leave the commit message blank!** Provide a detailed description of your commit! + + ### PRO TIP + + Ensure that if you supply a multitude of commits, they are **squashed into a single commit**: + + ``` + git remote add upstream https://github.com/discourse/core.git + git fetch upstream + git checkout new_discourse_branch + git rebase upstream/master + git rebase -i + + < Choose 'squash' for all of your commits except the first one. > + < Edit the commit message to make sense, and describe all your changes. > + + git push origin new_discourse_branch -f + ``` + +5. Update your branch + + ``` + git checkout master + git pull --rebase + ``` + +6. Fork + + ``` + git remote add mine git@github.com:/core.git + ``` + +7. Push to your remote + + ``` + git push mine new_discourse_branch + ``` + +8. Issue a Pull Request + + In order to make a pull request, + * Navigate to the Discourse repository you just pushed to (e.g. https://github.com/your-user-name/discourse) + * Click "Pull Request". + * Write your branch name in the branch field (this is filled with "master" by default) + * Click "Update Commit Range". + * Ensure the changesets you introduced are included in the "Commits" tab. + * Ensure that the "Files Changed" incorporate all of your changes. + * Fill in some details about your potential patch including a meaningful title. + * Click "Send pull request". + + Once these steps are done, you will soon receive feedback from The Discourse team! + +9. Responding to Feedback + + The Discourse team may recommend adjustments to your code, and this is perfectly normal. Part of interacting with a healthy open-source community requires you to be open to learning new techniques and strategies; *don't get discouraged!* Remember: if the Discourse team suggest changes to your code, **they care enough about your work that they want to include it**, and hope that you can assist by implementing those revisions on your own. diff --git a/COPYRIGHT.txt b/COPYRIGHT.txt new file mode 100644 index 00000000000..8aa830b10d3 --- /dev/null +++ b/COPYRIGHT.txt @@ -0,0 +1,33 @@ +All Discourse code is Copyright 2013 by Civilized Discourse Construction Kit, Inc. + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or (at +your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +for more details. + +You should have received a copy of the GNU General Public License +along with this program as the file LICENSE.txt; if not, please see +http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + +Discourse is a registered trademark of FIRSTNAME LASTNAME. + +Discourse includes works under other copyright notices and distributed +according to the terms of the GNU General Public License or a compatible +license (where indicated), including: + +Javascript + + Ember.js - Copyright (c) 2012-2013 Yehuda Katz, Tom Dale, Charles Jolley and Ember.js contributors + + jQuery - Copyright (c) 2010-2013 John Resig + +Ruby + + Rails - Copyright (c) 2005-2013 David Heinemeier Hansson, Rails Core Team contributors (MIT) + + Thin - Copyright (c) 2012-2013 Marc-Andre Cournoyer diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000000..42448796da3 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,77 @@ +# Discourse Developer Install Guide + +If you'd like to set up a development environment for Discourse, the easiest way is by using a virtual machine. + +### Getting Started + +1. Install the Xcode tools: https://developer.apple.com/xcode/ +2. Install VirtualBox: https://www.virtualbox.org/wiki/Downloads +3. Install Ruby 1.9.3. We recommend RVM: https://rvm.io/ +4. Open a terminal +5. Clone the project: `git@github.com:discourse/core.git` +6. Enter the project directory: `cd core` +7. Install vagrant: `gem install vagrant` + +### Using Vagrant + +When you're ready to start working, boot the VM: +``` +vagrant up +``` + +It should prompt you for your admin password. This is so it can mount your local files inside the VM for an easy workflow. + +(The first time you do this, it will take a while as it downloads the VM image and installs it. Go grab a coffee.) + +Once the machine has booted up, you can shell into it by typing: + +``` +vagrant ssh +``` + +### Keeping your VM up to date + +Now you're in a virtual machine is almost ready to start developing. It's a good idea to perform the following instructions +*every time* you pull from master to ensure your environment is still up to date. + +``` +bundle install +bundle exec rake db:migrate +bundle exec rake db:seed_fu +``` + +### Starting Rails + +Once your VM is up to date, you can start a rails instance using the following command: + +``` +bundle exec rails server +``` + +In a few seconds, rails will start server pages. To access them, open a web browser to http://localhost:4000 - if it all worked you should see discourse! Congratulations, you are ready to start working! + +You can now edit files on your local file system, using your favorite text editor or IDE. When you reload your web browser, it should have the latest changed. + +### Guard + Rspec + +If you're actively working on Discourse, we recommend that you run Guard. It'll automatically run our unit tests over and over, and includes support +for live CSS reloading. + +To use it, follow all the above steps. Once rails is running, open a new terminal window or tab, and then do this: + +``` +vagrant ssh +bundle exec guard -p +``` + +Wait a minute while it runs all our unit tests. Once it has completed, live reloading should start working. Simply save a file locally, wait a couple of seconds and you'll see it change in your browser. No reloading of pages should be necessary for the most part, although if something doesn't update you should refresh to confirm. + + +### Shutting down the VM + +When you're done working on Discourse, you can shut down Vagrant like so: + +``` +vagrant halt +``` + diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000000..e8703abb8f9 --- /dev/null +++ b/Gemfile @@ -0,0 +1,115 @@ +source 'http://rubygems.org' + +gem 'redis' +gem 'redis-rails' +gem 'hiredis' +gem 'em-redis' +gem 'rails' +gem 'pg' +gem 'haml' +gem 'sass' +gem 'rake' +# errbit is broken with 3.1.3 for now +gem 'airbrake', "3.1.2" +gem 'rest-client' +gem 'rails3_acts_as_paranoid', "~>0.2.0" +gem 'activerecord-postgres-hstore' +gem 'sidekiq' +gem 'fastimage' +gem 'nokogiri' +gem 'seed-fu' +gem 'sanitize' + + +gem 'slim', '<= 1.3.0' +gem 'sinatra', :require => nil +gem 'clockwork', :require => false + +gem 'i18n-js' +# gem 'rack-mini-profiler', '0.1.21' +# gem 'rack-mini-profiler', :path => '/home/sam/Source/MiniProfiler' +gem 'rack-mini-profiler', :git => 'git://github.com/SamSaffron/MiniProfiler' +gem 'oauth', :require => false +gem 'fast_xs' +gem 'pbkdf2' +gem 'simple_handlebars_rails', path: 'vendor/gems/simple_handlebars_rails' + +# Gem that enables support for plugins. It is required +gem 'discourse_plugin', path: 'vendor/gems/discourse_plugin' + +# Discourse Plugins (optional) +# Polls and Tasks have been disabled for launch, we need think all sorts of stuff through before adding them back in +# biggest concern is core support for custom sort orders, but there is also styling that just gets mishmashed into our core theme. +# gem 'discourse_poll', path: 'vendor/gems/discourse_poll' +gem 'discourse_emoji', path: 'vendor/gems/discourse_emoji' +# gem 'discourse_task', path: 'vendor/gems/discourse_task' + +gem 'rails_multisite', path: 'vendor/gems/rails_multisite' +gem 'message_bus', path: 'vendor/gems/message_bus' + +gem 'koala', :require => false +gem 'multi_json' +gem 'oj' +gem 'eventmachine' +gem 'thin' + +gem "active_model_serializers", :git => "git://github.com/rails-api/active_model_serializers.git" +gem 'has_ip_address' + +gem 'vestal_versions', :git => 'git://github.com/zhangyuan/vestal_versions' + +gem 'fog', :require => false + +# Gems used only for assets and not required +# in production environments by default. +# allow everywhere for now cause we are allowing asset debugging in prd +group :assets do + gem 'sass' + gem 'sass-rails' + gem 'coffee-rails' + gem 'uglifier' + # gem "asset_sync" + gem 'turbo-sprockets-rails3' +end + +# need this to compile coffee on the fly +gem 'coffee-script' + +gem 'hpricot' +gem 'jquery-rails' + +gem "ember-rails", :git => 'git://github.com/emberjs/ember-rails.git' # so we get the pre version +gem 'mustache' +gem 'therubyracer', :require => 'v8' +gem 'rinku' + + +gem 'ruby-openid', :require => 'openid' + +group :test, :development do + # Pretty printed test output + gem 'rspec-rails' + gem 'shoulda' + #gem 'turn', :require => false + gem 'jasminerice' + gem 'fabrication' + gem 'guard-jasmine' + gem 'guard-rspec' + gem 'guard-spork' + gem 'mocha', :require => false + gem 'test-unit', :require => "test/unit" + gem 'simplecov', :require => false + gem 'image_optim' + gem 'certified' + gem 'rb-fsevent' + gem 'rb-inotify', :require => RUBY_PLATFORM.include?('linux') && 'rb-inotify' + gem 'terminal-notifier-guard', :require => RUBY_PLATFORM.include?('darwin') && 'terminal-notifier-guard' +end + +group :development do + gem 'pry-rails' + gem 'better_errors' + gem 'binding_of_caller' # I tried adding this and got an occational crash +end + +# gem 'stacktrace', :require => false diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 00000000000..1a27a174107 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,442 @@ +GIT + remote: git://github.com/SamSaffron/MiniProfiler + revision: 8fa1391e1eda809d4e7d0f2c307ac8cea11ef540 + specs: + rack-mini-profiler (0.1.23) + rack (>= 1.1.3) + +GIT + remote: git://github.com/emberjs/ember-rails.git + revision: 587a55a8c53aae2193a3602895e89311eb8544b0 + specs: + ember-rails (0.9.2) + active_model_serializers + barber + execjs (>= 1.2) + railties (>= 3.1) + +GIT + remote: git://github.com/rails-api/active_model_serializers.git + revision: cef10cf01dfe18f72060bda279d5246c156ae737 + specs: + active_model_serializers (0.5.2) + activemodel (>= 3.0) + +GIT + remote: git://github.com/zhangyuan/vestal_versions + revision: 0ea75ec4e269b5a9e609639919ade0f36381a446 + specs: + vestal_versions (1.2.2) + activerecord (>= 3.0.0) + activesupport (>= 3.0.0) + +PATH + remote: vendor/gems/discourse_emoji + specs: + discourse_emoji (0.0.1) + +PATH + remote: vendor/gems/discourse_plugin + specs: + discourse_plugin (0.0.1) + +PATH + remote: vendor/gems/message_bus + specs: + message_bus (0.0.1) + eventmachine + rack (>= 1.1.3) + redis + thin + +PATH + remote: vendor/gems/rails_multisite + specs: + rails_multisite (0.0.1) + +PATH + remote: vendor/gems/simple_handlebars_rails + specs: + simple_handlebars_rails (0.0.1) + rails (~> 3.1) + +GEM + remote: http://rubygems.org/ + specs: + actionmailer (3.2.11) + actionpack (= 3.2.11) + mail (~> 2.4.4) + actionpack (3.2.11) + activemodel (= 3.2.11) + activesupport (= 3.2.11) + builder (~> 3.0.0) + erubis (~> 2.7.0) + journey (~> 1.0.4) + rack (~> 1.4.0) + rack-cache (~> 1.2) + rack-test (~> 0.6.1) + sprockets (~> 2.2.1) + activemodel (3.2.11) + activesupport (= 3.2.11) + builder (~> 3.0.0) + activerecord (3.2.11) + activemodel (= 3.2.11) + activesupport (= 3.2.11) + arel (~> 3.0.2) + tzinfo (~> 0.3.29) + activerecord-postgres-hstore (0.7.0) + rails + rake + activeresource (3.2.11) + activemodel (= 3.2.11) + activesupport (= 3.2.11) + activesupport (3.2.11) + i18n (~> 0.6) + multi_json (~> 1.0) + addressable (2.3.2) + airbrake (3.1.2) + activesupport + builder + arel (3.0.2) + barber (0.2.0) + execjs + better_errors (0.3.2) + coderay (>= 1.0.0) + erubis (>= 2.7.0) + binding_of_caller (0.6.8) + bourne (1.1.2) + mocha (= 0.10.5) + builder (3.0.4) + celluloid (0.12.4) + facter (>= 1.6.12) + timers (>= 1.0.0) + certified (0.1.1) + childprocess (0.3.7) + ffi (~> 1.0, >= 1.0.6) + clockwork (0.4.1) + tzinfo + coderay (1.0.8) + coffee-rails (3.2.2) + coffee-script (>= 2.2.0) + railties (~> 3.2.0) + coffee-script (2.2.0) + coffee-script-source + execjs + coffee-script-source (1.4.0) + connection_pool (1.0.0) + daemons (1.1.9) + diff-lcs (1.1.3) + em-redis (0.3.0) + eventmachine + erubis (2.7.0) + eventmachine (1.0.0) + excon (0.16.10) + execjs (1.4.0) + multi_json (~> 1.0) + fabrication (2.5.4) + facter (1.6.17) + faraday (0.8.5) + multipart-post (~> 1.1) + fast_xs (0.8.0) + fastimage (1.2.13) + ffi (1.3.1) + fog (1.9.0) + builder + excon (~> 0.14) + formatador (~> 0.2.0) + mime-types + multi_json (~> 1.0) + net-scp (~> 1.0.4) + net-ssh (>= 2.1.3) + nokogiri (~> 1.5.0) + ruby-hmac + formatador (0.2.4) + fspath (2.0.4) + guard (1.6.2) + listen (>= 0.6.0) + lumberjack (>= 1.0.2) + pry (>= 0.9.10) + terminal-table (>= 1.4.3) + thor (>= 0.14.6) + guard-jasmine (1.12.1) + childprocess + guard (>= 1.1.0) + multi_json + thor + guard-rspec (2.4.0) + guard (>= 1.1) + rspec (~> 2.11) + guard-spork (1.4.2) + childprocess (>= 0.2.3) + guard (>= 1.1) + spork (>= 0.8.4) + haml (3.1.7) + has_ip_address (0.0.1) + hike (1.2.1) + hiredis (0.4.5) + hpricot (0.8.6) + i18n (0.6.1) + i18n-js (2.1.2) + i18n + image_optim (0.7.2) + fspath (~> 2.0.3) + image_size (~> 1.1) + in_threads (~> 1.1.1) + progress (~> 2.4.0) + image_size (1.1.1) + in_threads (1.1.1) + jasminerice (0.0.10) + coffee-rails + haml + journey (1.0.4) + jquery-rails (2.2.0) + railties (>= 3.0, < 5.0) + thor (>= 0.14, < 2.0) + json (1.7.6) + koala (1.6.0) + addressable (~> 2.2) + faraday (~> 0.8) + multi_json (~> 1.3) + libv8 (3.11.8.13) + listen (0.7.2) + lumberjack (1.0.2) + mail (2.4.4) + i18n (>= 0.4.0) + mime-types (~> 1.16) + treetop (~> 1.4.8) + metaclass (0.0.1) + method_source (0.8.1) + mime-types (1.20.1) + mocha (0.10.5) + metaclass (~> 0.0.1) + multi_json (1.5.0) + multipart-post (1.1.5) + mustache (0.99.4) + net-scp (1.0.4) + net-ssh (>= 1.99.1) + net-ssh (2.6.3) + nokogiri (1.5.6) + oauth (0.4.7) + oj (2.0.3) + pbkdf2 (0.1.0) + pg (0.14.1) + polyglot (0.3.3) + progress (2.4.0) + pry (0.9.11.4) + coderay (~> 1.0.5) + method_source (~> 0.8) + slop (~> 3.4) + pry-rails (0.2.2) + pry (>= 0.9.10) + rack (1.4.4) + rack-cache (1.2) + rack (>= 0.4) + rack-protection (1.3.2) + rack + rack-ssl (1.3.3) + rack + rack-test (0.6.2) + rack (>= 1.0) + rails (3.2.11) + actionmailer (= 3.2.11) + actionpack (= 3.2.11) + activerecord (= 3.2.11) + activeresource (= 3.2.11) + activesupport (= 3.2.11) + bundler (~> 1.0) + railties (= 3.2.11) + rails3_acts_as_paranoid (0.2.5) + activerecord (~> 3.2) + railties (3.2.11) + actionpack (= 3.2.11) + activesupport (= 3.2.11) + rack-ssl (~> 1.3.2) + rake (>= 0.8.7) + rdoc (~> 3.4) + thor (>= 0.14.6, < 2.0) + rake (10.0.3) + rb-fsevent (0.9.3) + rb-inotify (0.9.0) + ffi (>= 0.5.0) + rdoc (3.12) + json (~> 1.4) + redis (3.0.2) + redis-actionpack (3.2.3) + actionpack (~> 3.2.3) + redis-rack (~> 1.4.0) + redis-store (~> 1.1.0) + redis-activesupport (3.2.3) + activesupport (~> 3.2.3) + redis-store (~> 1.1.0) + redis-namespace (1.2.1) + redis (~> 3.0.0) + redis-rack (1.4.2) + rack (~> 1.4.1) + redis-store (~> 1.1.0) + redis-rails (3.2.3) + redis-actionpack (~> 3.2.3) + redis-activesupport (~> 3.2.3) + redis-store (~> 1.1.0) + redis-store (1.1.3) + redis (>= 2.2.0) + ref (1.0.2) + rest-client (1.6.7) + mime-types (>= 1.16) + rinku (1.7.2) + rspec (2.12.0) + rspec-core (~> 2.12.0) + rspec-expectations (~> 2.12.0) + rspec-mocks (~> 2.12.0) + rspec-core (2.12.2) + rspec-expectations (2.12.1) + diff-lcs (~> 1.1.3) + rspec-mocks (2.12.2) + rspec-rails (2.12.2) + actionpack (>= 3.0) + activesupport (>= 3.0) + railties (>= 3.0) + rspec-core (~> 2.12.0) + rspec-expectations (~> 2.12.0) + rspec-mocks (~> 2.12.0) + ruby-hmac (0.4.0) + ruby-openid (2.2.2) + sanitize (2.0.3) + nokogiri (>= 1.4.4, < 1.6) + sass (3.2.5) + sass-rails (3.2.6) + railties (~> 3.2.0) + sass (>= 3.1.10) + tilt (~> 1.3) + seed-fu (2.2.0) + activerecord (~> 3.1) + activesupport (~> 3.1) + shoulda (3.3.2) + shoulda-context (~> 1.0.1) + shoulda-matchers (~> 1.4.1) + shoulda-context (1.0.2) + shoulda-matchers (1.4.2) + activesupport (>= 3.0.0) + bourne (~> 1.1.2) + sidekiq (2.7.0) + celluloid (~> 0.12.0) + connection_pool (~> 1.0) + multi_json (~> 1) + redis (~> 3) + redis-namespace + simplecov (0.7.1) + multi_json (~> 1.0) + simplecov-html (~> 0.7.1) + simplecov-html (0.7.1) + sinatra (1.3.4) + rack (~> 1.4) + rack-protection (~> 1.3) + tilt (~> 1.3, >= 1.3.3) + slim (1.3.0) + temple (~> 0.4.1) + tilt (~> 1.3.3) + slop (3.4.3) + spork (0.9.2) + sprockets (2.2.2) + hike (~> 1.2) + multi_json (~> 1.0) + rack (~> 1.0) + tilt (~> 1.1, != 1.3.0) + temple (0.4.1) + terminal-notifier-guard (1.5.3) + terminal-table (1.4.5) + test-unit (2.5.4) + therubyracer (0.11.3) + libv8 (~> 3.11.8.12) + ref + thin (1.5.0) + daemons (>= 1.0.9) + eventmachine (>= 0.12.6) + rack (>= 1.0.0) + thor (0.17.0) + tilt (1.3.3) + timers (1.1.0) + treetop (1.4.12) + polyglot + polyglot (>= 0.3.1) + turbo-sprockets-rails3 (0.3.6) + railties (> 3.2.8, < 4.0.0) + sprockets (>= 2.0.0) + tzinfo (0.3.35) + uglifier (1.3.0) + execjs (>= 0.3.0) + multi_json (~> 1.0, >= 1.0.2) + +PLATFORMS + ruby + +DEPENDENCIES + active_model_serializers! + activerecord-postgres-hstore + airbrake (= 3.1.2) + better_errors + binding_of_caller + certified + clockwork + coffee-rails + coffee-script + discourse_emoji! + discourse_plugin! + em-redis + ember-rails! + eventmachine + fabrication + fast_xs + fastimage + fog + guard-jasmine + guard-rspec + guard-spork + haml + has_ip_address + hiredis + hpricot + i18n-js + image_optim + jasminerice + jquery-rails + koala + message_bus! + mocha + multi_json + mustache + nokogiri + oauth + oj + pbkdf2 + pg + pry-rails + rack-mini-profiler! + rails + rails3_acts_as_paranoid (~> 0.2.0) + rails_multisite! + rake + rb-fsevent + rb-inotify + redis + redis-rails + rest-client + rinku + rspec-rails + ruby-openid + sanitize + sass + sass-rails + seed-fu + shoulda + sidekiq + simple_handlebars_rails! + simplecov + sinatra + slim (<= 1.3.0) + terminal-notifier-guard + test-unit + therubyracer + thin + turbo-sprockets-rails3 + uglifier + vestal_versions! diff --git a/Guardfile b/Guardfile new file mode 100644 index 00000000000..0fcebb98d8a --- /dev/null +++ b/Guardfile @@ -0,0 +1,87 @@ +guard 'spork' do + watch('config/application.rb') + watch('config/environment.rb') + watch(%r{^config/environments/.*\.rb$}) + watch(%r{^config/initializers/.*\.rb$}) + watch('Gemfile') + watch('Gemfile.lock') + watch('spec/spec_helper.rb') { :rspec } +end + +phantom_path = File.expand_path('~/phantomjs/bin/phantomjs') +phantom_path = nil unless File.exists?(phantom_path) + +jasmine_options = {:phantomjs_bin => phantom_path} + +if ENV['JASMINE_URL'] + jasmine_options[:jasmine_url] = ENV['JASMINE_URL'] + jasmine_options[:server] = :none +else + jasmine_options[:server] = :thin + jasmine_options[:port] = 8888 + jasmine_options[:server_timeout] = 300 +end + +guard 'jasmine', jasmine_options do watch(%r{spec/javascripts/spec\.(js\.coffee|js|coffee)$}) { "spec/javascripts" } + watch(%r{spec/javascripts/.+_spec\.(js\.coffee|js|coffee)$}) + watch(%r{app/assets/javascripts/(.+?)\.(js\.coffee|js|coffee)$}) { "spec/javascripts" } +end + +guard 'rspec', :focus_on_failed => true, :version => 2, :cli => "--drb" do + watch(%r{^spec/.+_spec\.rb$}) + #watch(%r{^lib/jobs/(.+)\.rb$}) { |m| "spec/components/jobs/#{m[1]}_spec.rb" } + watch(%r{^lib/(.+)\.rb$}) { |m| "spec/components/#{m[1]}_spec.rb" } + watch('spec/spec_helper.rb') { "spec" } + + # Rails example + watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } + watch(%r{^app/(.*)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" } + watch(%r{^app/controllers/(.+)_(controller)\.rb$}) { |m| "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb" } + watch(%r{^spec/support/(.+)\.rb$}) { "spec" } + watch('app/controllers/application_controller.rb') { "spec/controllers" } + + # Capybara request specs + watch(%r{^app/views/(.+)/.*\.(erb|haml)$}) { |m| "spec/requests/#{m[1]}_spec.rb" } + +end + +module ::Guard + class AutoReload < ::Guard::Guard + + require File.dirname(__FILE__) + '/config/environment' + def run_on_change(paths) + paths.map! do |p| + hash = nil + fullpath = Rails.root.to_s + "/" + p + hash = Digest::MD5.hexdigest(File.read(fullpath)) if File.exists? fullpath + p = p.sub /\.sass\.erb/, "" + p = p.sub /\.sass/, "" + p = p.sub /\.scss/, "" + p = p.sub /^app\/assets\/stylesheets/, "assets" + {name: p, hash: hash} + end + # target dev + MessageBus::Instance.new.publish "/file-change", paths + end + + def run_all + end + end +end + +Thread.new do + Listen.to('tmp/') do |modified,added,removed| + modified.each do |m| + MessageBus::Instance.new.publish "/file-change", ["refresh"] if m =~ /refresh_browser/ + end + end +end + +guard :autoreload do + watch(/tmp\/refresh_browser/) + watch(/\.css$/) + watch(/\.sass$/) + watch(/\.scss$/) + watch(/\.sass\.erb$/) + watch(/\.handlebars$/) +end diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 00000000000..52739bb2d86 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,27 @@ +# Discourse "Quick-and-Dirty" Install Guide + +We have deliberately left this section lacking. From our FAQ: + +> Discourse is brand new. Discourse is early beta software, and likely to remain so for many months. +> Please experiment with it, play with it, give us feedback, submit pull requests – but any consideration +> of fully adopting Discourse is for people and organizations who are eager to live on the bleeding and broken edge. + +When Discourse is ready for primetime we're going to provide several robust and easy ways to install it. +Until then, if you are feeling adventurous you can try to set up following components. + +- Postgres 9.1 + - Enable support for HSTORE + - Create a discourse database and seed it with a basic image +- Redis 2.6 +- Ruby 1.9.3 + - Install all rubygems via bundler + - Edit database.yml and redis.yml and point them at your databases. + - Prepackage all assets using rake + - Run the Rails database migrations + - Run a sidekiq process for background jobs + - Run a clockwork process for enqueing scheduled jobs + - Run several Rails processes, preferably behind a proxy like Nginx. + + + + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000000..94fb84639c4 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 00000000000..19255581d98 --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,6 @@ +# Discourse Plugin Architecture + +**Note: This is a work in progress!** + + + diff --git a/README.md b/README.md new file mode 100644 index 00000000000..5dbf528ecf6 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +![Logo](https://raw.github.com/discourse/core/master/images/discourse.png) + +Discourse is the 100% open source, next-generation discussion platform built for the next 10 years of the Internet. + +Whenever you need ... + +* a mailing list +* a forum to discuss something +* a chat room where you can type paragraphs + +... consider Discourse. + + +## Getting Started + +If you're interested in helping us develop Discourse, please start with our **[Discourse Developer Install Guide](https://github.com/discourse/core/blob/master/DEVELOPMENT.md)**, which includes instructions to get up and running in a development environment. + +We also have a **[Discourse "Quick-and-Dirty" Install Guide](https://github.com/discourse/core/blob/master/INSTALL.md)**. + +## Vision + +This is the **Civilized Discourse Construction Kit**, a fully open-source package of forum software that is free to use and contribute to. Discourse embraces the changes that are necessary to evolve forum software, namely: + +* A **flattened discussion**, which avoids the pains of threaded forums, and delivers a more robust, intuitive interface to join a conversation at any point. +* A **self-learning system**, capable of examining the behavior of the community, and adapting to budding moderators and forum trolls alike. +* A **seamless web-only** interface that delivers usability on both the desktop and the tablet, without the need for a native app. +* A **contemporary, robust technology stack**, so that both users and administrators alike have another choice BESIDES php. + +The Discourse team wishes to **foster an active community of contributors**, all of whom commit to delivering this continued vision, and ensure that online discussions can grow and thrive in an Internet age dominated by micro-blogging and diminishing attention spans. + +This vision translates to the following functional commitments: + +1. Support all contemporary browsers on the desktop: + * Internet Explorer 9.0, 10.0+ + * Firefox 16+ + * Google Chrome *infinite* + +2. Supporting the latest generation of tablets: + * iPad 2+ + * Android 4.1+ on 7" and 10" + * Windows 8 + +3. Deliver support for mobile/smartphones *as soon as possible*: + * Windows Phone 8 + * iPhone 4+ + * Android 4.0+ + +## Contributing + +Discourse is **100% free** and **open-source**. We encourage and support an active, healthy community that +accepts contributions from the public, and we'd like you to be a part of that community. + +In order to be prepared for contributing to Discourse, please: + +1. Review the **VISION** section above, which will help you understand the needs of the team, and the focus of the project, +2. Read & sign the **[Discourse Forums Contribution License Agreement](https://github.com/discourse/core-cla)**, to confirm you've read and acknowledged the legal aspects of your contributions, and +3. Dig into **[CONTRIBUTING.MD](https://github.com/discourse/core/blob/master/CONTRIBUTING.md)**, which houses all of the necessary info to: + * submit bugs, + * request new features, and + * step you through the entire process of preparing your code for a Pull Request. + +**We look forward to seeing your cool stuff!** + +## Expertise + +Discourse implements a variety of open source tech. You may wish to familiarize yourself with the various components that Discourse is built on, in order to be an effective contributor: + +### Languages/Frameworks + +1. [Ruby on Rails](https://github.com/rails/rails) - Our back end API is a Rails app. It responds to requests RESTfully and responds in JSON. +2. [Ember.js](https://github.com/emberjs/ember.js) - Our front end interface is an Ember.js app that communicates the Rails API. + +### Databases + +1. [PostgreSQL](http://www.postgresql.org/) - Our main data store is Postgres. +2. [Redis](http://redis.io/) - We use Redis for our job queue, rate limiting, as a cache and for transient data. + +### Ruby Gems + +The complete list of Ruby Gems used by Discourse can be found in [SOFTWARE.md](https://github.com/discourse/core/blob/master/SOFTWARE.md). + +## Versioning + +Discourse implements the Semantic Versioning guidelines. + +Releases will be numbered with the following format: + +`..` + +And constructed with the following guidelines: + +* Breaking backward compatibility bumps the major (and resets the minor and patch) +* New additions without breaking backward compatibility bumps the minor (and resets the patch) +* Bug fixes and misc changes bumps the patch + +For more information on SemVer, please visit http://semver.org/. + +## The Discourse Team + +The Discourse code contributors can be found in [AUTHORS.MD](https://github.com/discourse/core/blob/master/AUTHORS.md). For a complete list of the many individuals that contributed to the design and implementation of Discourse, please refer to the official website. + +## Copyright / License + +Copyright 2013 Civilized Discourse Construction Kit, Inc. + +Licensed under the GNU General Public License Version 2.0 (or later); +you may not use this work except in compliance with the License. +You may obtain a copy of the License in the LICENSE file, or at: + + http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000000..4a791a9b77d --- /dev/null +++ b/Rakefile @@ -0,0 +1,7 @@ +#!/usr/bin/env rake +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require File.expand_path('../config/application', __FILE__) + +Discourse::Application.load_tasks diff --git a/SOFTWARE.md b/SOFTWARE.md new file mode 100644 index 00000000000..5919b682ee1 --- /dev/null +++ b/SOFTWARE.md @@ -0,0 +1,47 @@ +# Discourse Ruby Gems + +The following Ruby Gems are used in Discourse: + +* [pg](https://rubygems.org/gems/pg) +* [redis](https://rubygems.org/gems/redis) +* [em-redis](https://rubygems.org/gems/em-redis) +* [Event Machine](https://rubygems.org/gems/event_machine) +* [Active Model Serializers](https://rubygems.org/gems/active_model_serializers) +* [Sidekiq](https://rubygems.org/gems/sidekiq) +* [Therubyracer](https://rubygems.org/gems/therubyracer) +* [Guard](https://rubygems.org/gems/guard) +* [OJ](https://rubygems.org/gems/oj) +* [rack-mini-profiler](https://rubygems.org/gems/rack-mini-profiler) +* [sass](https://rubygems.org/gems/sass) +* [rest-client](https://rubygems.org/gems/rest-client) +* [rails3_acts_as_paranoid](https://rubygems.org/gems/rails3_acts_as_paranoid) +* [activerecord-postgres-hstore](https://rubygems.org/gems/activerecord-postgres-hstore) +* [fastimage](https://rubygems.org/gems/fastimage) +* [seed-fu](https://rubygems.org/gems/seed-fu) +* [sanitize](https://rubygems.org/gems/sanitize) +* [clockwork](https://rubygems.org/gems/clockwork) +* [i18n-js](https://rubygems.org/gems/i18n-js) +* [pbkdf2](https://rubygems.org/gems/pbkdf2) +* [fast_xs](https://rubygems.org/gems/fast_xs) +* [koala](https://rubygems.org/gems/koala) +* [has_ip_address](https://rubygems.org/gems/has_ip_address) +* [vestal_versions](https://rubygems.org/gems/vestal_versions) +* [coffee-rails](https://rubygems.org/gems/coffee-rails) +* [uglifier](https://rubygems.org/gems/uglifier) +* [hpricot](https://rubygems.org/gems/hpricot) +* [uuidtools](https://rubygems.org/gems/uuidtools) +* [rinku](https://rubygems.org/gems/rinku) +* [ruby-openid](https://rubygems.org/gems/ruby-openid) +* [rspec](https://rubygems.org/gems/rspec) +* [shoulda](https://rubygems.org/gems/shoulda) +* [turn](https://rubygems.org/gems/turn) +* [jasminerice](https://rubygems.org/gems/jasminerice) +* [fabrication](https://rubygems.org/gems/fabrication) +* [mocha](https://rubygems.org/gems/mocha) +* [simplecov](https://rubygems.org/gems/simplecov) +* [image_optim](https://rubygems.org/gems/image_optim) +* [certified](https://rubygems.org/gems/certified) +* [rb-fsevent](https://rubygems.org/gems/rb-fsevent) +* [rb-inotify](https://rubygems.org/gems/rb-inotify) +* [terminal-notifier-guard](https://rubygems.org/gems/terminal-notifier-guard) +* [pry-rails](https://rubygems.org/gems/pry-rails) diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 00000000000..f974697d1be --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,14 @@ +# See https://github.com/discourse/core/blob/master/DEVELOPMENT.md +# +Vagrant::Config.run do |config| + config.vm.box = 'discourse-pre' + config.vm.box_url = 'http://www.discourse.org/vms/discourse-pre.box' + config.vm.network :hostonly, '192.168.10.200' + + config.vm.forward_port 3000, 4000 + config.vm.forward_port 1080, 4080 # Mailcatcher + + if RUBY_PLATFORM =~ /darwin/ + config.vm.share_folder("v-root", "/vagrant", ".", :nfs => true) + end +end diff --git a/adminjs b/adminjs new file mode 120000 index 00000000000..d64fd7021f5 --- /dev/null +++ b/adminjs @@ -0,0 +1 @@ +app/assets/javascripts/admin \ No newline at end of file diff --git a/app/assets/fonts/FontAwesome.otf b/app/assets/fonts/FontAwesome.otf new file mode 100755 index 00000000000..64049bf2e79 Binary files /dev/null and b/app/assets/fonts/FontAwesome.otf differ diff --git a/app/assets/fonts/fontawesome-webfont.eot b/app/assets/fonts/fontawesome-webfont.eot new file mode 100755 index 00000000000..11d2f415f4b Binary files /dev/null and b/app/assets/fonts/fontawesome-webfont.eot differ diff --git a/app/assets/fonts/fontawesome-webfont.ttf b/app/assets/fonts/fontawesome-webfont.ttf new file mode 100755 index 00000000000..88ef262202b Binary files /dev/null and b/app/assets/fonts/fontawesome-webfont.ttf differ diff --git a/app/assets/fonts/fontawesome-webfont.woff b/app/assets/fonts/fontawesome-webfont.woff new file mode 100755 index 00000000000..7e892f87823 Binary files /dev/null and b/app/assets/fonts/fontawesome-webfont.woff differ diff --git a/app/assets/fonts/zocial-regular-webfont.eot b/app/assets/fonts/zocial-regular-webfont.eot new file mode 100644 index 00000000000..5db5d21666a Binary files /dev/null and b/app/assets/fonts/zocial-regular-webfont.eot differ diff --git a/app/assets/fonts/zocial-regular-webfont.svg b/app/assets/fonts/zocial-regular-webfont.svg new file mode 100644 index 00000000000..130d83ca102 --- /dev/null +++ b/app/assets/fonts/zocial-regular-webfont.svg @@ -0,0 +1,333 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/fonts/zocial-regular-webfont.ttf b/app/assets/fonts/zocial-regular-webfont.ttf new file mode 100644 index 00000000000..a043566360a Binary files /dev/null and b/app/assets/fonts/zocial-regular-webfont.ttf differ diff --git a/app/assets/fonts/zocial-regular-webfont.woff b/app/assets/fonts/zocial-regular-webfont.woff new file mode 100644 index 00000000000..5a91cf3056c Binary files /dev/null and b/app/assets/fonts/zocial-regular-webfont.woff differ diff --git a/app/assets/images/auth/facebook.gif b/app/assets/images/auth/facebook.gif new file mode 100644 index 00000000000..b997b358f78 Binary files /dev/null and b/app/assets/images/auth/facebook.gif differ diff --git a/app/assets/images/auth/google.gif b/app/assets/images/auth/google.gif new file mode 100644 index 00000000000..1b6cd07bd8b Binary files /dev/null and b/app/assets/images/auth/google.gif differ diff --git a/app/assets/images/auth/twitter.png b/app/assets/images/auth/twitter.png new file mode 100644 index 00000000000..16dfa69477f Binary files /dev/null and b/app/assets/images/auth/twitter.png differ diff --git a/app/assets/images/auth/yahoo.gif b/app/assets/images/auth/yahoo.gif new file mode 100644 index 00000000000..42adbfa57f8 Binary files /dev/null and b/app/assets/images/auth/yahoo.gif differ diff --git a/app/assets/images/avatars/0.jpg b/app/assets/images/avatars/0.jpg new file mode 100644 index 00000000000..333bc6f6e33 Binary files /dev/null and b/app/assets/images/avatars/0.jpg differ diff --git a/app/assets/images/avatars/1.jpg b/app/assets/images/avatars/1.jpg new file mode 100644 index 00000000000..baefa551c71 Binary files /dev/null and b/app/assets/images/avatars/1.jpg differ diff --git a/app/assets/images/avatars/10.jpg b/app/assets/images/avatars/10.jpg new file mode 100644 index 00000000000..c8f45dcd431 Binary files /dev/null and b/app/assets/images/avatars/10.jpg differ diff --git a/app/assets/images/avatars/100.jpg b/app/assets/images/avatars/100.jpg new file mode 100644 index 00000000000..d799cd2acd4 Binary files /dev/null and b/app/assets/images/avatars/100.jpg differ diff --git a/app/assets/images/avatars/101.jpg b/app/assets/images/avatars/101.jpg new file mode 100644 index 00000000000..de9910f0840 Binary files /dev/null and b/app/assets/images/avatars/101.jpg differ diff --git a/app/assets/images/avatars/102.jpg b/app/assets/images/avatars/102.jpg new file mode 100644 index 00000000000..7088c12b6dd Binary files /dev/null and b/app/assets/images/avatars/102.jpg differ diff --git a/app/assets/images/avatars/103.jpg b/app/assets/images/avatars/103.jpg new file mode 100644 index 00000000000..277d481f477 Binary files /dev/null and b/app/assets/images/avatars/103.jpg differ diff --git a/app/assets/images/avatars/104.jpg b/app/assets/images/avatars/104.jpg new file mode 100644 index 00000000000..787c4ab792c Binary files /dev/null and b/app/assets/images/avatars/104.jpg differ diff --git a/app/assets/images/avatars/105.jpg b/app/assets/images/avatars/105.jpg new file mode 100644 index 00000000000..c1f90418ea8 Binary files /dev/null and b/app/assets/images/avatars/105.jpg differ diff --git a/app/assets/images/avatars/106.jpg b/app/assets/images/avatars/106.jpg new file mode 100644 index 00000000000..04782209da6 Binary files /dev/null and b/app/assets/images/avatars/106.jpg differ diff --git a/app/assets/images/avatars/107.jpg b/app/assets/images/avatars/107.jpg new file mode 100644 index 00000000000..ff5bfd7a278 Binary files /dev/null and b/app/assets/images/avatars/107.jpg differ diff --git a/app/assets/images/avatars/108.jpg b/app/assets/images/avatars/108.jpg new file mode 100644 index 00000000000..7011b72fcb0 Binary files /dev/null and b/app/assets/images/avatars/108.jpg differ diff --git a/app/assets/images/avatars/109.jpg b/app/assets/images/avatars/109.jpg new file mode 100644 index 00000000000..7a8c43d983a Binary files /dev/null and b/app/assets/images/avatars/109.jpg differ diff --git a/app/assets/images/avatars/11.jpg b/app/assets/images/avatars/11.jpg new file mode 100644 index 00000000000..4b89d1c71a5 Binary files /dev/null and b/app/assets/images/avatars/11.jpg differ diff --git a/app/assets/images/avatars/110.jpg b/app/assets/images/avatars/110.jpg new file mode 100644 index 00000000000..ff378683179 Binary files /dev/null and b/app/assets/images/avatars/110.jpg differ diff --git a/app/assets/images/avatars/111.jpg b/app/assets/images/avatars/111.jpg new file mode 100644 index 00000000000..78efc513dff Binary files /dev/null and b/app/assets/images/avatars/111.jpg differ diff --git a/app/assets/images/avatars/112.jpg b/app/assets/images/avatars/112.jpg new file mode 100644 index 00000000000..c6f30e6d8bc Binary files /dev/null and b/app/assets/images/avatars/112.jpg differ diff --git a/app/assets/images/avatars/113.jpg b/app/assets/images/avatars/113.jpg new file mode 100644 index 00000000000..65bfa5f2d1d Binary files /dev/null and b/app/assets/images/avatars/113.jpg differ diff --git a/app/assets/images/avatars/114.jpg b/app/assets/images/avatars/114.jpg new file mode 100644 index 00000000000..00889de5393 Binary files /dev/null and b/app/assets/images/avatars/114.jpg differ diff --git a/app/assets/images/avatars/115.jpg b/app/assets/images/avatars/115.jpg new file mode 100644 index 00000000000..dc60ebcebbd Binary files /dev/null and b/app/assets/images/avatars/115.jpg differ diff --git a/app/assets/images/avatars/116.jpg b/app/assets/images/avatars/116.jpg new file mode 100644 index 00000000000..1d424b10f7c Binary files /dev/null and b/app/assets/images/avatars/116.jpg differ diff --git a/app/assets/images/avatars/117.jpg b/app/assets/images/avatars/117.jpg new file mode 100644 index 00000000000..898ff6f54d0 Binary files /dev/null and b/app/assets/images/avatars/117.jpg differ diff --git a/app/assets/images/avatars/118.jpg b/app/assets/images/avatars/118.jpg new file mode 100644 index 00000000000..c61aa71322a Binary files /dev/null and b/app/assets/images/avatars/118.jpg differ diff --git a/app/assets/images/avatars/119.jpg b/app/assets/images/avatars/119.jpg new file mode 100644 index 00000000000..8b6e7b0397f Binary files /dev/null and b/app/assets/images/avatars/119.jpg differ diff --git a/app/assets/images/avatars/12.jpg b/app/assets/images/avatars/12.jpg new file mode 100644 index 00000000000..46daa73ae4c Binary files /dev/null and b/app/assets/images/avatars/12.jpg differ diff --git a/app/assets/images/avatars/120.jpg b/app/assets/images/avatars/120.jpg new file mode 100644 index 00000000000..f4476eb4048 Binary files /dev/null and b/app/assets/images/avatars/120.jpg differ diff --git a/app/assets/images/avatars/121.jpg b/app/assets/images/avatars/121.jpg new file mode 100644 index 00000000000..5df73cf42e3 Binary files /dev/null and b/app/assets/images/avatars/121.jpg differ diff --git a/app/assets/images/avatars/122.jpg b/app/assets/images/avatars/122.jpg new file mode 100644 index 00000000000..87a1ca759b3 Binary files /dev/null and b/app/assets/images/avatars/122.jpg differ diff --git a/app/assets/images/avatars/123.jpg b/app/assets/images/avatars/123.jpg new file mode 100644 index 00000000000..21918bab528 Binary files /dev/null and b/app/assets/images/avatars/123.jpg differ diff --git a/app/assets/images/avatars/124.jpg b/app/assets/images/avatars/124.jpg new file mode 100644 index 00000000000..ae3eb5765d1 Binary files /dev/null and b/app/assets/images/avatars/124.jpg differ diff --git a/app/assets/images/avatars/125.jpg b/app/assets/images/avatars/125.jpg new file mode 100644 index 00000000000..3b8e3e97eeb Binary files /dev/null and b/app/assets/images/avatars/125.jpg differ diff --git a/app/assets/images/avatars/126.jpg b/app/assets/images/avatars/126.jpg new file mode 100644 index 00000000000..ce7d29b2125 Binary files /dev/null and b/app/assets/images/avatars/126.jpg differ diff --git a/app/assets/images/avatars/127.jpg b/app/assets/images/avatars/127.jpg new file mode 100644 index 00000000000..ddd555874ee Binary files /dev/null and b/app/assets/images/avatars/127.jpg differ diff --git a/app/assets/images/avatars/128.jpg b/app/assets/images/avatars/128.jpg new file mode 100644 index 00000000000..8b83526dde1 Binary files /dev/null and b/app/assets/images/avatars/128.jpg differ diff --git a/app/assets/images/avatars/129.jpg b/app/assets/images/avatars/129.jpg new file mode 100644 index 00000000000..febde00da72 Binary files /dev/null and b/app/assets/images/avatars/129.jpg differ diff --git a/app/assets/images/avatars/13.jpg b/app/assets/images/avatars/13.jpg new file mode 100644 index 00000000000..ef0af0f5725 Binary files /dev/null and b/app/assets/images/avatars/13.jpg differ diff --git a/app/assets/images/avatars/130.jpg b/app/assets/images/avatars/130.jpg new file mode 100644 index 00000000000..b273edbffa2 Binary files /dev/null and b/app/assets/images/avatars/130.jpg differ diff --git a/app/assets/images/avatars/131.jpg b/app/assets/images/avatars/131.jpg new file mode 100644 index 00000000000..bfa98c856b5 Binary files /dev/null and b/app/assets/images/avatars/131.jpg differ diff --git a/app/assets/images/avatars/132.jpg b/app/assets/images/avatars/132.jpg new file mode 100644 index 00000000000..95b2853ff17 Binary files /dev/null and b/app/assets/images/avatars/132.jpg differ diff --git a/app/assets/images/avatars/133.jpg b/app/assets/images/avatars/133.jpg new file mode 100644 index 00000000000..75aceadb5bc Binary files /dev/null and b/app/assets/images/avatars/133.jpg differ diff --git a/app/assets/images/avatars/134.jpg b/app/assets/images/avatars/134.jpg new file mode 100644 index 00000000000..a8b41f26f19 Binary files /dev/null and b/app/assets/images/avatars/134.jpg differ diff --git a/app/assets/images/avatars/135.jpg b/app/assets/images/avatars/135.jpg new file mode 100644 index 00000000000..0ea04ee28b7 Binary files /dev/null and b/app/assets/images/avatars/135.jpg differ diff --git a/app/assets/images/avatars/136.jpg b/app/assets/images/avatars/136.jpg new file mode 100644 index 00000000000..4cf72453ce4 Binary files /dev/null and b/app/assets/images/avatars/136.jpg differ diff --git a/app/assets/images/avatars/137.jpg b/app/assets/images/avatars/137.jpg new file mode 100644 index 00000000000..6962b18cd38 Binary files /dev/null and b/app/assets/images/avatars/137.jpg differ diff --git a/app/assets/images/avatars/138.jpg b/app/assets/images/avatars/138.jpg new file mode 100644 index 00000000000..df51a3c4996 Binary files /dev/null and b/app/assets/images/avatars/138.jpg differ diff --git a/app/assets/images/avatars/14.jpg b/app/assets/images/avatars/14.jpg new file mode 100644 index 00000000000..a75f00a59b7 Binary files /dev/null and b/app/assets/images/avatars/14.jpg differ diff --git a/app/assets/images/avatars/15.jpg b/app/assets/images/avatars/15.jpg new file mode 100644 index 00000000000..b733a49accc Binary files /dev/null and b/app/assets/images/avatars/15.jpg differ diff --git a/app/assets/images/avatars/16.jpg b/app/assets/images/avatars/16.jpg new file mode 100644 index 00000000000..e3475ef53dd Binary files /dev/null and b/app/assets/images/avatars/16.jpg differ diff --git a/app/assets/images/avatars/17.jpg b/app/assets/images/avatars/17.jpg new file mode 100644 index 00000000000..4d7ef00868d Binary files /dev/null and b/app/assets/images/avatars/17.jpg differ diff --git a/app/assets/images/avatars/18.jpg b/app/assets/images/avatars/18.jpg new file mode 100644 index 00000000000..d179eb8e067 Binary files /dev/null and b/app/assets/images/avatars/18.jpg differ diff --git a/app/assets/images/avatars/19.jpg b/app/assets/images/avatars/19.jpg new file mode 100644 index 00000000000..3077d922114 Binary files /dev/null and b/app/assets/images/avatars/19.jpg differ diff --git a/app/assets/images/avatars/2.jpg b/app/assets/images/avatars/2.jpg new file mode 100644 index 00000000000..f33b4ec055c Binary files /dev/null and b/app/assets/images/avatars/2.jpg differ diff --git a/app/assets/images/avatars/20.jpg b/app/assets/images/avatars/20.jpg new file mode 100644 index 00000000000..6bd1cfb5671 Binary files /dev/null and b/app/assets/images/avatars/20.jpg differ diff --git a/app/assets/images/avatars/21.jpg b/app/assets/images/avatars/21.jpg new file mode 100644 index 00000000000..86dca6b9305 Binary files /dev/null and b/app/assets/images/avatars/21.jpg differ diff --git a/app/assets/images/avatars/22.jpg b/app/assets/images/avatars/22.jpg new file mode 100644 index 00000000000..aa0181c1235 Binary files /dev/null and b/app/assets/images/avatars/22.jpg differ diff --git a/app/assets/images/avatars/23.jpg b/app/assets/images/avatars/23.jpg new file mode 100644 index 00000000000..c1b56de2e06 Binary files /dev/null and b/app/assets/images/avatars/23.jpg differ diff --git a/app/assets/images/avatars/24.jpg b/app/assets/images/avatars/24.jpg new file mode 100644 index 00000000000..78e302e6871 Binary files /dev/null and b/app/assets/images/avatars/24.jpg differ diff --git a/app/assets/images/avatars/25.jpg b/app/assets/images/avatars/25.jpg new file mode 100644 index 00000000000..93c9d81421b Binary files /dev/null and b/app/assets/images/avatars/25.jpg differ diff --git a/app/assets/images/avatars/26.jpg b/app/assets/images/avatars/26.jpg new file mode 100644 index 00000000000..163c060ffaa Binary files /dev/null and b/app/assets/images/avatars/26.jpg differ diff --git a/app/assets/images/avatars/27.jpg b/app/assets/images/avatars/27.jpg new file mode 100644 index 00000000000..34c999a05b3 Binary files /dev/null and b/app/assets/images/avatars/27.jpg differ diff --git a/app/assets/images/avatars/28.jpg b/app/assets/images/avatars/28.jpg new file mode 100644 index 00000000000..f2baa6f7e4b Binary files /dev/null and b/app/assets/images/avatars/28.jpg differ diff --git a/app/assets/images/avatars/29.jpg b/app/assets/images/avatars/29.jpg new file mode 100644 index 00000000000..c5ffd0cb742 Binary files /dev/null and b/app/assets/images/avatars/29.jpg differ diff --git a/app/assets/images/avatars/3.jpg b/app/assets/images/avatars/3.jpg new file mode 100644 index 00000000000..5199884e181 Binary files /dev/null and b/app/assets/images/avatars/3.jpg differ diff --git a/app/assets/images/avatars/30.jpg b/app/assets/images/avatars/30.jpg new file mode 100644 index 00000000000..5b2012bb13e Binary files /dev/null and b/app/assets/images/avatars/30.jpg differ diff --git a/app/assets/images/avatars/31.jpg b/app/assets/images/avatars/31.jpg new file mode 100644 index 00000000000..1fd53318f76 Binary files /dev/null and b/app/assets/images/avatars/31.jpg differ diff --git a/app/assets/images/avatars/32.jpg b/app/assets/images/avatars/32.jpg new file mode 100644 index 00000000000..1903fb73b69 Binary files /dev/null and b/app/assets/images/avatars/32.jpg differ diff --git a/app/assets/images/avatars/33.jpg b/app/assets/images/avatars/33.jpg new file mode 100644 index 00000000000..87378327d58 Binary files /dev/null and b/app/assets/images/avatars/33.jpg differ diff --git a/app/assets/images/avatars/34.jpg b/app/assets/images/avatars/34.jpg new file mode 100644 index 00000000000..beceaad1f89 Binary files /dev/null and b/app/assets/images/avatars/34.jpg differ diff --git a/app/assets/images/avatars/35.jpg b/app/assets/images/avatars/35.jpg new file mode 100644 index 00000000000..964e80b69b7 Binary files /dev/null and b/app/assets/images/avatars/35.jpg differ diff --git a/app/assets/images/avatars/36.jpg b/app/assets/images/avatars/36.jpg new file mode 100644 index 00000000000..5e858d53c63 Binary files /dev/null and b/app/assets/images/avatars/36.jpg differ diff --git a/app/assets/images/avatars/37.jpg b/app/assets/images/avatars/37.jpg new file mode 100644 index 00000000000..8c62dd38cf2 Binary files /dev/null and b/app/assets/images/avatars/37.jpg differ diff --git a/app/assets/images/avatars/38.jpg b/app/assets/images/avatars/38.jpg new file mode 100644 index 00000000000..12b9d2dde36 Binary files /dev/null and b/app/assets/images/avatars/38.jpg differ diff --git a/app/assets/images/avatars/39.jpg b/app/assets/images/avatars/39.jpg new file mode 100644 index 00000000000..5043c0e2a81 Binary files /dev/null and b/app/assets/images/avatars/39.jpg differ diff --git a/app/assets/images/avatars/4.jpg b/app/assets/images/avatars/4.jpg new file mode 100644 index 00000000000..b3561e09cab Binary files /dev/null and b/app/assets/images/avatars/4.jpg differ diff --git a/app/assets/images/avatars/40.jpg b/app/assets/images/avatars/40.jpg new file mode 100644 index 00000000000..8ecaa03810a Binary files /dev/null and b/app/assets/images/avatars/40.jpg differ diff --git a/app/assets/images/avatars/41.jpg b/app/assets/images/avatars/41.jpg new file mode 100644 index 00000000000..1deab2c54b2 Binary files /dev/null and b/app/assets/images/avatars/41.jpg differ diff --git a/app/assets/images/avatars/42.jpg b/app/assets/images/avatars/42.jpg new file mode 100644 index 00000000000..6e8ca955577 Binary files /dev/null and b/app/assets/images/avatars/42.jpg differ diff --git a/app/assets/images/avatars/43.jpg b/app/assets/images/avatars/43.jpg new file mode 100644 index 00000000000..9d602a8349e Binary files /dev/null and b/app/assets/images/avatars/43.jpg differ diff --git a/app/assets/images/avatars/44.jpg b/app/assets/images/avatars/44.jpg new file mode 100644 index 00000000000..656ba0fd9c5 Binary files /dev/null and b/app/assets/images/avatars/44.jpg differ diff --git a/app/assets/images/avatars/45.jpg b/app/assets/images/avatars/45.jpg new file mode 100644 index 00000000000..8b9a28eb6e3 Binary files /dev/null and b/app/assets/images/avatars/45.jpg differ diff --git a/app/assets/images/avatars/46.jpg b/app/assets/images/avatars/46.jpg new file mode 100644 index 00000000000..abf62df72de Binary files /dev/null and b/app/assets/images/avatars/46.jpg differ diff --git a/app/assets/images/avatars/47.jpg b/app/assets/images/avatars/47.jpg new file mode 100644 index 00000000000..f736eba3de5 Binary files /dev/null and b/app/assets/images/avatars/47.jpg differ diff --git a/app/assets/images/avatars/48.jpg b/app/assets/images/avatars/48.jpg new file mode 100644 index 00000000000..3afc22b7a95 Binary files /dev/null and b/app/assets/images/avatars/48.jpg differ diff --git a/app/assets/images/avatars/49.jpg b/app/assets/images/avatars/49.jpg new file mode 100644 index 00000000000..a71e67c8f96 Binary files /dev/null and b/app/assets/images/avatars/49.jpg differ diff --git a/app/assets/images/avatars/5.jpg b/app/assets/images/avatars/5.jpg new file mode 100644 index 00000000000..aa888ce038a Binary files /dev/null and b/app/assets/images/avatars/5.jpg differ diff --git a/app/assets/images/avatars/50.jpg b/app/assets/images/avatars/50.jpg new file mode 100644 index 00000000000..47498194a8c Binary files /dev/null and b/app/assets/images/avatars/50.jpg differ diff --git a/app/assets/images/avatars/51.jpg b/app/assets/images/avatars/51.jpg new file mode 100644 index 00000000000..f24b184f29e Binary files /dev/null and b/app/assets/images/avatars/51.jpg differ diff --git a/app/assets/images/avatars/52.jpg b/app/assets/images/avatars/52.jpg new file mode 100644 index 00000000000..c8eb28b96a3 Binary files /dev/null and b/app/assets/images/avatars/52.jpg differ diff --git a/app/assets/images/avatars/53.jpg b/app/assets/images/avatars/53.jpg new file mode 100644 index 00000000000..fed2bd52bec Binary files /dev/null and b/app/assets/images/avatars/53.jpg differ diff --git a/app/assets/images/avatars/54.jpg b/app/assets/images/avatars/54.jpg new file mode 100644 index 00000000000..6068a2b4d32 Binary files /dev/null and b/app/assets/images/avatars/54.jpg differ diff --git a/app/assets/images/avatars/55.jpg b/app/assets/images/avatars/55.jpg new file mode 100644 index 00000000000..9ecb6dfce66 Binary files /dev/null and b/app/assets/images/avatars/55.jpg differ diff --git a/app/assets/images/avatars/56.jpg b/app/assets/images/avatars/56.jpg new file mode 100644 index 00000000000..b8ce39f4db8 Binary files /dev/null and b/app/assets/images/avatars/56.jpg differ diff --git a/app/assets/images/avatars/57.jpg b/app/assets/images/avatars/57.jpg new file mode 100644 index 00000000000..becf5faf2dd Binary files /dev/null and b/app/assets/images/avatars/57.jpg differ diff --git a/app/assets/images/avatars/58.jpg b/app/assets/images/avatars/58.jpg new file mode 100644 index 00000000000..a244807eb88 Binary files /dev/null and b/app/assets/images/avatars/58.jpg differ diff --git a/app/assets/images/avatars/59.jpg b/app/assets/images/avatars/59.jpg new file mode 100644 index 00000000000..93ec84f4bf3 Binary files /dev/null and b/app/assets/images/avatars/59.jpg differ diff --git a/app/assets/images/avatars/6.jpg b/app/assets/images/avatars/6.jpg new file mode 100644 index 00000000000..9a36246b6f6 Binary files /dev/null and b/app/assets/images/avatars/6.jpg differ diff --git a/app/assets/images/avatars/60.jpg b/app/assets/images/avatars/60.jpg new file mode 100644 index 00000000000..86b8cb1562e Binary files /dev/null and b/app/assets/images/avatars/60.jpg differ diff --git a/app/assets/images/avatars/61.jpg b/app/assets/images/avatars/61.jpg new file mode 100644 index 00000000000..329264f2000 Binary files /dev/null and b/app/assets/images/avatars/61.jpg differ diff --git a/app/assets/images/avatars/62.jpg b/app/assets/images/avatars/62.jpg new file mode 100644 index 00000000000..15416ae82cc Binary files /dev/null and b/app/assets/images/avatars/62.jpg differ diff --git a/app/assets/images/avatars/63.jpg b/app/assets/images/avatars/63.jpg new file mode 100644 index 00000000000..c5cebab3ad2 Binary files /dev/null and b/app/assets/images/avatars/63.jpg differ diff --git a/app/assets/images/avatars/64.jpg b/app/assets/images/avatars/64.jpg new file mode 100644 index 00000000000..d991f98e2f0 Binary files /dev/null and b/app/assets/images/avatars/64.jpg differ diff --git a/app/assets/images/avatars/65.jpg b/app/assets/images/avatars/65.jpg new file mode 100644 index 00000000000..4d792acfb1d Binary files /dev/null and b/app/assets/images/avatars/65.jpg differ diff --git a/app/assets/images/avatars/66.jpg b/app/assets/images/avatars/66.jpg new file mode 100644 index 00000000000..edca436cd5a Binary files /dev/null and b/app/assets/images/avatars/66.jpg differ diff --git a/app/assets/images/avatars/67.jpg b/app/assets/images/avatars/67.jpg new file mode 100644 index 00000000000..85e6554a61a Binary files /dev/null and b/app/assets/images/avatars/67.jpg differ diff --git a/app/assets/images/avatars/68.jpg b/app/assets/images/avatars/68.jpg new file mode 100644 index 00000000000..625e396ede5 Binary files /dev/null and b/app/assets/images/avatars/68.jpg differ diff --git a/app/assets/images/avatars/69.jpg b/app/assets/images/avatars/69.jpg new file mode 100644 index 00000000000..8a0ccc375b4 Binary files /dev/null and b/app/assets/images/avatars/69.jpg differ diff --git a/app/assets/images/avatars/7.jpg b/app/assets/images/avatars/7.jpg new file mode 100644 index 00000000000..8c04d50e06c Binary files /dev/null and b/app/assets/images/avatars/7.jpg differ diff --git a/app/assets/images/avatars/70.jpg b/app/assets/images/avatars/70.jpg new file mode 100644 index 00000000000..0e33b149d3b Binary files /dev/null and b/app/assets/images/avatars/70.jpg differ diff --git a/app/assets/images/avatars/71.jpg b/app/assets/images/avatars/71.jpg new file mode 100644 index 00000000000..dba82d8173f Binary files /dev/null and b/app/assets/images/avatars/71.jpg differ diff --git a/app/assets/images/avatars/72.jpg b/app/assets/images/avatars/72.jpg new file mode 100644 index 00000000000..399336bedf6 Binary files /dev/null and b/app/assets/images/avatars/72.jpg differ diff --git a/app/assets/images/avatars/73.jpg b/app/assets/images/avatars/73.jpg new file mode 100644 index 00000000000..2ee65c7f154 Binary files /dev/null and b/app/assets/images/avatars/73.jpg differ diff --git a/app/assets/images/avatars/74.jpg b/app/assets/images/avatars/74.jpg new file mode 100644 index 00000000000..75cc0d4af3d Binary files /dev/null and b/app/assets/images/avatars/74.jpg differ diff --git a/app/assets/images/avatars/75.jpg b/app/assets/images/avatars/75.jpg new file mode 100644 index 00000000000..fff0bf15182 Binary files /dev/null and b/app/assets/images/avatars/75.jpg differ diff --git a/app/assets/images/avatars/76.jpg b/app/assets/images/avatars/76.jpg new file mode 100644 index 00000000000..c70857272c8 Binary files /dev/null and b/app/assets/images/avatars/76.jpg differ diff --git a/app/assets/images/avatars/77.jpg b/app/assets/images/avatars/77.jpg new file mode 100644 index 00000000000..e254f097d3a Binary files /dev/null and b/app/assets/images/avatars/77.jpg differ diff --git a/app/assets/images/avatars/78.jpg b/app/assets/images/avatars/78.jpg new file mode 100644 index 00000000000..0d311ef663c Binary files /dev/null and b/app/assets/images/avatars/78.jpg differ diff --git a/app/assets/images/avatars/79.jpg b/app/assets/images/avatars/79.jpg new file mode 100644 index 00000000000..c0f4c5eda7d Binary files /dev/null and b/app/assets/images/avatars/79.jpg differ diff --git a/app/assets/images/avatars/8.jpg b/app/assets/images/avatars/8.jpg new file mode 100644 index 00000000000..ed907a9d55d Binary files /dev/null and b/app/assets/images/avatars/8.jpg differ diff --git a/app/assets/images/avatars/80.jpg b/app/assets/images/avatars/80.jpg new file mode 100644 index 00000000000..9100ac281a5 Binary files /dev/null and b/app/assets/images/avatars/80.jpg differ diff --git a/app/assets/images/avatars/81.jpg b/app/assets/images/avatars/81.jpg new file mode 100644 index 00000000000..fe1abfd1762 Binary files /dev/null and b/app/assets/images/avatars/81.jpg differ diff --git a/app/assets/images/avatars/82.jpg b/app/assets/images/avatars/82.jpg new file mode 100644 index 00000000000..c2b3e07e5b1 Binary files /dev/null and b/app/assets/images/avatars/82.jpg differ diff --git a/app/assets/images/avatars/83.jpg b/app/assets/images/avatars/83.jpg new file mode 100644 index 00000000000..895cd08bf53 Binary files /dev/null and b/app/assets/images/avatars/83.jpg differ diff --git a/app/assets/images/avatars/84.jpg b/app/assets/images/avatars/84.jpg new file mode 100644 index 00000000000..f3278f29095 Binary files /dev/null and b/app/assets/images/avatars/84.jpg differ diff --git a/app/assets/images/avatars/85.jpg b/app/assets/images/avatars/85.jpg new file mode 100644 index 00000000000..4513811cd42 Binary files /dev/null and b/app/assets/images/avatars/85.jpg differ diff --git a/app/assets/images/avatars/86.jpg b/app/assets/images/avatars/86.jpg new file mode 100644 index 00000000000..4d814a88643 Binary files /dev/null and b/app/assets/images/avatars/86.jpg differ diff --git a/app/assets/images/avatars/87.jpg b/app/assets/images/avatars/87.jpg new file mode 100644 index 00000000000..119101b8dc6 Binary files /dev/null and b/app/assets/images/avatars/87.jpg differ diff --git a/app/assets/images/avatars/88.jpg b/app/assets/images/avatars/88.jpg new file mode 100644 index 00000000000..f2aa09749ba Binary files /dev/null and b/app/assets/images/avatars/88.jpg differ diff --git a/app/assets/images/avatars/89.jpg b/app/assets/images/avatars/89.jpg new file mode 100644 index 00000000000..e3c90140c32 Binary files /dev/null and b/app/assets/images/avatars/89.jpg differ diff --git a/app/assets/images/avatars/9.jpg b/app/assets/images/avatars/9.jpg new file mode 100644 index 00000000000..ea9d5c3afa5 Binary files /dev/null and b/app/assets/images/avatars/9.jpg differ diff --git a/app/assets/images/avatars/90.jpg b/app/assets/images/avatars/90.jpg new file mode 100644 index 00000000000..18d895ffb76 Binary files /dev/null and b/app/assets/images/avatars/90.jpg differ diff --git a/app/assets/images/avatars/91.jpg b/app/assets/images/avatars/91.jpg new file mode 100644 index 00000000000..cbd3c97da13 Binary files /dev/null and b/app/assets/images/avatars/91.jpg differ diff --git a/app/assets/images/avatars/92.jpg b/app/assets/images/avatars/92.jpg new file mode 100644 index 00000000000..aa5b12156ed Binary files /dev/null and b/app/assets/images/avatars/92.jpg differ diff --git a/app/assets/images/avatars/93.jpg b/app/assets/images/avatars/93.jpg new file mode 100644 index 00000000000..6d4042a7a1d Binary files /dev/null and b/app/assets/images/avatars/93.jpg differ diff --git a/app/assets/images/avatars/94.jpg b/app/assets/images/avatars/94.jpg new file mode 100644 index 00000000000..4ee77cd453f Binary files /dev/null and b/app/assets/images/avatars/94.jpg differ diff --git a/app/assets/images/avatars/95.jpg b/app/assets/images/avatars/95.jpg new file mode 100644 index 00000000000..dfafa89c443 Binary files /dev/null and b/app/assets/images/avatars/95.jpg differ diff --git a/app/assets/images/avatars/96.jpg b/app/assets/images/avatars/96.jpg new file mode 100644 index 00000000000..50a2fdf0d34 Binary files /dev/null and b/app/assets/images/avatars/96.jpg differ diff --git a/app/assets/images/avatars/97.jpg b/app/assets/images/avatars/97.jpg new file mode 100644 index 00000000000..9de3139140c Binary files /dev/null and b/app/assets/images/avatars/97.jpg differ diff --git a/app/assets/images/avatars/98.jpg b/app/assets/images/avatars/98.jpg new file mode 100644 index 00000000000..3459eeadaa4 Binary files /dev/null and b/app/assets/images/avatars/98.jpg differ diff --git a/app/assets/images/avatars/99.jpg b/app/assets/images/avatars/99.jpg new file mode 100644 index 00000000000..fd61d20d640 Binary files /dev/null and b/app/assets/images/avatars/99.jpg differ diff --git a/app/assets/images/avatars/rename.rb b/app/assets/images/avatars/rename.rb new file mode 100644 index 00000000000..5fc145287c5 --- /dev/null +++ b/app/assets/images/avatars/rename.rb @@ -0,0 +1,9 @@ +all_files = [Dir["*.jpg"] + Dir["*.jpeg"]].flatten +all_files.each do |f| + File.rename(f, "#{rand(2**256).to_s(36)[0..7]}.jpg") +end + +all_files = [Dir["*.jpg"] + Dir["*.jpeg"]].flatten +all_files.each_with_index do |f, i| + File.rename(f, "#{i}.jpg") +end diff --git a/app/assets/images/chosen-sprite.png b/app/assets/images/chosen-sprite.png new file mode 100644 index 00000000000..113dc9885a6 Binary files /dev/null and b/app/assets/images/chosen-sprite.png differ diff --git a/app/assets/images/cool_avatars/coding_horror.png b/app/assets/images/cool_avatars/coding_horror.png new file mode 100644 index 00000000000..b2ef339d232 Binary files /dev/null and b/app/assets/images/cool_avatars/coding_horror.png differ diff --git a/app/assets/images/cool_avatars/evil_trout.jpg b/app/assets/images/cool_avatars/evil_trout.jpg new file mode 100644 index 00000000000..d46c42e4f5b Binary files /dev/null and b/app/assets/images/cool_avatars/evil_trout.jpg differ diff --git a/app/assets/images/cool_avatars/hanzo.png b/app/assets/images/cool_avatars/hanzo.png new file mode 100644 index 00000000000..1dcf8e0f100 Binary files /dev/null and b/app/assets/images/cool_avatars/hanzo.png differ diff --git a/app/assets/images/cool_avatars/sam.png b/app/assets/images/cool_avatars/sam.png new file mode 100644 index 00000000000..068a638bcfd Binary files /dev/null and b/app/assets/images/cool_avatars/sam.png differ diff --git a/app/assets/images/favicon.ico b/app/assets/images/favicon.ico new file mode 100644 index 00000000000..db5a23d7592 Binary files /dev/null and b/app/assets/images/favicon.ico differ diff --git a/app/assets/images/favicons/1282043220-favicon.ico b/app/assets/images/favicons/1282043220-favicon.ico new file mode 100644 index 00000000000..9dbcbb74961 Binary files /dev/null and b/app/assets/images/favicons/1282043220-favicon.ico differ diff --git a/app/assets/images/favicons/amazon.png b/app/assets/images/favicons/amazon.png new file mode 100644 index 00000000000..b17586e16f3 Binary files /dev/null and b/app/assets/images/favicons/amazon.png differ diff --git a/app/assets/images/favicons/apple.png b/app/assets/images/favicons/apple.png new file mode 100644 index 00000000000..82fa120fcd0 Binary files /dev/null and b/app/assets/images/favicons/apple.png differ diff --git a/app/assets/images/favicons/github.png b/app/assets/images/favicons/github.png new file mode 100644 index 00000000000..3070ab07f23 Binary files /dev/null and b/app/assets/images/favicons/github.png differ diff --git a/app/assets/images/favicons/google_play.png b/app/assets/images/favicons/google_play.png new file mode 100644 index 00000000000..46483852591 Binary files /dev/null and b/app/assets/images/favicons/google_play.png differ diff --git a/app/assets/images/favicons/twitter.png b/app/assets/images/favicons/twitter.png new file mode 100644 index 00000000000..9f5cf7e8586 Binary files /dev/null and b/app/assets/images/favicons/twitter.png differ diff --git a/app/assets/images/favicons/wikipedia.png b/app/assets/images/favicons/wikipedia.png new file mode 100644 index 00000000000..59a8063d3be Binary files /dev/null and b/app/assets/images/favicons/wikipedia.png differ diff --git a/app/assets/images/grippie.png b/app/assets/images/grippie.png new file mode 100644 index 00000000000..6524d4167d2 Binary files /dev/null and b/app/assets/images/grippie.png differ diff --git a/app/assets/images/logo-single.png b/app/assets/images/logo-single.png new file mode 100644 index 00000000000..9fc9748d9ff Binary files /dev/null and b/app/assets/images/logo-single.png differ diff --git a/app/assets/images/logo.png b/app/assets/images/logo.png new file mode 100644 index 00000000000..e67461e6cb8 Binary files /dev/null and b/app/assets/images/logo.png differ diff --git a/app/assets/images/posted.png b/app/assets/images/posted.png new file mode 100644 index 00000000000..7127aac1c6b Binary files /dev/null and b/app/assets/images/posted.png differ diff --git a/app/assets/images/spinner_96.gif b/app/assets/images/spinner_96.gif new file mode 100644 index 00000000000..24af6375eed Binary files /dev/null and b/app/assets/images/spinner_96.gif differ diff --git a/app/assets/images/spinner_96_w.gif b/app/assets/images/spinner_96_w.gif new file mode 100644 index 00000000000..11e6162e4cd Binary files /dev/null and b/app/assets/images/spinner_96_w.gif differ diff --git a/app/assets/images/thread-default.png b/app/assets/images/thread-default.png new file mode 100644 index 00000000000..0e12b0d0ad7 Binary files /dev/null and b/app/assets/images/thread-default.png differ diff --git a/app/assets/images/wmd-buttons.png b/app/assets/images/wmd-buttons.png new file mode 100644 index 00000000000..50b37090363 Binary files /dev/null and b/app/assets/images/wmd-buttons.png differ diff --git a/app/assets/images/wmd-quote-post.gif b/app/assets/images/wmd-quote-post.gif new file mode 100644 index 00000000000..b8df52419a1 Binary files /dev/null and b/app/assets/images/wmd-quote-post.gif differ diff --git a/app/assets/javascripts/admin.js b/app/assets/javascripts/admin.js new file mode 100644 index 00000000000..06ada5e0976 --- /dev/null +++ b/app/assets/javascripts/admin.js @@ -0,0 +1 @@ +//= require_tree ./admin diff --git a/app/assets/javascripts/admin/controllers/admin_customize_controller.js.coffee b/app/assets/javascripts/admin/controllers/admin_customize_controller.js.coffee new file mode 100644 index 00000000000..b1ea1505791 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_customize_controller.js.coffee @@ -0,0 +1,18 @@ +window.Discourse.AdminCustomizeController = Ember.Controller.extend + newCustomization: -> + item = Discourse.SiteCustomization.create(name: 'New Style') + @get('content').pushObject(item) + @set('content.selectedItem', item) + + selectStyle: (style)-> @set('content.selectedItem', style) + + save: -> @get('content.selectedItem').save() + + delete: -> + bootbox.confirm Em.String.i18n("admin.customize.delete_confirm"), Em.String.i18n("no_value"), Em.String.i18n("yes_value"), (result) => + if result + selected = @get('content.selectedItem') + selected.delete() + @set('content.selectedItem', null) + @get('content').removeObject(selected) + diff --git a/app/assets/javascripts/admin/controllers/admin_email_logs_controller.js.coffee b/app/assets/javascripts/admin/controllers/admin_email_logs_controller.js.coffee new file mode 100644 index 00000000000..9ecce40a519 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_email_logs_controller.js.coffee @@ -0,0 +1,17 @@ +window.Discourse.AdminEmailLogsController = Ember.ArrayController.extend Discourse.Presence, + + sendTestEmailDisabled: (-> + @blank('testEmailAddress') + ).property('testEmailAddress') + + sendTestEmail: -> + @set('sentTestEmail', false) + $.ajax + url: '/admin/email_logs/test', + type: 'POST' + data: + email_address: @get('testEmailAddress') + success: => + @set('sentTestEmail', true) + false + \ No newline at end of file diff --git a/app/assets/javascripts/admin/controllers/admin_flags_controller.js.coffee b/app/assets/javascripts/admin/controllers/admin_flags_controller.js.coffee new file mode 100644 index 00000000000..c1f0e781b8f --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_flags_controller.js.coffee @@ -0,0 +1,16 @@ +window.Discourse.AdminFlagsController = Ember.Controller.extend + + clearFlags: (item) -> + item.clearFlags().then (=> + @content.removeObject(item) + ), (-> + bootbox.alert("something went wrong") + ) + + adminOldFlagsView: (-> + @query == 'old' + ).property('query') + + adminActiveFlagsView: (-> + @query == 'active' + ).property('query') diff --git a/app/assets/javascripts/admin/controllers/admin_site_settings_controller.js.coffee b/app/assets/javascripts/admin/controllers/admin_site_settings_controller.js.coffee new file mode 100644 index 00000000000..b08a6e9cace --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_site_settings_controller.js.coffee @@ -0,0 +1,30 @@ +window.Discourse.AdminSiteSettingsController = Ember.ArrayController.extend Discourse.Presence, + + filter: null + onlyOverridden: false + + filteredContent: (-> + return null unless @present('content') + filter = @get('filter').toLowerCase() if @get('filter') + + @get('content').filter (item, index, enumerable) => + + return false if @get('onlyOverridden') and !item.get('overridden') + + if filter + return true if item.get('setting').toLowerCase().indexOf(filter) > -1 + return true if item.get('description').toLowerCase().indexOf(filter) > -1 + return true if item.get('value').toLowerCase().indexOf(filter) > -1 + return false + else + true + ).property('filter', 'content.@each', 'onlyOverridden') + + + resetDefault: (setting) -> + setting.set('value', setting.get('default')) + setting.save() + + save: (setting) -> setting.save() + + cancel: (setting) -> setting.resetValue() \ No newline at end of file diff --git a/app/assets/javascripts/admin/controllers/admin_users_list_controller.js.coffee b/app/assets/javascripts/admin/controllers/admin_users_list_controller.js.coffee new file mode 100644 index 00000000000..01fb4919f20 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_users_list_controller.js.coffee @@ -0,0 +1,45 @@ +window.Discourse.AdminUsersListController = Ember.ArrayController.extend Discourse.Presence, + + username: null + query: null + selectAll: false + content: null + + selectAllChanged: (-> + @get('content').each (user) => user.set('selected', @get('selectAll')) + ).observes('selectAll') + + filterUsers: Discourse.debounce(-> + @refreshUsers() + ,250).observes('username') + + orderChanged: (-> + @refreshUsers() + ).observes('query') + + showApproval: (-> + return false unless Discourse.SiteSettings.must_approve_users + return true if @get('query') is 'new' + return true if @get('query') is 'pending' + ).property('query') + + selectedCount: (-> + return 0 if @blank('content') + @get('content').filterProperty('selected').length + ).property('content.@each.selected') + + hasSelection: (-> + @get('selectedCount') > 0 + ).property('selectedCount') + + refreshUsers: -> + @set 'content', Discourse.AdminUser.findAll(@get('query'), @get('username')) + + show: (term) -> + if @get('query') == term + @refreshUsers() + else + @set('query', term) + + approveUsers: -> + Discourse.AdminUser.bulkApprove(@get('content').filterProperty('selected')) diff --git a/app/assets/javascripts/admin/models/admin_user.js.coffee b/app/assets/javascripts/admin/models/admin_user.js.coffee new file mode 100644 index 00000000000..148149b767f --- /dev/null +++ b/app/assets/javascripts/admin/models/admin_user.js.coffee @@ -0,0 +1,122 @@ +window.Discourse.AdminUser = Discourse.Model.extend + + # Revoke the user's admin access + revokeAdmin: -> + @set('admin',false) + @set('can_grant_admin',true) + @set('can_revoke_admin',false) + $.ajax "/admin/users/#{@get('id')}/revoke_admin", type: 'PUT' + + grantAdmin: -> + @set('admin',true) + @set('can_grant_admin',false) + @set('can_revoke_admin',true) + $.ajax "/admin/users/#{@get('id')}/grant_admin", type: 'PUT' + + refreshBrowsers: -> + $.ajax "/admin/users/#{@get('id')}/refresh_browsers", + type: 'POST' + bootbox.alert("Message sent to all clients!") + + + + approve: -> + @set('can_approve', false) + @set('approved', true) + @set('approved_by', Discourse.get('currentUser')) + $.ajax "/admin/users/#{@get('id')}/approve", type: 'PUT' + + username_lower:(-> + @get('username').toLowerCase() + ).property('username') + + trustLevel: (-> + Discourse.get('site.trust_levels').findProperty('id', @get('trust_level')) + ).property('trust_level') + + + canBan: ( -> + !@admin && !@moderator + ).property('admin','moderator') + + banDuration: (-> + banned_at = Date.create(@banned_at) + banned_till = Date.create(@banned_till) + + "#{banned_at.short()} - #{banned_till.short()}" + + ).property('banned_till', 'banned_at') + + ban: -> + debugger + if duration = parseInt(window.prompt(Em.String.i18n('admin.user.ban_duration'))) + if duration > 0 + $.ajax "/admin/users/#{@id}/ban", + type: 'PUT' + data: + duration: duration + success: -> + window.location.reload() + return + error: (e) => + error = Em.String.i18n('admin.user.ban_failed', error: "http: #{e.status} - #{e.body}") + bootbox.alert error + return + + unban: -> + $.ajax "/admin/users/#{@id}/unban", + type: 'PUT' + success: -> + window.location.reload() + return + error: (e) => + error = Em.String.i18n('admin.user.unban_failed', error: "http: #{e.status} - #{e.body}") + bootbox.alert error + return + + impersonate: -> + $.ajax "/admin/impersonate" + type: 'POST' + data: + username_or_email: @get('username') + success: -> + document.location = "/" + error: (e) => + @set('loading', false) + if e.status == 404 + bootbox.alert Em.String.i18n('admin.impersonate.not_found') + else + bootbox.alert Em.String.i18n('admin.impersonate.invalid') + +window.Discourse.AdminUser.reopenClass + + create: (result) -> + result = @_super(result) + result + + bulkApprove: (users) -> + users.each (user) -> + user.set('approved', true) + user.set('can_approve', false) + user.set('selected', false) + + $.ajax "/admin/users/approve-bulk", + type: 'PUT' + data: {users: users.map (u) -> u.id} + + find: (username)-> + promise = new RSVP.Promise() + $.ajax + url: "/admin/users/#{username}" + success: (result) -> promise.resolve(Discourse.AdminUser.create(result)) + promise + + findAll: (query, filter)-> + result = Em.A() + $.ajax + url: "/admin/users/list/#{query}.json" + data: {filter: filter} + success: (users) -> + users.each (u) -> result.pushObject(Discourse.AdminUser.create(u)) + result + diff --git a/app/assets/javascripts/admin/models/email_log.js.coffee b/app/assets/javascripts/admin/models/email_log.js.coffee new file mode 100644 index 00000000000..7c3da0807fd --- /dev/null +++ b/app/assets/javascripts/admin/models/email_log.js.coffee @@ -0,0 +1,17 @@ +window.Discourse.EmailLog = Discourse.Model.extend({}) + +window.Discourse.EmailLog.reopenClass + + create: (attrs) -> + attrs.user = Discourse.AdminUser.create(attrs.user) if attrs.user + @_super(attrs) + + findAll: (filter)-> + result = Em.A() + $.ajax + url: "/admin/email_logs.json" + data: {filter: filter} + success: (logs) -> + logs.each (log) -> result.pushObject(Discourse.EmailLog.create(log)) + result + diff --git a/app/assets/javascripts/admin/models/flagged_post.js.coffee b/app/assets/javascripts/admin/models/flagged_post.js.coffee new file mode 100644 index 00000000000..6d3f0fa0bc3 --- /dev/null +++ b/app/assets/javascripts/admin/models/flagged_post.js.coffee @@ -0,0 +1,62 @@ +window.Discourse.FlaggedPost = Discourse.Post.extend + flaggers: (-> + r = [] + @post_actions.each (a)=> + r.push(@userLookup[a.user_id]) + r + ).property() + + messages: (-> + r = [] + @post_actions.each (a)=> + if a.message + r.push + user: @userLookup[a.user_id] + message: a.message + r + ).property() + + lastFlagged: (-> + @post_actions[0].created_at + ).property() + + user: (-> + @userLookup[@user_id] + ).property() + + topicHidden: (-> + @get('topic_visible') == 'f' + ).property('topic_hidden') + + clearFlags: -> + promise = new RSVP.Promise() + $.ajax "/admin/flags/clear/#{@id}", + type: 'POST' + cache: false + success: -> + promise.resolve() + error: (e)-> + promise.reject() + + promise + + hiddenClass: (-> + "hidden-post" if @get('hidden') == "t" + ).property() + + +window.Discourse.FlaggedPost.reopenClass + + findAll: (filter) -> + result = Em.A() + $.ajax + url: "/admin/flags/#{filter}.json" + success: (data) -> + userLookup = {} + data.users.each (u) -> userLookup[u.id] = Discourse.User.create(u) + data.posts.each (p) -> + f = Discourse.FlaggedPost.create(p) + f.userLookup = userLookup + result.pushObject(f) + result + diff --git a/app/assets/javascripts/admin/models/site_customization.js.coffee b/app/assets/javascripts/admin/models/site_customization.js.coffee new file mode 100644 index 00000000000..46a8622d11d --- /dev/null +++ b/app/assets/javascripts/admin/models/site_customization.js.coffee @@ -0,0 +1,78 @@ +window.Discourse.SiteCustomization = Discourse.Model.extend + + init: -> + @_super() + @startTrackingChanges() + + trackedProperties: ['enabled','name', 'stylesheet', 'header', 'override_default_style'] + + description: (-> + "#{@.name}#{if @.enabled then ' (*)' else ''}" + ).property('selected', 'name') + + changed: (-> + return false unless @.originals + @trackedProperties.any (p)=> + @.originals[p] != @get(p) + ).property('override_default_style','enabled','name', 'stylesheet', 'header', 'originals') # TODO figure out how to call with apply + + startTrackingChanges: -> + @set('originals',{}) + + @trackedProperties.each (p)=> + @.originals[p] = @get(p) + true + + previewUrl: (-> + "/?preview-style=#{@get('key')}" + ).property('key') + + disableSave:(-> + !@get('changed') + ).property('changed') + + save: -> + @startTrackingChanges() + data = + name: @name + enabled: @enabled + stylesheet: @stylesheet + header: @header + override_default_style: @override_default_style + + $.ajax + url: "/admin/site_customizations#{if @id then '/' + @id else ''}" + data: + site_customization: data + type: if @id then 'PUT' else 'POST' + + delete: -> + return unless @id + $.ajax + url: "/admin/site_customizations/#{ @id }" + type: 'DELETE' + +SiteCustomizations = Ember.ArrayProxy.extend + selectedItemChanged: (-> + selected = @get('selectedItem') + @get('content').each (i)-> + i.set('selected', selected == i) + ).observes('selectedItem') + + +Discourse.SiteCustomization.reopenClass + findAll: -> + content = SiteCustomizations.create + content: [] + loading: true + + $.ajax + url: "/admin/site_customizations" + dataType: "json" + success: (data)=> + data?.site_customizations.each (c)-> + item = Discourse.SiteCustomization.create(c) + content.pushObject(item) + content.set('loading',false) + + content diff --git a/app/assets/javascripts/admin/models/site_setting.js.coffee b/app/assets/javascripts/admin/models/site_setting.js.coffee new file mode 100644 index 00000000000..8c7299683e8 --- /dev/null +++ b/app/assets/javascripts/admin/models/site_setting.js.coffee @@ -0,0 +1,42 @@ +window.Discourse.SiteSetting = Discourse.Model.extend Discourse.Presence, + + # Whether a property is short. + short: (-> + return true if @blank('value') + return @get('value').toString().length < 80 + ).property('value') + + # Whether the site setting has changed + dirty: (-> + @get('originalValue') != @get('value') + ).property('originalValue', 'value') + + overridden: (-> + val = @get('value') + defaultVal = @get('default') + return val.toString() != defaultVal.toString() if (val and defaultVal) + return val != defaultVal + ).property('value') + + resetValue: -> + @set('value', @get('originalValue')) + + save: -> + + # Update the setting + $.ajax "/admin/site_settings/#{@get('setting')}", + data: + value: @get('value') + type: 'PUT' + success: => @set('originalValue', @get('value')) + + +window.Discourse.SiteSetting.reopenClass + findAll: -> + result = Em.A() + $.get "/admin/site_settings", (settings) -> + settings.each (s) -> + s.originalValue = s.value + result.pushObject(Discourse.SiteSetting.create(s)) + result + diff --git a/app/assets/javascripts/admin/routes/admin_customize_route.js.coffee b/app/assets/javascripts/admin/routes/admin_customize_route.js.coffee new file mode 100644 index 00000000000..3eacf56f184 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_customize_route.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminCustomizeRoute = Discourse.Route.extend + model: -> Discourse.SiteCustomization.findAll() \ No newline at end of file diff --git a/app/assets/javascripts/admin/routes/admin_email_logs_route.js.coffee b/app/assets/javascripts/admin/routes/admin_email_logs_route.js.coffee new file mode 100644 index 00000000000..f19abfc7172 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_email_logs_route.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminEmailLogsRoute = Discourse.Route.extend + model: -> Discourse.EmailLog.findAll() \ No newline at end of file diff --git a/app/assets/javascripts/admin/routes/admin_flags_active_route.js.coffee b/app/assets/javascripts/admin/routes/admin_flags_active_route.js.coffee new file mode 100644 index 00000000000..edb08d5b2d8 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_flags_active_route.js.coffee @@ -0,0 +1,6 @@ +Discourse.AdminFlagsActiveRoute = Discourse.Route.extend + model: -> Discourse.FlaggedPost.findAll('active') + setupController: (controller, model) -> + c = @controllerFor('adminFlags') + c.set('content', model) + c.set('query', 'active') \ No newline at end of file diff --git a/app/assets/javascripts/admin/routes/admin_flags_old_route.js.coffee b/app/assets/javascripts/admin/routes/admin_flags_old_route.js.coffee new file mode 100644 index 00000000000..f51c8f396e2 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_flags_old_route.js.coffee @@ -0,0 +1,6 @@ +Discourse.AdminFlagsOldRoute = Discourse.Route.extend + model: -> Discourse.FlaggedPost.findAll('old') + setupController: (controller, model) -> + c = @controllerFor('adminFlags') + c.set('content', model) + c.set('query', 'old') \ No newline at end of file diff --git a/app/assets/javascripts/admin/routes/admin_routes.js.coffee b/app/assets/javascripts/admin/routes/admin_routes.js.coffee new file mode 100644 index 00000000000..1aa07c1f5d8 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_routes.js.coffee @@ -0,0 +1,17 @@ +Discourse.buildRoutes -> + @resource 'admin', path: '/admin', -> + @route 'dashboard', path: '/' + @route 'site_settings', path: '/site_settings' + @route 'email_logs', path: '/email_logs' + @route 'customize', path: '/customize' + + @resource 'adminFlags', path: '/flags', -> + @route 'active', path: '/active' + @route 'old', path: '/old' + + @resource 'adminUsers', path: '/users', -> + @resource 'adminUser', path: '/:username' + @resource 'adminUsersList', path: '/list', -> + @route 'active', path: '/active' + @route 'new', path: '/new' + @route 'pending', path: '/pending' diff --git a/app/assets/javascripts/admin/routes/admin_site_settings_route.js.coffee b/app/assets/javascripts/admin/routes/admin_site_settings_route.js.coffee new file mode 100644 index 00000000000..010ad430077 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_site_settings_route.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminSiteSettingsRoute = Discourse.Route.extend + model: -> Discourse.SiteSetting.findAll() diff --git a/app/assets/javascripts/admin/routes/admin_user_route.js.coffee b/app/assets/javascripts/admin/routes/admin_user_route.js.coffee new file mode 100644 index 00000000000..9362aca9496 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_user_route.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminUserRoute = Discourse.Route.extend + model: (params) -> Discourse.AdminUser.find(params.username) \ No newline at end of file diff --git a/app/assets/javascripts/admin/routes/admin_users_list_active_route.js.coffee b/app/assets/javascripts/admin/routes/admin_users_list_active_route.js.coffee new file mode 100644 index 00000000000..2ccced53141 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_users_list_active_route.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminUsersListActiveRoute = Discourse.Route.extend + setupController: (c) -> @controllerFor('adminUsersList').show('active') \ No newline at end of file diff --git a/app/assets/javascripts/admin/routes/admin_users_list_new_route.js.coffee b/app/assets/javascripts/admin/routes/admin_users_list_new_route.js.coffee new file mode 100644 index 00000000000..7dcf80a052b --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_users_list_new_route.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminUsersListNewRoute = Discourse.Route.extend + setupController: (c) -> @controllerFor('adminUsersList').show('new') \ No newline at end of file diff --git a/app/assets/javascripts/admin/routes/admin_users_list_pending_route.js.coffee b/app/assets/javascripts/admin/routes/admin_users_list_pending_route.js.coffee new file mode 100644 index 00000000000..cec059da82e --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_users_list_pending_route.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminUsersListNewRoute = Discourse.Route.extend + setupController: (c) -> @controllerFor('adminUsersList').show('pending') \ No newline at end of file diff --git a/app/assets/javascripts/admin/templates/admin.js.handlebars b/app/assets/javascripts/admin/templates/admin.js.handlebars new file mode 100644 index 00000000000..48c32d6ef1e --- /dev/null +++ b/app/assets/javascripts/admin/templates/admin.js.handlebars @@ -0,0 +1,23 @@ +
+
+
+ + + +
+
+ {{outlet}} +
+
+ +
+
+
+ diff --git a/app/assets/javascripts/admin/templates/customize.js.handlebars b/app/assets/javascripts/admin/templates/customize.js.handlebars new file mode 100644 index 00000000000..f9359dee02b --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize.js.handlebars @@ -0,0 +1,56 @@ + +
+
+ +
+ + +
+ +{{#if content.selectedItem}} +
+ + + {{#with content.selectedItem}} + {{view Ember.TextField class="style-name" valueBinding="name"}} + {{#if view.headerActive}} + {{view Discourse.AceEditorView contentBinding="header" mode="html"}} + {{/if}} + {{#if view.stylesheetActive}} + {{view Discourse.AceEditorView contentBinding="stylesheet" mode="css"}} + {{/if}} + {{/with}} +
+
+ {{i18n admin.customize.override_default}} {{view Ember.Checkbox checkedBinding="content.selectedItem.override_default_style"}} + {{i18n admin.customize.enabled}} {{view Ember.Checkbox checkedBinding="content.selectedItem.enabled"}} + {{#unless content.selectedItem.changed}} + {{i18n admin.customize.preview}} + | + {{i18n admin.customize.undo_preview}}
+ {{/unless}} +
+ +
+ + {{i18n admin.customize.delete}} + {{content.savingStatus}} +
+ +
+{{/if}} +
+ diff --git a/app/assets/javascripts/admin/templates/dashboard.js.handlebars b/app/assets/javascripts/admin/templates/dashboard.js.handlebars new file mode 100644 index 00000000000..fe5b381c738 --- /dev/null +++ b/app/assets/javascripts/admin/templates/dashboard.js.handlebars @@ -0,0 +1,4 @@ +

Welcome to the admin section.

+ +

Not much to see here right now. Why not try the Site Settings?

+ diff --git a/app/assets/javascripts/admin/templates/email_logs.js.handlebars b/app/assets/javascripts/admin/templates/email_logs.js.handlebars new file mode 100644 index 00000000000..371fd81b858 --- /dev/null +++ b/app/assets/javascripts/admin/templates/email_logs.js.handlebars @@ -0,0 +1,37 @@ +
+
+ {{view Discourse.TextField valueBinding="controller.testEmailAddress" placeholderKey="admin.email_logs.test_email_address"}} +
+
+ + {{#if controller.sentTestEmail}}{{i18n admin.email_logs.sent_test}}{{/if}} +
+
+ + + + + + + + + + {{#if controller.content.length}} + {{#group}} + {{#collection contentBinding="controller.content" tagName="tbody" itemTagName="tr"}} + + + + + {{/collection}} + {{/group}} + {{/if}} + +
{{i18n admin.email_logs.sent_at}}{{i18n user.title}}{{i18n admin.email_logs.to_address}}{{i18n admin.email_logs.email_type}}
{{date view.content.created_at}} + {{#if view.content.user}} + {{avatar view.content.user imageSize="tiny"}} + {{view.content.user.username}} + {{else}} + — + {{/if}} + {{view.content.to_address}}{{view.content.email_type}}
diff --git a/app/assets/javascripts/admin/templates/flags.js.handlebars b/app/assets/javascripts/admin/templates/flags.js.handlebars new file mode 100644 index 00000000000..cf567f32310 --- /dev/null +++ b/app/assets/javascripts/admin/templates/flags.js.handlebars @@ -0,0 +1,49 @@ +
+
+ +
+
+ + + + + + + + + + + + + + {{#each content}} + + + + + + + + + {{#each messages}} + + + + + + + + {{/each}} + {{/each}} + +
Flag by
{{avatar user imageSize="small"}}{{#if topicHidden}} {{/if}}

{{title}}


{{{excerpt}}} +
{{#each flaggers}}{{avatar this imageSize="small"}}{{/each}}{{date lastFlagged}} + {{#if controller.adminActiveFlagsView}} + + {{/if}} +
+
{{avatar user imageSize="small"}} {{message}}
+
diff --git a/app/assets/javascripts/admin/templates/site_settings.js.handlebars b/app/assets/javascripts/admin/templates/site_settings.js.handlebars new file mode 100644 index 00000000000..c5ebf3d76ef --- /dev/null +++ b/app/assets/javascripts/admin/templates/site_settings.js.handlebars @@ -0,0 +1,34 @@ +
+ +
+ {{view Discourse.TextField valueBinding="controller.filter" placeholderKey="type_to_filter"}} +
+ +
+ +{{#collection contentBinding="filteredContent" classNames="form-horizontal settings" itemClass="row setting"}} + {{#with view.content}} +
+ {{unbound setting}} +
+
+ {{view Ember.TextField valueBinding="value" classNames="input-xxlarge"}} +
{{unbound description}}
+
+ {{#if dirty}} +
+ + + {{else}} + {{#if overridden}} + + {{/if}} + {{/if}} + {{/with}} +{{/collection}} diff --git a/app/assets/javascripts/admin/templates/user.js.handlebars b/app/assets/javascripts/admin/templates/user.js.handlebars new file mode 100644 index 00000000000..c109eb7b853 --- /dev/null +++ b/app/assets/javascripts/admin/templates/user.js.handlebars @@ -0,0 +1,168 @@ +
+

{{i18n admin.user.basics}}

+ +
+
{{i18n user.username.title}}
+
{{content.username}}
+
+ + + {{i18n admin.user.show_public_profile}} + + {{#if content.can_impersonate}} + + {{/if}} +
+
+
+
{{i18n user.email.title}}
+ +
+
+
{{i18n user.avatar.title}}
+
{{avatar content imageSize="large"}}
+
+
+
{{i18n user.ip_address.title}}
+
{{content.ip_address}}
+
+ +
+
+
+ + +
+

{{i18n admin.user.permissions}}

+ +
+
{{i18n admin.users.approved}}
+
+ {{#if content.approved}} + {{i18n admin.user.approved_by}} + {{avatar approved_by imageSize="small"}} + {{content.approved_by.username}} + {{else}} + {{i18n no_value}} + {{/if}} + +
+
+ {{#if content.can_approve}} + + {{/if}} +
+
+ +
+
{{i18n admin.user.admin}}
+
{{content.admin}}
+
+ {{#if content.can_revoke_admin}} + + {{/if}} + {{#if content.can_grant_admin}} + + {{/if}} +
+ +
+
+
{{i18n admin.user.moderator}}
+
{{content.moderator}}
+
+
+
{{i18n trust_level}}
+
{{content.trustLevel.name}}
+
+
+
{{i18n admin.user.banned}}
+
{{content.is_banned}}
+
+ {{#if content.is_banned}} + {{#if content.canBan}} + + {{content.banDuration}} + {{/if}} + {{else}} + {{#if content.canBan}} + + {{/if}} + {{/if}} +
+
+
+ +
+

{{i18n admin.user.activity}}

+ +
+
{{i18n created}}
+
{{{content.created_at_age}}}
+
+
+
{{i18n admin.users.last_emailed}}
+
{{{content.last_emailed_age}}}
+
+
+
{{i18n last_seen}}
+
{{{content.last_seen_age}}}
+
+
+
{{i18n admin.user.like_count}}
+
{{content.like_count}}
+
+
+
{{i18n admin.user.topics_entered}}
+
{{content.topics_entered}}
+
+
+
{{i18n admin.user.post_count}}
+
{{content.post_count}}
+
+
+
{{i18n admin.user.posts_read_count}}
+
{{content.posts_read_count}}
+
+
+
{{i18n admin.user.flags_given_count}}
+
{{content.flags_given_count}}
+
+
+
{{i18n admin.user.flags_received_count}}
+
{{content.flags_received_count}}
+
+
+
{{i18n admin.user.private_topics_count}}
+
{{content.private_topics_count}}
+
+
+
{{i18n admin.user.time_read}}
+
{{{content.time_read}}}
+
+
+
{{i18n user.invited.days_visited}}
+
{{{content.days_visited}}}
+
+
+ diff --git a/app/assets/javascripts/admin/templates/users_list.js.handlebars b/app/assets/javascripts/admin/templates/users_list.js.handlebars new file mode 100644 index 00000000000..7da213cc503 --- /dev/null +++ b/app/assets/javascripts/admin/templates/users_list.js.handlebars @@ -0,0 +1,82 @@ +
+
+ +
+
+ {{view Discourse.TextField valueBinding="controller.username" placeholderKey="username"}} +
+
+ +{{#if hasSelection}} +
+ +
+{{/if}} + +{{#if content.length}} + + + {{#if showApproval}} + + {{/if}} + + + + + + + + + + {{#if showApproval}} + + {{/if}} + + + + + {{#each content}} + + {{#if controller.showApproval}} + + {{/if}} + + + + + + + + + + + + {{#if controller.showApproval}} + + {{/if}} + + {{/each}} + +
{{view Ember.Checkbox checkedBinding="selectAll"}} {{i18n username}}{{i18n email}}{{i18n admin.users.last_emailed}}{{i18n last_seen}}{{i18n admin.user.topics_entered}}{{i18n admin.user.posts_read_count}}{{i18n admin.user.time_read}}{{i18n created}}{{i18n admin.users.approved}} 
+ {{#if can_approve}} + {{view Ember.Checkbox checkedBinding="selected"}} + {{/if}} + + {{avatar this imageSize="small"}} + {{unbound username}}{{unbound email}}{{{unbound last_emailed_age}}}{{{unbound last_seen_age}}}{{{unbound topics_entered}}}{{{unbound posts_read_count}}}{{{unbound time_read}}}{{{unbound created_at_age}}} + {{#if approved}} + {{i18n yes_value}} + {{else}} + {{i18n no_value}} + {{/if}} + {{#if admin}}{{/if}} +
+{{else}} +
{{i18n loading}}
+{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/admin/translations.js.erb b/app/assets/javascripts/admin/translations.js.erb new file mode 100644 index 00000000000..2b6519bb79c --- /dev/null +++ b/app/assets/javascripts/admin/translations.js.erb @@ -0,0 +1,7 @@ +//= depend_on 'en.yml' + +<% SimplesIdeias::I18n.assert_usable_configuration! %> +<% admin = SimplesIdeias::I18n.translation_segments['app/assets/javascripts/i18n/admin.en.js'] + admin[:en][:js] = admin[:en].delete(:admin_js) +%> +$.extend(true, I18n.translations, <%= admin.to_json %>); diff --git a/app/assets/javascripts/admin/views/ace_editor_view.js.coffee b/app/assets/javascripts/admin/views/ace_editor_view.js.coffee new file mode 100644 index 00000000000..66486cd9234 --- /dev/null +++ b/app/assets/javascripts/admin/views/ace_editor_view.js.coffee @@ -0,0 +1,42 @@ +Discourse.AceEditorView = window.Discourse.View.extend + mode: 'css' + classNames: ['ace-wrapper'] + + contentChanged:(-> + if @editor && !@skipContentChangeEvent + @editor.getSession().setValue(@get('content')) + ).observes('content') + + render: (buffer) -> + buffer.push("
") + buffer.push(Handlebars.Utils.escapeExpression(@get('content'))) if @get('content') + buffer.push("
") + + willDestroyElement: -> + if @editor + @editor.destroy() + @editor = null + + didInsertElement: -> + initAce = => + @editor = ace.edit(@$('.ace')[0]) + @editor.setTheme("ace/theme/chrome") + @editor.setShowPrintMargin(false) + @editor.getSession().setMode("ace/mode/#{@get('mode')}") + @editor.on "change", (e)=> + # amending stuff as you type seems a bit out of scope for now - can revisit after launch + # changes = @get('changes') + # unless changes + # changes = [] + # @set('changes', changes) + # changes.push e.data + + @skipContentChangeEvent = true + @set('content', @editor.getSession().getValue()) + @skipContentChangeEvent = false + if window.ace + initAce() + else + $LAB.script('http://d1n0x3qji82z53.cloudfront.net/src-min-noconflict/ace.js').wait initAce + + diff --git a/app/assets/javascripts/admin/views/admin_customize_view.js.coffee b/app/assets/javascripts/admin/views/admin_customize_view.js.coffee new file mode 100644 index 00000000000..26201b41e47 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_customize_view.js.coffee @@ -0,0 +1,33 @@ +Discourse.AdminCustomizeView = window.Discourse.View.extend + templateName: 'admin/templates/customize' + classNames: ['customize'] + contentBinding: 'controller.content' + + init: -> + @_super() + @set('selected', 'stylesheet') + + headerActive: (-> + @get('selected') == 'header' + ).property('selected') + + stylesheetActive: (-> + @get('selected') == 'stylesheet' + ).property('selected') + + selectHeader: -> + @set('selected', 'header') + + selectStylesheet: -> + @set('selected', 'stylesheet') + + + didInsertElement: -> + Mousetrap.bindGlobal ['meta+s', 'ctrl+s'], => + @get('controller').save() + return false + + willDestroyElement: -> + Mousetrap.unbindGlobal('meta+s','ctrl+s') + + diff --git a/app/assets/javascripts/admin/views/admin_dashboard_view.js.coffee b/app/assets/javascripts/admin/views/admin_dashboard_view.js.coffee new file mode 100644 index 00000000000..8182098371d --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_dashboard_view.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminDashboardView = window.Discourse.View.extend + templateName: 'admin/templates/dashboard' \ No newline at end of file diff --git a/app/assets/javascripts/admin/views/admin_email_logs_view.js.coffee b/app/assets/javascripts/admin/views/admin_email_logs_view.js.coffee new file mode 100644 index 00000000000..b3831370cc0 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_email_logs_view.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminEmailLogsView = window.Discourse.View.extend + templateName: 'admin/templates/email_logs' \ No newline at end of file diff --git a/app/assets/javascripts/admin/views/admin_flags_view.js.coffee b/app/assets/javascripts/admin/views/admin_flags_view.js.coffee new file mode 100644 index 00000000000..0f96cd16459 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_flags_view.js.coffee @@ -0,0 +1,3 @@ +Discourse.AdminFlagsView = window.Discourse.View.extend + templateName: 'admin/templates/flags' + diff --git a/app/assets/javascripts/admin/views/admin_site_settings_view.js.coffee b/app/assets/javascripts/admin/views/admin_site_settings_view.js.coffee new file mode 100644 index 00000000000..8319287c334 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_site_settings_view.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminSiteSettingsView = window.Discourse.View.extend + templateName: 'admin/templates/site_settings' \ No newline at end of file diff --git a/app/assets/javascripts/admin/views/admin_user_view.js.coffee b/app/assets/javascripts/admin/views/admin_user_view.js.coffee new file mode 100644 index 00000000000..51124e17892 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_user_view.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminUserView = window.Discourse.View.extend + templateName: 'admin/templates/user' \ No newline at end of file diff --git a/app/assets/javascripts/admin/views/admin_users_list_view.js.coffee b/app/assets/javascripts/admin/views/admin_users_list_view.js.coffee new file mode 100644 index 00000000000..8759b992433 --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_users_list_view.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminUsersListView = window.Discourse.View.extend + templateName: 'admin/templates/users_list' diff --git a/app/assets/javascripts/admin/views/admin_view.js.coffee b/app/assets/javascripts/admin/views/admin_view.js.coffee new file mode 100644 index 00000000000..377a8913b9f --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_view.js.coffee @@ -0,0 +1,2 @@ +Discourse.AdminView = window.Discourse.View.extend + templateName: 'admin/templates/admin' \ No newline at end of file diff --git a/app/assets/javascripts/application.js.erb b/app/assets/javascripts/application.js.erb new file mode 100644 index 00000000000..46caccf9253 --- /dev/null +++ b/app/assets/javascripts/application.js.erb @@ -0,0 +1,51 @@ +// This is a manifest file that'll be compiled into including all the files listed below. +// Add new JavaScript/Coffee code in separate files in this directory and they'll automatically +// be included in the compiled file accessible from http://example.com/assets/application.js +// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +// the compiled file. +// +//= require ./env + +// probe framework first +//= require ./discourse/components/probes.js + +// Externals we need to load first +//= require ./external/jquery-1.8.2.js +//= require ./external/jquery.ui.widget.js +//= require ./external/handlebars-1.0.rc.2.js +//= require ./external/ember.js + +// Pagedown customizations +//= require ./pagedown_custom.js + +// The rest of the externals +//= require_tree ./external +//= require i18n +//= require discourse/translations + +//= require ./discourse/helpers/i18n_helpers +//= require ./discourse + +// Stuff we need to load first +//= require_tree ./discourse/mixins +//= require ./discourse/components/debounce +//= require ./discourse/views/view +//= require ./discourse/controllers/controller +//= require ./discourse/views/modal/modal_body_view +//= require ./discourse/models/model +//= require ./discourse/routes/discourse_route + +//= require_tree ./discourse/controllers +//= require_tree ./discourse/components +//= require_tree ./discourse/models +//= require_tree ./discourse/views +//= require_tree ./discourse/helpers +//= require_tree ./discourse/templates +//= require_tree ./discourse/routes + +<% + # Include javascripts + DiscoursePluginRegistry.javascripts.each do |js| + require_asset(js) + end +%> diff --git a/app/assets/javascripts/discourse.js.coffee b/app/assets/javascripts/discourse.js.coffee new file mode 100644 index 00000000000..aad1f9cb47a --- /dev/null +++ b/app/assets/javascripts/discourse.js.coffee @@ -0,0 +1,272 @@ +window.Discourse = Ember.Application.createWithMixins + rootElement: '#main' + + # Data we want to remember for a short period + transient: Em.Object.create() + + hasFocus: true + scrolling: false + + # The highest seen post number by topic + highestSeenByTopic: {} + + logoSmall: (-> + logo = Discourse.SiteSettings.logo_small_url + if logo && logo.length > 1 + "" + else + "" + ).property() + + titleChanged: (-> + title = "" + title += "#{@get('title')} - " if @get('title') + title += Discourse.SiteSettings.title + $('title').text(title) + + title = ("(*) " + title) if !@get('hasFocus') && @get('notify') + + # chrome bug workaround see: http://stackoverflow.com/questions/2952384/changing-the-window-title-when-focussing-the-window-doesnt-work-in-chrome + window.setTimeout (-> + document.title = "." + document.title = title + return), 200 + return + ).observes('title', 'hasFocus', 'notify') + + currentUserChanged: (-> + + bus = Discourse.MessageBus + + # We don't want to receive any previous user notidications + bus.unsubscribe "/notification" + + bus.callbackInterval = Discourse.SiteSettings.anon_polling_interval + bus.enableLongPolling = false + + user = @get('currentUser') + if user + bus.callbackInterval = Discourse.SiteSettings.polling_interval + bus.enableLongPolling = true + + if user.admin + bus.subscribe "/flagged_counts", (data) -> + user.set('site_flagged_posts_count', data.total) + bus.subscribe "/notification", ((data) -> + user.set('unread_notifications', data.unread_notifications) + user.set('unread_private_messages', data.unread_private_messages)), user.notification_channel_position + + ).observes('currentUser') + + notifyTitle: -> + @set('notify', true) + + # Browser aware replaceState + replaceState: (path) -> + if window.history && window.history.pushState && window.history.replaceState && !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/) + history.replaceState({path: path}, null, path) unless window.location.pathname is path + + openComposer: (opts) -> + # TODO, remove container link + Discourse.__container__.lookup('controller:composer')?.open(opts) + + # Like router.route, but allow full urls rather than relative ones + # HERE BE HACKS - uses the ember container for now until we can do this nicer. + routeTo: (path) -> + path = path.replace(/https?\:\/\/[^\/]+/, '') + + # If we're in the same topic, don't push the state + topicRegexp = /\/t\/([^\/]+)\/(\d+)\/?(\d+)?/ + newMatches = topicRegexp.exec(path); + if newTopicId = newMatches?[2] + oldMatches = topicRegexp.exec(window.location.pathname); + if (oldTopicId = oldMatches?[2]) && (oldTopicId is newTopicId) + Discourse.replaceState(path) + topicController = Discourse.__container__.lookup('controller:topic') + opts = {trackVisit: false} + opts.nearPost = newMatches[3] if newMatches[3] + topicController.get('content').loadPosts(opts) + return + + + # Be wary of looking up the router. In this case, we have links in our + # HTML, say form compiled markdown posts, that need to be routed. + router = Discourse.__container__.lookup('router:main') + router.router.updateURL(path) + router.handleURL(path) + + # Scroll to the top if we're not replacing state + + + # The classes of buttons to show on a post + postButtons: (-> + Discourse.SiteSettings.post_menu.split("|").map (i) -> "#{i.replace(/\+/, '').capitalize()}" + ).property('Discourse.SiteSettings.post_menu') + + bindDOMEvents: -> + + $html = $('html') + # Add the discourse touch event + hasTouch = false + hasTouch = true if $html.hasClass('touch') + hasTouch = true if (Modernizr.prefixed("MaxTouchPoints", navigator) > 1) + + if hasTouch + $html.addClass('discourse-touch') + @touch = true + @hasTouch = true + else + $html.addClass('discourse-no-touch') + @touch = false + + $('#main').on 'click.discourse', '[data-not-implemented=true]', (e) => + e.preventDefault() + alert Em.String.i18n('not_implemented') + false + + $('#main').on 'click.discourse', 'a', (e) => + + return if (e.isDefaultPrevented() || e.metaKey || e.ctrlKey) + $currentTarget = $(e.currentTarget) + + href = $currentTarget.attr('href') + return if href is undefined + return if href is '#' + return if $currentTarget.attr('target') + return if $currentTarget.data('auto-route') + return if href.indexOf("mailto:") is 0 + + if href.match(/^http[s]?:\/\//i) && !href.match new RegExp("^http:\\/\\/" + window.location.hostname,"i") + return + + e.preventDefault() + @routeTo(href) + + false + + $(window).focus( => + @set('hasFocus',true) + @set('notify',false) + ).blur( => + @set('hasFocus',false) + ) + + logout: -> + username = @get('currentUser.username') + Discourse.KeyValueStore.abandonLocal() + $.ajax "/session/#{username}", + type: 'DELETE' + success: (result) => + # To keep lots of our variables unbound, we can handle a redirect on logging out. + window.location.reload() + + # fancy probes in ember + insertProbes: -> + + return unless console? + + topLevel = (fn,name) -> + window.probes.measure fn, + name: name + before: (data,owner, args) -> + if owner + window.probes.clear() + + after: (data, owner, args) -> + if owner && data.time > 10 + f = (name,data) -> + "#{name} - #{data.count} calls #{(data.time + 0.0).toFixed(2)}ms" if data && data.count + + if console && console.group + console.group(f(name, data)) + else + console.log("") + console.log(f(name,data)) + + ary = [] + for n,v of window.probes + continue if n == name || v.time < 1 + ary.push(k: n, v: v) + + ary.sortBy((item) -> if item.v && item.v.time then -item.v.time else 0).each (item)-> + console.log output if output = f("#{item.k}", item.v) + console?.groupEnd?() + + window.probes.clear() + + + Ember.View.prototype.renderToBuffer = window.probes.measure Ember.View.prototype.renderToBuffer, "renderToBuffer" + + Discourse.routeTo = topLevel(Discourse.routeTo, "Discourse.routeTo") + Ember.run.end = topLevel(Ember.run.end, "Ember.run.end") + + return + + authenticationComplete: (options)-> + # TODO, how to dispatch this to the view without the container? + loginView = Discourse.__container__.lookup('controller:modal').get('currentView') + loginView.authenticationComplete(options) + + buildRoutes: (builder) -> + oldBuilder = Discourse.routeBuilder + Discourse.routeBuilder = -> + oldBuilder.call(@) if oldBuilder + builder.call(@) + + start: -> + @bindDOMEvents() + Discourse.SiteSettings = PreloadStore.getStatic('siteSettings') + Discourse.MessageBus.start() + Discourse.KeyValueStore.init("discourse_", Discourse.MessageBus) + Discourse.insertProbes() + + + # subscribe to any site customizations that are loaded + $('link.custom-css').each -> + split = @href.split("/") + id = split[split.length-1].split(".css")[0] + stylesheet = @ + Discourse.MessageBus.subscribe "/file-change/#{id}", (data)=> + $(stylesheet).data('orig', stylesheet.href) unless $(stylesheet).data('orig') + orig = $(stylesheet).data('orig') + sp = orig.split(".css?") + stylesheet.href = sp[0] + ".css?" + data + + $('header.custom').each -> + header = $(this) + Discourse.MessageBus.subscribe "/header-change/#{$(@).data('key')}", (data)-> + header.html(data) + + # possibly move this to dev only + Discourse.MessageBus.subscribe "/file-change", (data)-> + Ember.TEMPLATES["empty"] = Handlebars.compile("") + data.each (me)-> + if me == "refresh" + document.location.reload(true) + else if me.name.substr(-10) == "handlebars" + js = me.name.replace(".handlebars","").replace("app/assets/javascripts","/assets") + $LAB.script(js + "?hash=" + me.hash).wait -> + templateName = js.replace(".js","").replace("/assets/","") + $.each Ember.View.views, -> + if(@get('templateName')==templateName) + @set('templateName','empty') + @rerender() + Em.run.next => + @set('templateName', templateName) + @rerender() + else + $('link').each -> + if @.href.match(me.name) and me.hash + $(@).data('orig', @.href) unless $(@).data('orig') + @.href = $(@).data('orig') + "&hash=" + me.hash + +window.Discourse.Router = Discourse.Router.reopen(location: 'discourse_location') + +# since we have no jquery-rails these days, hook up csrf token +csrf_token = $('meta[name=csrf-token]').attr('content') + +$.ajaxPrefilter (options,originalOptions,xhr) -> + unless options.crossDomain + xhr.setRequestHeader('X-CSRF-Token', csrf_token) + return + diff --git a/app/assets/javascripts/discourse/components/autocomplete.js.coffee b/app/assets/javascripts/discourse/components/autocomplete.js.coffee new file mode 100644 index 00000000000..412e5eb4455 --- /dev/null +++ b/app/assets/javascripts/discourse/components/autocomplete.js.coffee @@ -0,0 +1,255 @@ +( ($) -> + + template = null + + $.fn.autocomplete = (options)-> + + return if @.length == 0 + + if options && options.cancel && @.data("closeAutocomplete") + @.data("closeAutocomplete")() + return this + + alert "only supporting one matcher at the moment" unless @.length == 1 + + autocompleteOptions = null + selectedOption = null + completeStart = null + completeEnd = null + me = @ + div = null + + # input is handled differently + isInput = @[0].tagName == "INPUT" + + inputSelectedItems = [] + addInputSelectedItem = (item) -> + + transformed = options.transformComplete(item) if options.transformComplete + d = $("
#{transformed || item}
") + prev = me.parent().find('.item:last') + if prev.length == 0 + me.parent().prepend(d) + else + prev.after(d) + + inputSelectedItems.push(item) + + if options.onChangeItems + options.onChangeItems(inputSelectedItems) + + d.find('a').click -> + closeAutocomplete() + inputSelectedItems.splice($.inArray(item),1) + $(this).parent().parent().remove() + if options.onChangeItems + options.onChangeItems(inputSelectedItems) + + if isInput + + width = @.width() + height = @.height() + + wrap = @.wrap("
").parent() + + wrap.width(width) + + @.width(80) + @.attr('name', @.attr('name') + "-renamed") + + vals = @.val().split(",") + + vals.each (x)-> + unless x == "" + x = options.reverseTransform(x) if options.reverseTransform + addInputSelectedItem(x) + + @.val("") + completeStart = 0 + wrap.click => + @.focus() + true + + + markSelected = -> + links = div.find('li a') + links.removeClass('selected') + $(links[selectedOption]).addClass('selected') + + renderAutocomplete = -> + div.hide().remove() if div + return if autocompleteOptions.length == 0 + div = $(options.template(options: autocompleteOptions)) + + ul = div.find('ul') + selectedOption = 0 + markSelected() + ul.find('li').click -> + selectedOption = ul.find('li').index(this) + completeTerm(autocompleteOptions[selectedOption]) + + pos = null + if isInput + pos = + left: 0 + top: 0 + else + pos = me.caretPosition(pos: completeStart, key: options.key) + + div.css(left: "-1000px") + me.parent().append(div) + + mePos = me.position() + + borderTop = parseInt(me.css('border-top-width')) || 0 + div.css + position: 'absolute', + top: (mePos.top + pos.top - div.height() + borderTop) + 'px', + left: (mePos.left + pos.left + 27) + 'px' + + + updateAutoComplete = (r)-> + return if completeStart == null + autocompleteOptions = r + if !r || r.length == 0 + closeAutocomplete() + else + renderAutocomplete() + + closeAutocomplete = -> + div.hide().remove() if div + div = null + completeStart = null + autocompleteOptions = null + + # chain to allow multiples + oldClose = me.data("closeAutocomplete") + me.data "closeAutocomplete", -> + oldClose() if oldClose + closeAutocomplete() + + completeTerm = (term) -> + if term + if isInput + me.val("") + addInputSelectedItem(term) + else + term = options.transformComplete(term) if options.transformComplete + text = me.val() + text = text.substring(0, completeStart) + (options.key || "") + term + ' ' + text.substring(completeEnd+1, text.length) + me.val(text) + Discourse.Utilities.setCaretPosition(me[0], completeStart + 1 + term.length) + closeAutocomplete() + + $(@).keypress (e) -> + + + if !options.key + return + + # keep hunting backwards till you hit a + + if e.which == options.key.charCodeAt(0) + caretPosition = Discourse.Utilities.caretPosition(me[0]) + prevChar = me.val().charAt(caretPosition-1) + if !prevChar || /\s/.test(prevChar) + completeStart = completeEnd = caretPosition + term = "" + options.dataSource term, updateAutoComplete + return + + $(@).keydown (e) -> + + completeStart = 0 if !options.key + + return if e.which == 16 + + if completeStart == null && e.which == 8 && options.key #backspace + + c = Discourse.Utilities.caretPosition(me[0]) + next = me[0].value[c] + nextIsGood = next == undefined || /\s/.test(next) + + c-=1 + initial = c + + prevIsGood = true + while prevIsGood && c >= 0 + c -=1 + prev = me[0].value[c] + stopFound = prev == options.key + if stopFound + prev = me[0].value[c-1] + if !prev || /\s/.test(prev) + completeStart = c + caretPosition = completeEnd = initial + term = me[0].value.substring(c+1, initial) + options.dataSource term, updateAutoComplete + return true + + prevIsGood = /[a-zA-Z\.]/.test(prev) + + + if e.which == 27 # esc key + if completeStart != null + closeAutocomplete() + return false + return true + + + if (completeStart != null) + + caretPosition = Discourse.Utilities.caretPosition(me[0]) + # If we've backspaced past the beginning, cancel unless no key + if caretPosition <= completeStart && options.key + closeAutocomplete() + return false + + # Keyboard codes! So 80's. + switch e.which + when 13, 39, 9 # enter, tab or right arrow completes + return true unless autocompleteOptions + if selectedOption >= 0 and userToComplete = autocompleteOptions[selectedOption] + completeTerm(userToComplete) + else + # We're cancelling it, really. + return true + + closeAutocomplete() + return false + when 38 # up arrow + selectedOption = selectedOption - 1 + selectedOption = 0 if selectedOption < 0 + markSelected() + return false + when 40 # down arrow + total = autocompleteOptions.length + selectedOption = selectedOption + 1 + selectedOption = total - 1 if selectedOption >= total + selectedOption = 0 if selectedOption < 0 + markSelected() + return false + else + + # otherwise they're typing - let's search for it! + completeEnd = caretPosition + caretPosition-- if (e.which == 8) + + if caretPosition < 0 + closeAutocomplete() + if isInput + i = wrap.find('a:last') + i.click() if i + + return false + + term = me.val().substring(completeStart+(if options.key then 1 else 0), caretPosition) + if (e.which > 48 && e.which < 90) + term += String.fromCharCode(e.which) + else + term += "," unless e.which == 8 # backspace + options.dataSource term, updateAutoComplete + return true + + +)(jQuery) diff --git a/app/assets/javascripts/discourse/components/bbcode.js.coffee b/app/assets/javascripts/discourse/components/bbcode.js.coffee new file mode 100644 index 00000000000..a9f62b013db --- /dev/null +++ b/app/assets/javascripts/discourse/components/bbcode.js.coffee @@ -0,0 +1,130 @@ +Discourse.BBCode = + + QUOTE_REGEXP: /\[quote=([^\]]*)\]([\s\S]*?)\[\/quote\]/im + + # Define our replacers + replacers: + + base: + withoutArgs: + "ol": (_, content) -> "
    #{content}
" + "li": (_, content) -> "
  • #{content}
  • " + "ul": (_, content) -> "
      #{content}
    " + "code": (_, content) -> "
    #{content}
    " + "url": (_, url) -> "#{url}" + "email": (_, address) -> "#{address}" + "img": (_, src) -> "" + withArgs: + "url": (_, href, title) -> "#{title}" + "email": (_, address, title) -> "#{title}" + "color": (_, color, content) -> + return content unless /^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(color) + "#{content}" + + # For HTML emails + email: + withoutArgs: + "b": (_, content) -> "#{content}" + "i": (_, content) -> "#{content}" + "u": (_, content) -> "#{content}" + "s": (_, content) -> "#{content}" + "spoiler": (_, content) -> "#{content}" + + withArgs: + "size": (_, size, content) -> "#{content}" + + # For sane environments that support CSS + default: + withoutArgs: + "b": (_, content) -> "#{content}" + "i": (_, content) -> "#{content}" + "u": (_, content) -> "#{content}" + "s": (_, content) -> "#{content}" + "spoiler": (_, content) -> "#{content}" + + withArgs: + "size": (_, size, content) -> "#{content}" + + # Apply a particular set of replacers + apply: (text, environment) -> + replacer = Discourse.BBCode.parsedReplacers()[environment] + replacer.forEach (r) -> text = text.replace r.regexp, r.fn + text + + parsedReplacers: -> + return @parsed if @parsed + result = {} + + Object.keys Discourse.BBCode.replacers, (name, rules) -> + parsed = result[name] = [] + + Object.keys Object.merge(Discourse.BBCode.replacers.base.withoutArgs, rules.withoutArgs), (tag, val) -> + parsed.push(regexp: RegExp("\\[#{tag}\\]([\\s\\S]*?)\\[\\/#{tag}\\]", "igm"), fn: val) + + Object.keys Object.merge(Discourse.BBCode.replacers.base.withArgs, rules.withArgs), (tag, val) -> + parsed.push(regexp: RegExp("\\[#{tag}=?(.+?)\\\]([\\s\\S]*?)\\[\\/#{tag}\\]", "igm"), fn: val) + + @parsed = result + @parsed + + buildQuoteBBCode: (post, contents="") -> + sansQuotes = contents.replace(@QUOTE_REGEXP, '').trim() + return "" if sansQuotes.length == 0 + + # Strip the HTML from cooked + tmp = document.createElement('div') + tmp.innerHTML = post.get('cooked') + stripped = tmp.textContent||tmp.innerText + + # Let's remove any non alphanumeric characters as a kind of hash. Yes it's + # not accurate but it should work almost every time we need it to. It would be unlikely + # that the user would quote another post that matches in exactly this way. + stripped_hashed = stripped.replace(/[^a-zA-Z0-9]/g, '') + contents_hashed = contents.replace(/[^a-zA-Z0-9]/g, '') + + result = "[quote=\"#{post.get('username')}, post:#{post.get('post_number')}, topic:#{post.get('topic_id')}" + + # If the quote is the full message, attribute it as such + if stripped_hashed == contents_hashed + result += ", full:true" + + result += "\"]#{sansQuotes}[/quote]\n\n" + + formatQuote: (text, opts) -> + + # Replace quotes with appropriate markup + while matches = @QUOTE_REGEXP.exec(text) + paramsString = matches[1] + paramsString = paramsString.replace(/\"/g, '') + paramsSplit = paramsString.split(/\, */) + + params=[] + paramsSplit.each (p, i) -> + if i > 0 + assignment = p.split(':') + if assignment[0] and assignment[1] + params.push(key: assignment[0], value: assignment[1].trim()) + + username = paramsSplit[0] + + # Arguments for formatting + args = + username: username + params: params + quote: matches[2].trim() + avatarImg: opts.lookupAvatar(username) if opts.lookupAvatar + + templateName = 'quote' + templateName = "quote_#{opts.environment}" if opts?.environment + + text = text.replace(matches[0], "

    " + HANDLEBARS_TEMPLATES[templateName](args) + "

    ") + + text + + format: (text, opts) -> + text = Discourse.BBCode.apply(text, opts?.environment || 'default') + + # Add quotes + text = Discourse.BBCode.formatQuote(text, opts) + + text diff --git a/app/assets/javascripts/discourse/components/caret_position.js.coffee b/app/assets/javascripts/discourse/components/caret_position.js.coffee new file mode 100644 index 00000000000..dca0de17c63 --- /dev/null +++ b/app/assets/javascripts/discourse/components/caret_position.js.coffee @@ -0,0 +1,101 @@ +# caret position in textarea ... very hacky ... sorry +(($) -> + + # http://stackoverflow.com/questions/263743/how-to-get-caret-position-in-textarea + getCaret = (el) -> + if el.selectionStart + return el.selectionStart + else if document.selection + el.focus() + r = document.selection.createRange() + return 0 if r is null + re = el.createTextRange() + rc = re.duplicate() + re.moveToBookmark r.getBookmark() + rc.setEndPoint "EndToStart", re + return rc.text.length + 0 + + clone = null + $.fn.caretPosition = (options) -> + + clone.remove() if clone + span = $("#pos span") + textarea = $(this) + getStyles = (el, prop) -> + if el.currentStyle + el.currentStyle + else + document.defaultView.getComputedStyle el, "" + + styles = getStyles(textarea[0]) + clone = $("

    ").appendTo("body") + p = clone.find("p") + clone.width textarea.width() + clone.height textarea.height() + + important = (prop) -> + styles.getPropertyValue(prop) + + clone.css + border: "1px solid black" + padding: important("padding") + resize: important("resize") + "max-height": textarea.height() + "px" + "overflow-y": "auto" + "word-wrap": "break-word" + position: "absolute" + left: "-7000px" + + p.css + margin: 0 + padding: 0 + "word-wrap": "break-word" + "letter-spacing": important("letter-spacing") + "font-family": important("font-family") + "font-size": important("font-size") + "line-height": important("line-height") + + before = undefined + after = undefined + pos = if options && options.pos then options.pos else getCaret(textarea[0]) + val = textarea.val().replace("\r", "") + if (options && options.key) + val = val.substring(0,pos) + options.key + val.substring(pos) + + before = pos - 1 + after = pos + insertSpaceAfterBefore = false + + # if before and after are \n insert a space + insertSpaceAfterBefore = true if val[before] is "\n" and val[after] is "\n" + guard = (v) -> + buf = v.replace(//g,">") + buf = buf.replace(/[ ]/g, "​ ​") + buf.replace(/\n/g,"
    ") + + + makeCursor = (pos, klass, color) -> + l = val.substring(pos, pos + 1) + return "
    " if l is "\n" + "" + guard(l) + "" + + html = "" + if before >= 0 + html += guard(val.substring(0, pos - 1)) + makeCursor(before, "before", "#d0ffff") + html += makeCursor(0, "post-before", "#d0ffff") if insertSpaceAfterBefore + if after >= 0 + html += makeCursor(after, "after", "#ffd0ff") + html += guard(val.substring(after + 1)) if after - 1 < val.length + p.html html + clone.scrollTop textarea.scrollTop() + letter = p.find("span:first") + pos = letter.offset() + pos.left = pos.left + letter.width() if letter.hasClass("before") + pPos = p.offset() + #clone.hide().remove() + + left: pos.left - pPos.left + top: (pos.top - pPos.top) - clone.scrollTop() +) jQuery diff --git a/app/assets/javascripts/discourse/components/click_track.js.coffee b/app/assets/javascripts/discourse/components/click_track.js.coffee new file mode 100644 index 00000000000..eca79505245 --- /dev/null +++ b/app/assets/javascripts/discourse/components/click_track.js.coffee @@ -0,0 +1,64 @@ +# We use this object to keep track of click counts. +window.Discourse.ClickTrack = + + # Pass the event of the click here and we'll do the magic! + trackClick: (e) -> + + $a = $(e.currentTarget) + + e.preventDefault() + + # We don't track clicks on quote back buttons + return true if $a.hasClass('back') or $a.hasClass('quote-other-topic') + + # Remove the href, put it as a data attribute + unless $a.data('href') + $a.addClass('no-href') + $a.data('href', $a.attr('href')) + $a.attr('href', null) + + # Don't route to this URL + $a.data('auto-route', true) + + href = $a.data('href') + $article = $a.closest('article') + postId = $article.data('post-id') + topicId = $('#topic').data('topic-id') + userId = $a.data('user-id') + userId = $article.data('user-id') unless userId + + ownLink = userId and (userId is Discourse.get('currentUser.id')) + + # Build a Redirect URL + trackingUrl = "/clicks/track?url=" + encodeURIComponent(href) + trackingUrl += "&post_id=" + encodeURI(postId) if postId and (not $a.data('ignore-post-id')) + trackingUrl += "&topic_id=" + encodeURI(topicId) if topicId + + # Update badge clicks unless it's our own + unless ownLink + $badge = $('span.badge', $a) + if $badge.length == 1 + count = parseInt($badge.html()) + $badge.html(count + 1) + + # If they right clicked, change the destination href + if e.which is 3 + destination = if Discourse.SiteSettings.track_external_right_clicks then trackingUrl else href + $a.attr('href', destination) + return true + + # if they want to open in a new tab, do an AJAX request + if (e.metaKey || e.ctrlKey || e.which is 2) + $.get "/clicks/track", url: href, post_id: postId, topic_id: topicId, redirect: false + window.open(href, '_blank') + return false + + # If we're on the same site, use the router and track via AJAX + if href.indexOf(window.location.origin) == 0 + $.get "/clicks/track", url: href, post_id: postId, topic_id: topicId, redirect: false + Discourse.routeTo(href) + return false + + # Otherwise, use a custom URL with a redirect + window.location = trackingUrl + false diff --git a/app/assets/javascripts/discourse/components/debounce.js.coffee b/app/assets/javascripts/discourse/components/debounce.js.coffee new file mode 100644 index 00000000000..2973f53cd54 --- /dev/null +++ b/app/assets/javascripts/discourse/components/debounce.js.coffee @@ -0,0 +1,20 @@ +window.Discourse.debounce = (func, wait, trickle) -> + timeout = null + return -> + context = @ + args = arguments + later = -> + timeout = null + func.apply(context, args) + + if timeout != null && trickle + # already queued, let it through + return + + if typeof wait == "function" + currentWait = wait() + else + currentWait = wait + + clearTimeout(timeout) if timeout + timeout = setTimeout(later, currentWait) diff --git a/app/assets/javascripts/discourse/components/discourse_text_field.js.coffee b/app/assets/javascripts/discourse/components/discourse_text_field.js.coffee new file mode 100644 index 00000000000..63c77ce4b8d --- /dev/null +++ b/app/assets/javascripts/discourse/components/discourse_text_field.js.coffee @@ -0,0 +1,7 @@ +Discourse.TextField = Ember.TextField.extend + + attributeBindings: ['autocorrect', 'autocapitalize'] + + placeholder: (-> + Em.String.i18n(@get('placeholderKey')) + ).property('placeholderKey') diff --git a/app/assets/javascripts/discourse/components/div_resizer.js.coffee b/app/assets/javascripts/discourse/components/div_resizer.js.coffee new file mode 100644 index 00000000000..03baa92918b --- /dev/null +++ b/app/assets/javascripts/discourse/components/div_resizer.js.coffee @@ -0,0 +1,61 @@ +#based off text area resizer by Ryan O'Dell : http://plugins.jquery.com/misc/textarea.js +(($) -> + + div = undefined + originalPos = undefined + originalDivHeight = undefined + lastMousePos = 0 + min = 230 + grip = undefined + wrappedEndDrag = undefined + wrappedPerformDrag = undefined + + startDrag = (e,opts) -> + div = $(e.data.el) + div.addClass('clear-transitions') + div.blur() + lastMousePos = mousePosition(e).y + originalPos = lastMousePos + originalDivHeight = div.height() + wrappedPerformDrag = ( -> + (e) -> performDrag(e,opts) + )() + wrappedEndDrag = ( -> + (e) -> endDrag(e,opts) + )() + $(document).mousemove(wrappedPerformDrag).mouseup wrappedEndDrag + false + performDrag = (e,opts) -> + thisMousePos = mousePosition(e).y + size = originalDivHeight + (originalPos - thisMousePos) + lastMousePos = thisMousePos + size = Math.max(min, size) + div.height size + "px" + endDrag e,opts if size < min + false + endDrag = (e,opts) -> + $(document).unbind("mousemove", wrappedPerformDrag).unbind "mouseup", wrappedEndDrag + div.removeClass('clear-transitions') + div.focus() + opts.resize() if opts.resize + div = null + mousePosition = (e) -> + x: e.clientX + document.documentElement.scrollLeft + y: e.clientY + document.documentElement.scrollTop + + $.fn.DivResizer = (opts) -> + @each -> + div = $(this) + return if (div.hasClass("processed")) + + div.addClass("processed") + staticOffset = null + + start = -> + (e) -> startDrag(e,opts) + + grippie = div.prepend("
    ").find('.grippie').bind("mousedown", + el: this + , start()) +) jQuery + diff --git a/app/assets/javascripts/discourse/components/eyeline.coffee b/app/assets/javascripts/discourse/components/eyeline.coffee new file mode 100644 index 00000000000..12ce60f4b6b --- /dev/null +++ b/app/assets/javascripts/discourse/components/eyeline.coffee @@ -0,0 +1,64 @@ +# +# Track visible elements on the screen +# +# You can register for triggers on: +# focusChanged: -> the top element we're focusing on +# seenElement: -> if we've seen the element +# +class Discourse.Eyeline + + constructor: (@selector) -> + + # Call this whenever we want to consider what is currently being seen by the browser + update: -> + docViewTop = $(window).scrollTop() + windowHeight = $(window).height() + docViewBottom = docViewTop + windowHeight + documentHeight = $(document).height() + + $elements = $(@selector) + + atBottom = false + if bottomOffset = $elements.last().offset() + atBottom = (bottomOffset.top <= docViewBottom) and (bottomOffset.top >= docViewTop) + + # Whether we've seen any elements in this search + foundElement = false + + $results = $(@selector) + $results.each (i, elem) => + $elem = $(elem) + + elemTop = $elem.offset().top + elemBottom = elemTop + $elem.height() + + markSeen = false + + # It's seen if... + # ...the element is vertically within the top and botom + markSeen = true if ((elemTop <= docViewBottom) and (elemTop >= docViewTop)) + # ...the element top is above the top and the bottom is below the bottom (large elements) + markSeen = true if ((elemTop <= docViewTop) and (elemBottom >= docViewBottom)) + # ...we're at the bottom and the bottom of the element is visible (large bottom elements) + markSeen = true if atBottom and (elemBottom >= docViewTop) + + return true unless markSeen + + # If you hit the bottom we mark all the elements as seen. Otherwise, just the first one + unless atBottom + @trigger('saw', detail: $elem) + @trigger('sawTop', detail: $elem) if i == 0 + return false + + @trigger('sawTop', detail: $elem) if i == 0 + @trigger('sawBottom', detail: $elem) if i == ($results.length - 1) + + # Call this when we know aren't loading any more elements. Mark the rest + # as seen + flushRest: -> + $(@selector).each (i, elem) => + $elem = $(elem) + @trigger('saw', detail: $elem) + + +RSVP.EventTarget.mixin(Discourse.Eyeline.prototype) \ No newline at end of file diff --git a/app/assets/javascripts/discourse/components/key_value_store.coffee b/app/assets/javascripts/discourse/components/key_value_store.coffee new file mode 100644 index 00000000000..a2afaab84c5 --- /dev/null +++ b/app/assets/javascripts/discourse/components/key_value_store.coffee @@ -0,0 +1,33 @@ +# key value store +# + +window.Discourse.KeyValueStore = (-> + initialized = false + context = "" + + init: (ctx,messageBus) -> + initialized = true + context = ctx + + abandonLocal: -> + return unless localStorage && initialized + i=localStorage.length-1 + while i >= 0 + k = localStorage.key(i) + localStorage.removeItem(k) if k.substring(0, context.length) == context + i-- + return true + + remove: (key)-> + localStorage.removeItem(context + key) + + set: (opts)-> + return false unless localStorage && initialized + localStorage[context + opts["key"]] = opts["value"] + + + get: (key)-> + return null unless localStorage + localStorage[context + key] +)() + diff --git a/app/assets/javascripts/discourse/components/message_bus.js.coffee b/app/assets/javascripts/discourse/components/message_bus.js.coffee new file mode 100644 index 00000000000..17fff21dadb --- /dev/null +++ b/app/assets/javascripts/discourse/components/message_bus.js.coffee @@ -0,0 +1,114 @@ +window.Discourse.MessageBus = ( -> + + # http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript + uniqueId = -> 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace /[xy]/g, (c)-> + r = Math.random()*16 | 0 + v = if c == 'x' then r else (r&0x3|0x8) + v.toString(16) + + clientId = uniqueId() + + responseCallbacks = {} + callbacks = [] + queue = [] + interval = null + + failCount = 0 + + isHidden = -> + if document.hidden != undefined + document.hidden + else if document.webkitHidden != undefined + document.webkitHidden + else if document.msHidden != undefined + document.msHidden + else if document.mozHidden != undefined + document.mozHidden + else + # fallback to problamatic window.focus + !Discourse.get('hasFocus') + + enableLongPolling: true + callbackInterval: 60000 + maxPollInterval: (3 * 60 * 1000) + callbacks: callbacks + clientId: clientId + + #TODO + stop: + false + + # Start polling + start: (opts={})-> + + poll = => + if callbacks.length == 0 + setTimeout poll, 500 + return + + data = {} + callbacks.each (c)-> + data[c.channel] = if c.last_id == undefined then -1 else c.last_id + + gotData = false + + @longPoll = $.ajax "/message-bus/#{clientId}/poll?#{if isHidden() || !@enableLongPolling then "dlp=t" else ""}", + data: data + cache: false + dataType: 'json' + type: 'POST' + headers: + 'X-SILENCE-LOGGER': 'true' + success: (messages) => + failCount = 0 + messages.each (message) => + gotData = true + callbacks.each (callback) -> + if callback.channel == message.channel + callback.last_id = message.message_id + callback.func(message.data) + if message["channel"] == "/__status" + callback.last_id = message.data[callback.channel] if message.data[callback.channel] != undefined + return + error: + failCount += 1 + complete: => + if gotData + setTimeout poll, 100 + else + interval = @callbackInterval + if failCount > 2 + interval = interval * failCount + else if isHidden() + # slowning down stuff a lot when hidden + # we will need to add a lot of fine tuning here + interval = interval * 4 + + if interval > @maxPollInterval + interval = @maxPollInterval + + setTimeout poll, interval + @longPoll = null + return + + poll() + return + + # Subscribe to a channel + subscribe: (channel,func,lastId)-> + callbacks.push {channel:channel, func:func, last_id: lastId} + @longPoll.abort() if @longPoll + + # Unsubscribe from a channel + unsubscribe: (channel) -> + # TODO proper globbing + if channel.endsWith("*") + channel = channel.substr(0, channel.length-1) + glob = true + callbacks = callbacks.filter (callback) -> + if glob + callback.channel.substr(0, channel.length) != channel + else + callback.channel != channel + @longPoll.abort() if @longPoll +)() diff --git a/app/assets/javascripts/discourse/components/pagedown_editor.js.coffee b/app/assets/javascripts/discourse/components/pagedown_editor.js.coffee new file mode 100644 index 00000000000..937b03b1fd5 --- /dev/null +++ b/app/assets/javascripts/discourse/components/pagedown_editor.js.coffee @@ -0,0 +1,24 @@ +window.Discourse.PagedownEditor = Ember.ContainerView.extend + elementId: 'pagedown-editor' + + init: -> + + @_super() + + # Add a button bar + @pushObject Em.View.create(elementId: 'wmd-button-bar') + @pushObject Em.TextArea.create(valueBinding: 'parentView.value', elementId: 'wmd-input') + @pushObject Em.View.createWithMixins Discourse.Presence, + elementId: 'wmd-preview', + classNameBindings: [':preview', 'hidden'] + + hidden: (-> + @blank('parentView.value') + ).property('parentView.value') + + + didInsertElement: -> + $wmdInput = $('#wmd-input') + $wmdInput.data('init', true) + @editor = new Markdown.Editor(Discourse.Utilities.markdownConverter()) + @editor.run() diff --git a/app/assets/javascripts/discourse/components/probes.js b/app/assets/javascripts/discourse/components/probes.js new file mode 100644 index 00000000000..b26dd138d43 --- /dev/null +++ b/app/assets/javascripts/discourse/components/probes.js @@ -0,0 +1,122 @@ +/* + * JavaScript probing framework by Sam Saffron + * MIT license + * + * + * Examples: + * + +someFunction = window.probes.measure(someFunction, { + name: "somename" // or function(args) { return "name"; }, + before: function(data, owner, args) { + // if owner is true, we are not in a recursive function call. + // + // data contains the bucker of data already measuer + // data.count >= 0 + // data.time is the total time measured till now + // + // arguments contains the original arguments sent to the function + }, + after: function(data, owner, args) { + // same format as before + } +}); + + +// minimal +someFunction = window.probes.measure(someFunction, "someFunction"); + + * + * + * */ +(function(){ + var measure, clear; + + clear = function() { + window.probes = { + clear: clear, + measure: measure + }; + }; + + measure = function(fn,options) { + // start is outside so we measure time around recursive calls properly + var start = null, nameParam, before, after; + if (!options) { + options = {}; + } + + if (typeof options === "string") { + nameParam = options; + } + else + { + nameParam = options["name"]; + + if (nameParam === "measure" || nameParam == "clear") { + throw Error("can not be called measure or clear"); + } + + if (!nameParam) + { + throw Error("you must specify the name option measure(fn, {name: 'some name'})"); + } + + before = options["before"]; + after = options["after"]; + } + + var now = (function(){ + var perf = window.performance || {}; + var time = perf.now || perf.mozNow || perf.webkitNow || perf.msNow || perf.oNow; + return time ? time.bind(perf) : function() { return new Date().getTime(); }; + })(); + + return function() { + var name = nameParam; + if (typeof name == "function"){ + name = nameParam(arguments); + } + var p = window.probes[name]; + var owner = start === null; + + if (before) { + // would like to avoid try catch so its optimised properly by chrome + before(p, owner, arguments); + } + + if (p === undefined) { + window.probes[name] = {count: 0, time: 0, currentTime: 0}; + p = window.probes[name]; + } + + var callStart; + if (owner) { + start = now(); + callStart = start; + } + else if(after) + { + callStart = now(); + } + + var r = fn.apply(this, arguments); + if (owner && start) { + p.time += now() - start; + start = null; + } + p.count += 1; + + if (after) { + p.currentTime = now() - callStart; + // would like to avoid try catch so its optimised properly by chrome + after(p, owner, arguments); + } + + return r; + } + } + + clear(); + +})(); diff --git a/app/assets/javascripts/discourse/components/screen_track.js.coffee b/app/assets/javascripts/discourse/components/screen_track.js.coffee new file mode 100644 index 00000000000..0b39486e0f8 --- /dev/null +++ b/app/assets/javascripts/discourse/components/screen_track.js.coffee @@ -0,0 +1,128 @@ +# We use this class to track how long posts in a topic are on the screen. +# This could be a potentially awesome metric to keep track of. +window.Discourse.ScreenTrack = Ember.Object.extend + + # Don't send events if we haven't scrolled in a long time + PAUSE_UNLESS_SCROLLED: 1000*60*3 + + # After 6 minutes stop tracking read position on post + MAX_TRACKING_TIME: 1000*60*6 + + totalTimings: {} + + # Elements to track + timings: {} + topicTime: 0 + + cancelled: false + + track: (elementId, postNumber) -> + @timings["##{elementId}"] = + time: 0 + postNumber: postNumber + + guessedSeen: (postNumber) -> + @highestSeen = postNumber if postNumber > (@highestSeen || 0) + + # Reset our timers + reset: -> + @lastTick = new Date().getTime() + @lastFlush = 0 + @cancelled = false + + # Start tracking + start: -> + @reset() + @lastScrolled = new Date().getTime() + @interval = setInterval => + @tick() + , 1000 + + # Cancel and eject any tracking we have buffered + cancel: -> + @cancelled = true + @timings = {} + @topicTime = 0 + clearInterval(@interval) + @interval = null + + # Stop tracking and flush buffered read records + stop: -> + clearInterval(@interval) + @interval = null + @flush() + + scrolled: -> + @lastScrolled = new Date().getTime() + + flush: -> + + return if @cancelled + + # We don't log anything unless we're logged in + return unless Discourse.get('currentUser') + + newTimings = {} + Object.values @timings, (timing) => + @totalTimings[timing.postNumber] ||= 0 + if timing.time > 0 and @totalTimings[timing.postNumber] < @MAX_TRACKING_TIME + @totalTimings[timing.postNumber] += timing.time + newTimings[timing.postNumber] = timing.time + timing.time = 0 + + topicId = @get('topic_id') + + highestSeenByTopic = Discourse.get('highestSeenByTopic') + if (highestSeenByTopic[topicId] || 0) < @highestSeen + highestSeenByTopic[topicId] = @highestSeen + + + unless Object.isEmpty(newTimings) + $.ajax '/topics/timings' + data: + timings: newTimings + topic_time: @topicTime + highest_seen: @highestSeen + topic_id: topicId + cache: false + type: 'POST' + headers: + 'X-SILENCE-LOGGER': 'true' + @topicTime = 0 + + @lastFlush = 0 + + tick: -> + + # If the user hasn't scrolled the browser in a long time, stop tracking time read + sinceScrolled = new Date().getTime() - @lastScrolled + if sinceScrolled > @PAUSE_UNLESS_SCROLLED + @reset() + return + + diff = new Date().getTime() - @lastTick + @lastFlush += diff + @lastTick = new Date().getTime() + + @flush() if @lastFlush > (Discourse.SiteSettings.flush_timings_secs * 1000) + + # Don't track timings if we're not in focus + return unless Discourse.get("hasFocus") + + @topicTime += diff + + docViewTop = $(window).scrollTop() + $('header').height() + docViewBottom = docViewTop + $(window).height() + + Object.keys @timings, (id) => + $element = $(id) + + if ($element.length == 1) + elemTop = $element.offset().top + elemBottom = elemTop + $element.height() + + # If part of the element is on the screen, increase the counter + if (docViewTop <= elemTop <= docViewBottom) or (docViewTop <= elemBottom <= docViewBottom) + timing = @timings[id] + timing.time = timing.time + diff + diff --git a/app/assets/javascripts/discourse/components/syntax_highlighting.js.coffee b/app/assets/javascripts/discourse/components/syntax_highlighting.js.coffee new file mode 100644 index 00000000000..03db4644879 --- /dev/null +++ b/app/assets/javascripts/discourse/components/syntax_highlighting.js.coffee @@ -0,0 +1,8 @@ +# Helper object for syntax highlighting. Uses highlight.js which is loaded +# on demand. +window.Discourse.SyntaxHighlighting = + + apply: ($elem) -> + $('pre code[class]', $elem).each (i, e) => + $LAB.script("/javascripts/highlight-handlebars.pack.js").wait -> + hljs.highlightBlock(e) diff --git a/app/assets/javascripts/discourse/components/transition_helper.js.coffee b/app/assets/javascripts/discourse/components/transition_helper.js.coffee new file mode 100644 index 00000000000..ee4a029e93c --- /dev/null +++ b/app/assets/javascripts/discourse/components/transition_helper.js.coffee @@ -0,0 +1,25 @@ +# CSS transitions are a PITA, often we need to queue some js after a transition, this helper ensures +# it happens after the transition +# + +# SO: http://stackoverflow.com/questions/9943435/css3-animation-end-techniques +dummy = document.createElement("div") +eventNameHash = + webkit: "webkitTransitionEnd" + Moz: "transitionend" + O: "oTransitionEnd" + ms: "MSTransitionEnd" + +transitionEnd = (_getTransitionEndEventName = -> + retValue = "transitionend" + Object.keys(eventNameHash).some (vendor) -> + if vendor + "TransitionProperty" of dummy.style + retValue = eventNameHash[vendor] + true + + retValue +)() + +window.Discourse.TransitionHelper = + after: (element, callback) -> + $(element).on(transitionEnd, callback) diff --git a/app/assets/javascripts/discourse/components/user_search.js.coffee b/app/assets/javascripts/discourse/components/user_search.js.coffee new file mode 100644 index 00000000000..bd77191c3e0 --- /dev/null +++ b/app/assets/javascripts/discourse/components/user_search.js.coffee @@ -0,0 +1,51 @@ +cache = {} +cacheTopicId = null +cacheTime = null + +doSearch = (term,topicId,success)-> + $.ajax + url: '/users/search/users' + dataType: 'JSON' + data: {term: term, topic_id: topicId} + success: (r)-> + cache[term] = r + cacheTime = new Date() + success(r) + +debouncedSearch = Discourse.debounce(doSearch, 200) + +window.Discourse.UserSearch = + search: (options) -> + + term = options.term || "" + callback = options.callback + exclude = options.exclude || [] + topicId = options.topicId + limit = options.limit || 5 + + throw "missing callback" unless callback + + #TODO site setting for allowed regex in username ? + if term.match(/[^a-zA-Z0-9\_\.]/) + callback([]) + return true + + cache = {} if (new Date() - cacheTime) > 30000 + cache = {} if cacheTopicId != topicId + cacheTopicId = topicId + + success = (r)-> + result = [] + r.users.each (u)-> + result.push(u) if exclude.indexOf(u.username) == -1 + return false if result.length > limit + true + callback(result) + + if cache[term] + success(cache[term]) + else + debouncedSearch(term, topicId, success) + true + + diff --git a/app/assets/javascripts/discourse/components/utilities.coffee b/app/assets/javascripts/discourse/components/utilities.coffee new file mode 100644 index 00000000000..6fdaee54cf5 --- /dev/null +++ b/app/assets/javascripts/discourse/components/utilities.coffee @@ -0,0 +1,165 @@ +baseUrl = null +site = null + +Discourse.Utilities = + + translateSize: (size)-> + switch size + when 'tiny' then size=20 + when 'small' then size=25 + when 'medium' then size=32 + when 'large' then size=45 + return size + + # Create a badge like category link + categoryLink: (category) -> + return "" unless category + + slug = Em.get(category, 'slug') + color = Em.get(category, 'color') + name = Em.get(category, 'name') + + "#{name}" + + avatarUrl: (username, size, template)-> + return "" unless username + size = Discourse.Utilities.translateSize(size) + rawSize = (size * (window.devicePixelRatio || 1)).toFixed() + + return template.replace(/\{size\}/g, rawSize) if template + + "/users/#{username.toLowerCase()}/avatar/#{rawSize}?__ws=#{encodeURIComponent(Discourse.BaseUrl || "")}" + + avatarImg: (options)-> + size = Discourse.Utilities.translateSize(options.size) + title = options.title || "" + extraClasses = options.extraClasses || "" + url = Discourse.Utilities.avatarUrl(options.username, options.size, options.avatarTemplate) + "" + + postUrl: (slug, topicId, postNumber)-> + url = "/t/" + url += slug + "/" if slug + url += topicId + url += "/#{postNumber}" if postNumber > 1 + url + + emailValid: (email)-> + # see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript + re = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ + re.test(email) + + selectedText: -> + t = '' + if window.getSelection + t = window.getSelection().toString() + else if document.getSelection + t = document.getSelection().toString() + else if document.selection + t = document.selection.createRange().text + String(t).trim() + + # Determine the position of the caret in an element + caretPosition: (el) -> + + return el.selectionStart if el.selectionStart + + if document.selection + el.focus() + r = document.selection.createRange() + return 0 if r == null + + re = el.createTextRange() + rc = re.duplicate() + re.moveToBookmark(r.getBookmark()) + rc.setEndPoint('EndToStart', re) + return rc.text.length + return 0 + + # Set the caret's position + setCaretPosition: (ctrl, pos) -> + if(ctrl.setSelectionRange) + ctrl.focus() + ctrl.setSelectionRange(pos,pos) + return + + if (ctrl.createTextRange) + range = ctrl.createTextRange() + range.collapse(true) + range.moveEnd('character', pos) + range.moveStart('character', pos) + range.select() + + markdownConverter: (opts)-> + converter = new Markdown.Converter() + + mentionLookup = opts.mentionLookup if opts + mentionLookup = mentionLookup || Discourse.Mention.lookupCache + + # Before cooking callbacks + converter.hooks.chain "preConversion", (text) => + @trigger 'beforeCook', detail: text, opts: opts + @textResult || text + + # Support autolinking of www.something.com + converter.hooks.chain "preConversion", (text) -> + text.replace /(^|[\s\n])(www\.[a-z\.\-\_\(\)\/\?\=\%0-9]+)/gim, (full, _, rest) -> + " #{rest}" + + # newline prediction in trivial cases + unless Discourse.SiteSettings.traditional_markdown_linebreaks + converter.hooks.chain "preConversion", (text) -> + result = text.replace /(^[\w\<][^\n]*\n+)/gim, (t) -> + return t if t.match /\n{2}/gim + t = t.replace "\n"," \n" + + # github style fenced code + converter.hooks.chain "preConversion", (text) -> + result = text.replace /^`{3}(?:(.*$)\n)?([\s\S]*?)^`{3}/gm, (wholeMatch,m1,m2) -> + escaped = Handlebars.Utils.escapeExpression(m2) + "
    #{escaped}
    " + + converter.hooks.chain "postConversion", (text) -> + return "" unless text + # don't to mention voodoo in pres + text = text.replace /
    ([\s\S]*@[\s\S]*)<\/pre>/gi, (wholeMatch, inner) ->
    +        "
    #{inner.replace(/@/g, '@')}
    " + + # Add @mentions of names + text = text.replace(/([\s\t>,:'|";\]])(@[A-Za-z0-9_-|\.]*[A-Za-z0-9_-|]+)(?=[\s\t<\!:|;',"\?\.])/g, (x,pre,name) -> + if mentionLookup(name.substr(1)) + "#{pre}#{name}" + else + "#{pre}#{name}") + + # a primitive attempt at oneboxing, this regex gives me much eye sores + text = text.replace /(
  • )?((

    |
    )[\s\n\r]*)(]*)>([^<]+<\/a>[\s\n\r]*(?=<\/p>|
    ))/gi, -> + + # We don't onebox items in a list + return arguments[0] if arguments[1] + + url = arguments[5] + onebox = Discourse.Onebox.lookupCache(url) if Discourse && Discourse.Onebox + if onebox and !onebox.isBlank() + return arguments[2] + onebox + else + return arguments[2] + arguments[4] + " class=\"onebox\" target=\"_blank\">" + arguments[6] + + converter.hooks.chain "postConversion", (text) => + Discourse.BBCode.format(text, opts) + + converter + + + # Takes raw input and cooks it to display nicely (mostly markdown) + cook: (raw, opts) -> + + # Make sure we've got a string + return "" unless raw + return "" unless raw.length > 0 + + @converter = @markdownConverter(opts) + @converter.makeHtml(raw) + + +RSVP.EventTarget.mixin(Discourse.Utilities) diff --git a/app/assets/javascripts/discourse/controllers/application_controller.js.coffee b/app/assets/javascripts/discourse/controllers/application_controller.js.coffee new file mode 100644 index 00000000000..04b2e38a7ae --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/application_controller.js.coffee @@ -0,0 +1,6 @@ +window.Discourse.ApplicationController = Ember.Controller.extend + + needs: ['modal'] + + showLogin: -> + @get('controllers.modal')?.show(Discourse.LoginView.create()) \ No newline at end of file diff --git a/app/assets/javascripts/discourse/controllers/composer_controller.js.coffee b/app/assets/javascripts/discourse/controllers/composer_controller.js.coffee new file mode 100644 index 00000000000..60281b07d8e --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/composer_controller.js.coffee @@ -0,0 +1,173 @@ +window.Discourse.ComposerController = Ember.Controller.extend Discourse.Presence, + + needs: ['modal', 'topic'] + + togglePreview: -> + @get('content').togglePreview() + + # Import a quote from the post + importQuote: -> + @get('content').importQuote() + + appendText: (text) -> + c = @get('content') + c.appendText(text) if c + + save: -> + composer = @get('content') + composer.set('disableDrafts', true) + composer.save(imageSizes: @get('view').imageSizes()) + .then (opts) => + opts = opts || {} + @close() + Discourse.routeTo(opts.post.get('url')) + , (error) => + composer.set('disableDrafts', false) + bootbox.alert error + + saveDraft: -> + model = @get('content') + model.saveDraft() if model + + # Open the reply view + # + # opts: + # action - The action we're performing: edit, reply or createTopic + # post - The post we're replying to, if present + # topic - The topic we're replying to, if present + # quote - If we're opening a reply from a quote, the quote we're making + # + open: (opts={}) -> + opts.promise = promise = opts.promise || new RSVP.Promise + + unless opts.draftKey + alert("composer was opened without a draft key") + throw "composer opened without a proper draft key" + + # ensure we have a view now, without it transitions are going to be messed + view = @get('view') + unless view + view = Discourse.ComposerView.create + controller: @ + view.appendTo($('#main')) + @set('view', view) + # the next runloop is too soon, need to get the control rendered and then + # we need to change stuff, otherwise css animations don't kick in + Em.run.next => + Em.run.next => + @open(opts) + return promise + + composer = @get('content') + + if composer && opts.draftKey != composer.draftKey && composer.composeState == Discourse.Composer.DRAFT + @close() + composer = null + + if composer && !opts.tested && composer.wouldLoseChanges() + if composer.composeState == Discourse.Composer.DRAFT && composer.draftKey == opts.draftKey && composer.action == opts.action + composer.set('composeState', Discourse.Composer.OPEN) + promise.resolve() + return promise + else + opts.tested = true + @cancel(( => @open(opts) ),( => promise.reject())) unless opts.ignoreIfChanged + return promise + + + # we need a draft sequence, without it drafts are bust + if opts.draftSequence == undefined + Discourse.Draft.get(opts.draftKey).then (data)=> + opts.draftSequence = data.draft_sequence + opts.draft = data.draft + @open(opts) + return promise + + + if opts.draft + composer = Discourse.Composer.loadDraft(opts.draftKey, opts.draftSequence, opts.draft) + composer?.set('topic', opts.topic) + + composer = composer || Discourse.Composer.open(opts) + + @set('content', composer) + @set('view.content', composer) + promise.resolve() + return promise + + wouldLoseChanges: -> + composer = @get('content') + composer && composer.wouldLoseChanges() + + # View a new reply we've made + viewNewReply: -> + Discourse.routeTo(@get('createdPost.url')) + @close() + false + + destroyDraft: -> + key = @get('content.draftKey') + Discourse.Draft.clear(key, @get('content.draftSequence')) if key + + cancel: (success, fail) -> + if @get('content.hasMetaData') || ((@get('content.reply') || "") != (@get('content.originalText') || "")) + bootbox.confirm Em.String.i18n("post.abandon"), Em.String.i18n("no_value"), Em.String.i18n("yes_value"), (result) => + if result + @destroyDraft() + @close() + success() if typeof success == "function" + else + fail() if typeof fail == "function" + else + # it is possible there is some sort of crazy draft with no body ... just give up on it + @destroyDraft() + @close() + success() if typeof success == "function" + + return + + click: -> + if @get('content.composeState') == Discourse.Composer.DRAFT + @set('content.composeState', Discourse.Composer.OPEN) + false + + shrink: -> + if @get('content.reply') == @get('content.originalText') then @close() else @collapse() + + collapse: -> + @saveDraft() + @set('content.composeState', Discourse.Composer.DRAFT) + + close: -> + @set('content', null) + @set('view.content', null) + + closeIfCollapsed: -> + if @get('content.composeState') == Discourse.Composer.DRAFT + @close() + + closeAutocomplete: -> + $('#wmd-input').autocomplete(cancel: true) + + # Toggle the reply view + toggle: -> + @closeAutocomplete() + + switch @get('content.composeState') + when Discourse.Composer.OPEN + if @blank('content.reply') and @blank('content.title') then @close() else @shrink() + when Discourse.Composer.DRAFT + @set('content.composeState', Discourse.Composer.OPEN) + when Discourse.Composer.SAVING + @close() + + false + + # ESC key hit + hitEsc: -> + @shrink() if @get('content.composeState') == @OPEN + + + showOptions: -> + @get('controllers.modal')?.show(Discourse.ArchetypeOptionsModalView.create(archetype: @get('content.archetype'), metaData: @get('content.metaData'))) + diff --git a/app/assets/javascripts/discourse/controllers/controller.js.coffee b/app/assets/javascripts/discourse/controllers/controller.js.coffee new file mode 100644 index 00000000000..e7815426c58 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/controller.js.coffee @@ -0,0 +1 @@ +Discourse.Controller = Ember.Controller.extend(Discourse.Presence) \ No newline at end of file diff --git a/app/assets/javascripts/discourse/controllers/header_controller.js.coffee b/app/assets/javascripts/discourse/controllers/header_controller.js.coffee new file mode 100644 index 00000000000..9bde32aaf65 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/header_controller.js.coffee @@ -0,0 +1,7 @@ +Discourse.HeaderController = Ember.Controller.extend Discourse.Presence, + topic: null + showExtraInfo: false + + toggleStar: -> + @get('topic')?.toggleStar() + false \ No newline at end of file diff --git a/app/assets/javascripts/discourse/controllers/list_categories_controller.js.coffee b/app/assets/javascripts/discourse/controllers/list_categories_controller.js.coffee new file mode 100644 index 00000000000..ece7d5bf373 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/list_categories_controller.js.coffee @@ -0,0 +1,21 @@ +Discourse.ListCategoriesController = Ember.ObjectController.extend Discourse.Presence, + needs: ['modal'] + + categoriesEven: (-> + return Em.A() if @blank('categories') + @get('categories').filter (item, index) -> (index % 2) == 0 + ).property('categories.@each') + + categoriesOdd: (-> + return Em.A() if @blank('categories') + @get('categories').filter (item, index) -> (index % 2) == 1 + ).property('categories.@each') + + editCategory: (category) -> + @get('controllers.modal').show(Discourse.EditCategoryView.create(category: category)) + false + + canEdit: (-> + u = Discourse.get('currentUser') + u && u.admin + ).property() diff --git a/app/assets/javascripts/discourse/controllers/list_controller.js.coffee b/app/assets/javascripts/discourse/controllers/list_controller.js.coffee new file mode 100644 index 00000000000..4be14f1d9c2 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/list_controller.js.coffee @@ -0,0 +1,73 @@ +Discourse.ListController = Ember.Controller.extend Discourse.Presence, + currentUserBinding: 'Discourse.currentUser' + categoriesBinding: 'Discourse.site.categories' + categoryBinding: 'topicList.category' + + canCreateCategory: false + canCreateTopic: false + + needs: ['composer', 'modal', 'listTopics'] + + availableNavItems: (-> + summary = @get('filterSummary') + loggedOn = !!Discourse.get('currentUser') + hasCategories = !!@get('categories') + + Discourse.SiteSettings.top_menu.split("|").map((i)-> + Discourse.NavItem.fromText i, + loggedOn: loggedOn + hasCategories: hasCategories + countSummary: summary + ).filter((i)-> i != null) + + ).property('filterSummary') + + load: (filterMode) -> + @set('loading', true) + if filterMode == 'categories' + return Ember.Deferred.promise (deferred) => + Discourse.CategoryList.list(filterMode).then (items) => + @set('loading', false) + @set('filterMode', filterMode) + @set('categoryMode', true) + deferred.resolve(items) + else + current = (@get('availableNavItems').filter (f)=> f.name == filterMode)[0] + current = Discourse.NavItem.create(name: filterMode) unless current + + return Ember.Deferred.promise (deferred) => + Discourse.TopicList.list(current).then (items) => + @set('filterSummary', items.filter_summary) + @set('filterMode', filterMode) + @set('loading', false) + deferred.resolve(items) + + + # Put in the appropriate page title based on our view + updateTitle: (-> + if @get('filterMode') == 'categories' + Discourse.set('title', Em.String.i18n('categories_list')) + else + if @present('category') + Discourse.set('title', "#{@get('category.name').capitalize()} #{Em.String.i18n('topic.list')}") + else + Discourse.set('title', Em.String.i18n('topic.list')) + + ).observes('filterMode', 'category') + + # Create topic button + createTopic: -> + topicList = @get('controllers.listTopics.content') + return unless topicList + + @get('controllers.composer').open + categoryName: @get('category.name') + action: Discourse.Composer.CREATE_TOPIC + draftKey: topicList.get('draft_key') + draftSequence: topicList.get('draft_sequence') + + createCategory: -> + @get('controllers.modal')?.show(Discourse.EditCategoryView.create()) + + +Discourse.ListController.reopenClass(filters: ['popular','favorited','read','unread','new','posted']) diff --git a/app/assets/javascripts/discourse/controllers/list_topics_controller.js.coffee b/app/assets/javascripts/discourse/controllers/list_topics_controller.js.coffee new file mode 100644 index 00000000000..66466ecf98d --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/list_topics_controller.js.coffee @@ -0,0 +1,53 @@ +Discourse.ListTopicsController = Ember.ObjectController.extend + needs: ['list','composer'] + + # If we're changing our channel + previousChannel: null + + filterModeChanged: (-> + # Unsubscribe from a previous channel if necessary + if previousChannel = @get('previousChannel') + Discourse.MessageBus.unsubscribe "/#{previousChannel}" + @set('previousChannel', null) + + filterMode = @get('controllers.list.filterMode') + return unless filterMode + + channel = filterMode + Discourse.MessageBus.subscribe "/#{channel}", (data) => + @get('content').insert(data) + @set('previousChannel', channel) + + ).observes('controllers.list.filterMode') + + draftLoaded: (-> + draft = @get('content.draft') + if(draft) + @get('controllers.composer').open + draft: draft + draftKey: @get('content.draft_key'), + draftSequence: @get('content.draft_sequence') + ignoreIfChanged: true + + ).observes('content.draft') + + # Star a topic + toggleStar: (topic) -> + topic.toggleStar() + false + + observer: (-> + @set('filterMode', @get('controllser.list.filterMode')) + ).observes('controller.list.filterMode') + + + # Show newly inserted topics + showInserted: (e) -> + + # Move inserted into topics + @get('content.topics').unshiftObjects @get('content.inserted') + + # Clear inserted + @set('content.inserted', Em.A()) + + false diff --git a/app/assets/javascripts/discourse/controllers/modal_controller.js.coffee b/app/assets/javascripts/discourse/controllers/modal_controller.js.coffee new file mode 100644 index 00000000000..cbec1440e7c --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/modal_controller.js.coffee @@ -0,0 +1,3 @@ +Discourse.ModalController = Ember.Controller.extend Discourse.Presence, + + show: (view) -> @set('currentView', view) diff --git a/app/assets/javascripts/discourse/controllers/preferences_controller.js.coffee b/app/assets/javascripts/discourse/controllers/preferences_controller.js.coffee new file mode 100644 index 00000000000..37e184af87f --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/preferences_controller.js.coffee @@ -0,0 +1,54 @@ +Discourse.PreferencesController = Ember.ObjectController.extend Discourse.Presence, + + # By default we haven't saved anything + saved: false + + saveDisabled: (-> + return true if @get('saving') + return true if @blank('content.name') + return true if @blank('content.email') + false + ).property('saving', 'content.name', 'content.email') + + digestFrequencies: (-> + freqs = Em.A() + freqs.addObject(name: Em.String.i18n('user.email_digests.daily'), value: 1) + freqs.addObject(name: Em.String.i18n('user.email_digests.weekly'), value: 7) + freqs.addObject(name: Em.String.i18n('user.email_digests.bi_weekly'), value: 14) + freqs + ).property() + + autoTrackDurations: (-> + freqs = Em.A() + freqs.addObject(name: Em.String.i18n('user.auto_track_options.never'), value: -1) + freqs.addObject(name: Em.String.i18n('user.auto_track_options.always'), value: 0) + freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_seconds', count: 30), value: 30000) + freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_minutes', count: 1), value: 60000) + freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_minutes', count: 2), value: 120000) + freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_minutes', count: 5), value: 300000) + freqs + ).property() + + save: -> + @set('saving', true) + + # Cook the bio for preview + @get('content').save (result) => + @set('saving', false) + if result + @set('content.bio_cooked', Discourse.Utilities.cook(@get('content.bio_raw'))) + @set('saved', true) + else + alert 'failed' + + saveButtonText: (-> + return Em.String.i18n('saving') if @get('saving') + return Em.String.i18n('save') + ).property('saving') + + changePassword: -> + unless @get('passwordProgress') + @set('passwordProgress','(generating email)') + @get('content').changePassword (message)=> + @set('changePasswordProgress', false) + @set('passwordProgress', "(#{message})") diff --git a/app/assets/javascripts/discourse/controllers/preferences_email_controller.js.coffee b/app/assets/javascripts/discourse/controllers/preferences_email_controller.js.coffee new file mode 100644 index 00000000000..a73013ebb0f --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/preferences_email_controller.js.coffee @@ -0,0 +1,35 @@ +Discourse.PreferencesEmailController = Ember.ObjectController.extend Discourse.Presence, + + taken: false + saving: false + error: false + success: false + + saveDisabled: (-> + return true if @get('saving') + return true if @blank('newEmail') + return true if @get('taken') + return true if @get('unchanged') + ).property('newEmail', 'taken', 'unchanged', 'saving') + + unchanged: (-> + @get('newEmail') == @get('content.email') + ).property('newEmail', 'content.email') + + initializeEmail: (-> + @set('newEmail', @get('content.email')) + ).observes('content.email') + + saveButtonText: (-> + return Em.String.i18n("saving") if @get('saving') + Em.String.i18n("user.change_email.action") + ).property('saving') + + changeEmail: -> + @set('saving', true) + @get('content').changeEmail(@get('newEmail')).then => + @set('success', true) + , => + # Error + @set('error', true) + @set('saving', false) diff --git a/app/assets/javascripts/discourse/controllers/preferences_username_controller.js.coffee b/app/assets/javascripts/discourse/controllers/preferences_username_controller.js.coffee new file mode 100644 index 00000000000..d0af0084b1c --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/preferences_username_controller.js.coffee @@ -0,0 +1,40 @@ +Discourse.PreferencesUsernameController = Ember.ObjectController.extend Discourse.Presence, + + taken: false + saving: false + error: false + + saveDisabled: (-> + return true if @get('saving') + return true if @blank('newUsername') + return true if @get('taken') + return true if @get('unchanged') + ).property('newUsername', 'taken', 'unchanged', 'saving') + + unchanged: (-> + @get('newUsername') == @get('content.username') + ).property('newUsername', 'content.username') + + checkTaken: (-> + @set('taken', false) + return if @blank('newUsername') + return if @get('unchanged') + Discourse.User.checkUsername(@get('newUsername')).then (result) => + @set('taken', true) unless result.available + ).observes('newUsername') + + saveButtonText: (-> + return Em.String.i18n("saving") if @get('saving') + Em.String.i18n("user.change_username.action") + ).property('saving') + + changeUsername: -> + bootbox.confirm Em.String.i18n("user.change_username.confirm"), Em.String.i18n("no_value"), Em.String.i18n("yes_value"), (result) => + if result + @set('saving', true) + @get('content').changeUsername(@get('newUsername')).then => + window.location = "/users/#{@get('newUsername').toLowerCase()}/preferences" + , => + # Error + @set('error', true) + @set('saving', false) diff --git a/app/assets/javascripts/discourse/controllers/quote_button_controller.js.coffee b/app/assets/javascripts/discourse/controllers/quote_button_controller.js.coffee new file mode 100644 index 00000000000..72a8abb9310 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/quote_button_controller.js.coffee @@ -0,0 +1,70 @@ +Discourse.QuoteButtonController = Discourse.Controller.extend + + needs: ['topic', 'composer'] + + started: null + + # If the buffer is cleared, clear out other state (post) + bufferChanged: (-> + @set('post', null) if @blank('buffer') + ).observes('buffer') + + + mouseDown: (e) -> + @started = [e.pageX, e.pageY] + + mouseUp: (e) -> + if @started[1] > e.pageY + @started = [e.pageX, e.pageY] + + selectText: (e) -> + return unless Discourse.get('currentUser') + return unless @get('controllers.topic.content.can_create_post') + + selectedText = Discourse.Utilities.selectedText() + return if @get('buffer') == selectedText + return if @get('lastSelected') == selectedText + + @set('post', e.context) + @set('buffer', selectedText) + + top = e.pageY + 5 + left = e.pageX + 5 + $quoteButton = $('.quote-button') + if @started + top = @started[1] - 50 + left = ((left - @started[0]) / 2) + @started[0] - ($quoteButton.width() / 2) + + $quoteButton.css(top: top, left: left) + @started = null + + false + + quoteText: (e) -> + + e.stopPropagation() + post = @get('post') + + composerController = @get('controllers.composer') + + composerOpts = + post: post + action: Discourse.Composer.REPLY + draftKey: @get('post.topic.draft_key') + + # If the composer is associated with a different post, we don't change it. + if composerPost = composerController.get('content.post') + composerOpts.post = composerPost if (composerPost.get('id') != @get('post.id')) + + buffer = @get('buffer') + quotedText = Discourse.BBCode.buildQuoteBBCode(post, buffer) + + if composerController.wouldLoseChanges() + composerController.appendText(quotedText) + else + composerController.open(composerOpts).then => + composerController.appendText(quotedText) + + @set('buffer', '') + + false diff --git a/app/assets/javascripts/discourse/controllers/share_controller.js.coffee b/app/assets/javascripts/discourse/controllers/share_controller.js.coffee new file mode 100644 index 00000000000..8ed5a3dad93 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/share_controller.js.coffee @@ -0,0 +1,14 @@ +Discourse.ShareController = Ember.Controller.extend + + # When the user clicks the post number, we pop up a share box + shareLink: (e, url) -> + x = e.pageX - 150 + x = 25 if x < 25 + $('#share-link').css(left: "#{x}px", top: "#{e.pageY - 100}px") + @set('link', url) + false + + # Close the share controller + close: -> + @set('link', '') + false diff --git a/app/assets/javascripts/discourse/controllers/static_controller.js.coffee b/app/assets/javascripts/discourse/controllers/static_controller.js.coffee new file mode 100644 index 00000000000..a73a9cfb7be --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/static_controller.js.coffee @@ -0,0 +1,21 @@ +Discourse.StaticController = Ember.Controller.extend + + content: null + + loadPath: (path) -> + @set('content', null) + + # Load from

    + {{outlet}} +
    + +{{render modal}} diff --git a/app/assets/javascripts/discourse/templates/composer.js.handlebars b/app/assets/javascripts/discourse/templates/composer.js.handlebars new file mode 100644 index 00000000000..ebb64eaf74c --- /dev/null +++ b/app/assets/javascripts/discourse/templates/composer.js.handlebars @@ -0,0 +1,77 @@ +
    {{i18n composer.uploading}}
    + +
    + +
    + + + {{#if content.viewOpen}} +
    +
    {{{content.actionTitle}}}:
    + + {{#if content.editTitle}} +
    + + {{#if content.creatingPrivateMessage}} + {{view Discourse.TextField id="private-message-users" class="span8" placeholderKey="composer.users_placeholder"}} + {{/if}} + {{view Discourse.TextField valueBinding="content.title" tabindex="1" id="reply-title" maxlength="255" class="span8" placeholderKey="composer.title_placeholder"}} + {{#unless content.creatingPrivateMessage}} + {{view Discourse.ComboboxViewCategory valueAttribute="name" contentBinding="Discourse.site.categories" valueBinding="content.categoryName"}} + {{#if content.archetype.hasOptions}} + + {{/if}} + {{/unless}} +
    + {{/if}} + + +
    +
    +
    + {{view Discourse.NotifyingTextArea parentBinding="view" tabindex="2" valueBinding="content.reply" id="wmd-input" placeholderKey="composer.reply_placeholder"}} +
    +
    +
    +
    + {{#if Discourse.currentUser}} + {{{content.toggleText}}} +
    + + {{/if}} +
    + + {{#if Discourse.currentUser}} +
    + + {{i18n cancel}} + + {{#if view.loadingImage}} +
    + Uploading image {{view.uploadProgress}}% cancel +
    + {{/if}} +
    + {{/if}} + +
    + {{else}} +
    +
    +
    + {{#if content.createdPost}} + {{i18n composer.saved}} + {{else}} + {{i18n composer.saving}} + {{/if}} +
    +
    + {{i18n composer.saved_draft}} +
    +
    + +
    + {{/if}} + +
    +
    diff --git a/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars b/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars new file mode 100644 index 00000000000..bda94a39392 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/embedded_post.js.handlebars @@ -0,0 +1,17 @@ +{{#with view.content}} +
    + +
    + {{{unbound cooked}}} +
    +
    +{{/with}} diff --git a/app/assets/javascripts/discourse/templates/excerpt/category.js.handlebars b/app/assets/javascripts/discourse/templates/excerpt/category.js.handlebars new file mode 100644 index 00000000000..db20ce8f6c4 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/excerpt/category.js.handlebars @@ -0,0 +1,26 @@ +
    + {{unbound view.name}} + + {{#if view.excerpt}} +
    + {{{view.excerpt}}} + {{i18n learn_more}} +
    + {{/if}} + +
    +
    {{view.topics_year}}
    {{i18n year}}
    +
    {{view.topics_month}}
    {{i18n month}}
    +
    {{view.topics_week}}
    {{i18n week}}
    +
    + +
    + diff --git a/app/assets/javascripts/discourse/templates/excerpt/close.handlebars b/app/assets/javascripts/discourse/templates/excerpt/close.handlebars new file mode 100644 index 00000000000..54d28953d33 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/excerpt/close.handlebars @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/excerpt/post.js.handlebars b/app/assets/javascripts/discourse/templates/excerpt/post.js.handlebars new file mode 100644 index 00000000000..b05611d81a5 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/excerpt/post.js.handlebars @@ -0,0 +1,21 @@ +
    + {{avatar view imageSize="large"}} +
    +
    + {{{unbound view.excerpt}}} +
    {{unbound view.created_at}}
    +
    + diff --git a/app/assets/javascripts/discourse/templates/excerpt/user.js.handlebars b/app/assets/javascripts/discourse/templates/excerpt/user.js.handlebars new file mode 100644 index 00000000000..89aee2e823e --- /dev/null +++ b/app/assets/javascripts/discourse/templates/excerpt/user.js.handlebars @@ -0,0 +1,10 @@ +

    {{view.name}}

    +{{avatar view imageSize="large"}} +
    + {{unbound view.excerpt}} +
    + diff --git a/app/assets/javascripts/discourse/templates/featured_topics.js.handlebars b/app/assets/javascripts/discourse/templates/featured_topics.js.handlebars new file mode 100644 index 00000000000..03a9941be75 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/featured_topics.js.handlebars @@ -0,0 +1,45 @@ + + + + + + + + {{#each topics itemTagName="tr"}} + + + + {{/each}} + +
    + {{categoryLink this}} + +
    + {{#each featured_users}} + {{avatar this imageSize="small"}} + {{/each}} +
    +
    {{i18n posts}}{{i18n age}}
    {{number posts_count}}{{{unbound age}}}
    + diff --git a/app/assets/javascripts/discourse/templates/flag.js.handlebars b/app/assets/javascripts/discourse/templates/flag.js.handlebars new file mode 100644 index 00000000000..1ddebe9b7bc --- /dev/null +++ b/app/assets/javascripts/discourse/templates/flag.js.handlebars @@ -0,0 +1,33 @@ + + + {{#if view.showSubmit}} + + {{/if}} diff --git a/app/assets/javascripts/discourse/templates/header.js.handlebars b/app/assets/javascripts/discourse/templates/header.js.handlebars new file mode 100644 index 00000000000..f655bb9e314 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/header.js.handlebars @@ -0,0 +1,114 @@ +
    +
    +
    + {{#if controller.showExtraInfo}} + {{#linkTo list.popular}}{{{Discourse.logoSmall}}}{{/linkTo}} + {{else}} + {{#linkTo list.popular}}{{/linkTo}} + {{/if}} +
    + + {{view Discourse.TopicExtraInfoView}} + +
    + {{#unless controller.showExtraInfo}} +
    + {{#if view.currentUser}} + {{unbound view.currentUser.name}} + {{else}} + + {{/if}} +
    + {{/unless}} + + + {{view Discourse.SearchView currentUserBinding="view.currentUser"}} + +
    + {{#if view.notifications}} + + {{else}} +
    {{i18n notifications.none}}
    + {{/if}} +
    + +
    +
      + {{#if Discourse.currentUser.admin}} +
    • {{i18n admin_title}}
    • +
    • {{i18n flags_title}}
    • + {{/if}} +
    • + {{#titledLinkTo "list.popular" titleKey="filters.popular.help"}}{{i18n filters.popular.title}}{{/titledLinkTo}} +
    • +
    • {{#linkTo 'faq'}}{{i18n faq}}{{/linkTo}}
    • + {{#if Discourse.currentUser.admin}} +
    • + {{#linkTo "list.favorited"}} + {{i18n filters.favorited.title}} + {{/linkTo}} +
    • +
    • + {{#linkTo "list.read"}} + {{i18n filters.read.title}} + {{/linkTo}} +
    • + {{/if}} + {{#if view.categories}} +
    • + {{#linkTo "list.categories"}}{{i18n filters.categories.title}}{{/linkTo}} +
    • + + {{#each view.categories}} +
    • + {{categoryLink this}} + {{unbound topic_count}} +
    • + {{/each}} + {{/if}} + +
    +
    + +
    +
    +
    + + diff --git a/app/assets/javascripts/discourse/templates/history.js.handlebars b/app/assets/javascripts/discourse/templates/history.js.handlebars new file mode 100644 index 00000000000..61221b285ab --- /dev/null +++ b/app/assets/javascripts/discourse/templates/history.js.handlebars @@ -0,0 +1,43 @@ + + diff --git a/app/assets/javascripts/discourse/templates/image_selector.js.handlebars b/app/assets/javascripts/discourse/templates/image_selector.js.handlebars new file mode 100644 index 00000000000..4a6d2377437 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/image_selector.js.handlebars @@ -0,0 +1,36 @@ + + +{{#if view.localSelected}} + + +{{else}} + + +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/list/categories.js.handlebars b/app/assets/javascripts/discourse/templates/list/categories.js.handlebars new file mode 100644 index 00000000000..a80cca454b9 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/list/categories.js.handlebars @@ -0,0 +1,8 @@ +
    + {{each categoriesOdd itemViewClass="Discourse.FeaturedTopicsView"}} +
    +
    + {{each categoriesEven itemViewClass="Discourse.FeaturedTopicsView"}} +
    + +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/list/list.js.handlebars b/app/assets/javascripts/discourse/templates/list/list.js.handlebars new file mode 100644 index 00000000000..c294ab930b9 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/list/list.js.handlebars @@ -0,0 +1,44 @@ +
    +
    + + + {{#if controller.canCreateTopic}} + + {{/if}} + + {{#if controller.canCreateCategory}} + + {{/if}} + +
    +
    + +
    +
    + +
    +
    + {{#if controller.loading}} +
    + + + + +
    +
    {{i18n loading}}
    +
    +
    + {{/if}} + + {{outlet listView}} +
    +
    + +
    +
    + + diff --git a/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars b/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars new file mode 100644 index 00000000000..557e1c46126 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/list/topic_list_item.js.handlebars @@ -0,0 +1,53 @@ + + {{#if Discourse.currentUser.id}} + + + + {{/if}} + + + {{view Discourse.TopicStatusView topicBinding="this"}} + {{{topicLink this showTagIfPresent="controller.category"}}} + {{#if unread}} + {{unread}} + {{/if}} + {{#if displayNewPosts}} + {{displayNewPosts}} + {{/if}} + {{#if unseen}} + + {{/if}} + + + {{categoryLink category}} + + + + {{#each posters}} + {{avatar this usernamePath="user.username" imageSize="small"}} + {{/each}} + + + {{number posts_count numberKey="posts_long"}} + + + {{#if like_count}} + {{like_count}} + {{/if}} + + + {{number views numberKey="views_long"}} + + {{#if singlePost}} + + {{{age}}} + + + {{else}} + + {{{age}}} + + + {{{last_post_age}}} + + {{/if}} diff --git a/app/assets/javascripts/discourse/templates/list/topics.js.handlebars b/app/assets/javascripts/discourse/templates/list/topics.js.handlebars new file mode 100644 index 00000000000..04c16f88822 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/list/topics.js.handlebars @@ -0,0 +1,59 @@ +{{#unless controller.loading}} + {{#if content.loaded}} +
    + {{#unless content.emptyListTip}} + + + + {{#if Discourse.currentUser}} + + {{/if}} + + + + + + + + + + + {{#if view.rollUp}} + + + + + + {{else}} + {{#group}} + {{collection contentBinding="content.inserted" tagName="tbody" itemViewClass="Discourse.TopicListItemView"}} + {{/group}} + {{/if}} + + {{#group}} + {{collection contentBinding="content.topics" tagName="tbody" itemViewClass="Discourse.TopicListItemView"}} + {{/group}} + +
      + {{i18n topic.title}} + {{i18n category_title}}{{i18n top_contributors}}{{i18n posts}}{{i18n likes}}{{i18n views}}{{i18n activity}}
    +
    + {{countI18n new_topics_inserted countBinding="view.insertedCount"}} + {{i18n show_new_topics}} +
    +
    + {{else}} +

    + {{content.emptyListTip}} +

    + {{/unless}} +
    + +
    + {{#if view.loading}} +
    {{i18n topic.loading_more}}
    + {{/if}} +
    + + {{/if}} +{{/unless}} diff --git a/app/assets/javascripts/discourse/templates/modal/archetype_options.js.handlebars b/app/assets/javascripts/discourse/templates/modal/archetype_options.js.handlebars new file mode 100644 index 00000000000..1c638975c99 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/archetype_options.js.handlebars @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/modal/create_account.js.handlebars b/app/assets/javascripts/discourse/templates/modal/create_account.js.handlebars new file mode 100644 index 00000000000..737db276fa0 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/create_account.js.handlebars @@ -0,0 +1,60 @@ +{{#unless view.complete}} + + + +{{/unless}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/modal/edit_category.js.handlebars b/app/assets/javascripts/discourse/templates/modal/edit_category.js.handlebars new file mode 100644 index 00000000000..9415888ce0c --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/edit_category.js.handlebars @@ -0,0 +1,22 @@ + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/modal/forgot_password.js.handlebars b/app/assets/javascripts/discourse/templates/modal/forgot_password.js.handlebars new file mode 100644 index 00000000000..a3ad51b8f98 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/forgot_password.js.handlebars @@ -0,0 +1,9 @@ + + diff --git a/app/assets/javascripts/discourse/templates/modal/invite.js.handlebars b/app/assets/javascripts/discourse/templates/modal/invite.js.handlebars new file mode 100644 index 00000000000..da28a7cef5e --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/invite.js.handlebars @@ -0,0 +1,25 @@ + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/modal/invite_private.js.handlebars b/app/assets/javascripts/discourse/templates/modal/invite_private.js.handlebars new file mode 100644 index 00000000000..2dba6ea7611 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/invite_private.js.handlebars @@ -0,0 +1,25 @@ + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/modal/login.js.handlebars b/app/assets/javascripts/discourse/templates/modal/login.js.handlebars new file mode 100644 index 00000000000..b605373bd66 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/login.js.handlebars @@ -0,0 +1,44 @@ + + diff --git a/app/assets/javascripts/discourse/templates/modal/modal_errors.js.handlebars b/app/assets/javascripts/discourse/templates/modal/modal_errors.js.handlebars new file mode 100644 index 00000000000..1f1257160ae --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/modal_errors.js.handlebars @@ -0,0 +1,8 @@ +{{#if view.errors}} + {{#each view.errors}} +
    + + {{this}} +
    + {{/each}} +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/modal/modal_header.js.handlebars b/app/assets/javascripts/discourse/templates/modal/modal_header.js.handlebars new file mode 100644 index 00000000000..36eb45faf3f --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/modal_header.js.handlebars @@ -0,0 +1,5 @@ + + diff --git a/app/assets/javascripts/discourse/templates/modal/move_selected.js.handlebars b/app/assets/javascripts/discourse/templates/modal/move_selected.js.handlebars new file mode 100644 index 00000000000..ecba34fe951 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/move_selected.js.handlebars @@ -0,0 +1,20 @@ + + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/modal/option_boolean.js.handlebars b/app/assets/javascripts/discourse/templates/modal/option_boolean.js.handlebars new file mode 100644 index 00000000000..2cb9c13848b --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/option_boolean.js.handlebars @@ -0,0 +1,6 @@ + + +{{description}} diff --git a/app/assets/javascripts/discourse/templates/participant.js.handlebars b/app/assets/javascripts/discourse/templates/participant.js.handlebars new file mode 100644 index 00000000000..130f8ec191d --- /dev/null +++ b/app/assets/javascripts/discourse/templates/participant.js.handlebars @@ -0,0 +1,4 @@ + + {{unbound post_count}} + {{avatar this imageSize="medium"}} + diff --git a/app/assets/javascripts/discourse/templates/post.js.handlebars b/app/assets/javascripts/discourse/templates/post.js.handlebars new file mode 100644 index 00000000000..a485931a863 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/post.js.handlebars @@ -0,0 +1,67 @@ +
    + +
    + +
    +
    + {{#if controller.multiSelect}} + + {{else}} +
    + {{/if}} + + {{#if showUserReplyTab}} + + {{#if loadingParent}} + {{i18n loading}} + {{else}} + {{i18n post.in_reply_to}} + {{avatar reply_to_user imageSize="tiny"}} + {{reply_to_user.username}} + {{/if}} + + {{/if}} + + + +
    +
    + {{view Discourse.PrependPostView postBinding="this"}} +
    {{{cooked}}}
    + {{view Discourse.PostMenuView postBinding="this" postViewBinding="view"}} +
    + {{view Discourse.RepliesView contentBinding="replies" postViewBinding="view"}} + {{view Discourse.ActionsHistoryView contentBinding="actionsHistory"}} + {{view Discourse.TopicSummaryView postBinding="this"}} +
    + +
    + {{collection contentBinding="internalLinks" itemViewClass="Discourse.PostLinkView" tagName="ul" classNames="post-links"}} + {{#if controller.content.can_reply_as_new_topic}} + {{i18n post.reply_as_new_topic}} + {{/if}} +
    +
    + +
    diff --git a/app/assets/javascripts/discourse/templates/quote.js.shbrs b/app/assets/javascripts/discourse/templates/quote.js.shbrs new file mode 100644 index 00000000000..3310f820c29 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/quote.js.shbrs @@ -0,0 +1,9 @@ + diff --git a/app/assets/javascripts/discourse/templates/search.js.handlebars b/app/assets/javascripts/discourse/templates/search.js.handlebars new file mode 100644 index 00000000000..c740ff313d0 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/search.js.handlebars @@ -0,0 +1,29 @@ +{{view Ember.TextField valueBinding="view.term" placeholderBinding="view.searchPlaceholder"}} +{{#with view}} + {{#unless loading}} + {{#unless noResults}} + {{#each content}} +
      +
    • + {{name}} + {{#if more}} + {{i18n show_more}} + {{else}} + {{#if view.showCancelFilter}} + + {{/if}} + {{/if}} +
    • + {{view Discourse.SearchResultsTypeView typeBinding="type" contentBinding="results"}} +
    + {{/each}} + {{else}} +
    + {{i18n search.no_results}} +
    + {{/unless}} + {{else}} +
    + {{/unless}} +{{/with}} + diff --git a/app/assets/javascripts/discourse/templates/search/category_result.js.handlebars b/app/assets/javascripts/discourse/templates/search/category_result.js.handlebars new file mode 100644 index 00000000000..7cbee1710ac --- /dev/null +++ b/app/assets/javascripts/discourse/templates/search/category_result.js.handlebars @@ -0,0 +1,6 @@ +{{#with view.content}} + + {{unbound title}} + +{{/with}} + diff --git a/app/assets/javascripts/discourse/templates/search/topic_result.js.handlebars b/app/assets/javascripts/discourse/templates/search/topic_result.js.handlebars new file mode 100644 index 00000000000..37d58aed426 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/search/topic_result.js.handlebars @@ -0,0 +1,6 @@ +{{#with view.content}} + + {{unbound title}} + +{{/with}} + diff --git a/app/assets/javascripts/discourse/templates/search/user_result.js.handlebars b/app/assets/javascripts/discourse/templates/search/user_result.js.handlebars new file mode 100644 index 00000000000..92fa2dbbb4a --- /dev/null +++ b/app/assets/javascripts/discourse/templates/search/user_result.js.handlebars @@ -0,0 +1,7 @@ +{{#with view.content}} + + {{avatar this usernamePath="title" imageSize="small"}} + {{unbound title}} + +{{/with}} + diff --git a/app/assets/javascripts/discourse/templates/selected_posts.js.handlebars b/app/assets/javascripts/discourse/templates/selected_posts.js.handlebars new file mode 100644 index 00000000000..013a4b1a50d --- /dev/null +++ b/app/assets/javascripts/discourse/templates/selected_posts.js.handlebars @@ -0,0 +1,11 @@ +

    {{countI18n topic.multi_select.description countBinding="controller.selectedCount"}}

    + +{{#if canDeleteSelected}} + +{{/if}} + +{{#if canMoveSelected}} + +{{/if}} + +

    {{i18n topic.multi_select.cancel}}

    diff --git a/app/assets/javascripts/discourse/templates/share.js.handlebars b/app/assets/javascripts/discourse/templates/share.js.handlebars new file mode 100644 index 00000000000..45569c41a99 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/share.js.handlebars @@ -0,0 +1,5 @@ +

    {{view.title}}

    +
    + diff --git a/app/assets/javascripts/discourse/templates/static.js.handlebars b/app/assets/javascripts/discourse/templates/static.js.handlebars new file mode 100644 index 00000000000..5e32255134b --- /dev/null +++ b/app/assets/javascripts/discourse/templates/static.js.handlebars @@ -0,0 +1,9 @@ +
    +
    + {{#if content}} + {{{content}}} + {{else}} +
    {{i18n loading}}
    + {{/if}} +
    +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/suggested_topic.js.handlebars b/app/assets/javascripts/discourse/templates/suggested_topic.js.handlebars new file mode 100644 index 00000000000..2a0f3f388ae --- /dev/null +++ b/app/assets/javascripts/discourse/templates/suggested_topic.js.handlebars @@ -0,0 +1,43 @@ +{{#with view.content}} + {{#group}} + + {{unbound title}} + {{#if unread}} + {{unbound unread}} + {{/if}} + {{#if new_posts}} + {{unbound new_posts}} + {{/if}} + {{#if unseen}} + + {{/if}} + + + {{categoryLink category}} + + {{number posts_count numberKey="posts_long"}} + + + {{#if like_count}} + {{like_count}} + {{/if}} + + + {{number views numberKey="views_long"}} + + {{#if singlePost}} + + {{{age}}} + + + {{else}} + + {{{age}}} + + + {{{last_post_age}}} + + {{/if}} + + {{/group}} +{{/with}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/topic.js.handlebars b/app/assets/javascripts/discourse/templates/topic.js.handlebars new file mode 100644 index 00000000000..df9ff98681c --- /dev/null +++ b/app/assets/javascripts/discourse/templates/topic.js.handlebars @@ -0,0 +1,141 @@ +{{#if content}} + {{#if loaded}} + + {{#if view.firstPostLoaded}} +
    +
    +
    + {{#if view.showFavoriteButton}} + + {{/if}} + {{#if view.editingTopic}} + + {{view Discourse.ComboboxViewCategory valueAttribute="name" contentBinding="view.categories" valueBinding="view.topic.categoryName"}} + + + {{else}} +

    + {{#if view.topic.title}} + {{view Discourse.TopicStatusView topicBinding="view.topic"}} + {{unbound view.topic.displayTitle}} + {{else}} + {{#if view.topic.missing}} + {{i18n topic.not_found.title}} + {{else}} + {{i18n topic.loading}} + {{/if}} + {{/if}} + {{categoryLink view.topic.category}} + + {{#if view.topic.can_edit}} + + {{/if}} +

    + {{/if}} +
    +
    +
    + {{/if}} + +
    + + {{view Discourse.SelectedPostsView}} +
    +
    +
    +
    + +
    + + {{#if view.loadingAbove}} +
    {{i18n loading}}
    + {{/if}} + {{view Discourse.TopicPostsView contentBinding="content.posts" topicViewBinding="view"}} + + {{#if view.loadingBelow}} +
    {{i18n loading}}
    + {{/if}} +
    +
    + + {{#if view.loading}} + {{#unless view.loadingBelow}} +
    {{i18n loading}}
    + {{/unless}} + {{else}} + {{#if view.fullyLoaded}} + {{view Discourse.TopicFooterButtonsView topicBinding="controller.content"}} + + {{#if controller.content.suggested_topics}} +
    + +

    {{i18n suggested_topics.title}}

    + +
    + + + + + + + + + + + {{each controller.content.suggested_topics tagName="tbody" itemTagName="tr" itemViewClass="Discourse.SuggestedTopicView"}} +
    + {{i18n topic.title}} + {{i18n category_title}}{{i18n posts}}{{i18n likes}}{{i18n views}}{{i18n activity}}
    +
    +
    +

    {{{unbound view.browseMoreMessage}}}

    +
    + {{/if}} + {{/if}} + {{/if}} + + +
    +
    + +
    + + {{else}} + {{#if message}} +
    +
    + +

    {{message}}

    + +

    + {{#linkTo list.popular}}{{i18n topic.back_to_list}}{{/linkTo}} +

    +
    + {{else}} +
    +
    {{i18n loading}}
    +
    + {{/if}} + {{/if}} +{{/if}} + +{{#if controller.filter}} +
    + {{filterDesc}} + {{i18n topic.filters.cancel}} +
    +{{/if}} + +{{render share}} +{{render quoteButton}} + +{{#if Discourse.currentUser.admin}} + {{render topicAdminMenu controller.content}} +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars b/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars new file mode 100644 index 00000000000..7d10dcc8d84 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/topic_admin_menu.js.handlebars @@ -0,0 +1,61 @@ +{{#if visible}} +
    +

    {{i18n admin_title}}

    + +
      +
    • + +
    • + + {{#if content.can_delete}} +
    • + +
    • + {{/if}} + +
    • + {{#if content.closed}} + + {{else}} + + {{/if}} +
    • + +
    • + {{#if content.pinned}} + + {{else}} + + {{/if}} +
    • + +
    • + {{#if content.archived}} + + {{else}} + + {{/if}} +
    • + +
    • + {{#if content.visible}} + + {{else}} + + {{/if}} +
    • + + {{#if view.topic.canConvertToRegular}} +
    • + +
    • + {{/if}} + +
    • + +
    • +
    +
    +{{else}} + +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/topic_extra_info.js.handlebars b/app/assets/javascripts/discourse/templates/topic_extra_info.js.handlebars new file mode 100644 index 00000000000..6697e2320b0 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/topic_extra_info.js.handlebars @@ -0,0 +1,19 @@ +{{#if view.showFavoriteButton}} + +{{/if}} + +

    +{{#if view.topic.title}} + {{view Discourse.TopicStatusView topicBinding="view.topic"}} + {{view Discourse.AutoSizedTextView tagName="span" class="auto-sizer" contentBinding="view.topic.displayTitle"}} +{{else}} + {{#if view.topic.missing}} + {{i18n topic.not_found.title}} + {{else}} + {{i18n topic.loading}} + {{/if}} +{{/if}} +{{#if view.topic.category}} + {{categoryLink view.topic.category}} +{{/if}} +

    diff --git a/app/assets/javascripts/discourse/templates/topic_summary/best_of_toggle.js.handlebars b/app/assets/javascripts/discourse/templates/topic_summary/best_of_toggle.js.handlebars new file mode 100644 index 00000000000..eb9b32fa14a --- /dev/null +++ b/app/assets/javascripts/discourse/templates/topic_summary/best_of_toggle.js.handlebars @@ -0,0 +1,4 @@ +

    {{i18n best_of.title}}

    +

    {{{i18n best_of.description count="controller.content.posts_count"}}}

    + + diff --git a/app/assets/javascripts/discourse/templates/topic_summary/info.js.handlebars b/app/assets/javascripts/discourse/templates/topic_summary/info.js.handlebars new file mode 100644 index 00000000000..b2e459c0bc6 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/topic_summary/info.js.handlebars @@ -0,0 +1,104 @@ + + + +{{#if view.summaryView.collapsed}} + +{{else}} + +
    +
      +
    • +

      {{i18n created}}

      + {{avatar view.topic.created_by imageSize="tiny"}} + {{date view.topic.created_at}} +
    • +
    • +

      {{i18n last_post}}

      + {{avatar view.topic.last_poster imageSize="tiny"}} + {{date view.topic.last_posted_at}} +
    • +
    • +

      {{i18n posts}}

      + {{number view.topic.posts_count}} +
    • +
    • +

      {{i18n views}}

      + {{number view.topic.views}} +
    • +
    +
    + + {{#if view.topic.participants}} +
    + {{#each view.topic.participants}}{{view Discourse.ParticipantView participantBinding="this"}}{{/each}} +
    + {{/if}} + + {{#if view.parentView.infoLinks}} + + {{/if}} + + + +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/topic_summary/private_message.js.handlebars b/app/assets/javascripts/discourse/templates/topic_summary/private_message.js.handlebars new file mode 100644 index 00000000000..b61798b62ad --- /dev/null +++ b/app/assets/javascripts/discourse/templates/topic_summary/private_message.js.handlebars @@ -0,0 +1,19 @@ +

    {{i18n private_message_info.title}}

    +

    {{{i18n private_message_info.description}}}

    + +{{#if controller.content.can_invite_to}} +
    + +
    +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/user/activity.js.handlebars b/app/assets/javascripts/discourse/templates/user/activity.js.handlebars new file mode 100644 index 00000000000..c02deec5020 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/activity.js.handlebars @@ -0,0 +1,54 @@ + +
    + +
    + + +
    +
    + {{#if content.website}} +
    {{i18n user.website}}:
    {{content.websiteName}}
    + {{/if}} +
    {{i18n user.created}}:
    {{date content.created_at}}
    + {{#if content.last_posted_at}} +
    {{i18n user.last_posted}}:
    {{date content.last_posted_at}}
    + {{/if}} + {{#if content.last_seen_at}} +
    {{i18n user.last_seen}}:
    {{date content.last_seen_at}}
    + {{/if}} + {{#if content.invited_by}} +
    {{i18n user.invited_by}}:
    {{#linkTo user.activity content.invited_by}}{{content.invited_by.username}}{{/linkTo}}
    + {{/if}} + {{#if content.email}} +
    {{i18n user.email.title}}:
    {{content.email}}
    + {{/if}} +
    {{i18n user.trust_level}}:
    {{content.trustLevel.name}}
    +
    +
    + + {{#if content.can_edit}} +
    + +
    + {{/if}} + + +
    + +{{view Discourse.UserStreamView streamBinding="stream"}} diff --git a/app/assets/javascripts/discourse/templates/user/email.js.handlebars b/app/assets/javascripts/discourse/templates/user/email.js.handlebars new file mode 100644 index 00000000000..a7911b9f5fb --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/email.js.handlebars @@ -0,0 +1,46 @@ +
    + +
    +
    +

    {{i18n user.change_email.title}}

    +
    +
    + + {{#if success}} +
    +
    +

    {{i18n user.change_email.success}}

    +
    +
    + {{else}} + {{#if error}} +
    +
    +
    {{i18n user.change_email.error}}
    +
    +
    + {{/if}} + +
    + +
    + {{view Ember.TextField valueBinding="controller.newEmail" elementId="change_email" classNames="input-xxlarge"}} +
    +
    + {{#if controller.taken}} + {{i18n user.change_email.taken}} + {{else}} + {{i18n user.email.instructions}} + {{/if}} + +
    +
    + +
    +
    + +
    +
    + {{/if}} + +
    diff --git a/app/assets/javascripts/discourse/templates/user/invited.js.handlebars b/app/assets/javascripts/discourse/templates/user/invited.js.handlebars new file mode 100644 index 00000000000..c4d04ff8967 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/invited.js.handlebars @@ -0,0 +1,70 @@ +
    + {{#if content.empty}} +
    + {{i18n user.invited.none username="content.user.username"}} +
    + {{else}} + {{#if content.redeemed}} +
    +

    {{i18n user.invited.redeemed}}

    +
    + + + + + + + + + + + {{#each content.redeemed}} + + + + + + + + + + {{/each}} +
    {{i18n user.invited.user}}{{i18n user.invited.redeemed_at}}{{i18n user.last_seen}}{{i18n user.invited.topics_entered}}{{i18n user.invited.posts_read_count}}{{i18n user.invited.time_read}}{{i18n user.invited.days_visited}}
    + {{avatar user imageSize="tiny"}} + {{user.username}} + {{date redeemed_at}}{{date user.last_seen_at}}{{number user.topics_entered}}{{number user.posts_read_count}}{{{unbound user.time_read}}}{{{unbound user.days_visited}}} + / + {{{unbound user.days_since_created}}}
    +
    +
    + {{/if}} + + {{#if content.pending}} +
    +

    {{i18n user.invited.pending}}

    +
    + + + + + + + {{#each content.pending}} + + + + + + {{/each}} +
    {{i18n user.email.title}}{{i18n created}} 
    {{email}}{{date created_at}} + {{#if rescinded}} + {{i18n user.invited.rescinded}} + {{else}} + + {{/if}} +
    +
    +
    + {{/if}} + {{/if}} +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars new file mode 100644 index 00000000000..ab97892edfc --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/preferences.js.handlebars @@ -0,0 +1,108 @@ +
    + +
    +
    +

    {{i18n user.information}}

    +
    +
    + +
    + +
    + {{content.username}} + {{#linkTo "preferences.username" class="btn pad-left"}}{{i18n user.change_username.action}}{{/linkTo}} +
    +
    + {{{i18n user.username.instructions username="content.username"}}} +
    +
    + +
    + +
    + {{view Ember.TextField valueBinding="content.name" classNames="input-xxlarge"}} +
    +
    + {{i18n user.name.instructions}} +
    +
    + +
    + +
    + {{content.email}} + {{#linkTo "preferences.email" class="btn pad-left"}}{{i18n user.change_email.action}}{{/linkTo}} +
    +
    + {{i18n user.email.instructions}} +
    +
    + +
    + +
    + {{i18n user.change_password}} {{controller.passwordProgress}} +
    +
    + +
    + +
    + {{avatar content imageSize="large"}} +
    +
    + {{{i18n user.avatar.instructions}}} {{content.email}} +
    +
    + +
    + +
    + {{view Discourse.PagedownEditor valueBinding="content.bio_raw"}} +
    +
    + +
    + +
    + {{view Ember.TextField valueBinding="content.website" classNames="input-xxlarge"}} +
    +
    + +
    + +
    + + + {{#if content.email_digests}} +
    + {{view Discourse.ComboboxView valueAttribute="value" contentBinding="controller.digestFrequencies" valueBinding="content.digest_after_days"}} +
    + {{/if}} + + +
    +
    + {{i18n user.email.frequency}} +
    +
    + +
    + +
    + + {{view Discourse.ComboboxView valueAttribute="value" contentBinding="controller.autoTrackDurations" valueBinding="content.auto_track_topics_after_msecs"}} +
    +
    + +
    +
    + + {{#if saved}}{{i18n saved}}{{/if}} +
    +
    + +
    diff --git a/app/assets/javascripts/discourse/templates/user/private_messages.js.handlebars b/app/assets/javascripts/discourse/templates/user/private_messages.js.handlebars new file mode 100644 index 00000000000..f16c3c9b6c8 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/private_messages.js.handlebars @@ -0,0 +1,23 @@ +
    + +
    + + + +
    +{{view Discourse.UserStreamView streamBinding="stream"}} diff --git a/app/assets/javascripts/discourse/templates/user/stream.js.handlebars b/app/assets/javascripts/discourse/templates/user/stream.js.handlebars new file mode 100644 index 00000000000..fb280f331f8 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/stream.js.handlebars @@ -0,0 +1,17 @@ +
    + {{#collection contentBinding="stream" itemClass="item"}} + {{#with view.content}} +
    +
    {{avatar this imageSize="large" extraClasses="actor" avatarTemplatePath="avatar_template"}}
    + {{date path="created_at" leaveAgo="true"}} + {{unbound name}}
    + {{unbound description}} + #{{unbound post_number}} {{unbound title}} +
    +

    + {{{unbound excerpt}}} +

    + {{/with}} + {{/collection}} +
    +
    diff --git a/app/assets/javascripts/discourse/templates/user/user.js.handlebars b/app/assets/javascripts/discourse/templates/user/user.js.handlebars new file mode 100644 index 00000000000..77b04aed428 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/user.js.handlebars @@ -0,0 +1,46 @@ +
    +
    +
    +

    {{content.name}}{{unbound content.username}}

    + + {{#if viewingSelf}} + + {{/if}} + {{#if Discourse.currentUser.admin}} + {{i18n admin.user.show_admin_profile}} + {{/if}} + +
    + {{avatar content imageSize="120"}} +
    +
    +
    +
    +
    + +
    +
    + {{outlet userOutlet}} +
    + diff --git a/app/assets/javascripts/discourse/templates/user/username.js.handlebars b/app/assets/javascripts/discourse/templates/user/username.js.handlebars new file mode 100644 index 00000000000..8deef57833d --- /dev/null +++ b/app/assets/javascripts/discourse/templates/user/username.js.handlebars @@ -0,0 +1,36 @@ +
    + +
    +
    +

    {{i18n user.change_username.title}}

    +
    +
    + + {{#if error}} +
    +
    +
    {{i18n user.change_username.error}}
    +
    +
    + {{/if}} + +
    + +
    + {{view Ember.TextField valueBinding="controller.newUsername" elementId="change_username" classNames="input-xxlarge"}} +
    +
    + {{#if controller.taken}} + {{i18n user.change_username.taken}} + {{/if}} +
    +
    + +
    +
    + + {{#if saved}}{{i18n saved}}{{/if}} +
    +
    + +
    diff --git a/app/assets/javascripts/discourse/translations.js.erb b/app/assets/javascripts/discourse/translations.js.erb new file mode 100644 index 00000000000..3ace45233a8 --- /dev/null +++ b/app/assets/javascripts/discourse/translations.js.erb @@ -0,0 +1,5 @@ +//= depend_on 'en.yml' + +<% SimplesIdeias::I18n.assert_usable_configuration! %> +var I18n = I18n || {}; +I18n.translations = <%= SimplesIdeias::I18n.translation_segments['app/assets/javascripts/i18n/en.js'].to_json %>; diff --git a/app/assets/javascripts/discourse/views/actions_history_view.js.coffee b/app/assets/javascripts/discourse/views/actions_history_view.js.coffee new file mode 100644 index 00000000000..43de9810276 --- /dev/null +++ b/app/assets/javascripts/discourse/views/actions_history_view.js.coffee @@ -0,0 +1,57 @@ +window.Discourse.ActionsHistoryView = Em.View.extend Discourse.Presence, + tagName: 'section' + classNameBindings: [':post-actions', 'hidden'] + + hidden: (-> + @blank('content') + ).property('content.@each') + + usersChanged: (-> + @rerender() + ).observes('content.@each', 'content.users.@each') + + # This was creating way too many bound ifs and subviews in the handlebars version. + render: (buffer) -> + return unless @present('content') + + @get('content').forEach (c) -> + buffer.push("
    ") + if c.get('users') + c.get('users').forEach (u) -> + buffer.push("") + buffer.push Discourse.Utilities.avatarImg + size: 'small' + username: u.get('username') + avatarTemplate: u.get('avatar_template') + buffer.push("") + + buffer.push(" #{c.get('actionType.long_form')}.") + else + buffer.push("#{c.get('description')}.") + + if c.get('can_act') + alsoName = Em.String.i18n("post.actions.it_too", alsoName: c.get('actionType.alsoName')) + buffer.push(" #{alsoName}.") + + if c.get('can_undo') + alsoName = Em.String.i18n("post.actions.undo", alsoName: c.get('actionType.alsoNameLower')) + buffer.push(" #{alsoName}.") + buffer.push("
    ") + + click: (e) -> + $target = $(e.target) + + # User wants to know who actioned it + if actionTypeId = $target.data('who-acted') + @get('controller').whoActed(@content.findProperty('id', actionTypeId)) + return false + + if actionTypeId = $target.data('act') + @get('controller').act(@content.findProperty('id', actionTypeId)) + return false + + if actionTypeId = $target.data('undo') + @get('controller').undoAction(@content.findProperty('id', actionTypeId)) + return false + + false \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/application_view.js.coffee b/app/assets/javascripts/discourse/views/application_view.js.coffee new file mode 100644 index 00000000000..c71008d905d --- /dev/null +++ b/app/assets/javascripts/discourse/views/application_view.js.coffee @@ -0,0 +1,2 @@ +window.Discourse.ApplicationView = Ember.View.extend + templateName: 'application' diff --git a/app/assets/javascripts/discourse/views/archetype_options_modal_view.js.coffee b/app/assets/javascripts/discourse/views/archetype_options_modal_view.js.coffee new file mode 100644 index 00000000000..d865a8199a3 --- /dev/null +++ b/app/assets/javascripts/discourse/views/archetype_options_modal_view.js.coffee @@ -0,0 +1,3 @@ +window.Discourse.ArchetypeOptionsModalView = window.Discourse.ModalBodyView.extend + templateName: 'modal/archetype_options' + title: Em.String.i18n('topic.options') \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/auto_sized_text_view.js.coffee b/app/assets/javascripts/discourse/views/auto_sized_text_view.js.coffee new file mode 100644 index 00000000000..dae2ccc64de --- /dev/null +++ b/app/assets/javascripts/discourse/views/auto_sized_text_view.js.coffee @@ -0,0 +1,18 @@ +Discourse.AutoSizedTextView = Ember.View.extend + render: (buffer)-> + null + + didInsertElement: (e) -> + me = @$() + me.text(@get('content')) + lh = lineHeight = parseInt(me.css("line-height")) + fontSize = parseInt(me.css("font-size")) + + while me.height() > lineHeight && fontSize > 12 + fontSize -= 1 + lh -=1 + me.css("font-size", "#{fontSize}px") + me.css("line-height", "#{lh}px") + + + diff --git a/app/assets/javascripts/discourse/views/button_view.js.coffee b/app/assets/javascripts/discourse/views/button_view.js.coffee new file mode 100644 index 00000000000..71063f7fcb3 --- /dev/null +++ b/app/assets/javascripts/discourse/views/button_view.js.coffee @@ -0,0 +1,16 @@ +Discourse.ButtonView = Ember.View.extend Discourse.Presence, + tagName: 'button' + classNameBindings: [':btn', ':standard', 'dropDownToggle'] + attributeBindings: ['data-not-implemented', 'title', 'data-toggle', 'data-share-url'] + + title: (-> + Em.String.i18n(@get('helpKey') || @get('textKey')) + ).property('helpKey') + + text: (-> + Em.String.i18n(@get('textKey')) + ).property('textKey') + + render: (buffer) -> + @renderIcon(buffer) if @renderIcon + buffer.push(@get('text')) diff --git a/app/assets/javascripts/discourse/views/combobox_view.js.coffee b/app/assets/javascripts/discourse/views/combobox_view.js.coffee new file mode 100644 index 00000000000..8c69f7d989f --- /dev/null +++ b/app/assets/javascripts/discourse/views/combobox_view.js.coffee @@ -0,0 +1,24 @@ +Discourse.ComboboxView = window.Ember.View.extend + tagName: 'select' + classNames: ['combobox'] + valueAttribute: 'id' + + render: (buffer) -> + if @get('none') + buffer.push("") + + selected = @get('value')?.toString() + if @get('content') + @get('content').each (o) => + val = o[@get('valueAttribute')]?.toString() + selectedText = if val == selected then "selected" else "" + data = "" + if @dataAttributes + @dataAttributes.forEach (a) => + data += "data-#{a}=\"#{o.get(a)}\" " + buffer.push("") + + didInsertElement: -> + $elem = @.$() + $elem.chosen(template: @template, disable_search_threshold: 5) + $elem.change (e) => @set('value', $(e.target).val()) diff --git a/app/assets/javascripts/discourse/views/combobox_view_category.js.coffee b/app/assets/javascripts/discourse/views/combobox_view_category.js.coffee new file mode 100644 index 00000000000..172144ac5a8 --- /dev/null +++ b/app/assets/javascripts/discourse/views/combobox_view_category.js.coffee @@ -0,0 +1,8 @@ +window.Discourse.ComboboxViewCategory = Discourse.ComboboxView.extend + + none: 'category.none' + dataAttributes: ['color'] + + template: (text, templateData) -> + return text unless templateData.color + "#{text}" \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/composer_view.js.coffee b/app/assets/javascripts/discourse/views/composer_view.js.coffee new file mode 100644 index 00000000000..76bc39f5f2e --- /dev/null +++ b/app/assets/javascripts/discourse/views/composer_view.js.coffee @@ -0,0 +1,248 @@ +window.Discourse.ComposerView = window.Discourse.View.extend + templateName: 'composer' + elementId: 'reply-control' + classNameBindings: ['content.creatingPrivateMessage:private-message', + 'composeState', + 'content.loading', + 'content.editTitle', + 'postMade', + 'content.creatingTopic:topic', + 'content.showPreview', + 'content.hidePreview'] + + composeState: (-> + state = @get('content.composeState') + unless state + state = Discourse.Composer.CLOSED + state + ).property('content.composeState') + + + draftStatus: (-> + @$('.saving-draft').text(@get('content.draftStatus') || "") + ).observes('content.draftStatus') + + # Disable fields when we're loading + loadingChanged: (-> + if @get('loading') + $('#wmd-input, #reply-title').prop('disabled', 'disabled') + else + $('#wmd-input, #reply-title').prop('disabled', '') + ).observes('loading') + + postMade: (-> + return 'created-post' if @present('controller.createdPost') + null + ).property('content.createdPost') + + observeReplyChanges: (-> + + return if @get('content.hidePreview') + + Ember.run.next null, => + if @editor + @editor.refreshPreview() + # if the caret is on the last line ensure preview scrolled to bottom + caretPosition = Discourse.Utilities.caretPosition(@wmdInput[0]) + unless @wmdInput.val().substring(caretPosition).match /\n/ + $wmdPreview = $('#wmd-preview:visible') + if $wmdPreview.length > 0 + $wmdPreview.scrollTop($wmdPreview[0].scrollHeight) + + + ).observes('content.reply', 'content.hidePreview') + + willDestroyElement: -> + $('body').off 'keydown.composer' + + resize: (-> + # this still needs to wait on animations, need a clean way to do that + Em.run.next null, => + replyControl = $('#reply-control') + h = replyControl.height() || 0 + $('.topic-area').css('padding-bottom', "#{h}px") + ).observes('content.composeState') + + didInsertElement: -> + + # Delegate ESC to the composer + $('body').on 'keydown.composer', (e) => + @get('controller').hitEsc() if e.which == 27 + + replyControl = $('#reply-control') + replyControl.DivResizer(resize: @resize) + Discourse.TransitionHelper.after(replyControl, @resize) + + click: -> + @get('controller').click() + + # Called after the preview renders. Debounced for performance + afterRender: Discourse.debounce(-> + $wmdPreview = $('#wmd-preview') + return unless ($wmdPreview.length > 0) + Discourse.SyntaxHighlighting.apply($wmdPreview) + refresh = @get('controller.content.post.id') isnt undefined + $('a.onebox', $wmdPreview).each (i, e) => Discourse.Onebox.load(e, refresh) + $('span.mention', $wmdPreview).each (i, e) => Discourse.Mention.load(e, refresh) + , 100) + + cancelUpload: -> + # TODO + + initEditor: -> + + # not quite right, need a callback to pass in, meaning this gets called once, + # but if you start replying to another topic it will get the avatars wrong + @wmdInput = $wmdInput = $('#wmd-input') + return if $wmdInput.length == 0 || $wmdInput.data('init') == true + + Discourse.ComposerView.trigger("initWmdEditor") + + template = Handlebars.compile("
    + +
    ") + + transformTemplate = Handlebars.compile("{{avatar this imageSize=\"tiny\"}} {{this.username}}") + + $wmdInput.data('init', true) + $wmdInput.autocomplete + template: template + dataSource: (term,callback) => + Discourse.UserSearch.search + term: term, + callback: callback, + topicId: @get('controller.controllers.topic.content.id') + key: "@" + transformComplete: (v) -> + v.username + + selected = [] + $('#private-message-users').val(@get('content.targetUsernames')).autocomplete + template: template + dataSource: (term, callback) -> + Discourse.UserSearch.search + term: term, + callback: callback, + exclude: selected + onChangeItems: (items) => + items = $.map items, (i) -> if i.username then i.username else i + @set('content.targetUsernames', items.join(",")) + selected = items + transformComplete: transformTemplate + reverseTransform: (i) -> {username: i} + + topic = @get('topic') + @editor = editor = new Markdown.Editor(Discourse.Utilities.markdownConverter( + lookupAvatar: (username) -> + Discourse.Utilities.avatarImg(username: username, size: 'tiny') + )) + + $uploadTarget = $('#reply-control') + @editor.hooks.insertImageDialog = (callback) => + callback(null) + @get('controller.controllers.modal').show(Discourse.ImageSelectorView.create(composer: @, uploadTarget: $uploadTarget)) + true + @editor.hooks.onPreviewRefresh = => @afterRender() + @editor.run() + @set('editor', @editor) + + @loadingChanged() + + saveDraft = Discourse.debounce((=> @get('controller').saveDraft()),2000) + + $wmdInput.keyup => + saveDraft() + return true + + $('#reply-title').keyup => + saveDraft() + return true + + # In case it's still bound somehow + $uploadTarget.fileupload('destroy') + + # Add the upload action + $uploadTarget.fileupload + url: '/uploads' + dataType: 'json' + timeout: 20000 + formData: + topic_id: 1234 + paste: (e, data) => + if data.files.length > 0 + @set('loadingImage', true) + @set('uploadProgress', 0) + true + drop: (e, data)=> + if e.originalEvent.dataTransfer.files.length == 1 + @set('loadingImage', true) + @set('uploadProgress', 0) + + progressall:(e,data)=> + progress = parseInt(data.loaded / data.total * 100, 10) + @set('uploadProgress', progress) + + done: (e, data) => + @set('loadingImage', false) + upload = data.result + html = "" + @addMarkdown(html) + + fail: (e, data) => + bootbox.alert Em.String.i18n('post.errors.upload') + @set('loadingImage', false) + + + # I hate to use Em.run.later, but I don't think there's a way of waiting for a CSS transition + # to finish. + Em.run.later($, (=> + replyTitle = $('#reply-title') + + @resize() + + if replyTitle.length + replyTitle.putCursorAtEnd() + else + $wmdInput.putCursorAtEnd() + ) + , 300) + + addMarkdown: (text)-> + ctrl = $('#wmd-input').get(0) + caretPosition = Discourse.Utilities.caretPosition(ctrl) + + current = @get('content.reply') + @set('content.reply', current.substring(0, caretPosition) + text + current.substring(caretPosition, current.length)) + Em.run.next => + Discourse.Utilities.setCaretPosition(ctrl, caretPosition + text.length) + + # Uses javascript to get the image sizes from the preview, if present + imageSizes: -> + result = {} + + $('#wmd-preview img').each (i, e) -> + $img = $(e) + result[$img.prop('src')] = {width: $img.width(), height: $img.height()} + result + + childDidInsertElement: (e)-> + @initEditor() + + +# not sure if this is the right way, keeping here for now, we could use a mixin perhaps +Discourse.NotifyingTextArea = Ember.TextArea.extend + + placeholder: (-> + Em.String.i18n(@get('placeholderKey')) + ).property('placeholderKey') + + didInsertElement: -> + @get('parent').childDidInsertElement(@) + +RSVP.EventTarget.mixin(Discourse.ComposerView) diff --git a/app/assets/javascripts/discourse/views/dropdown_button_view.js.coffee b/app/assets/javascripts/discourse/views/dropdown_button_view.js.coffee new file mode 100644 index 00000000000..3acb72cdd41 --- /dev/null +++ b/app/assets/javascripts/discourse/views/dropdown_button_view.js.coffee @@ -0,0 +1,41 @@ +Discourse.DropdownButtonView = Ember.View.extend Discourse.Presence, + classNames: ['btn-group'] + attributeBindings: ['data-not-implemented'] + + didInsertElement: (e) -> + @.$('ul li').on 'click', (e) => + e.preventDefault() + @clicked $(e.currentTarget).data('id') + false + + clicked: (id) -> null + + textChanged: (-> + @rerender() + ).observes('text','longDescription') + + render: (buffer) -> + + buffer.push("

    #{@get('title')}

    ") + buffer.push("") + + buffer.push("") + + if desc = @get('longDescription') + buffer.push("

    ") + buffer.push(desc) + buffer.push("

    ") + diff --git a/app/assets/javascripts/discourse/views/embedded_post_view.js.coffee b/app/assets/javascripts/discourse/views/embedded_post_view.js.coffee new file mode 100644 index 00000000000..06ef245596b --- /dev/null +++ b/app/assets/javascripts/discourse/views/embedded_post_view.js.coffee @@ -0,0 +1,8 @@ +window.Discourse.EmbeddedPostView = Ember.View.extend + templateName: 'embedded_post' + classNames: ['reply'] + screenTrackBinding: 'postView.screenTrack' + + didInsertElement: -> + postView = @get('postView') || @get('parentView.postView') + postView.get('screenTrack').track(@get('elementId'), @get('post.post_number')) diff --git a/app/assets/javascripts/discourse/views/excerpt/excerpt_category_view.js.coffee b/app/assets/javascripts/discourse/views/excerpt/excerpt_category_view.js.coffee new file mode 100644 index 00000000000..349e6c03a19 --- /dev/null +++ b/app/assets/javascripts/discourse/views/excerpt/excerpt_category_view.js.coffee @@ -0,0 +1,29 @@ +window.Discourse.ExcerptCategoryView = Ember.View.extend + + editCategory: -> + @get('parentView').close() + + # We create an attribute, id, with the old name so we can rename it. + cat = @get('category') + + cat.set('id', cat.get('slug')) + @get('controller.controllers.modal')?.showView(Discourse.EditCategoryView.create(category: cat)) + false + + deleteCategory: -> + @get('parentView').close() + + bootbox.confirm Em.String.i18n("category.delete_confirm"), (result) => + if result + @get('category').delete -> + Discourse.get('appController').reloadSession -> Discourse.get('router').route("/categories") + + false + + didInsertElement: -> + @set 'category', Discourse.Category.create + name: @get('name') + color: @get('color') + slug: @get('slug') + excerpt: @get('excerpt') + topic_url: @get('topic_url') diff --git a/app/assets/javascripts/discourse/views/excerpt/excerpt_post_view.js.coffee b/app/assets/javascripts/discourse/views/excerpt/excerpt_post_view.js.coffee new file mode 100644 index 00000000000..ab1faf4c0d8 --- /dev/null +++ b/app/assets/javascripts/discourse/views/excerpt/excerpt_post_view.js.coffee @@ -0,0 +1,19 @@ +window.Discourse.ExcerptPostView = Ember.View.extend + mute: -> + @update(true) + + unmute: -> + @update(false) + + refreshLater: Discourse.debounce((-> + @get('controller.controllers.listController').refresh() + ), 1000) + + + update: (v)-> + @set('muted',v) + $.post "/t/#{@topic_id}/#{if v then "mute" else "unmute"}", + _method: 'put' + success: => + # I experimented with this, but if feels like whackamole + # @refreshLater() diff --git a/app/assets/javascripts/discourse/views/excerpt/excerpt_user_view.js.coffee b/app/assets/javascripts/discourse/views/excerpt/excerpt_user_view.js.coffee new file mode 100644 index 00000000000..7cf574a0e5e --- /dev/null +++ b/app/assets/javascripts/discourse/views/excerpt/excerpt_user_view.js.coffee @@ -0,0 +1,18 @@ +window.Discourse.ExcerptUserView = Ember.View.extend + privateMessage: (e) -> + $target = @get("link") + postView = Ember.View.views[$target.closest('.ember-view')[0].id] + post = postView.get("post") + url = post.get("url") + username = post.get("username") + Discourse.router.route('/users/' + Discourse.currentUser.username.toLowerCase() + "/private-messages") + + # TODO figure out a way for it to open the composer cleanly AFTER the navigation happens. + composerController = Discourse.get('router.composerController') + composerController.open + action: Discourse.Composer.PRIVATE_MESSAGE + usernames: username + archetypeId: 'private_message' + draftKey: 'new_private_message' + reply: window.location.href.split("/").splice(0,3).join("/") + url + diff --git a/app/assets/javascripts/discourse/views/excerpt/excerpt_view.js.coffee b/app/assets/javascripts/discourse/views/excerpt/excerpt_view.js.coffee new file mode 100644 index 00000000000..018ebe95e52 --- /dev/null +++ b/app/assets/javascripts/discourse/views/excerpt/excerpt_view.js.coffee @@ -0,0 +1,154 @@ +window.Discourse.ExcerptView = Ember.ContainerView.extend + classNames: ['excerpt-view'] + classNameBindings: ['position', 'size'] + + childViews: ['closeView'] + + closeView: Ember.View.create + templateName: 'excerpt/close' + + # Position the tooltip on the screen. There's probably a nicer way of coding this. + locationChanged: (-> + loc = @get('location') + @.$().css(loc) + ).observes('location') + + visibleChanged: (-> + return if @get('disabled') + if @get('visible') + unless @get('opening') + @set('opening', true) + @set('closing', false) + $('.excerpt-view').stop().fadeIn('fast', => @set('opening', false)) + else + unless @get('closing') + @set('closing', true) + @set('opening', false) + $('.excerpt-view').stop().fadeOut('slow', => @set('closing', false)) + ).observes('visible') + + urlChanged: (-> + if @get('url') + @set('visible', false) + @ajax = $.ajax + url: "/excerpt", + data: + url: @get('url') + success: (tooltip) => + + # Make sure we still have a URL (if it changed, we no longer care about this request.) + return unless @get('url') + $('.excerpt-view').stop().hide().css({opacity: 1}) + @set('closing', false) + @set('location',@get('desiredLocation')) + + tooltip.created_at = Date.create(tooltip.created_at).relative() if tooltip.created_at + + viewClass = Discourse["Excerpt#{tooltip.type}View"] || Em.View + + excerpt = Em.Object.create(tooltip) + excerpt.set('templateName', "excerpt/#{tooltip.type.toLowerCase()}") + + if @get('contentsView') + @removeObject(@get('contentsView')) + + instance = viewClass.create(excerpt) + instance.set("link", @hovering) + @set('contentsView', instance) + @addObject(instance) + + @set('excerpt', tooltip) + @set('visible', true) + error: => + @close() + complete: + @ajax = null + + ).observes('url') + + close: -> + Em.run.cancel(@closeTimer) + Em.run.cancel(@openTimer) + @set('url', null) + @set('visible', false) + false + + closeSoon: -> + @closeTimer = Em.run.later => + @close() + , 200 + + disable: -> + @set('disabled',true) + Em.run.cancel(@openTimer) + Em.run.cancel(@closeTimer) + @set('visible', false) + @ajax.abort() if @ajax && @ajax.abort + $('.excerpt-view').stop().hide() + + enable: -> + @set('disabled', false) + + didInsertElement: -> + + # lets disable this puppy for now, it looks unprofessional + return + + # We don't do hovering on touch devices + return if Discourse.get('touch') + + # If they dash into the excerpt, keep it open until they leave + $('.excerpt-view').on 'mouseover', (e) => Em.run.cancel(@closeTimer) + $('.excerpt-view').on 'mouseleave', (e) => @closeSoon() + + $('#main').on 'mouseover', '.excerptable', (e) => + + $target = $(e.currentTarget) + @hovering = $target + + # Make sure they're holding in place before we pop it up to mimimize annoyance + Em.run.cancel(@openTimer) + Em.run.cancel(@closeTimer) + @openTimer = Em.run.later => + pos = $target.offset() + pos.top = pos.top - $(window).scrollTop() + + positionText = $target.data('excerpt-position') || 'top' + + margin = 25 + height = @.$().height() + topPosY = (pos.top - height) - margin + bottomPosY = (pos.top + margin) + + + # Switch to right if there's no room on top + if positionText == 'top' + positionText = 'bottom' if topPosY < 10 + + switch positionText + when 'right' + pos.left = pos.left + $target.width() + margin + pos.top = pos.top - $target.height() + when 'left' + pos.left = pos.left - @.$().width() - margin + pos.top = pos.top - $target.height() + when 'top' + pos.top = topPosY + when 'bottom' + pos.top = bottomPosY + + if (pos.left || 0) <= 0 && (pos.top || 0) <= 0 + # somehow, sometimes, we are trying to position stuff in weird spots, just skip it + return + + @set('position', positionText) + @set('desiredLocation', pos) + @set('size', $target.data('excerpt-size')) + @set('url', $target.prop('href')) + , if @get('visible') or @get('closing') then 100 else Discourse.SiteSettings.popup_delay + + $('#main').on 'mouseleave', '.excerptable', (e) => + Em.run.cancel(@openTimer) + @closeSoon() + + diff --git a/app/assets/javascripts/discourse/views/featured_threads_view.js.coffee b/app/assets/javascripts/discourse/views/featured_threads_view.js.coffee new file mode 100644 index 00000000000..d0a6daf83dd --- /dev/null +++ b/app/assets/javascripts/discourse/views/featured_threads_view.js.coffee @@ -0,0 +1,7 @@ +window.Discourse.FeaturedTopicsView = Ember.View.extend + templateName: 'featured_topics' + classNames: ['category-list-item'] + + init: -> + @._super() + @set('context', @get('content')) \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/featured_topics_view.js.coffee b/app/assets/javascripts/discourse/views/featured_topics_view.js.coffee new file mode 100644 index 00000000000..dd91e043b89 --- /dev/null +++ b/app/assets/javascripts/discourse/views/featured_topics_view.js.coffee @@ -0,0 +1,3 @@ +window.Discourse.FeaturedTopicsView = Ember.View.extend + templateName: 'featured_topics' + classNames: ['category-list-item'] diff --git a/app/assets/javascripts/discourse/views/flag_view.js.coffee b/app/assets/javascripts/discourse/views/flag_view.js.coffee new file mode 100644 index 00000000000..ad020ed1b78 --- /dev/null +++ b/app/assets/javascripts/discourse/views/flag_view.js.coffee @@ -0,0 +1,53 @@ +window.Discourse.FlagView = Ember.View.extend + templateName: 'flag' + title: Em.String.i18n('flagging.title') + + changePostActionType: (action) -> + @set('postActionTypeId', action.id) + @set('isCustomFlag', action.is_custom_flag) + Em.run.next -> $("#radio_#{action.name_key}").prop('checked', 'true') + false + + createFlag: -> + actionType = Discourse.get("site").postActionTypeById(@get('postActionTypeId')) + @get("post.actionByName.#{actionType.get('name_key')}")?.act(message: @get('customFlagMessage')) + $('#discourse-modal').modal('hide') + false + + customPlaceholder: (-> + Em.String.i18n("flagging.custom_placeholder") + ).property() + + showSubmit: (-> + if @get("postActionTypeId") + if @get("isCustomFlag") + m = @get("customFlagMessage") + return m && m.length >= 10 && m.length <= 500 + else + return true + false + ).property("isCustomFlag","customFlagMessage", "postActionTypeId") + + customFlagMessageChanged: (-> + minLen = 10 + len = @get('customFlagMessage')?.length || 0 + @set("customMessageLengthClasses", "too-short custom-message-length") + if len == 0 + message = Em.String.i18n("flagging.custom_message.at_least", n: minLen) + else if len < minLen + message = Em.String.i18n("flagging.custom_message.more", n: minLen - len) + else + message = Em.String.i18n("flagging.custom_message.left", n: 500 - len) + @set("customMessageLengthClasses", "ok custom-message-length") + @set("customMessageLength",message) + + ).observes("customFlagMessage") + + didInsertElement: -> + @customFlagMessageChanged() + @set('postActionTypeId', null) + $flagModal = $('#flag-modal') + + # Would be nice if there were an EmberJs radio button to do this for us. Oh well, one should be coming + # in an upcoming release. + $("input[type='radio']", $flagModal).prop('checked', false) diff --git a/app/assets/javascripts/discourse/views/header_view.js.coffee b/app/assets/javascripts/discourse/views/header_view.js.coffee new file mode 100644 index 00000000000..9dc43c26076 --- /dev/null +++ b/app/assets/javascripts/discourse/views/header_view.js.coffee @@ -0,0 +1,93 @@ +window.Discourse.HeaderView = Ember.View.extend + tagName: 'header' + classNames: ['d-header', 'clearfix'] + classNameBindings: ['editingTopic'] + templateName: 'header' + siteBinding: 'Discourse.site' + currentUserBinding: 'Discourse.currentUser' + categoriesBinding: 'site.categories' + topicBinding: 'Discourse.router.topicController.content' + + showDropdown: ($target) -> + elementId = $target.data('dropdown') || $target.data('notifications') + $dropdown = $("##{elementId}") + + $li = $target.closest('li') + $ul = $target.closest('ul') + $li.addClass('active') + $('li', $ul).not($li).removeClass('active') + $('.d-dropdown').not($dropdown).fadeOut('fast') + $dropdown.fadeIn('fast') + $dropdown.find('input[type=text]').focus().select() + + $html = $('html') + + hideDropdown = () => + $dropdown.fadeOut('fast') + $li.removeClass('active') + $html.data('hide-dropdown', null) + $html.off 'click.d-dropdown touchstart.d-dropdown' + + $html.on 'click.d-dropdown touchstart.d-dropdown', (e) => + return true if $(e.target).closest('.d-dropdown').length > 0 + hideDropdown() + + $html.data('hide-dropdown', hideDropdown) + + false + + showNotifications: -> + $.get("/notifications").then (result) => + @set('notifications', result.map (n) => Discourse.Notification.create(n)) + + # We've seen all the notifications now + @set('currentUser.unread_notifications', 0) + @set('currentUser.unread_private_messages', 0) + + @showDropdown($('#user-notifications')) + + false + + examineDockHeader: -> + unless @docAt + outlet = $('#main-outlet') + return unless outlet && outlet.length == 1 + @docAt = outlet.offset().top + + offset = window.pageYOffset || $('html').scrollTop() + + if offset >= @docAt + unless @dockedHeader + $body = $('body') + $body.addClass('docked') + @dockedHeader = true + else + if @dockedHeader + $('body').removeClass('docked') + @dockedHeader = false + + + willDestroyElement: -> + $(window).unbind 'scroll.discourse-dock' + $(document).unbind 'touchmove.discourse-dock' + + + didInsertElement: -> + @.$('a[data-dropdown]').on 'click touchstart', (e) => @showDropdown($(e.currentTarget)) + @.$('a.unread-private-messages, a.unread-notifications, a[data-notifications]').on 'click touchstart', (e) => @showNotifications(e) + + $(window).bind 'scroll.discourse-dock', => @examineDockHeader() + $(document).bind 'touchmove.discourse-dock', => @examineDockHeader() + @examineDockHeader() + + # Delegate ESC to the composer + $('body').on 'keydown.header', (e) => + + # Hide dropdowns + if e.which == 27 + @.$('li').removeClass('active') + @.$('.d-dropdown').fadeOut('fast') + + if @get('editingTopic') + @finishedEdit() if e.which == 13 + @cancelEdit() if e.which == 27 diff --git a/app/assets/javascripts/discourse/views/history_view.js.coffee b/app/assets/javascripts/discourse/views/history_view.js.coffee new file mode 100644 index 00000000000..08c48e9ec9f --- /dev/null +++ b/app/assets/javascripts/discourse/views/history_view.js.coffee @@ -0,0 +1,33 @@ +window.Discourse.HistoryView = Ember.View.extend + templateName: 'history' + title: 'History' + modalClass: 'history-modal' + + loadSide: (side) -> + if @get("version#{side}") + orig = @get('originalPost') + version = @get("version#{side}.number") + + if version == orig.get('version') + @set("post#{side}", orig) + else + Discourse.Post.loadVersion orig.get('id'), version, (post) => + @set("post#{side}", post) + + changedLeftVersion: (-> @loadSide("Left") ).observes('versionLeft') + changedRightVersion: (-> @loadSide("Right") ).observes('versionRight') + + + didInsertElement: -> + @set('loading', true) + @set('postLeft', null) + @set('postRight', null) + + @get('originalPost').loadVersions (result) => + @set('loading', false) + + @set('versionLeft', result.first()) + @set('versionRight', result.last()) + @set('versions', result) + + diff --git a/app/assets/javascripts/discourse/views/image_selector.js.coffee b/app/assets/javascripts/discourse/views/image_selector.js.coffee new file mode 100644 index 00000000000..d711dc0cae6 --- /dev/null +++ b/app/assets/javascripts/discourse/views/image_selector.js.coffee @@ -0,0 +1,31 @@ +window.Discourse.ImageSelectorView = Ember.View.extend + templateName: 'image_selector' + classNames: ['image-selector'] + title: 'Insert Image' + + init: -> + @._super() + @set('localSelected', true) + + selectLocal: -> + @set('localSelected', true) + + selectRemote: -> + @set('localSelected', false) + + + remoteSelected: (-> + !@get('localSelected') + ).property('localSelected') + + + upload: -> + @get('uploadTarget').fileupload('send', fileInput: $('#filename-input')) + $('#discourse-modal').modal('hide') + + add: -> + @get('composer').addMarkdown("![image](#{$('#fileurl-input').val()})") + $('#discourse-modal').modal('hide') + + + diff --git a/app/assets/javascripts/discourse/views/input_tip_view.js.coffee b/app/assets/javascripts/discourse/views/input_tip_view.js.coffee new file mode 100644 index 00000000000..888de8b6ca6 --- /dev/null +++ b/app/assets/javascripts/discourse/views/input_tip_view.js.coffee @@ -0,0 +1,20 @@ +Discourse.InputTipView = Ember.View.extend Discourse.Presence, + templateName: 'input_tip' + classNameBindings: [':tip', 'good','bad'] + + good: (-> + !@get('validation.failed') + ).property('validation') + + bad: (-> + @get('validation.failed') + ).property('validation') + + triggerRender: (-> + @rerender() + ).observes('validation') + + render: (buffer) -> + if reason = @get('validation.reason') + icon = if @get('good') then 'icon-ok' else 'icon-remove' + buffer.push " #{reason}" \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/list/list_categories_view.js.coffee b/app/assets/javascripts/discourse/views/list/list_categories_view.js.coffee new file mode 100644 index 00000000000..41d455fc9e2 --- /dev/null +++ b/app/assets/javascripts/discourse/views/list/list_categories_view.js.coffee @@ -0,0 +1,5 @@ +window.Discourse.ListCategoriesView = Ember.View.extend + templateName: 'list/categories' + + didInsertElement: -> + Discourse.set('title', Em.String.i18n("category.list")) diff --git a/app/assets/javascripts/discourse/views/list/list_topics_view.js.coffee b/app/assets/javascripts/discourse/views/list/list_topics_view.js.coffee new file mode 100644 index 00000000000..6d8aadc630a --- /dev/null +++ b/app/assets/javascripts/discourse/views/list/list_topics_view.js.coffee @@ -0,0 +1,50 @@ +window.Discourse.ListTopicsView = Ember.View.extend Discourse.Scrolling, Discourse.Presence, + templateName: 'list/topics' + categoryBinding: 'Discourse.router.listController.category' + filterModeBinding: 'Discourse.router.listController.filterMode' + + insertedCount: (-> + inserted = @get('controller.inserted') + return 0 unless inserted + inserted.length + ).property('controller.inserted.@each') + + rollUp: (-> + @get('insertedCount') > Discourse.SiteSettings.new_topics_rollup + ).property('insertedCount') + + loadedMore: false + currentTopicId: null + + willDestroyElement: -> @unbindScrolling() + + didInsertElement: -> + @bindScrolling() + eyeline = new Discourse.Eyeline('.topic-list-item') + eyeline.on 'sawBottom', => @loadMore() + + if scrollPos = Discourse.get('transient.topicListScrollPos') + Em.run.next -> $('html, body').scrollTop(scrollPos) + else + Em.run.next -> $('html, body').scrollTop(0) + + @set('eyeline', eyeline) + @set('currentTopicId', null) + + loadMore: -> + return if @get('loading') + @set('loading', true) + @get('controller.content').loadMoreTopics().then (hasMoreResults) => + @set('loadedMore', true) + @set('loading', false) + Em.run.next => @saveScrollPos() + @get('eyeline').flushRest() unless hasMoreResults + + # Remember where we were scrolled to + saveScrollPos: -> + Discourse.set('transient.topicListScrollPos', $(window).scrollTop()) + + # When the topic list is scrolled + scrolled: (e) -> + @saveScrollPos() + @get('eyeline')?.update() diff --git a/app/assets/javascripts/discourse/views/list/list_view.js.coffee b/app/assets/javascripts/discourse/views/list/list_view.js.coffee new file mode 100644 index 00000000000..18fdea9d261 --- /dev/null +++ b/app/assets/javascripts/discourse/views/list/list_view.js.coffee @@ -0,0 +1,16 @@ +window.Discourse.ListView = Ember.View.extend + templateName: 'list/list' + composeViewBinding: Ember.Binding.oneWay('Discourse.composeView') + categoriesBinding: 'Discourse.site.categories' + + # The window has been scrolled + scrolled: (e) -> + currentView = @get('container.currentView') + currentView?.scrolled?(e) + + createTopicText: (-> + if @get('controller.category.name') + Em.String.i18n("topic.create_in", categoryName: @get('controller.category.name')) + else + Em.String.i18n("topic.create") + ).property('controller.category.name') \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/list/topic_list_item_view.js.coffee b/app/assets/javascripts/discourse/views/list/topic_list_item_view.js.coffee new file mode 100644 index 00000000000..4d764f5ab55 --- /dev/null +++ b/app/assets/javascripts/discourse/views/list/topic_list_item_view.js.coffee @@ -0,0 +1,26 @@ +window.Discourse.TopicListItemView = Ember.View.extend + tagName: 'tr' + templateName: 'list/topic_list_item' + classNameBindings: ['content.archived', ':topic-list-item'] + attributeBindings: ['data-topic-id'] + + 'data-topic-id': (-> @get('content.id') ).property('content.id') + + init: -> + @._super() + @set('context', @get('content')) + + highlight: -> + $topic = @.$() + originalCol = $topic.css('backgroundColor') + $topic.css(backgroundColor: "#ffffcc").animate(backgroundColor: originalCol, 2500) + + didInsertElement: -> + + if Discourse.get('transient.lastTopicIdViewed') == @get('content.id') + Discourse.set('transient.lastTopicIdViewed', null) + @highlight() + return + + @highlight() if @get('content.highlightAfterInsert') + diff --git a/app/assets/javascripts/discourse/views/modal/archetype_options_view.js.coffee b/app/assets/javascripts/discourse/views/modal/archetype_options_view.js.coffee new file mode 100644 index 00000000000..e933debd6b2 --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/archetype_options_view.js.coffee @@ -0,0 +1,16 @@ +window.Discourse.ArchetypeOptionsView = Em.ContainerView.extend + metaDataBinding: 'parentView.metaData' + + init: -> + @_super() + metaData = @get('metaData') + + @get('archetype.options').forEach (a) => + switch a.option_type + when 1 + checked = + @pushObject Discourse.OptionBooleanView.create + content: a + checked: (metaData.get(a.key) == 'true') + + diff --git a/app/assets/javascripts/discourse/views/modal/create_account_view.js.coffee b/app/assets/javascripts/discourse/views/modal/create_account_view.js.coffee new file mode 100644 index 00000000000..7f6d5098dd0 --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/create_account_view.js.coffee @@ -0,0 +1,140 @@ +window.Discourse.CreateAccountView = window.Discourse.ModalBodyView.extend Discourse.Presence, + templateName: 'modal/create_account' + title: Em.String.i18n('create_account.title') + uniqueUsernameValidation: null + complete: false + + + submitDisabled: (-> + return true if @get('nameValidation.failed') + return true if @get('emailValidation.failed') + return true if @get('usernameValidation.failed') + return true if @get('passwordValidation.failed') + false + ).property('nameValidation.failed', 'emailValidation.failed', 'usernameValidation.failed', 'passwordValidation.failed') + + passwordRequired: (-> + @blank('authOptions.auth_provider') + ).property('authOptions.auth_provider') + + # Validate the name + nameValidation: (-> + # If blank, fail without a reason + return Discourse.InputValidation.create(failed: true) if @blank('accountName') + + # If too short + return Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.name.too_short')) if @get('accountName').length < 3 + + # Looks good! + Discourse.InputValidation.create(ok: true, reason: Em.String.i18n('user.name.ok')) + ).property('accountName') + + + # Check the email address + emailValidation: (-> + # If blank, fail without a reason + return Discourse.InputValidation.create(failed: true) if @blank('accountEmail') + + email = @get("accountEmail") + if (@get('authOptions.email') is email) and @get('authOptions.email_valid') + return Discourse.InputValidation.create(ok: true, reason: Em.String.i18n('user.email.authenticated', provider: @get('authOptions.auth_provider'))) + + if Discourse.Utilities.emailValid(email) + return Discourse.InputValidation.create(ok: true, reason: Em.String.i18n('user.email.ok')) + + return Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.email.invalid')) + ).property('accountEmail') + + usernameMatch: (-> + if @get('emailValidation.failed') + if @shouldCheckUsernameMatch() + @set('uniqueUsernameValidation', Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.username.enter_email'))) + else + @set('uniqueUsernameValidation', Discourse.InputValidation.create(failed: true)) + else if @shouldCheckUsernameMatch() + @set('uniqueUsernameValidation', Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.username.checking'))) + @checkUsernameAvailability() + ).observes('accountEmail') + + basicUsernameValidation: (-> + @set('uniqueUsernameValidation', null) + + # If blank, fail without a reason + return Discourse.InputValidation.create(failed: true) if @blank('accountUsername') # + + # If too short + return Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.username.too_short')) if @get('accountUsername').length < 3 + + @checkUsernameAvailability() + + # Let's check it out asynchronously + Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.username.checking')) + + ).property('accountUsername') + + shouldCheckUsernameMatch: -> + !@blank('accountUsername') and @get('accountUsername').length > 2 + + checkUsernameAvailability: Discourse.debounce(-> + if @shouldCheckUsernameMatch() + Discourse.User.checkUsername(@get('accountUsername'), @get('accountEmail')).then (result) => + if result.available + if result.global_match + @set('uniqueUsernameValidation', Discourse.InputValidation.create(ok: true, reason: Em.String.i18n('user.username.global_match'))) + else + @set('uniqueUsernameValidation', Discourse.InputValidation.create(ok: true, reason: Em.String.i18n('user.username.available'))) + else + if result.suggestion + if result.global_match != undefined and result.global_match == false + @set('uniqueUsernameValidation', Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.username.global_mismatch', result))) + else + @set('uniqueUsernameValidation', Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.username.not_available', result))) + else if result.errors + @set('uniqueUsernameValidation', Discourse.InputValidation.create(failed: true, reason: result.errors.join(' '))) + else + @set('uniqueUsernameValidation', Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.username.enter_email', result))) + , 500) + + # Actually wait for the async name check before we're 100% sure we're good to go + usernameValidation: (-> + basicValidation = @get('basicUsernameValidation') + uniqueUsername = @get('uniqueUsernameValidation') + return uniqueUsername if uniqueUsername + basicValidation + ).property('uniqueUsernameValidation', 'basicUsernameValidation') + + # Validate the password + passwordValidation: (-> + + return Discourse.InputValidation.create(ok: true) unless @get('passwordRequired') + + # If blank, fail without a reason + password = @get("accountPassword") + return Discourse.InputValidation.create(failed: true) if @blank('accountPassword') + + # If too short + return Discourse.InputValidation.create(failed: true, reason: Em.String.i18n('user.password.too_short')) if password.length < 6 + + # Looks good! + Discourse.InputValidation.create(ok: true, reason: Em.String.i18n('user.password.ok')) + ).property('accountPassword') + + + createAccount: -> + name = @get('accountName') + email = @get('accountEmail') + password = @get('accountPassword') + username = @get('accountUsername') + + Discourse.User.createAccount(name, email, password, username).then (result) => + + if result.success + @flash(result.message) + @set('complete', true) + else + @flash(result.message, 'error') + + if result.active + window.location.reload() + , => + @flash(Em.String.i18n('create_account.failed'), 'error') \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/modal/edit_category_view.js.coffee b/app/assets/javascripts/discourse/views/modal/edit_category_view.js.coffee new file mode 100644 index 00000000000..3183957d46b --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/edit_category_view.js.coffee @@ -0,0 +1,45 @@ +window.Discourse.EditCategoryView = window.Discourse.ModalBodyView.extend + templateName: 'modal/edit_category' + appControllerBinding: 'Discourse.appController' + + disabled: (-> + return true if @get('saving') + return true unless @get('category.name') + return true unless @get('category.color') + false + ).property('category.name', 'category.color') + + colorStyle: (-> + "background-color: ##{@get('category.color')};" + ).property('category.color') + + title: (-> + if @get('category.id') then "Edit Category" else "Create Category" + ).property('category.id') + + buttonTitle: (-> + if @get('saving') then "Saving..." else @get('title') + ).property('title', 'saving') + + didInsertElement: -> + + @._super() + + if @get('category') + @set('id', @get('category.slug')) + else + @set('category', Discourse.Category.create(color: 'AB9364')) + + saveSuccess: (result) -> + $('#discourse-modal').modal('hide') + window.location = "/category/#{result.category.slug}" + + saveCategory: -> + + @set('saving', true) + @get('category').save + success: (result) => @saveSuccess(result) + error: (errors) => + @displayErrors(errors) + @set('saving', false) + diff --git a/app/assets/javascripts/discourse/views/modal/forgot_password_view.js.coffee b/app/assets/javascripts/discourse/views/modal/forgot_password_view.js.coffee new file mode 100644 index 00000000000..2089de65844 --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/forgot_password_view.js.coffee @@ -0,0 +1,12 @@ +window.Discourse.ForgotPasswordView = window.Discourse.ModalBodyView.extend Discourse.Presence, + templateName: 'modal/forgot_password' + title: Em.String.i18n('forgot_password.title') + + # You need a value in the field to submit it. + submitDisabled: (-> @blank('accountEmailOrUsername')).property('accountEmailOrUsername') + + submit: -> + $.post("/session/forgot_password", username: @get('accountEmailOrUsername')) + # don't tell people what happened, this keeps it more secure (ensure same on server) + @flash(Em.String.i18n('forgot_password.complete')) + false \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/modal/invite_modal_view.js.coffee b/app/assets/javascripts/discourse/views/modal/invite_modal_view.js.coffee new file mode 100644 index 00000000000..cdd127a1f82 --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/invite_modal_view.js.coffee @@ -0,0 +1,42 @@ +window.Discourse.InviteModalView = window.Discourse.ModalBodyView.extend Discourse.Presence, + templateName: 'modal/invite' + title: Em.String.i18n('topic.invite_reply.title') + + email: null + error: false + saving: false + finished: false + + disabled: (-> + return true if @get('saving') + return true if @blank('email') + return true unless Discourse.Utilities.emailValid(@get('email')) + false + ).property('email', 'saving') + + buttonTitle: (-> + return Em.String.i18n('topic.inviting') if @get('saving') + return Em.String.i18n('topic.invite_reply.title') + ).property('saving') + + successMessage: (-> + Em.String.i18n('topic.invite_reply.success', email: @get('email')) + ).property('email') + + didInsertElement: -> + Em.run.next => @.$('input').focus() + + createInvite: -> + @set('saving', true) + @set('error', false) + + @get('topic').inviteUser(@get('email')).then => + # Success + @set('saving', false) + @set('finished', true) + , => + # Failure + @set('error', true) + @set('saving', false) + + false diff --git a/app/assets/javascripts/discourse/views/modal/invite_private_modal_view.js.coffee b/app/assets/javascripts/discourse/views/modal/invite_private_modal_view.js.coffee new file mode 100644 index 00000000000..8d5af6959b0 --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/invite_private_modal_view.js.coffee @@ -0,0 +1,37 @@ +window.Discourse.InvitePrivateModalView = window.Discourse.ModalBodyView.extend Discourse.Presence, + templateName: 'modal/invite_private' + title: Em.String.i18n('topic.invite_private.title') + + email: null + error: false + saving: false + finished: false + + disabled: (-> + return true if @get('saving') + @blank('emailOrUsername') + ).property('emailOrUsername', 'saving') + + buttonTitle: (-> + return Em.String.i18n('topic.inviting') if @get('saving') + return Em.String.i18n('topic.invite_private.action') + ).property('saving') + + didInsertElement: -> + Em.run.next => @.$('input').focus() + + invite: -> + @set('saving', true) + @set('error', false) + + # Invite the user to the private conversation + @get('topic').inviteUser(@get('emailOrUsername')).then => + # Success + @set('saving', false) + @set('finished', true) + , => + # Failure + @set('error', true) + @set('saving', false) + + false \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/modal/login_view.js.coffee b/app/assets/javascripts/discourse/views/modal/login_view.js.coffee new file mode 100644 index 00000000000..5a7ab47908f --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/login_view.js.coffee @@ -0,0 +1,99 @@ +window.Discourse.LoginView = window.Discourse.ModalBodyView.extend Discourse.Presence, + templateName: 'modal/login' + siteBinding: 'Discourse.site' + title: Em.String.i18n('login.title') + authenticate: null + loggingIn: false + + showView: (view) -> @get('controller').show(view) + + newAccount: -> + @showView(Discourse.CreateAccountView.create()) + + forgotPassword: -> + @showView(Discourse.ForgotPasswordView.create()) + + loginButtonText: (-> + return Em.String.i18n('login.logging_in') if @get('loggingIn') + return Em.String.i18n('login.title') + ).property('loggingIn') + + loginDisabled: (-> + return true if @get('loggingIn') + return true if @blank('loginName') or @blank('loginPassword') + false + ).property('loginName', 'loginPassword', 'loggingIn') + + login: -> + @set('loggingIn', true) + $.post("/session", login: @get('loginName'), password: @get('loginPassword')) + .success (result) => + if result.error + @set('loggingIn', false) + @flash(result.error, 'error') + else + window.location.reload() + .fail (result) => + @flash(Em.String.i18n('login.error'), 'error') + @set('loggingIn', false) + false + + authMessage: (-> + return "" if @blank('authenticate') + Em.String.i18n("login.#{@get('authenticate')}.message") + ).property('authenticate') + + twitterLogin: ()-> + @set('authenticate', 'twitter') + left = @get('lastX') - 400 + top = @get('lastY') - 200 + window.open("/twitter/frame", "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top) + + facebookLogin: ()-> + @set('authenticate', 'facebook') + left = @get('lastX') - 400 + top = @get('lastY') - 200 + window.open("/facebook/frame", "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top) + + openidLogin: (provider)-> + left = @get('lastX') - 400 + top = @get('lastY') - 200 + if(provider == "yahoo") + @set("authenticate", 'yahoo') + window.open("/user_open_ids/frame?provider=yahoo", "_blank", "menubar=no,status=no,height=400,width=800,left=" + left + ",top=" + top) + else + window.open("/user_open_ids/frame?provider=google", "_blank", "menubar=no,status=no,height=500,width=850,left=" + left + ",top=" + top) + @set("authenticate", 'google') + + authenticationComplete: (options)-> + + if options['awaiting_approval'] + @flash(Em.String.i18n('login.awaiting_approval'), 'success') + @set('authenticate', null) + return + + if options['awaiting_activation'] + @flash(Em.String.i18n('login.awaiting_confirmation'), 'success') + @set('authenticate', null) + return + + # Reload the page if we're authenticated + if options['authenticated'] + window.location.reload() + return + + @showView Discourse.CreateAccountView.create + accountEmail: options['email'] + accountUsername: options['username'] + accountName: options['name'] + authOptions: options + + mouseMove: (e) -> + @set('lastX', e.screenX) + @set('lastY', e.screenY) + + didInsertElement: (e) -> + Em.run.next => + $('#login-account-password').keydown (e) => + @login() if e.keyCode == 13 + diff --git a/app/assets/javascripts/discourse/views/modal/modal_body_view.js.coffee b/app/assets/javascripts/discourse/views/modal/modal_body_view.js.coffee new file mode 100644 index 00000000000..d0c48f9310d --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/modal_body_view.js.coffee @@ -0,0 +1,18 @@ +window.Discourse.ModalBodyView = window.Discourse.View.extend + + # Focus on first element + didInsertElement: -> + Em.run.next => + @.$('form input:first').focus() + + # Pass the errors to our errors view + displayErrors: (errors, callback) -> + @set('parentView.modalErrorsView.errors', errors) + callback?() + + # Just use jQuery to show an alert. We don't need anythign fancier for now + # like an actual ember view + flash: (msg, flashClass="success") -> + $alert = $('#modal-alert').hide().removeClass('alert-error', 'alert-success') + $alert.addClass("alert alert-#{flashClass}").html(msg) + $alert.fadeIn() \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/modal/modal_view.js.coffee b/app/assets/javascripts/discourse/views/modal/modal_view.js.coffee new file mode 100644 index 00000000000..45a3518bba3 --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/modal_view.js.coffee @@ -0,0 +1,22 @@ +window.Discourse.ModalView = Ember.ContainerView.extend + childViews: ['modalHeaderView', 'modalBodyView', 'modalErrorsView'] + classNames: ['modal', 'hidden'] + classNameBindings: ['controller.currentView.modalClass'] + elementId: 'discourse-modal' + + modalHeaderView: Ember.View.create + templateName: 'modal/modal_header' + titleBinding: 'controller.currentView.title' + + modalBodyView: Ember.ContainerView.create(currentViewBinding: 'controller.currentView') + modalErrorsView: Ember.View.create(templateName: 'modal/modal_errors') + + viewChanged: (-> + + @set('modalErrorsView.errors', null) + if view = @get('controller.currentView') + $('#modal-alert').hide() + Em.run.next => @.$().modal('show') + + ).observes('controller.currentView') + diff --git a/app/assets/javascripts/discourse/views/modal/move_selected_view.js.coffee b/app/assets/javascripts/discourse/views/modal/move_selected_view.js.coffee new file mode 100644 index 00000000000..1fc79c683f9 --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/move_selected_view.js.coffee @@ -0,0 +1,39 @@ +window.Discourse.MoveSelectedView = window.Discourse.ModalBodyView.extend Discourse.Presence, + templateName: 'modal/move_selected' + title: Em.String.i18n('topic.move_selected.title') + + saving: false + + selectedCount: (-> + return 0 unless @get('selectedPosts') + @get('selectedPosts').length + ).property('selectedPosts') + + buttonDisabled: (-> + return true if @get('saving') + @blank('topicName') + ).property('saving', 'topicName') + + buttonTitle: (-> + return Em.String.i18n('saving') if @get('saving') + return Em.String.i18n('topic.move_selected.title') + ).property('saving') + + movePosts: -> + @set('saving', true) + + postIds = @get('selectedPosts').map (p) -> p.get('id') + + Discourse.Topic.movePosts(@get('topic.id'), @get('topicName'), postIds).then (result) => + if result.success + $('#discourse-modal').modal('hide') + Em.run.next -> + Discourse.routeTo(result.url) + else + @flash(Em.String.i18n('topic.move_selected.error')) + @set('saving', false) + , => + @flash(Em.String.i18n('topic.move_selected.error')) + @set('saving', false) + + false \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/modal/option_boolean_view.js.coffee b/app/assets/javascripts/discourse/views/modal/option_boolean_view.js.coffee new file mode 100644 index 00000000000..0d931f3d601 --- /dev/null +++ b/app/assets/javascripts/discourse/views/modal/option_boolean_view.js.coffee @@ -0,0 +1,14 @@ +window.Discourse.OptionBooleanView = Em.View.extend + classNames: ['archetype-option'] + composerControllerBinding: 'Discourse.router.composerController' + templateName: "modal/option_boolean" + + checkedChanged: (-> + metaData = @get('parentView.metaData') + metaData.set(@get('content.key'), if @get('checked') then 'true' else 'false') + @get('controller.controllers.composer').saveDraft() + ).observes('checked') + + init: -> + @._super() + @set('context', @get('content')) \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/nav_item_view.js.coffee b/app/assets/javascripts/discourse/views/nav_item_view.js.coffee new file mode 100644 index 00000000000..0fcf5e09e59 --- /dev/null +++ b/app/assets/javascripts/discourse/views/nav_item_view.js.coffee @@ -0,0 +1,36 @@ +window.Discourse.NavItemView = Ember.View.extend + tagName: 'li' + classNameBindings: ['isActive','content.hasIcon:has-icon'] + attributeBindings: ['title'] + title: (-> + name = @get('content.name') + categoryName = @get('content.categoryName') + if categoryName + extra = {categoryName: categoryName} + name = "category" + Ember.String.i18n("filters.#{name}.help", extra) + ).property("content.filter") + + isActive: (-> + return "active" if @get("content.name") == @get("controller.filterMode") + "" + ).property("content.name","controller.filterMode") + + hidden: (-> not @get('content.visible')).property('content.visible') + + name: (-> + name = @get('content.name') + categoryName = @get('content.categoryName') + extra = count: @get('content.count') || 0 + if categoryName + name = 'category' + extra.categoryName = categoryName.capitalize() + I18n.t("js.filters.#{name}.title", extra) + ).property('count') + + render: (buffer) -> + content = @get('content') + buffer.push("") + buffer.push("") if content.get('hasIcon') + buffer.push(@get('name')) + buffer.push("") diff --git a/app/assets/javascripts/discourse/views/notifications_view.js.coffee b/app/assets/javascripts/discourse/views/notifications_view.js.coffee new file mode 100644 index 00000000000..0e836baeb7a --- /dev/null +++ b/app/assets/javascripts/discourse/views/notifications_view.js.coffee @@ -0,0 +1,5 @@ +window.Discourse.NotificationsView = Ember.View.extend + classNameBindings: ['content.read', ':notifications'] + templateName: 'notifications' + + diff --git a/app/assets/javascripts/discourse/views/parent_view.js.coffee b/app/assets/javascripts/discourse/views/parent_view.js.coffee new file mode 100644 index 00000000000..bf7a24c626f --- /dev/null +++ b/app/assets/javascripts/discourse/views/parent_view.js.coffee @@ -0,0 +1,14 @@ +window.Discourse.ParentView = Discourse.EmbeddedPostView.extend + + # Nice animation for when the replies appear + didInsertElement: -> + @_super() + + $parentPost = @get('postView').$('section.parent-post') + + # Animate unless we're on a touch device + if Discourse.get('touch') + $parentPost.show() + else + $parentPost.slideDown() + diff --git a/app/assets/javascripts/discourse/views/participant_view.js.coffee b/app/assets/javascripts/discourse/views/participant_view.js.coffee new file mode 100644 index 00000000000..9fb71c683bd --- /dev/null +++ b/app/assets/javascripts/discourse/views/participant_view.js.coffee @@ -0,0 +1,7 @@ +window.Discourse.ParticipantView = Ember.View.extend + templateName: 'participant' + + toggled: (-> + @get('controller.userFilters').contains(@get('participant.username')) + ).property('controller.userFilters.[]') + diff --git a/app/assets/javascripts/discourse/views/post_link_view.js.coffee b/app/assets/javascripts/discourse/views/post_link_view.js.coffee new file mode 100644 index 00000000000..f8cd822985c --- /dev/null +++ b/app/assets/javascripts/discourse/views/post_link_view.js.coffee @@ -0,0 +1,16 @@ +window.Discourse.PostLinkView = Ember.View.extend + tagName: 'li' + classNameBindings: ['direction'] + + direction: (-> + return 'incoming' if @get('content.reflection') + null + ).property('content.reflection') + + render: (buffer) -> + buffer.push("\n") + buffer.push("") + buffer.push(@get('content.title')) + if clicks = @get('content.clicks') + buffer.push("\n#{clicks}") + buffer.push("") \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/post_menu_view.js.coffee b/app/assets/javascripts/discourse/views/post_menu_view.js.coffee new file mode 100644 index 00000000000..9961dd16fad --- /dev/null +++ b/app/assets/javascripts/discourse/views/post_menu_view.js.coffee @@ -0,0 +1,101 @@ +# +# This class replaces a containerView of many buttons, which was responsible for 100ms +# of client rendering or so on a fast computer. It might be slightly uglier, but it's +# _much_ faster. +# +window.Discourse.PostMenuView = Ember.View.extend Discourse.Presence, + tagName: 'section' + classNames: ['post-menu-area', 'clearfix'] + + # Delegate to render#{button} + render: (buffer) -> + post = @get('post') + + @renderReplies(post, buffer) + buffer.push("") + + # Delegate click actions + click: (e) -> + $target = $(e.target) + action = $target.data('action') || $target.parent().data('action') + return unless action + @["click#{action.capitalize()}"]?() + + # Trigger re rendering + needsToRender: (-> + @rerender() + ).observes('post.deleted_at', 'post.flagsAvailable.@each', 'post.url', 'post.bookmarked', 'post.reply_count', 'post.replyBelowUrl') + + # Replies Button + renderReplies: (post, buffer) -> + + return if @get('post.replyFollowing') + + reply_count = post.get('reply_count') + return if reply_count == 0 + + buffer.push("") + + clickReplies: -> @get('postView').showReplies() + + # Delete button + renderDelete: (post, buffer) -> + return unless post.get('can_delete') + + title = if post.get('deleted_at') then Em.String.i18n("post.controls.undelete") else Em.String.i18n("post.controls.delete") + buffer.push("") + + clickDelete: -> @get('controller').deletePost(@get('post')) + + # Like button + renderLike: (post, buffer) -> + return unless post.get('actionByName.like.can_act') + buffer.push("") + + clickLike: -> @get('post.actionByName.like')?.act() + + # Flag button + renderFlag: (post, buffer) -> + return unless @present('post.flagsAvailable') + buffer.push("") + + clickFlag: -> @get('controller').showFlags(@get('post')) + + # Edit button + renderEdit: (post, buffer) -> + return unless post.get('can_edit') + buffer.push("") + + clickEdit: -> @get('controller').editPost(@get('post')) + + # Share button + renderShare: (post, buffer) -> + buffer.push("") + + + # Reply button + renderReply: (post, buffer) -> + return unless @get('controller.content.can_create_post') + buffer.push("") + + clickReply: -> @get('controller').replyToPost(@get('post')) + + + # Bookmark button + renderBookmark: (post, buffer) -> + return unless Discourse.get('currentUser') + icon = 'bookmark' + icon += '-empty' unless @get('post.bookmarked') + buffer.push("") + + clickBookmark: -> @get('post').toggleProperty('bookmarked') + diff --git a/app/assets/javascripts/discourse/views/post_view.js.coffee b/app/assets/javascripts/discourse/views/post_view.js.coffee new file mode 100644 index 00000000000..179b299c9fb --- /dev/null +++ b/app/assets/javascripts/discourse/views/post_view.js.coffee @@ -0,0 +1,227 @@ +window.Discourse.PostView = Ember.View.extend + classNames: ['topic-post', 'clearfix'] + templateName: 'post' + classNameBindings: ['lastPostClass', 'postTypeClass', 'selectedClass', 'post.hidden:hidden', 'isDeleted:deleted', 'parentPost:replies-above'] + siteBinding: Ember.Binding.oneWay('Discourse.site') + composeViewBinding: Ember.Binding.oneWay('Discourse.composeView') + quoteButtonViewBinding: Ember.Binding.oneWay('Discourse.quoteButtonView') + postBinding: 'content' + + isDeleted: (-> + !!@get('post.deleted_at') + ).property('post.deleted_at') + + #TODO really we should do something cleaner here... this makes it work in debug but feels really messy + screenTrack: (-> + parentView = @get('parentView') + screenTrack = null + while parentView && !screenTrack + screenTrack = parentView.get('screenTrack') + parentView = parentView.get('parentView') + screenTrack + ).property('parentView') + + lastPostClass: (-> + return 'last-post' if @get('post.lastPost') + ).property('post.lastPost') + + postTypeClass: (-> + return 'moderator' if @get('post.post_type') == Discourse.Post.MODERATOR_ACTION_TYPE + 'regular' + ).property('post.post_type') + + selectedClass: (-> + return 'selected' if @get('post.selected') + null + ).property('post.selected') + + # If the cooked content changed, add the quote controls + cookedChanged: (-> + Em.run.next => @insertQuoteControls() + ).observes('post.cooked') + + init: -> + @._super() + @set('context', @get('content')) + + mouseDown: (e) -> + if qbc = Discourse.get('router.quoteButtonController') + qbc.mouseDown(e) + + mouseUp: (e) -> + if qbc = Discourse.get('router.quoteButtonController') + qbc.mouseUp(e) + + if @get('controller.multiSelect') && (e.metaKey || e.ctrlKey) + @toggleProperty('post.selected') + + $target = $(e.target) + return unless $target.closest('.cooked').length > 0 + if qbc = @get('controller.controllers.quoteButton') + e.context = @get('post') + qbc.selectText(e) + + + selectText: (-> + return Em.String.i18n('topic.multi_select.selected', count: @get('controller.selectedCount')) if @get('post.selected') + Em.String.i18n('topic.multi_select.select') + ).property('post.selected', 'controller.selectedCount') + + repliesHidden: (-> + !@get('repliesShown') + ).property('repliesShown') + + # Click on the replies button + showReplies: -> + + # If the reply is below, we route to it + if replyBelowUrl = @get('post.replyBelowUrl') + Discourse.routeTo(replyBelowUrl) + return false + + if @get('repliesShown') + @set('repliesShown', false) + else + @get('post').loadReplies().then => @set('repliesShown', true) + + false + + # Toggle visibility of parent post + toggleParent: (e) -> + + $parent = @.$('.parent-post') + if @get('parentPost') + $('nav', $parent).removeClass('toggled') + + # Don't animate on touch + if Discourse.get('touch') + $parent.hide() + @set('parentPost', null) + else + $parent.slideUp => @set('parentPost', null) + + else + post = @get('post') + @set('loadingParent', true) + $('nav', $parent).addClass('toggled') + Discourse.Post.loadByPostNumber post.get('topic_id'), post.get('reply_to_post_number'), (result) => + @set('loadingParent', false) + @set('parentPost', result) + + false + + updateQuoteElements: ($aside, desc) -> + navLink = "" + + quoteTitle = Em.String.i18n("post.follow_quote") + if postNumber = $aside.data('post') + + # If we have a topic reference + if topicId = $aside.data('topic') + topic = @get('controller.content') + + # If it's the same topic as ours, build the URL from the topic object + if topic and topic.get('id') is topicId + navLink = "" + else + # Made up slug should be replaced with canonical URL + navLink = "" + else if topic = @get('controller.content') + # assume the same topic + navLink = "" + + # Only add the expand/contract control if it's not a full post + expandContract = "" + unless $aside.data('full') + expandContract = "" + $aside.css('cursor', 'pointer') + + $('.quote-controls', $aside).html("#{expandContract}#{navLink}") + + toggleQuote: ($aside) -> + + @toggleProperty('quoteExpanded') + + if @get('quoteExpanded') + @updateQuoteElements($aside, 'chevron-up') + + # Show expanded quote + $blockQuote = $('blockquote', $aside) + @originalContents = $blockQuote.html() + + originalText = $blockQuote.text().trim() + + $blockQuote.html(Em.String.i18n("loading")) + + post = @get('post') + topic_id = post.get('topic_id') + topic_id = $aside.data('topic') if $aside.data('topic') + + jQuery.getJSON "/posts/by_number/#{topic_id}/#{$aside.data('post')}", (result) => + parsed = $(result.cooked) + parsed.replaceText(originalText, "#{originalText}") + + $blockQuote.showHtml(parsed) + else + # Hide expanded quote + @updateQuoteElements($aside, 'chevron-down') + $('blockquote', $aside).showHtml(@originalContents) + + false + + # Show how many times links have been clicked on + showLinkCounts: -> + if link_counts = @get('post.link_counts') + link_counts.each (lc) => + if lc.clicks > 0 + @.$(".cooked a[href]").each -> + link = $(this) + if link.attr('href') == lc.url + link.append("#{lc.clicks}") + + # Add the quote controls to a post + insertQuoteControls: -> + + @.$('aside.quote').each (i, e) => + $aside = $(e) + + @updateQuoteElements($aside, 'chevron-down') + $title = $('.title', $aside) + + # Unless it's a full quote, allow click to expand + unless $aside.data('full') or $title.data('has-quote-controls') + $title.on 'click', (e) => + return true if $(e.target).is('a') + @toggleQuote($aside) + $title.data('has-quote-controls', true) + + didInsertElement: (e) -> + + $post = @.$() + post = @get('post') + + # Do we want to scroll to this post now that we've inserted it? + if postNumber = post.get('scrollToAfterInsert') + Discourse.TopicView.scrollTo @get('post.topic_id'), postNumber + + if postNumber == post.get('post_number') + $contents = $('.topic-body .contents', $post) + originalCol = $contents.css('backgroundColor') + $contents.css(backgroundColor: "#ffffcc").animate(backgroundColor: originalCol, 2500) + + @showLinkCounts() + @get('screenTrack')?.track(@.$().prop('id'), @get('post.post_number')) + + # Add syntax highlighting + Discourse.SyntaxHighlighting.apply($post) + + # If we're scrolling upwards, adjust the scroll position accordingly + if scrollTo = @get('post.scrollTo') + newSize = ($(document).height() - scrollTo.height) + scrollTo.top + $('body').scrollTop(newSize) + $('section.divider').addClass('fade') + + # Find all the quotes + @insertQuoteControls() + + diff --git a/app/assets/javascripts/discourse/views/prepend_post_view.js.coffee b/app/assets/javascripts/discourse/views/prepend_post_view.js.coffee new file mode 100644 index 00000000000..69e4ccf0958 --- /dev/null +++ b/app/assets/javascripts/discourse/views/prepend_post_view.js.coffee @@ -0,0 +1,7 @@ +window.Discourse.PrependPostView = Em.ContainerView.extend + + init: -> + @_super() + @trigger('prependPostContent') + + diff --git a/app/assets/javascripts/discourse/views/quote_buton_view.js.coffee b/app/assets/javascripts/discourse/views/quote_buton_view.js.coffee new file mode 100644 index 00000000000..fb2e285c595 --- /dev/null +++ b/app/assets/javascripts/discourse/views/quote_buton_view.js.coffee @@ -0,0 +1,26 @@ +window.Discourse.QuoteButtonView = Discourse.View.extend + classNames: ['quote-button'] + classNameBindings: ['hasBuffer'] + + render: (buffer) -> buffer.push("quote reply") + + hasBuffer: (-> + return 'visible' if @present('controller.buffer') + null + ).property('controller.buffer') + + willDestroyElement: -> + $(document).unbind("mousedown.quote-button") + + didInsertElement: -> + # Clear quote button if they click elsewhere + $(document).bind "mousedown.quote-button", (e) => + return if $(e.target).hasClass('quote-button') + return if $(e.target).hasClass('create') + @controller.mouseDown(e) + @set('controller.lastSelected', @get('controller.buffer')) + @set('controller.buffer', '') + + click: (e) -> + @get('controller').quoteText(e) + diff --git a/app/assets/javascripts/discourse/views/replies_view.js.coffee b/app/assets/javascripts/discourse/views/replies_view.js.coffee new file mode 100644 index 00000000000..34ab2d636b8 --- /dev/null +++ b/app/assets/javascripts/discourse/views/replies_view.js.coffee @@ -0,0 +1,13 @@ +window.Discourse.RepliesView = Ember.CollectionView.extend + templateName: 'replies' + tagName: 'section' + classNames: ['replies-list', 'embedded-posts', 'bottom'] + itemViewClass: Discourse.EmbeddedPostView + + repliesShown: (-> + $this = @.$() + if @get('parentView.repliesShown') + Em.run.next -> $this.slideDown() + else + Em.run.next -> $this.slideUp() + ).observes('parentView.repliesShown') diff --git a/app/assets/javascripts/discourse/views/search/search_results_type_view.js.coffee b/app/assets/javascripts/discourse/views/search/search_results_type_view.js.coffee new file mode 100644 index 00000000000..c38aa632afa --- /dev/null +++ b/app/assets/javascripts/discourse/views/search/search_results_type_view.js.coffee @@ -0,0 +1,20 @@ +window.Discourse.SearchResultsTypeView = Ember.CollectionView.extend + tagName: 'ul' + + + itemViewClass: Ember.View.extend({ + tagName: 'li' + templateName: (-> + "search/#{@get('parentView.type')}_result" + ).property('parentView.type') + classNameBindings: ['selectedClass', 'parentView.type'] + selectedIndexBinding: 'parentView.parentView.selectedIndex' + + # Is this row currently selected by the keyboard? + selectedClass: (-> + return 'selected' if @get('content.index') == @get('selectedIndex') + null + ).property('selectedIndex') + + }) + diff --git a/app/assets/javascripts/discourse/views/search/search_view.js.coffee b/app/assets/javascripts/discourse/views/search/search_view.js.coffee new file mode 100644 index 00000000000..d4fdba0a22c --- /dev/null +++ b/app/assets/javascripts/discourse/views/search/search_view.js.coffee @@ -0,0 +1,115 @@ +window.Discourse.SearchView = Ember.View.extend Discourse.Presence, + tagName: 'div' + classNames: ['d-dropdown'] + elementId: 'search-dropdown' + templateName: 'search' + + didInsertElement: -> + # Delegate ESC to the composer + $('body').on 'keydown.search', (e) => + if $('#search-dropdown').is(':visible') + switch e.which + when 13 + @select() + when 38 # up arrow + @moveUp() + when 40 # down arrow + @moveDown() + + searchPlaceholder: (-> + Em.String.i18n("search.placeholder") + ).property() + + # If we need to perform another search + newSearchNeeded: (-> + @set('noResults', false) + if @present('term') + @set('loading', true) + @searchTerm(@get('term'), @get('typeFilter')) + else + @set('results', null) + @set('selectedIndex', 0) + ).observes('term', 'typeFilter') + + showCancelFilter: (-> + return false if @get('loading') + return @present('typeFilter') + ).property('typeFilter', 'loading') + + termChanged: (-> + @cancelType() + ).observes('term') + + # We can re-order them based on the context + content: (-> + if results = @get('results') + # Make it easy to find the results by type + results_hashed = {} + results.each (r) -> results_hashed[r.type] = r + + path = Discourse.get('router.currentState.path') + + # Default order + order = ['topic', 'category', 'user'] + + results = (order.map (o) -> results_hashed[o]).without(undefined) + + index = 0 + results.each (result) -> + result.results.each (item) -> item.index = index++ + + results + ).property('results') + + updateProgress: (-> + if results = @get('results') + @set('noResults', results.length == 0) + @set('loading', false) + ).observes('results') + + searchTerm: (term, typeFilter) -> + if @currentSearch + @currentSearch.abort() + @currentSearch = null + + @searcher = @searcher || Discourse.debounce((term, typeFilter) => + @currentSearch = $.ajax + url: '/search' + data: + term: term + type_filter: typeFilter + success: (results) => + @set('results', results) + , 300) + + @searcher(term, typeFilter) + + resultCount: (-> + return 0 if @blank('content') + count = 0 + @get('content').each (result) -> + count += result.results.length + count + ).property('content') + + moreOfType: (e) -> + @set('typeFilter', e.context) + false + + cancelType: -> + @set('typeFilter', null) + false + + moveUp: -> + return if @get('selectedIndex') == 0 + @set('selectedIndex', @get('selectedIndex') - 1) + + moveDown: -> + return if @get('resultCount') == (@get('selectedIndex') + 1) + @set('selectedIndex', @get('selectedIndex') + 1) + + select: -> + return if @get('loading') + href = $('#search-dropdown li.selected a').prop('href') + Discourse.routeTo(href) if href + false diff --git a/app/assets/javascripts/discourse/views/selected_posts_view.js.coffee b/app/assets/javascripts/discourse/views/selected_posts_view.js.coffee new file mode 100644 index 00000000000..1d5ecd6be31 --- /dev/null +++ b/app/assets/javascripts/discourse/views/selected_posts_view.js.coffee @@ -0,0 +1,9 @@ +window.Discourse.SelectedPostsView = Ember.View.extend + elementId: 'selected-posts' + templateName: 'selected_posts' + topicBinding: 'controller.content' + classNameBindings: ['customVisibility'] + + customVisibility: (-> + return 'hidden' unless @get('controller.multiSelect') + ).property('controller.multiSelect') \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/share_view.js.coffee b/app/assets/javascripts/discourse/views/share_view.js.coffee new file mode 100644 index 00000000000..eebf486c688 --- /dev/null +++ b/app/assets/javascripts/discourse/views/share_view.js.coffee @@ -0,0 +1,50 @@ +window.Discourse.ShareView = Discourse.View.extend + templateName: 'share' + elementId: 'share-link' + classNameBindings: ['hasLink'] + + title: (-> + if @get('controller.type') == 'topic' + Em.String.i18n('share.topic') + else + Em.String.i18n('share.post') + ).property('controller.type') + + hasLink: (-> + return 'visible' if @present('controller.link') + null + ).property('controller.link') + + linkChanged: (-> + if @present('controller.link') + $('#share-link input').val(@get('controller.link')).select().focus() + ).observes('controller.link') + + didInsertElement: -> + + $('html').on 'click.outside-share-link', (e) => + return if @.$().has(e.target).length isnt 0 + @get('controller').close() + return true + $('html').on 'touchstart.outside-share-link', (e) => + return if @.$().has(e.target).length isnt 0 + @get('controller').close() + return true + + $('html').on 'click.discoure-share-link', '[data-share-url]', (e) => + e.preventDefault() + $currentTarget = $(e.currentTarget) + url = $currentTarget.data('share-url') + + # Relative urls + if url.indexOf("/") is 0 + url = window.location.protocol + "//" + window.location.host + url + + @get('controller').shareLink(e, url) + false + + + willDestroyElement: -> + $('html').off 'click.discoure-share-link' + $('html').off 'click.outside-share-link' + $('html').off 'touchstart.outside-share-link' diff --git a/app/assets/javascripts/discourse/views/suggested_topic_view.js.coffee b/app/assets/javascripts/discourse/views/suggested_topic_view.js.coffee new file mode 100644 index 00000000000..19175befae3 --- /dev/null +++ b/app/assets/javascripts/discourse/views/suggested_topic_view.js.coffee @@ -0,0 +1,2 @@ +Discourse.SuggestedTopicView = Ember.View.extend + templateName: 'suggested_topic' diff --git a/app/assets/javascripts/discourse/views/topic_admin_menu_view.js.coffee b/app/assets/javascripts/discourse/views/topic_admin_menu_view.js.coffee new file mode 100644 index 00000000000..ac491e2de70 --- /dev/null +++ b/app/assets/javascripts/discourse/views/topic_admin_menu_view.js.coffee @@ -0,0 +1,11 @@ +window.Discourse.TopicAdminMenuView = Em.View.extend + + willDestroyElement: -> + $('html').off 'mouseup.discourse-topic-admin-menu' + + didInsertElement: -> + $('html').on 'mouseup.discourse-topic-admin-menu', (e) => + $target = $(e.target) + if $target.is('button') or @.$().has($target).length is 0 + @get('controller').hide() + diff --git a/app/assets/javascripts/discourse/views/topic_extra_info_view.js.coffee b/app/assets/javascripts/discourse/views/topic_extra_info_view.js.coffee new file mode 100644 index 00000000000..9eb9e2dc8d9 --- /dev/null +++ b/app/assets/javascripts/discourse/views/topic_extra_info_view.js.coffee @@ -0,0 +1,12 @@ +Discourse.TopicExtraInfoView = Ember.ContainerView.extend + classNameBindings: [':extra-info-wrapper', 'controller.showExtraInfo'] + childViews: ['extraInfo'] + + extraInfo: Em.View.createWithMixins + templateName: 'topic_extra_info' + classNames: ['extra-info'] + topicBinding: 'controller.topic' + + showFavoriteButton: (-> + Discourse.currentUser && !@get('topic.isPrivateMessage') + ).property('topic.isPrivateMessage') diff --git a/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js.coffee b/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js.coffee new file mode 100644 index 00000000000..cf2de07a6a0 --- /dev/null +++ b/app/assets/javascripts/discourse/views/topic_footer_buttons_view.js.coffee @@ -0,0 +1,84 @@ +window.Discourse.TopicFooterButtonsView = Ember.ContainerView.extend + elementId: 'topic-footer-buttons' + topicBinding: 'controller.content' + + init: -> + @_super() + @createButtons() + + # Add the buttons below a topic + createButtons: -> + topic = @get('topic') + + if Discourse.get('currentUser') + unless topic.get('isPrivateMessage') + # We hide some controls from private messages + + if @get('topic.can_invite_to') + @addObject Discourse.ButtonView.create + textKey: 'topic.invite_reply.title' + helpKey: 'topic.invite_reply.help' + renderIcon: (buffer) -> buffer.push("") + click: -> @get('controller').showInviteModal() + + @addObject Discourse.ButtonView.createWithMixins + textKey: 'favorite.title' + helpKey: 'favorite.help' + favoriteChanged: (-> @rerender() ).observes('controller.content.starred') + click: -> @get('controller').toggleStar() + renderIcon: (buffer) -> + extraClass = 'starred' if @get('controller.content.starred') + buffer.push("") + + @addObject Discourse.ButtonView.create + textKey: 'topic.share.title' + helpKey: 'topic.share.help' + renderIcon: (buffer) -> buffer.push("") + 'data-share-url': topic.get('url') + + @addObject Discourse.ButtonView.createWithMixins + classNames: ['btn', 'btn-primary', 'create'] + text: (-> + archetype = @get('controller.content.archetype') + return customTitle if customTitle = @get("parentView.replyButtonText#{archetype.capitalize()}") + Em.String.i18n("topic.reply.title") + ).property() + renderIcon: (buffer) -> buffer.push("") + click: -> @get('controller').reply() + helpKey: 'topic.reply.help' + + unless topic.get('isPrivateMessage') + @addObject Discourse.DropdownButtonView.createWithMixins + topic: topic + title: Em.String.i18n('topic.notifications.title') + longDescriptionBinding: 'topic.notificationReasonText' + text: (-> + key = switch @get('topic.notification_level') + when Discourse.Topic.NotificationLevel.WATCHING then 'watching' + when Discourse.Topic.NotificationLevel.TRACKING then 'tracking' + when Discourse.Topic.NotificationLevel.REGULAR then 'regular' + when Discourse.Topic.NotificationLevel.MUTE then 'muted' + icon = switch key + when 'watching' then ' ' + when 'tracking' then ' ' + when 'regular' then '' + when 'muted' then ' ' + "#{icon}#{Ember.String.i18n("topic.notifications.#{key}.title")}" + ).property('topic.notification_level') + dropDownContent: [ + [Discourse.Topic.NotificationLevel.WATCHING, 'topic.notifications.watching'], + [Discourse.Topic.NotificationLevel.TRACKING, 'topic.notifications.tracking'], + [Discourse.Topic.NotificationLevel.REGULAR, 'topic.notifications.regular'], + [Discourse.Topic.NotificationLevel.MUTE, 'topic.notifications.muted'] + ] + clicked: (id) -> + @get('topic').updateNotifications(id) + + @trigger('additionalButtons', @) + + else + # If not logged in give them a login control + @addObject Discourse.ButtonView.create + textKey: 'topic.login_reply' + classNames: ['btn', 'btn-primary', 'create'] + click: -> @get('controller.controllers.modal')?.show(Discourse.LoginView.create()) diff --git a/app/assets/javascripts/discourse/views/topic_posts_view.js.coffee b/app/assets/javascripts/discourse/views/topic_posts_view.js.coffee new file mode 100644 index 00000000000..c0c8503afd5 --- /dev/null +++ b/app/assets/javascripts/discourse/views/topic_posts_view.js.coffee @@ -0,0 +1,4 @@ +window.Discourse.TopicPostsView = Em.CollectionView.extend + itemViewClass: Discourse.PostView + + didInsertElement: -> @get('topicView').postsRendered() diff --git a/app/assets/javascripts/discourse/views/topic_status_view.js.coffee b/app/assets/javascripts/discourse/views/topic_status_view.js.coffee new file mode 100644 index 00000000000..892ec96089b --- /dev/null +++ b/app/assets/javascripts/discourse/views/topic_status_view.js.coffee @@ -0,0 +1,30 @@ +window.Discourse.TopicStatusView = Discourse.View.extend + classNames: ['topic-statuses'] + + hasDisplayableStatus: (-> + return true if @get('topic.closed') + return true if @get('topic.pinned') + return true unless @get('topic.archetype.isDefault') + return true unless @get('topic.visible') + false + ).property('topic.closed', 'topic.pinned', 'topic.visible') + + statusChanged: (-> + @rerender() + ).observes('topic.closed', 'topic.pinned', 'topic.visible') + + renderIcon: (buffer, name, key) -> + title = Em.String.i18n("topic_statuses.#{key}.help") + buffer.push("") + + render: (buffer) -> + return unless @get('hasDisplayableStatus') + + # Allow a plugin to add a custom icon to a topic + @trigger('addCustomIcon', buffer) + + @renderIcon(buffer, 'lock', 'locked') if @get('topic.closed') + @renderIcon(buffer, 'pushpin', 'pinned') if @get('topic.pinned') + @renderIcon(buffer, 'eye-close', 'invisible') unless @get('topic.visible') + + diff --git a/app/assets/javascripts/discourse/views/topic_summary/topic_links_view.js.coffee b/app/assets/javascripts/discourse/views/topic_summary/topic_links_view.js.coffee new file mode 100644 index 00000000000..a833e70529c --- /dev/null +++ b/app/assets/javascripts/discourse/views/topic_summary/topic_links_view.js.coffee @@ -0,0 +1,2 @@ +window.Discourse.TopicLinksView = Ember.View.extend + templateName: 'topic_summary/links' \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/topic_summary/topic_summary_view.js.coffee b/app/assets/javascripts/discourse/views/topic_summary/topic_summary_view.js.coffee new file mode 100644 index 00000000000..693f0df5560 --- /dev/null +++ b/app/assets/javascripts/discourse/views/topic_summary/topic_summary_view.js.coffee @@ -0,0 +1,63 @@ +window.Discourse.TopicSummaryView = Ember.ContainerView.extend Discourse.Presence, + topicBinding: 'controller.content' + classNameBindings: ['hidden', ':topic-summary'] + LINKS_SHOWN: 5 + + collapsed: true + allLinksShown: false + + showAllLinksControls: (-> + return false if @blank('topic.links') + return false if @get('allLinksShown') + return false if @get('topic.links.length') <= @LINKS_SHOWN + true + ).property('allLinksShown', 'topic.links') + + infoLinks: (-> + return [] if @blank('topic.links') + allLinks = @get('topic.links') + return allLinks if @get('allLinksShown') + return allLinks.slice(0, @LINKS_SHOWN) + ).property('topic.links', 'allLinksShown') + + newPostCreated: (-> + @rerender() + ).observes('topic.posts_count') + + hidden: (-> + return true unless @get('post.post_number') == 1 + return false if @get('controller.content.archetype') == 'private_message' + return true unless @get('controller.content.archetype') == 'regular' + @get('controller.content.posts_count') < 2 + ).property() + + init: -> + @_super() + return if @get('hidden') + + @pushObject Em.View.create(templateName: 'topic_summary/info', topic: @get('topic'), summaryView: @) + @trigger('appendSummaryInformation', @) + + toggleMore: -> + @toggleProperty('collapsed') + + showAllLinks: -> + @set('allLinksShown', true) + + appendSummaryInformation: (container) -> + + # If we have a best of view + if @get('controller.showBestOf') + container.pushObject Discourse.View.create + templateName: 'topic_summary/best_of_toggle' + tagName: 'section' + classNames: ['information'] + + # If we have a private message + if @get('topic.isPrivateMessage') + container.pushObject Discourse.View.create + templateName: 'topic_summary/private_message' + tagName: 'section' + classNames: ['information'] + + diff --git a/app/assets/javascripts/discourse/views/topic_view.js.coffee b/app/assets/javascripts/discourse/views/topic_view.js.coffee new file mode 100644 index 00000000000..953ed7a97fb --- /dev/null +++ b/app/assets/javascripts/discourse/views/topic_view.js.coffee @@ -0,0 +1,417 @@ +window.Discourse.TopicView = Ember.View.extend Discourse.Scrolling, + templateName: 'topic' + topicBinding: 'controller.content' + userFiltersBinding: 'controller.userFilters' + classNameBindings: ['controller.multiSelect:multi-select', 'topic.archetype'] + siteBinding: 'Discourse.site' + categoriesBinding: 'site.categories' + progressPosition: 1 + + menuVisible: true + + + SHORT_POST: 1200 + + # Update the progress bar using sweet animations + updateBar: (-> + return unless @get('topic.loaded') + $topicProgress = $('#topic-progress') + return unless $topicProgress.length + + # Don't show progress when there is only one post + if @get('topic.highest_post_number') is 1 + $topicProgress.hide() + else + $topicProgress.show() + + ratio = @get('progressPosition') / @get('topic.highest_post_number') + + totalWidth = $topicProgress.width() + progressWidth = ratio * totalWidth + bg = $topicProgress.find('.bg') + + bg.stop(true,true) + currentWidth = bg.width() + + if currentWidth == totalWidth + bg.width(currentWidth - 1) + + if progressWidth == totalWidth + bg.css("border-right-width", "0px") + else + bg.css("border-right-width", "1px") + + if currentWidth == 0 + bg.width(progressWidth) + else + bg.animate(width: progressWidth, 400) + + ).observes('progressPosition', 'topic.highest_post_number', 'topic.loaded') + + updateTitle: (-> + title = @get('topic.title') + Discourse.set('title', title) if title + ).observes('topic.loaded', 'topic.title') + + newPostsPresent: (-> + if @get('topic.highest_post_number') + @updateBar() + @examineRead() + ).observes('topic.highest_post_number') + + currentPostChanged: (-> + + current = @get('controller.currentPost') + topic = @get('topic') + return unless current and topic + + @set('maxPost', current) if current > (@get('maxPost') || 0) + + postUrl = topic.get('url') + if current > 1 + postUrl += "/#{current}" + else + postUrl += "/best_of" if @get('controller.bestOf') + + Discourse.replaceState(postUrl) + + # Show appropriate jump tools + if current is 1 then $('#jump-top').attr('disabled', true) else $('#jump-top').attr('disabled', false) + if current is @get('topic.highest_post_number') then $('#jump-bottom').attr('disabled', true) else $('#jump-bottom').attr('disabled', false) + + ).observes('controller.currentPost', 'controller.bestOf', 'topic.highest_post_number') + + composeChanged: (-> + composerController = Discourse.get('router.composerController') + composerController.clearState() + composerController.set('topic', @get('topic')) + ).observes('composer') + + # This view is being removed. Shut down operations + willDestroyElement: -> + @unbindScrolling() + @get('controller').unsubscribe() + @get('screenTrack')?.stop() + @set('screenTrack', null) + $(window).unbind 'scroll.discourse-on-scroll' + $(document).unbind 'touchmove.discourse-on-scroll' + $(window).unbind 'resize.discourse-on-scroll' + @resetExamineDockCache() + + didInsertElement: (e) -> + onScroll = Discourse.debounce((=> @onScroll()), 10) + $(window).bind 'scroll.discourse-on-scroll', onScroll + $(document).bind 'touchmove.discourse-on-scroll', onScroll + $(window).bind 'resize.discourse-on-scroll', onScroll + + @bindScrolling() + @get('controller').subscribe() + + # Insert our screen tracker + screenTrack = Discourse.ScreenTrack.create(topic_id: @get('topic.id')) + screenTrack.start() + @set('screenTrack', screenTrack) + + # Track the user's eyeline + eyeline = new Discourse.Eyeline('.topic-post') + eyeline.on 'saw', (e) => @postSeen(e.detail) + eyeline.on 'sawBottom', (e) => @nextPage(e.detail) + eyeline.on 'sawTop', (e) => @prevPage(e.detail) + @set('eyeline', eyeline) + + @.$().on 'mouseup.discourse-redirect', '.cooked a, a.track-link', (e) -> + Discourse.ClickTrack.trackClick(e) + + @onScroll() + + # Triggered from the post view all posts are rendered + postsRendered: (postDiv, post)-> + $window = $(window) + $lastPost = $('.row:last') + # we consider stuff at the end of the list as read, right away (if it is visible) + if $window.height() + $window.scrollTop() >= $lastPost.offset().top + $lastPost.height() + @examineRead() + else + # last is not in view, so only examine in 2 seconds + Em.run.later => + @examineRead() + , 2000 + + resetRead: (e) -> + @get('screenTrack').cancel() + @set('screenTrack', null) + @get('controller').unsubscribe() + + @get('topic').resetRead => + @set('controller.message', "Your read position has been reset.") + @set('controller.loaded', false) + + # Called for every post seen + postSeen: ($post) -> + @set('postNumberSeen', null) + postView = Ember.View.views[$post.prop('id')] + if postView + post = postView.get('post') + @set('postNumberSeen', post.get('post_number')) + if post.get('post_number') > (@get('topic.last_read_post_number') || 0) + @set('topic.last_read_post_number', post.get('post_number')) + unless post.get('read') + post.set('read', true) + @get('screenTrack')?.guessedSeen(post.get('post_number')) + + observeFirstPostLoaded: (-> + posts = @get('topic.posts') + + # TODO topic.posts stores non ember objects in it for a period of time, this is bad + loaded = posts && posts[0] && posts[0].post_number == 1 + + # I avoided a computed property cause I did not want to set it, over and over again + old = @get('firstPostLoaded') + if loaded + @set('firstPostLoaded', true) unless old == true + else + @set('firstPostLoaded', false) unless old == false + + ).observes('topic.posts.@each') + + # Load previous posts if there are some + prevPage: ($post) -> + postView = Ember.View.views[$post.prop('id')] + return unless postView + post = postView.get('post') + return unless post + + # We don't load upwards from the first page + return if post.post_number == 1 + + # double check + if @topic && @topic.posts && @topic.posts.length > 0 && @topic.posts.first().post_number != post.post_number + return + + # half mutex + return if @loading + + @set('loading', true) + @set('loadingAbove', true) + + opts = $.extend {postsBefore: post.get('post_number')}, @get('controller.postFilters') + Discourse.Topic.find(@get('topic.id'), opts).then (result) => + posts = @get('topic.posts') + + # Add a scrollTo record to the last post inserted to the DOM + lastPostNum = result.posts.first().post_number + result.posts.each (p) => + newPost = Discourse.Post.create(p, @get('topic')) + if p.post_number == lastPostNum + newPost.set 'scrollTo', top: $(window).scrollTop(), height: $(document).height() + posts.unshiftObject(newPost) + + @set('loading', false) + @set('loadingAbove', false) + + + fullyLoaded: (-> + @seenBottom || @topic.at_bottom + ).property('topic.at_bottom', 'seenBottom') + + # Load new posts if there are some + nextPage: ($post) -> + + return if @loading || @seenBottom + postView = Ember.View.views[$post.prop('id')] + return unless postView + post = postView.get('post') + @loadMore(post) + + postCountChanged:(-> + @set('seenBottom',false) + @get('eyeline')?.update() + ).observes('topic.highest_post_number') + + loadMore: (post)-> + return if @loading || @seenBottom + + # Don't load if we know we're at the bottom + if @get('topic.highest_post_number') is post.get('post_number') + @get('eyeline')?.flushRest() + + # Update our current post to the last number we saw + @set('controller.currentPost', postNumberSeen) if postNumberSeen = @get('postNumberSeen') + return + + # Don't double load ever + if @topic.posts.last().post_number != post.post_number + return + + @set('loadingBelow', true) + @set('loading', true) + opts = $.extend {postsAfter: post.get('post_number')}, @get('controller.postFilters') + Discourse.Topic.find(@get('topic.id'), opts).then (result) => + if result.at_bottom || result.posts.length == 0 + @set('seenBottom', 'true') + + @get('topic').pushPosts result.posts.map (p) => + Discourse.Post.create(p, @get('topic')) + + if result.suggested_topics + suggested = Em.A() + result.suggested_topics.each (st) -> + suggested.pushObject(Discourse.Topic.create(st)) + @set('topic.suggested_topics', suggested) + + @set('loadingBelow', false) + @set('loading', false) + + # Examine which posts are on the screen and mark them as read. Also figure out if we + # need to load more posts. + examineRead: -> + # Track posts time on screen + @get('screenTrack')?.scrolled() + + # Update what we can see + @get('eyeline')?.update() + + # Update our current post to the last number we saw + @set('controller.currentPost', postNumberSeen) if postNumberSeen = @get('postNumberSeen') + + cancelEdit: -> + @set('editingTopic', false) + + finishedEdit: -> + if @get('editingTopic') + topic = @get('topic') + topic.set('title', $('#edit-title').val()) + topic.save() + @set('editingTopic', false) + + editTopic: -> + return false unless @get('topic.can_edit') + @set('editingTopic', true) + false + + showFavoriteButton: (-> + Discourse.currentUser && !@get('topic.isPrivateMessage') + ).property('topic.isPrivateMessage') + + resetExamineDockCache: -> + @docAt = null + @dockedTitle = false + @dockedCounter = false + + detectDockPosition: -> + rows = $(".topic-post") + return unless rows.length > 0 + + i = parseInt(rows.length / 2, 10) + increment = parseInt(rows.length / 4, 10) + goingUp = `undefined` + + winOffset = window.pageYOffset || $('html').scrollTop() + winHeight = window.innerHeight || $(window).height() + + loop + break if i is 0 or (i >= rows.length - 1) + + current = $(rows[i]) + offset = current.offset() + + if offset.top - winHeight < winOffset + if offset.top + current.outerHeight() - window.innerHeight > winOffset + break + else + i = i + increment + break if goingUp isnt `undefined` and increment is 1 and not goingUp + goingUp = true + else + i = i - increment + break if goingUp isnt `undefined` and increment is 1 and goingUp + goingUp = false + + if increment > 1 + increment = parseInt(increment / 2, 10) + goingUp = `undefined` + if increment == 0 + increment = 1 + goingUp = `undefined` + + postView = Ember.View.views[rows[i].id] + return unless postView + post = postView.get('post') + return unless post + @set('progressPosition', post.get('post_number')) + + return + + ensureDockIsTestedOnChange: (-> + # this is subtle, firstPostLoaded will trigger ember to render the view containing #topic-title + # onScroll needs do know about it to be able to make a decision about the dock + # + + Em.run.next @, @onScroll + ).observes('firstPostLoaded') + + onScroll: -> + @detectDockPosition() + offset = window.pageYOffset || $('html').scrollTop() + firstLoaded = @get('firstPostLoaded') + + unless @docAt + title = $('#topic-title') + if title && title.length == 1 + @docAt = title.offset().top + + if @docAt + @set('controller.showExtraHeaderInfo', offset >= @docAt || !firstLoaded) + else + @set('controller.showExtraHeaderInfo', !firstLoaded) + + + # there is a whole bunch of caching we could add here + $lastPost = $('.last-post') + lastPostOffset = $lastPost.offset() + + return unless lastPostOffset # there is an edge case while stuff is loading + + if offset >= (lastPostOffset.top + $lastPost.height()) - $(window).height() + unless @dockedCounter + $('#topic-progress-wrapper').addClass('docked') + @dockedCounter = true + else + if @dockedCounter + $('#topic-progress-wrapper').removeClass('docked') + @dockedCounter = false + + browseMoreMessage: (-> + opts = {popularLink: "#{Em.String.i18n("topic.view_popular_topics")}"} + + if category = @get('controller.content.category') + opts.catLink = Discourse.Utilities.categoryLink(category) + Ember.String.i18n("topic.read_more_in_category", opts) + else + opts.catLink = "#{Em.String.i18n("topic.browse_all_categories")}" + Ember.String.i18n("topic.read_more", opts) + ).property() + + + # The window has been scrolled + scrolled: (e) -> @examineRead() + +window.Discourse.TopicView.reopenClass + + # Scroll to a given post, if in the DOM. Returns whether it was in the DOM or not. + scrollTo: (topicId, postNumber, callback) -> + + + # Make sure we're looking at the topic we want to scroll to + return false unless parseInt(topicId) == parseInt($('#topic').data('topic-id')) + + existing = $("#post_#{postNumber}") + if existing.length + if postNumber == 1 + $('html, body').scrollTop(0) + else + $('html, body').scrollTop(existing.offset().top - 55) + return true + + false + diff --git a/app/assets/javascripts/discourse/views/user/activity_filter_view.js.coffee b/app/assets/javascripts/discourse/views/user/activity_filter_view.js.coffee new file mode 100644 index 00000000000..15e4701a5a4 --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/activity_filter_view.js.coffee @@ -0,0 +1,24 @@ +window.Discourse.ActivityFilterView = Em.View.extend Discourse.Presence, + tagName: 'li' + classNameBindings: ['active'] + + active: (-> + if content = @get('content') + return parseInt(@get('controller.content.streamFilter')) is parseInt(Em.get(content, 'action_type')) + else + return @blank('controller.content.streamFilter') + ).property('controller.content.streamFilter', 'content.action_type') + + render: (buffer) -> + if content = @get('content') + count = Em.get(content, 'count') + description = Em.get(content, 'description') + else + count = @get('count') + description = Em.String.i18n("user.filters.all") + + buffer.push("#{description} (#{count})") + + click: -> + @get('controller.content').filterStream(@get('content.action_type')) + false \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/user/preferences_email_view.js.coffee b/app/assets/javascripts/discourse/views/user/preferences_email_view.js.coffee new file mode 100644 index 00000000000..9a15387e88d --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/preferences_email_view.js.coffee @@ -0,0 +1,6 @@ +window.Discourse.PreferencesEmailView = Ember.View.extend + templateName: 'user/email' + classNames: ['user-preferences'] + + didInsertElement: -> + $('#change_email').focus() \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/user/preferences_username_view.js.coffee b/app/assets/javascripts/discourse/views/user/preferences_username_view.js.coffee new file mode 100644 index 00000000000..c67722c64a5 --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/preferences_username_view.js.coffee @@ -0,0 +1,7 @@ +window.Discourse.PreferencesUsernameView = Ember.View.extend + templateName: 'user/username' + classNames: ['user-preferences'] + + + didInsertElement: -> + $('#change_username').focus() \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/user/preferences_view.js.coffee b/app/assets/javascripts/discourse/views/user/preferences_view.js.coffee new file mode 100644 index 00000000000..9176e9bd943 --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/preferences_view.js.coffee @@ -0,0 +1,5 @@ +window.Discourse.PreferencesView = Ember.View.extend + templateName: 'user/preferences' + classNames: ['user-preferences'] + + diff --git a/app/assets/javascripts/discourse/views/user/user_activity_view.js.coffee b/app/assets/javascripts/discourse/views/user/user_activity_view.js.coffee new file mode 100644 index 00000000000..a083b26aeb3 --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/user_activity_view.js.coffee @@ -0,0 +1,8 @@ +window.Discourse.UserActivityView = Ember.View.extend + templateName: 'user/activity' + currentUserBinding: 'Discourse.currentUser' + userBinding: 'controller.content' + + + didInsertElement: -> + window.scrollTo(0, 0) \ No newline at end of file diff --git a/app/assets/javascripts/discourse/views/user/user_invited_view.js.coffee b/app/assets/javascripts/discourse/views/user/user_invited_view.js.coffee new file mode 100644 index 00000000000..2901493bac4 --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/user_invited_view.js.coffee @@ -0,0 +1,3 @@ +window.Discourse.UserInvitedView = Ember.View.extend + templateName: 'user/invited' + diff --git a/app/assets/javascripts/discourse/views/user/user_private_messages_view.js.coffee b/app/assets/javascripts/discourse/views/user/user_private_messages_view.js.coffee new file mode 100644 index 00000000000..a3cb2649e47 --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/user_private_messages_view.js.coffee @@ -0,0 +1,17 @@ +window.Discourse.UserPrivateMessagesView = Ember.View.extend + templateName: 'user/private_messages' + elementId: 'user-private-messages' + + selectCurrent: (evt) -> + t = $(evt.currentTarget) + t.closest('.action-list').find('li').removeClass('active') + t.closest('li').addClass('active') + + inbox: (evt)-> + @selectCurrent(evt) + @set('controller.filter', 13) + + sentMessages: (evt) -> + @selectCurrent(evt) + @set('controller.filter', 12) + diff --git a/app/assets/javascripts/discourse/views/user/user_stream_view.js.coffee b/app/assets/javascripts/discourse/views/user/user_stream_view.js.coffee new file mode 100644 index 00000000000..cf59c560910 --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/user_stream_view.js.coffee @@ -0,0 +1,31 @@ +window.Discourse.UserStreamView = Ember.View.extend Discourse.Scrolling, + templateName: 'user/stream' + currentUserBinding: 'Discourse.currentUser' + userBinding: 'controller.content' + + scrolled: (e) -> + $userStreamBottom = $('#user-stream-bottom') + return if $userStreamBottom.data('loading') + return unless $userStreamBottom and (position = $userStreamBottom.position()) + docViewTop = $(window).scrollTop() + windowHeight = $(window).height() + docViewBottom = docViewTop + windowHeight + + @set('loading', true) + if (position.top < docViewBottom) + $userStreamBottom.data('loading', true) + @set('loading', true) + @get('controller.content').loadMoreUserActions => + @set('loading', false) + Em.run.next => + $userStreamBottom.data('loading', null) + + + willDestroyElement: -> + Discourse.MessageBus.unsubscribe "/users/#{@get('user.username').toLowerCase()}" + @unbindScrolling() + + didInsertElement: -> + Discourse.MessageBus.subscribe "/users/#{@get('user.username').toLowerCase()}", (data)=> + @get('user').loadUserAction(data) + @bindScrolling() diff --git a/app/assets/javascripts/discourse/views/user/user_view.js.coffee b/app/assets/javascripts/discourse/views/user/user_view.js.coffee new file mode 100644 index 00000000000..523844c4495 --- /dev/null +++ b/app/assets/javascripts/discourse/views/user/user_view.js.coffee @@ -0,0 +1,2 @@ +window.Discourse.UserView = Ember.View.extend + templateName: 'user/user' diff --git a/app/assets/javascripts/discourse/views/view.js.coffee b/app/assets/javascripts/discourse/views/view.js.coffee new file mode 100644 index 00000000000..66dd672d489 --- /dev/null +++ b/app/assets/javascripts/discourse/views/view.js.coffee @@ -0,0 +1,6 @@ +window.Discourse.View = Ember.View.extend Discourse.Presence, + + # Overwrite this to do a different display + displayErrors: (errors, callback) -> + alert(errors.join("\n")) + callback?() diff --git a/app/assets/javascripts/env.js.coffee b/app/assets/javascripts/env.js.coffee new file mode 100644 index 00000000000..960efe3c8a3 --- /dev/null +++ b/app/assets/javascripts/env.js.coffee @@ -0,0 +1,8 @@ +# These will help us migrate up to the new ember's default behavior +window.ENV = + CP_DEFAULT_CACHEABLE: true + VIEW_PRESERVES_CONTEXT: true + MANDATORY_SETTER: false # make it more like ember.prod.js + +window.Discourse = {} +window.Discourse.SiteSettings = {} diff --git a/app/assets/javascripts/external/LAB.js b/app/assets/javascripts/external/LAB.js new file mode 100644 index 00000000000..e710dfea28b --- /dev/null +++ b/app/assets/javascripts/external/LAB.js @@ -0,0 +1,5 @@ +/*! LAB.js (LABjs :: Loading And Blocking JavaScript) + v2.0.3 (c) Kyle Simpson + MIT License +*/ +(function(o){var K=o.$LAB,y="UseLocalXHR",z="AlwaysPreserveOrder",u="AllowDuplicates",A="CacheBust",B="BasePath",C=/^[^?#]*\//.exec(location.href)[0],D=/^\w+\:\/\/\/?[^\/]+/.exec(C)[0],i=document.head||document.getElementsByTagName("head"),L=(o.opera&&Object.prototype.toString.call(o.opera)=="[object Opera]")||("MozAppearance"in document.documentElement.style),q=document.createElement("script"),E=typeof q.preload=="boolean",r=E||(q.readyState&&q.readyState=="uninitialized"),F=!r&&q.async===true,M=!r&&!F&&!L;function G(a){return Object.prototype.toString.call(a)=="[object Function]"}function H(a){return Object.prototype.toString.call(a)=="[object Array]"}function N(a,c){var b=/^\w+\:\/\//;if(/^\/\/\/?/.test(a)){a=location.protocol+a}else if(!b.test(a)&&a.charAt(0)!="/"){a=(c||"")+a}return b.test(a)?a:((a.charAt(0)=="/"?D:C)+a)}function s(a,c){for(var b in a){if(a.hasOwnProperty(b)){c[b]=a[b]}}return c}function O(a){var c=false;for(var b=0;b0){for(var a=0;a=0;){d=n.shift();a=a[d.type].apply(null,d.args)}return a},noConflict:function(){o.$LAB=K;return m},sandbox:function(){return J()}};return m}o.$LAB=J();(function(a,c,b){if(document.readyState==null&&document[a]){document.readyState="loading";document[a](c,b=function(){document.removeEventListener(c,b,false);document.readyState="complete"},false)}})("addEventListener","DOMContentLoaded")})(this); \ No newline at end of file diff --git a/app/assets/javascripts/external/Markdown.Converter.js b/app/assets/javascripts/external/Markdown.Converter.js new file mode 100644 index 00000000000..c9059e640b4 --- /dev/null +++ b/app/assets/javascripts/external/Markdown.Converter.js @@ -0,0 +1,1314 @@ +var Markdown; + +if (typeof exports === "object" && typeof require === "function") // we're in a CommonJS (e.g. Node.js) module + Markdown = exports; +else + Markdown = {}; + +// The following text is included for historical reasons, but should +// be taken with a pinch of salt; it's not all true anymore. + +// +// Wherever possible, Showdown is a straight, line-by-line port +// of the Perl version of Markdown. +// +// This is not a normal parser design; it's basically just a +// series of string substitutions. It's hard to read and +// maintain this way, but keeping Showdown close to the original +// design makes it easier to port new features. +// +// More importantly, Showdown behaves like markdown.pl in most +// edge cases. So web applications can do client-side preview +// in Javascript, and then build identical HTML on the server. +// +// This port needs the new RegExp functionality of ECMA 262, +// 3rd Edition (i.e. Javascript 1.5). Most modern web browsers +// should do fine. Even with the new regular expression features, +// We do a lot of work to emulate Perl's regex functionality. +// The tricky changes in this file mostly have the "attacklab:" +// label. Major or self-explanatory changes don't. +// +// Smart diff tools like Araxis Merge will be able to match up +// this file with markdown.pl in a useful way. A little tweaking +// helps: in a copy of markdown.pl, replace "#" with "//" and +// replace "$text" with "text". Be sure to ignore whitespace +// and line endings. +// + + +// +// Usage: +// +// var text = "Markdown *rocks*."; +// +// var converter = new Markdown.Converter(); +// var html = converter.makeHtml(text); +// +// alert(html); +// +// Note: move the sample code to the bottom of this +// file before uncommenting it. +// + +(function () { + + function identity(x) { return x; } + function returnFalse(x) { return false; } + + function HookCollection() { } + + HookCollection.prototype = { + + chain: function (hookname, func) { + var original = this[hookname]; + if (!original) + throw new Error("unknown hook " + hookname); + + if (original === identity) + this[hookname] = func; + else + this[hookname] = function (x) { return func(original(x)); } + }, + set: function (hookname, func) { + if (!this[hookname]) + throw new Error("unknown hook " + hookname); + this[hookname] = func; + }, + addNoop: function (hookname) { + this[hookname] = identity; + }, + addFalse: function (hookname) { + this[hookname] = returnFalse; + } + }; + + Markdown.HookCollection = HookCollection; + + // g_urls and g_titles allow arbitrary user-entered strings as keys. This + // caused an exception (and hence stopped the rendering) when the user entered + // e.g. [push] or [__proto__]. Adding a prefix to the actual key prevents this + // (since no builtin property starts with "s_"). See + // http://meta.stackoverflow.com/questions/64655/strange-wmd-bug + // (granted, switching from Array() to Object() alone would have left only __proto__ + // to be a problem) + function SaveHash() { } + SaveHash.prototype = { + set: function (key, value) { + this["s_" + key] = value; + }, + get: function (key) { + return this["s_" + key]; + } + }; + + Markdown.Converter = function () { + var pluginHooks = this.hooks = new HookCollection(); + pluginHooks.addNoop("plainLinkText"); // given a URL that was encountered by itself (without markup), should return the link text that's to be given to this link + pluginHooks.addNoop("preConversion"); // called with the orignal text as given to makeHtml. The result of this plugin hook is the actual markdown source that will be cooked + pluginHooks.addNoop("postConversion"); // called with the final cooked HTML code. The result of this plugin hook is the actual output of makeHtml + + // + // Private state of the converter instance: + // + + // Global hashes, used by various utility routines + var g_urls; + var g_titles; + var g_html_blocks; + + // Used to track when we're inside an ordered or unordered list + // (see _ProcessListItems() for details): + var g_list_level; + + this.makeHtml = function (text) { + + // + // Main function. The order in which other subs are called here is + // essential. Link and image substitutions need to happen before + // _EscapeSpecialCharsWithinTagAttributes(), so that any *'s or _'s in the + // and tags get encoded. + // + + // This will only happen if makeHtml on the same converter instance is called from a plugin hook. + // Don't do that. + if (g_urls) + throw new Error("Recursive call to converter.makeHtml"); + + // Create the private state objects. + g_urls = new SaveHash(); + g_titles = new SaveHash(); + g_html_blocks = []; + g_list_level = 0; + + text = pluginHooks.preConversion(text); + + // attacklab: Replace ~ with ~T + // This lets us use tilde as an escape char to avoid md5 hashes + // The choice of character is arbitray; anything that isn't + // magic in Markdown will work. + text = text.replace(/~/g, "~T"); + + // attacklab: Replace $ with ~D + // RegExp interprets $ as a special character + // when it's in a replacement string + text = text.replace(/\$/g, "~D"); + + // Standardize line endings + text = text.replace(/\r\n/g, "\n"); // DOS to Unix + text = text.replace(/\r/g, "\n"); // Mac to Unix + + // Make sure text begins and ends with a couple of newlines: + text = "\n\n" + text + "\n\n"; + + // Convert all tabs to spaces. + text = _Detab(text); + + // Strip any lines consisting only of spaces and tabs. + // This makes subsequent regexen easier to write, because we can + // match consecutive blank lines with /\n+/ instead of something + // contorted like /[ \t]*\n+/ . + text = text.replace(/^[ \t]+$/mg, ""); + + // Turn block-level HTML blocks into hash entries + text = _HashHTMLBlocks(text); + + // Strip link definitions, store in hashes. + text = _StripLinkDefinitions(text); + + text = _RunBlockGamut(text); + + text = _UnescapeSpecialChars(text); + + // attacklab: Restore dollar signs + text = text.replace(/~D/g, "$$"); + + // attacklab: Restore tildes + text = text.replace(/~T/g, "~"); + + text = pluginHooks.postConversion(text); + + g_html_blocks = g_titles = g_urls = null; + + return text; + }; + + function _StripLinkDefinitions(text) { + // + // Strips link definitions from text, stores the URLs and titles in + // hash references. + // + + // Link defs are in the form: ^[id]: url "optional title" + + /* + text = text.replace(/ + ^[ ]{0,3}\[(.+)\]: // id = $1 attacklab: g_tab_width - 1 + [ \t]* + \n? // maybe *one* newline + [ \t]* + ? // url = $2 + (?=\s|$) // lookahead for whitespace instead of the lookbehind removed below + [ \t]* + \n? // maybe one newline + [ \t]* + ( // (potential) title = $3 + (\n*) // any lines skipped = $4 attacklab: lookbehind removed + [ \t]+ + ["(] + (.+?) // title = $5 + [")] + [ \t]* + )? // title is optional + (?:\n+|$) + /gm, function(){...}); + */ + + text = text.replace(/^[ ]{0,3}\[(.+)\]:[ \t]*\n?[ \t]*?(?=\s|$)[ \t]*\n?[ \t]*((\n*)["(](.+?)[")][ \t]*)?(?:\n+)/gm, + function (wholeMatch, m1, m2, m3, m4, m5) { + m1 = m1.toLowerCase(); + g_urls.set(m1, _EncodeAmpsAndAngles(m2)); // Link IDs are case-insensitive + if (m4) { + // Oops, found blank lines, so it's not a title. + // Put back the parenthetical statement we stole. + return m3; + } else if (m5) { + g_titles.set(m1, m5.replace(/"/g, """)); + } + + // Completely remove the definition from the text + return ""; + } + ); + + return text; + } + + function _HashHTMLBlocks(text) { + + // Hashify HTML blocks: + // We only want to do this for block-level HTML tags, such as headers, + // lists, and tables. That's because we still want to wrap

    s around + // "paragraphs" that are wrapped in non-block-level tags, such as anchors, + // phrase emphasis, and spans. The list of tags we're looking for is + // hard-coded: + var block_tags_a = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del" + var block_tags_b = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math" + + // First, look for nested blocks, e.g.: + //

    + //
    + // tags for inner block must be indented. + //
    + //
    + // + // The outermost tags must start at the left margin for this to match, and + // the inner nested divs must be indented. + // We need to do this before the next, more liberal match, because the next + // match will start at the first `
    ` and stop at the first `
    `. + + // attacklab: This regex can be expensive when it fails. + + /* + text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_a) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*?\n // any number of lines, minimally matching + // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del)\b[^\r]*?\n<\/\2>[ \t]*(?=\n+))/gm, hashElement); + + // + // Now match more liberally, simply from `\n` to `\n` + // + + /* + text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_b) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*? // any number of lines, minimally matching + .* // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math)\b[^\r]*?.*<\/\2>[ \t]*(?=\n+)\n)/gm, hashElement); + + // Special case just for
    . It was easier to make a special case than + // to make the other regex more complicated. + + /* + text = text.replace(/ + \n // Starting after a blank line + [ ]{0,3} + ( // save in $1 + (<(hr) // start tag = $2 + \b // word break + ([^<>])*? + \/?>) // the matching end tag + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashElement); + */ + text = text.replace(/\n[ ]{0,3}((<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g, hashElement); + + // Special case for standalone HTML comments: + + /* + text = text.replace(/ + \n\n // Starting after a blank line + [ ]{0,3} // attacklab: g_tab_width - 1 + ( // save in $1 + -]|-[^>])(?:[^-]|-[^-])*)--) // see http://www.w3.org/TR/html-markup/syntax.html#comments and http://meta.stackoverflow.com/q/95256 + > + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashElement); + */ + text = text.replace(/\n\n[ ]{0,3}(-]|-[^>])(?:[^-]|-[^-])*)--)>[ \t]*(?=\n{2,}))/g, hashElement); + + // PHP and ASP-style processor instructions ( and <%...%>) + + /* + text = text.replace(/ + (?: + \n\n // Starting after a blank line + ) + ( // save in $1 + [ ]{0,3} // attacklab: g_tab_width - 1 + (?: + <([?%]) // $2 + [^\r]*? + \2> + ) + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashElement); + */ + text = text.replace(/(?:\n\n)([ ]{0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g, hashElement); + + return text; + } + + function hashElement(wholeMatch, m1) { + var blockText = m1; + + // Undo double lines + blockText = blockText.replace(/^\n+/, ""); + + // strip trailing blank lines + blockText = blockText.replace(/\n+$/g, ""); + + // Replace the element text with a marker ("~KxK" where x is its key) + blockText = "\n\n~K" + (g_html_blocks.push(blockText) - 1) + "K\n\n"; + + return blockText; + } + + function _RunBlockGamut(text, doNotUnhash) { + // + // These are all the transformations that form block-level + // tags like paragraphs, headers, and list items. + // + text = _DoHeaders(text); + + // Do Horizontal Rules: + var replacement = "
    \n"; + text = text.replace(/^[ ]{0,2}([ ]?\*[ ]?){3,}[ \t]*$/gm, replacement); + text = text.replace(/^[ ]{0,2}([ ]?-[ ]?){3,}[ \t]*$/gm, replacement); + text = text.replace(/^[ ]{0,2}([ ]?_[ ]?){3,}[ \t]*$/gm, replacement); + + text = _DoLists(text); + text = _DoCodeBlocks(text); + text = _DoBlockQuotes(text); + + // We already ran _HashHTMLBlocks() before, in Markdown(), but that + // was to escape raw HTML in the original Markdown source. This time, + // we're escaping the markup we've just created, so that we don't wrap + //

    tags around block-level tags. + text = _HashHTMLBlocks(text); + text = _FormParagraphs(text, doNotUnhash); + + return text; + } + + function _RunSpanGamut(text) { + // + // These are all the transformations that occur *within* block-level + // tags like paragraphs, headers, and list items. + // + + text = _DoCodeSpans(text); + text = _EscapeSpecialCharsWithinTagAttributes(text); + text = _EncodeBackslashEscapes(text); + + // Process anchor and image tags. Images must come first, + // because ![foo][f] looks like an anchor. + text = _DoImages(text); + text = _DoAnchors(text); + + // Make links out of things like `` + // Must come after _DoAnchors(), because you can use < and > + // delimiters in inline links like [this](). + text = _DoAutoLinks(text); + + text = text.replace(/~P/g, "://"); // put in place to prevent autolinking; reset now + + text = _EncodeAmpsAndAngles(text); + text = _DoItalicsAndBold(text); + + // Do hard breaks: + text = text.replace(/ +\n/g, "
    \n"); + + return text; + } + + function _EscapeSpecialCharsWithinTagAttributes(text) { + // + // Within tags -- meaning between < and > -- encode [\ ` * _] so they + // don't conflict with their use in Markdown for code, italics and strong. + // + + // Build a regex to find HTML tags and comments. See Friedl's + // "Mastering Regular Expressions", 2nd Ed., pp. 200-201. + + // SE: changed the comment part of the regex + + var regex = /(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|-]|-[^>])(?:[^-]|-[^-])*)--)>)/gi; + + text = text.replace(regex, function (wholeMatch) { + var tag = wholeMatch.replace(/(.)<\/?code>(?=.)/g, "$1`"); + tag = escapeCharacters(tag, wholeMatch.charAt(1) == "!" ? "\\`*_/" : "\\`*_"); // also escape slashes in comments to prevent autolinking there -- http://meta.stackoverflow.com/questions/95987 + return tag; + }); + + return text; + } + + function _DoAnchors(text) { + // + // Turn Markdown link shortcuts into XHTML
    tags. + // + // + // First, handle reference-style links: [link text] [id] + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[] // or anything else + )* + ) + \] + + [ ]? // one optional space + (?:\n[ ]*)? // one optional newline followed by spaces + + \[ + (.*?) // id = $3 + \] + ) + ()()()() // pad remaining backreferences + /g, writeAnchorTag); + */ + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, writeAnchorTag); + + // + // Next, inline-style links: [link text](url "optional title") + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[\]] // or anything else + )* + ) + \] + \( // literal paren + [ \t]* + () // no id, so leave $3 empty + ? + [ \t]* + ( // $5 + (['"]) // quote char = $6 + (.*?) // Title = $7 + \6 // matching quote + [ \t]* // ignore any spaces/tabs between closing quote and ) + )? // title is optional + \) + ) + /g, writeAnchorTag); + */ + + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, writeAnchorTag); + + // + // Last, handle reference-style shortcuts: [link text] + // These must come last in case you've also got [link test][1] + // or [link test](/foo) + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ([^\[\]]+) // link text = $2; can't contain '[' or ']' + \] + ) + ()()()()() // pad rest of backreferences + /g, writeAnchorTag); + */ + text = text.replace(/(\[([^\[\]]+)\])()()()()()/g, writeAnchorTag); + + return text; + } + + function writeAnchorTag(wholeMatch, m1, m2, m3, m4, m5, m6, m7) { + if (m7 == undefined) m7 = ""; + var whole_match = m1; + var link_text = m2.replace(/:\/\//g, "~P"); // to prevent auto-linking withing the link. will be converted back after the auto-linker runs + var link_id = m3.toLowerCase(); + var url = m4; + var title = m7; + + if (url == "") { + if (link_id == "") { + // lower-case and turn embedded newlines into spaces + link_id = link_text.toLowerCase().replace(/ ?\n/g, " "); + } + url = "#" + link_id; + + if (g_urls.get(link_id) != undefined) { + url = g_urls.get(link_id); + if (g_titles.get(link_id) != undefined) { + title = g_titles.get(link_id); + } + } + else { + if (whole_match.search(/\(\s*\)$/m) > -1) { + // Special case for explicit empty url + url = ""; + } else { + return whole_match; + } + } + } + url = encodeProblemUrlChars(url); + url = escapeCharacters(url, "*_"); + var result = ""; + + return result; + } + + function _DoImages(text) { + // + // Turn Markdown image shortcuts into tags. + // + + // + // First, handle reference-style labeled images: ![alt text][id] + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + !\[ + (.*?) // alt text = $2 + \] + + [ ]? // one optional space + (?:\n[ ]*)? // one optional newline followed by spaces + + \[ + (.*?) // id = $3 + \] + ) + ()()()() // pad rest of backreferences + /g, writeImageTag); + */ + text = text.replace(/(!\[(.*?)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, writeImageTag); + + // + // Next, handle inline images: ![alt text](url "optional title") + // Don't forget: encode * and _ + + /* + text = text.replace(/ + ( // wrap whole match in $1 + !\[ + (.*?) // alt text = $2 + \] + \s? // One optional whitespace character + \( // literal paren + [ \t]* + () // no id, so leave $3 empty + ? // src url = $4 + [ \t]* + ( // $5 + (['"]) // quote char = $6 + (.*?) // title = $7 + \6 // matching quote + [ \t]* + )? // title is optional + \) + ) + /g, writeImageTag); + */ + text = text.replace(/(!\[(.*?)\]\s?\([ \t]*()?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, writeImageTag); + + return text; + } + + function attributeEncode(text) { + // unconditionally replace angle brackets here -- what ends up in an attribute (e.g. alt or title) + // never makes sense to have verbatim HTML in it (and the sanitizer would totally break it) + return text.replace(/>/g, ">").replace(/" + _RunSpanGamut(m1) + "\n\n"; } + ); + + text = text.replace(/^(.+)[ \t]*\n-+[ \t]*\n+/gm, + function (matchFound, m1) { return "

    " + _RunSpanGamut(m1) + "

    \n\n"; } + ); + + // atx-style headers: + // # Header 1 + // ## Header 2 + // ## Header 2 with closing hashes ## + // ... + // ###### Header 6 + // + + /* + text = text.replace(/ + ^(\#{1,6}) // $1 = string of #'s + [ \t]* + (.+?) // $2 = Header text + [ \t]* + \#* // optional closing #'s (not counted) + \n+ + /gm, function() {...}); + */ + + text = text.replace(/^(\#{1,6})[ \t]*(.+?)[ \t]*\#*\n+/gm, + function (wholeMatch, m1, m2) { + var h_level = m1.length; + return "" + _RunSpanGamut(m2) + "\n\n"; + } + ); + + return text; + } + + function _DoLists(text) { + // + // Form HTML ordered (numbered) and unordered (bulleted) lists. + // + + // attacklab: add sentinel to hack around khtml/safari bug: + // http://bugs.webkit.org/show_bug.cgi?id=11231 + text += "~0"; + + // Re-usable pattern to match any entirel ul or ol list: + + /* + var whole_list = / + ( // $1 = whole list + ( // $2 + [ ]{0,3} // attacklab: g_tab_width - 1 + ([*+-]|\d+[.]) // $3 = first list item marker + [ \t]+ + ) + [^\r]+? + ( // $4 + ~0 // sentinel for workaround; should be $ + | + \n{2,} + (?=\S) + (?! // Negative lookahead for another list item marker + [ \t]* + (?:[*+-]|\d+[.])[ \t]+ + ) + ) + ) + /g + */ + var whole_list = /^(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm; + + if (g_list_level) { + text = text.replace(whole_list, function (wholeMatch, m1, m2) { + var list = m1; + var list_type = (m2.search(/[*+-]/g) > -1) ? "ul" : "ol"; + + var result = _ProcessListItems(list, list_type); + + // Trim any trailing whitespace, to put the closing `` + // up on the preceding line, to get it past the current stupid + // HTML block parser. This is a hack to work around the terrible + // hack that is the HTML block parser. + result = result.replace(/\s+$/, ""); + result = "<" + list_type + ">" + result + "\n"; + return result; + }); + } else { + whole_list = /(\n\n|^\n?)(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/g; + text = text.replace(whole_list, function (wholeMatch, m1, m2, m3) { + var runup = m1; + var list = m2; + + var list_type = (m3.search(/[*+-]/g) > -1) ? "ul" : "ol"; + var result = _ProcessListItems(list, list_type); + result = runup + "<" + list_type + ">\n" + result + "\n"; + return result; + }); + } + + // attacklab: strip sentinel + text = text.replace(/~0/, ""); + + return text; + } + + var _listItemMarkers = { ol: "\\d+[.]", ul: "[*+-]" }; + + function _ProcessListItems(list_str, list_type) { + // + // Process the contents of a single ordered or unordered list, splitting it + // into individual list items. + // + // list_type is either "ul" or "ol". + + // The $g_list_level global keeps track of when we're inside a list. + // Each time we enter a list, we increment it; when we leave a list, + // we decrement. If it's zero, we're not in a list anymore. + // + // We do this because when we're not inside a list, we want to treat + // something like this: + // + // I recommend upgrading to version + // 8. Oops, now this line is treated + // as a sub-list. + // + // As a single paragraph, despite the fact that the second line starts + // with a digit-period-space sequence. + // + // Whereas when we're inside a list (or sub-list), that line will be + // treated as the start of a sub-list. What a kludge, huh? This is + // an aspect of Markdown's syntax that's hard to parse perfectly + // without resorting to mind-reading. Perhaps the solution is to + // change the syntax rules such that sub-lists must start with a + // starting cardinal number; e.g. "1." or "a.". + + g_list_level++; + + // trim trailing blank lines: + list_str = list_str.replace(/\n{2,}$/, "\n"); + + // attacklab: add sentinel to emulate \z + list_str += "~0"; + + // In the original attacklab showdown, list_type was not given to this function, and anything + // that matched /[*+-]|\d+[.]/ would just create the next
  • , causing this mismatch: + // + // Markdown rendered by WMD rendered by MarkdownSharp + // ------------------------------------------------------------------ + // 1. first 1. first 1. first + // 2. second 2. second 2. second + // - third 3. third * third + // + // We changed this to behave identical to MarkdownSharp. This is the constructed RegEx, + // with {MARKER} being one of \d+[.] or [*+-], depending on list_type: + + /* + list_str = list_str.replace(/ + (^[ \t]*) // leading whitespace = $1 + ({MARKER}) [ \t]+ // list marker = $2 + ([^\r]+? // list item text = $3 + (\n+) + ) + (?= + (~0 | \2 ({MARKER}) [ \t]+) + ) + /gm, function(){...}); + */ + + var marker = _listItemMarkers[list_type]; + var re = new RegExp("(^[ \\t]*)(" + marker + ")[ \\t]+([^\\r]+?(\\n+))(?=(~0|\\1(" + marker + ")[ \\t]+))", "gm"); + var last_item_had_a_double_newline = false; + list_str = list_str.replace(re, + function (wholeMatch, m1, m2, m3) { + var item = m3; + var leading_space = m1; + var ends_with_double_newline = /\n\n$/.test(item); + var contains_double_newline = ends_with_double_newline || item.search(/\n{2,}/) > -1; + + if (contains_double_newline || last_item_had_a_double_newline) { + item = _RunBlockGamut(_Outdent(item), /* doNotUnhash = */true); + } + else { + // Recursion for sub-lists: + item = _DoLists(_Outdent(item)); + item = item.replace(/\n$/, ""); // chomp(item) + item = _RunSpanGamut(item); + } + last_item_had_a_double_newline = ends_with_double_newline; + return "
  • " + item + "
  • \n"; + } + ); + + // attacklab: strip sentinel + list_str = list_str.replace(/~0/g, ""); + + g_list_level--; + return list_str; + } + + function _DoCodeBlocks(text) { + // + // Process Markdown `
    ` blocks.
    +            //  
    +
    +            /*
    +            text = text.replace(/
    +                (?:\n\n|^)
    +                (                               // $1 = the code block -- one or more lines, starting with a space/tab
    +                    (?:
    +                        (?:[ ]{4}|\t)           // Lines must start with a tab or a tab-width of spaces - attacklab: g_tab_width
    +                        .*\n+
    +                    )+
    +                )
    +                (\n*[ ]{0,3}[^ \t\n]|(?=~0))    // attacklab: g_tab_width
    +            /g ,function(){...});
    +            */
    +
    +            // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug
    +            text += "~0";
    +
    +            text = text.replace(/(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=~0))/g,
    +                function (wholeMatch, m1, m2) {
    +                    var codeblock = m1;
    +                    var nextChar = m2;
    +
    +                    codeblock = _EncodeCode(_Outdent(codeblock));
    +                    codeblock = _Detab(codeblock);
    +                    codeblock = codeblock.replace(/^\n+/g, ""); // trim leading newlines
    +                    codeblock = codeblock.replace(/\n+$/g, ""); // trim trailing whitespace
    +
    +                    codeblock = "
    " + codeblock + "\n
    "; + + return "\n\n" + codeblock + "\n\n" + nextChar; + } + ); + + // attacklab: strip sentinel + text = text.replace(/~0/, ""); + + return text; + } + + function hashBlock(text) { + text = text.replace(/(^\n+|\n+$)/g, ""); + return "\n\n~K" + (g_html_blocks.push(text) - 1) + "K\n\n"; + } + + function _DoCodeSpans(text) { + // + // * Backtick quotes are used for spans. + // + // * You can use multiple backticks as the delimiters if you want to + // include literal backticks in the code span. So, this input: + // + // Just type ``foo `bar` baz`` at the prompt. + // + // Will translate to: + // + //

    Just type foo `bar` baz at the prompt.

    + // + // There's no arbitrary limit to the number of backticks you + // can use as delimters. If you need three consecutive backticks + // in your code, use four for delimiters, etc. + // + // * You can use spaces to get literal backticks at the edges: + // + // ... type `` `bar` `` ... + // + // Turns to: + // + // ... type `bar` ... + // + + /* + text = text.replace(/ + (^|[^\\]) // Character before opening ` can't be a backslash + (`+) // $2 = Opening run of ` + ( // $3 = The code block + [^\r]*? + [^`] // attacklab: work around lack of lookbehind + ) + \2 // Matching closer + (?!`) + /gm, function(){...}); + */ + + text = text.replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm, + function (wholeMatch, m1, m2, m3, m4) { + var c = m3; + c = c.replace(/^([ \t]*)/g, ""); // leading whitespace + c = c.replace(/[ \t]*$/g, ""); // trailing whitespace + c = _EncodeCode(c); + c = c.replace(/:\/\//g, "~P"); // to prevent auto-linking. Not necessary in code *blocks*, but in code spans. Will be converted back after the auto-linker runs. + return m1 + "" + c + ""; + } + ); + + return text; + } + + function _EncodeCode(text) { + // + // Encode/escape certain characters inside Markdown code runs. + // The point is that in code, these characters are literals, + // and lose their special Markdown meanings. + // + // Encode all ampersands; HTML entities are not + // entities within a Markdown code span. + text = text.replace(/&/g, "&"); + + // Do the angle bracket song and dance: + text = text.replace(//g, ">"); + + // Now, escape characters that are magic in Markdown: + text = escapeCharacters(text, "\*_{}[]\\", false); + + // jj the line above breaks this: + //--- + + //* Item + + // 1. Subitem + + // special char: * + //--- + + return text; + } + + function _DoItalicsAndBold(text) { + + // must go first: + text = text.replace(/([\W_]|^)(\*\*|__)(?=\S)([^\r]*?\S[\*_]*)\2([\W_]|$)/g, + "$1$3$4"); + + text = text.replace(/([\W_]|^)(\*|_)(?=\S)([^\r\*_]*?\S)\2([\W_]|$)/g, + "$1$3$4"); + + return text; + } + + function _DoBlockQuotes(text) { + + /* + text = text.replace(/ + ( // Wrap whole match in $1 + ( + ^[ \t]*>[ \t]? // '>' at the start of a line + .+\n // rest of the first line + (.+\n)* // subsequent consecutive lines + \n* // blanks + )+ + ) + /gm, function(){...}); + */ + + text = text.replace(/((^[ \t]*>[ \t]?.+\n(.+\n)*\n*)+)/gm, + function (wholeMatch, m1) { + var bq = m1; + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + + bq = bq.replace(/^[ \t]*>[ \t]?/gm, "~0"); // trim one level of quoting + + // attacklab: clean up hack + bq = bq.replace(/~0/g, ""); + + bq = bq.replace(/^[ \t]+$/gm, ""); // trim whitespace-only lines + bq = _RunBlockGamut(bq); // recurse + + bq = bq.replace(/(^|\n)/g, "$1 "); + // These leading spaces screw with
     content, so we need to fix that:
    +                    bq = bq.replace(
    +                            /(\s*
    [^\r]+?<\/pre>)/gm,
    +                        function (wholeMatch, m1) {
    +                            var pre = m1;
    +                            // attacklab: hack around Konqueror 3.5.4 bug:
    +                            pre = pre.replace(/^  /mg, "~0");
    +                            pre = pre.replace(/~0/g, "");
    +                            return pre;
    +                        });
    +
    +                    return hashBlock("
    \n" + bq + "\n
    "); + } + ); + return text; + } + + function _FormParagraphs(text, doNotUnhash) { + // + // Params: + // $text - string to process with html

    tags + // + + // Strip leading and trailing lines: + text = text.replace(/^\n+/g, ""); + text = text.replace(/\n+$/g, ""); + + var grafs = text.split(/\n{2,}/g); + var grafsOut = []; + + var markerRe = /~K(\d+)K/; + + // + // Wrap

    tags. + // + var end = grafs.length; + for (var i = 0; i < end; i++) { + var str = grafs[i]; + + // if this is an HTML marker, copy it + if (markerRe.test(str)) { + grafsOut.push(str); + } + else if (/\S/.test(str)) { + str = _RunSpanGamut(str); + str = str.replace(/^([ \t]*)/g, "

    "); + str += "

    " + grafsOut.push(str); + } + + } + // + // Unhashify HTML blocks + // + if (!doNotUnhash) { + end = grafsOut.length; + for (var i = 0; i < end; i++) { + var foundAny = true; + while (foundAny) { // we may need several runs, since the data may be nested + foundAny = false; + grafsOut[i] = grafsOut[i].replace(/~K(\d+)K/g, function (wholeMatch, id) { + foundAny = true; + return g_html_blocks[id]; + }); + } + } + } + return grafsOut.join("\n\n"); + } + + function _EncodeAmpsAndAngles(text) { + // Smart processing for ampersands and angle brackets that need to be encoded. + + // Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin: + // http://bumppo.net/projects/amputator/ + text = text.replace(/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/g, "&"); + + // Encode naked <'s + text = text.replace(/<(?![a-z\/?\$!])/gi, "<"); + + return text; + } + + function _EncodeBackslashEscapes(text) { + // + // Parameter: String. + // Returns: The string, with after processing the following backslash + // escape sequences. + // + + // attacklab: The polite way to do this is with the new + // escapeCharacters() function: + // + // text = escapeCharacters(text,"\\",true); + // text = escapeCharacters(text,"`*_{}[]()>#+-.!",true); + // + // ...but we're sidestepping its use of the (slow) RegExp constructor + // as an optimization for Firefox. This function gets called a LOT. + + text = text.replace(/\\(\\)/g, escapeCharacters_callback); + text = text.replace(/\\([`*_{}\[\]()>#+-.!])/g, escapeCharacters_callback); + return text; + } + + function _DoAutoLinks(text) { + + // note that at this point, all other URL in the text are already hyperlinked as
    + // *except* for the case + + // automatically add < and > around unadorned raw hyperlinks + // must be preceded by space/BOF and followed by non-word/EOF character + text = text.replace(/(^|\s)(https?|ftp)(:\/\/[-A-Z0-9+&@#\/%?=~_|\[\]\(\)!:,\.;]*[-A-Z0-9+&@#\/%=~_|\[\]\)])($|\W)/gi, "$1<$2$3>$4"); + + // autolink anything like + + var replacer = function (wholematch, m1) { + m1encoded = m1.replace(/\_\_/, '%5F%5F'); + return "" + pluginHooks.plainLinkText(m1) + ""; + } + text = text.replace(/<((https?|ftp):[^'">\s]+)>/gi, replacer); + + return text; + } + + function _UnescapeSpecialChars(text) { + // + // Swap back in all the special characters we've hidden. + // + text = text.replace(/~E(\d+)E/g, + function (wholeMatch, m1) { + var charCodeToReplace = parseInt(m1); + return String.fromCharCode(charCodeToReplace); + } + ); + return text; + } + + function _Outdent(text) { + // + // Remove one level of line-leading tabs or spaces + // + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + + text = text.replace(/^(\t|[ ]{1,4})/gm, "~0"); // attacklab: g_tab_width + + // attacklab: clean up hack + text = text.replace(/~0/g, "") + + return text; + } + + function _Detab(text) { + if (!/\t/.test(text)) + return text; + + var spaces = [" ", " ", " ", " "], + skew = 0, + v; + + return text.replace(/[\n\t]/g, function (match, offset) { + if (match === "\n") { + skew = offset + 1; + return match; + } + v = (offset - skew) % 4; + skew = offset + 1; + return spaces[v]; + }); + } + + // + // attacklab: Utility functions + // + + var _problemUrlChars = /(?:["'*()[\]:]|~D)/g; + + // hex-encodes some unusual "problem" chars in URLs to avoid URL detection problems + function encodeProblemUrlChars(url) { + if (!url) + return ""; + + var len = url.length; + + return url.replace(_problemUrlChars, function (match, offset) { + if (match == "~D") // escape for dollar + return "%24"; + if (match == ":") { + if (offset == len - 1 || /[0-9\/]/.test(url.charAt(offset + 1))) + return ":" + } + return "%" + match.charCodeAt(0).toString(16); + }); + } + + + function escapeCharacters(text, charsToEscape, afterBackslash) { + // First we have to escape the escape characters so that + // we can build a character class out of them + var regexString = "([" + charsToEscape.replace(/([\[\]\\])/g, "\\$1") + "])"; + + if (afterBackslash) { + regexString = "\\\\" + regexString; + } + + var regex = new RegExp(regexString, "g"); + text = text.replace(regex, escapeCharacters_callback); + + return text; + } + + + function escapeCharacters_callback(wholeMatch, m1) { + var charCodeToEscape = m1.charCodeAt(0); + return "~E" + charCodeToEscape + "E"; + } + + }; // end of the Markdown.Converter constructor + +})(); diff --git a/app/assets/javascripts/external/Markdown.Editor.js b/app/assets/javascripts/external/Markdown.Editor.js new file mode 100644 index 00000000000..80aa78fc246 --- /dev/null +++ b/app/assets/javascripts/external/Markdown.Editor.js @@ -0,0 +1,2213 @@ +// needs Markdown.Converter.js at the moment + + +// To insert extra buttons: +// +// Before this file is required, define a PagedownCustom object. Give it an attribtue of insertButtons, which is an array +// of the buttons you want to insert. For example: +// +// window.PagedownCustom = { +// insertButtons: [ +// { +// id: 'wmd-bark', +// description: 'Bark', +// execute: function() { +// return alert('woof!'); +// } +// } +// ] +// }; +// + +(function () { + + var util = {}, + position = {}, + ui = {}, + doc = window.document, + re = window.RegExp, + nav = window.navigator, + SETTINGS = { lineLength: 72 }, + + // Used to work around some browser bugs where we can't use feature testing. + uaSniffed = { + isIE: /msie/.test(nav.userAgent.toLowerCase()), + isIE_5or6: /msie 6/.test(nav.userAgent.toLowerCase()) || /msie 5/.test(nav.userAgent.toLowerCase()), + isOpera: /opera/.test(nav.userAgent.toLowerCase()) + }; + + + // ------------------------------------------------------------------- + // YOUR CHANGES GO HERE + // + // I've tried to localize the things you are likely to change to + // this area. + // ------------------------------------------------------------------- + + // The text that appears on the upper part of the dialog box when + // entering links. + var linkDialogText = "

    Insert Hyperlink

    http://example.com/ \"optional title\"

    "; + var imageDialogText = "

    Insert Image

    http://example.com/images/diagram.jpg \"optional title\"

    Need free image hosting?

    "; + + // The default text that appears in the dialog input box when entering + // links. + var imageDefaultText = "http://"; + var linkDefaultText = "http://"; + + var defaultHelpHoverTitle = "Markdown Editing Help"; + + // ------------------------------------------------------------------- + // END OF YOUR CHANGES + // ------------------------------------------------------------------- + + // help, if given, should have a property "handler", the click handler for the help button, + // and can have an optional property "title" for the button's tooltip (defaults to "Markdown Editing Help"). + // If help isn't given, not help button is created. + // + // The constructed editor object has the methods: + // - getConverter() returns the markdown converter object that was passed to the constructor + // - run() actually starts the editor; should be called after all necessary plugins are registered. Calling this more than once is a no-op. + // - refreshPreview() forces the preview to be updated. This method is only available after run() was called. + Markdown.Editor = function (markdownConverter, idPostfix, help) { + + idPostfix = idPostfix || ""; + + var hooks = this.hooks = new Markdown.HookCollection(); + hooks.addNoop("onPreviewRefresh"); // called with no arguments after the preview has been refreshed + hooks.addNoop("postBlockquoteCreation"); // called with the user's selection *after* the blockquote was created; should return the actual to-be-inserted text + hooks.addFalse("insertImageDialog"); /* called with one parameter: a callback to be called with the URL of the image. If the application creates + * its own image insertion dialog, this hook should return true, and the callback should be called with the chosen + * image url (or null if the user cancelled). If this hook returns false, the default dialog will be used. + */ + + this.getConverter = function () { return markdownConverter; } + + var that = this, + panels; + + this.run = function () { + if (panels) + return; // already initialized + + panels = new PanelCollection(idPostfix); + var commandManager = new CommandManager(hooks); + var previewManager = new PreviewManager(markdownConverter, panels, function () { hooks.onPreviewRefresh(); }); + var undoManager, uiManager; + + if (!/\?noundo/.test(doc.location.href)) { + undoManager = new UndoManager(function () { + previewManager.refresh(); + if (uiManager) // not available on the first call + uiManager.setUndoRedoButtonStates(); + }, panels); + this.textOperation = function (f) { + undoManager.setCommandMode(); + f(); + that.refreshPreview(); + } + } + + uiManager = new UIManager(idPostfix, panels, undoManager, previewManager, commandManager, help); + uiManager.setUndoRedoButtonStates(); + + var forceRefresh = that.refreshPreview = function () { previewManager.refresh(true); }; + + forceRefresh(); + }; + + } + + // before: contains all the text in the input box BEFORE the selection. + // after: contains all the text in the input box AFTER the selection. + function Chunks() { } + + // startRegex: a regular expression to find the start tag + // endRegex: a regular expresssion to find the end tag + Chunks.prototype.findTags = function (startRegex, endRegex) { + + var chunkObj = this; + var regex; + + if (startRegex) { + + regex = util.extendRegExp(startRegex, "", "$"); + + this.before = this.before.replace(regex, + function (match) { + chunkObj.startTag = chunkObj.startTag + match; + return ""; + }); + + regex = util.extendRegExp(startRegex, "^", ""); + + this.selection = this.selection.replace(regex, + function (match) { + chunkObj.startTag = chunkObj.startTag + match; + return ""; + }); + } + + if (endRegex) { + + regex = util.extendRegExp(endRegex, "", "$"); + + this.selection = this.selection.replace(regex, + function (match) { + chunkObj.endTag = match + chunkObj.endTag; + return ""; + }); + + regex = util.extendRegExp(endRegex, "^", ""); + + this.after = this.after.replace(regex, + function (match) { + chunkObj.endTag = match + chunkObj.endTag; + return ""; + }); + } + }; + + // If remove is false, the whitespace is transferred + // to the before/after regions. + // + // If remove is true, the whitespace disappears. + Chunks.prototype.trimWhitespace = function (remove) { + var beforeReplacer, afterReplacer, that = this; + if (remove) { + beforeReplacer = afterReplacer = ""; + } else { + beforeReplacer = function (s) { that.before += s; return ""; } + afterReplacer = function (s) { that.after = s + that.after; return ""; } + } + + this.selection = this.selection.replace(/^(\s*)/, beforeReplacer).replace(/(\s*)$/, afterReplacer); + }; + + + Chunks.prototype.skipLines = function (nLinesBefore, nLinesAfter, findExtraNewlines) { + + if (nLinesBefore === undefined) { + nLinesBefore = 1; + } + + if (nLinesAfter === undefined) { + nLinesAfter = 1; + } + + nLinesBefore++; + nLinesAfter++; + + var regexText; + var replacementText; + + // chrome bug ... documented at: http://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985 + if (navigator.userAgent.match(/Chrome/)) { + "X".match(/()./); + } + + this.selection = this.selection.replace(/(^\n*)/, ""); + + this.startTag = this.startTag + re.$1; + + this.selection = this.selection.replace(/(\n*$)/, ""); + this.endTag = this.endTag + re.$1; + this.startTag = this.startTag.replace(/(^\n*)/, ""); + this.before = this.before + re.$1; + this.endTag = this.endTag.replace(/(\n*$)/, ""); + this.after = this.after + re.$1; + + if (this.before) { + + regexText = replacementText = ""; + + while (nLinesBefore--) { + regexText += "\\n?"; + replacementText += "\n"; + } + + if (findExtraNewlines) { + regexText = "\\n*"; + } + this.before = this.before.replace(new re(regexText + "$", ""), replacementText); + } + + if (this.after) { + + regexText = replacementText = ""; + + while (nLinesAfter--) { + regexText += "\\n?"; + replacementText += "\n"; + } + if (findExtraNewlines) { + regexText = "\\n*"; + } + + this.after = this.after.replace(new re(regexText, ""), replacementText); + } + }; + + // end of Chunks + + // A collection of the important regions on the page. + // Cached so we don't have to keep traversing the DOM. + // Also holds ieCachedRange and ieCachedScrollTop, where necessary; working around + // this issue: + // Internet explorer has problems with CSS sprite buttons that use HTML + // lists. When you click on the background image "button", IE will + // select the non-existent link text and discard the selection in the + // textarea. The solution to this is to cache the textarea selection + // on the button's mousedown event and set a flag. In the part of the + // code where we need to grab the selection, we check for the flag + // and, if it's set, use the cached area instead of querying the + // textarea. + // + // This ONLY affects Internet Explorer (tested on versions 6, 7 + // and 8) and ONLY on button clicks. Keyboard shortcuts work + // normally since the focus never leaves the textarea. + function PanelCollection(postfix) { + this.buttonBar = doc.getElementById("wmd-button-bar" + postfix); + this.preview = doc.getElementById("wmd-preview" + postfix); + this.input = doc.getElementById("wmd-input" + postfix); + }; + + // Returns true if the DOM element is visible, false if it's hidden. + // Checks if display is anything other than none. + util.isVisible = function (elem) { + + if (window.getComputedStyle) { + // Most browsers + return window.getComputedStyle(elem, null).getPropertyValue("display") !== "none"; + } + else if (elem.currentStyle) { + // IE + return elem.currentStyle["display"] !== "none"; + } + }; + + + // Adds a listener callback to a DOM element which is fired on a specified + // event. + util.addEvent = function (elem, event, listener) { + if (elem.attachEvent) { + // IE only. The "on" is mandatory. + elem.attachEvent("on" + event, listener); + } + else { + // Other browsers. + elem.addEventListener(event, listener, false); + } + }; + + + // Removes a listener callback from a DOM element which is fired on a specified + // event. + util.removeEvent = function (elem, event, listener) { + if (elem.detachEvent) { + // IE only. The "on" is mandatory. + elem.detachEvent("on" + event, listener); + } + else { + // Other browsers. + elem.removeEventListener(event, listener, false); + } + }; + + // Converts \r\n and \r to \n. + util.fixEolChars = function (text) { + text = text.replace(/\r\n/g, "\n"); + text = text.replace(/\r/g, "\n"); + return text; + }; + + // Extends a regular expression. Returns a new RegExp + // using pre + regex + post as the expression. + // Used in a few functions where we have a base + // expression and we want to pre- or append some + // conditions to it (e.g. adding "$" to the end). + // The flags are unchanged. + // + // regex is a RegExp, pre and post are strings. + util.extendRegExp = function (regex, pre, post) { + + if (pre === null || pre === undefined) { + pre = ""; + } + if (post === null || post === undefined) { + post = ""; + } + + var pattern = regex.toString(); + var flags; + + // Replace the flags with empty space and store them. + pattern = pattern.replace(/\/([gim]*)$/, function (wholeMatch, flagsPart) { + flags = flagsPart; + return ""; + }); + + // Remove the slash delimiters on the regular expression. + pattern = pattern.replace(/(^\/|\/$)/g, ""); + pattern = pre + pattern + post; + + return new re(pattern, flags); + } + + // UNFINISHED + // The assignment in the while loop makes jslint cranky. + // I'll change it to a better loop later. + position.getTop = function (elem, isInner) { + var result = elem.offsetTop; + if (!isInner) { + while (elem = elem.offsetParent) { + result += elem.offsetTop; + } + } + return result; + }; + + position.getHeight = function (elem) { + return elem.offsetHeight || elem.scrollHeight; + }; + + position.getWidth = function (elem) { + return elem.offsetWidth || elem.scrollWidth; + }; + + position.getPageSize = function () { + + var scrollWidth, scrollHeight; + var innerWidth, innerHeight; + + // It's not very clear which blocks work with which browsers. + if (self.innerHeight && self.scrollMaxY) { + scrollWidth = doc.body.scrollWidth; + scrollHeight = self.innerHeight + self.scrollMaxY; + } + else if (doc.body.scrollHeight > doc.body.offsetHeight) { + scrollWidth = doc.body.scrollWidth; + scrollHeight = doc.body.scrollHeight; + } + else { + scrollWidth = doc.body.offsetWidth; + scrollHeight = doc.body.offsetHeight; + } + + if (self.innerHeight) { + // Non-IE browser + innerWidth = self.innerWidth; + innerHeight = self.innerHeight; + } + else if (doc.documentElement && doc.documentElement.clientHeight) { + // Some versions of IE (IE 6 w/ a DOCTYPE declaration) + innerWidth = doc.documentElement.clientWidth; + innerHeight = doc.documentElement.clientHeight; + } + else if (doc.body) { + // Other versions of IE + innerWidth = doc.body.clientWidth; + innerHeight = doc.body.clientHeight; + } + + var maxWidth = Math.max(scrollWidth, innerWidth); + var maxHeight = Math.max(scrollHeight, innerHeight); + return [maxWidth, maxHeight, innerWidth, innerHeight]; + }; + + // Handles pushing and popping TextareaStates for undo/redo commands. + // I should rename the stack variables to list. + function UndoManager(callback, panels) { + + var undoObj = this; + var undoStack = []; // A stack of undo states + var stackPtr = 0; // The index of the current state + var mode = "none"; + var lastState; // The last state + var timer; // The setTimeout handle for cancelling the timer + var inputStateObj; + + // Set the mode for later logic steps. + var setMode = function (newMode, noSave) { + if (mode != newMode) { + mode = newMode; + if (!noSave) { + saveState(); + } + } + + if (!uaSniffed.isIE || mode != "moving") { + timer = setTimeout(refreshState, 1); + } + else { + inputStateObj = null; + } + }; + + var refreshState = function (isInitialState) { + inputStateObj = new TextareaState(panels, isInitialState); + timer = undefined; + }; + + this.setCommandMode = function () { + mode = "command"; + saveState(); + timer = setTimeout(refreshState, 0); + }; + + this.canUndo = function () { + return stackPtr > 1; + }; + + this.canRedo = function () { + if (undoStack[stackPtr + 1]) { + return true; + } + return false; + }; + + // Removes the last state and restores it. + this.undo = function () { + + if (undoObj.canUndo()) { + if (lastState) { + // What about setting state -1 to null or checking for undefined? + lastState.restore(); + lastState = null; + } + else { + undoStack[stackPtr] = new TextareaState(panels); + undoStack[--stackPtr].restore(); + + if (callback) { + callback(); + } + } + } + + mode = "none"; + panels.input.focus(); + refreshState(); + }; + + // Redo an action. + this.redo = function () { + + if (undoObj.canRedo()) { + + undoStack[++stackPtr].restore(); + + if (callback) { + callback(); + } + } + + mode = "none"; + panels.input.focus(); + refreshState(); + }; + + // Push the input area state to the stack. + var saveState = function () { + var currState = inputStateObj || new TextareaState(panels); + + if (!currState) { + return false; + } + if (mode == "moving") { + if (!lastState) { + lastState = currState; + } + return; + } + if (lastState) { + if (undoStack[stackPtr - 1].text != lastState.text) { + undoStack[stackPtr++] = lastState; + } + lastState = null; + } + undoStack[stackPtr++] = currState; + undoStack[stackPtr + 1] = null; + if (callback) { + callback(); + } + }; + + var handleCtrlYZ = function (event) { + + var handled = false; + + if (event.ctrlKey || event.metaKey) { + + // IE and Opera do not support charCode. + var keyCode = event.charCode || event.keyCode; + var keyCodeChar = String.fromCharCode(keyCode); + + switch (keyCodeChar) { + + case "y": + case "Y": + if (!event.shiftKey) { + undoObj.redo(); + handled = true; + } + break; + + case "Z": + case "z": + if (!event.shiftKey) { + undoObj.undo(); + } + else { + undoObj.redo(); + } + handled = true; + break; + } + } + + if (handled) { + if (event.preventDefault) { + event.preventDefault(); + } + if (window.event) { + window.event.returnValue = false; + } + return; + } + }; + + // Set the mode depending on what is going on in the input area. + var handleModeChange = function (event) { + + if (!event.ctrlKey && !event.metaKey) { + + var keyCode = event.keyCode; + + if ((keyCode >= 33 && keyCode <= 40) || (keyCode >= 63232 && keyCode <= 63235)) { + // 33 - 40: page up/dn and arrow keys + // 63232 - 63235: page up/dn and arrow keys on safari + setMode("moving"); + } + else if (keyCode == 8 || keyCode == 46 || keyCode == 127) { + // 8: backspace + // 46: delete + // 127: delete + setMode("deleting"); + } + else if (keyCode == 13) { + // 13: Enter + setMode("newlines"); + } + else if (keyCode == 27) { + // 27: escape + setMode("escape"); + } + else if ((keyCode < 16 || keyCode > 20) && keyCode != 91) { + // 16-20 are shift, etc. + // 91: left window key + // I think this might be a little messed up since there are + // a lot of nonprinting keys above 20. + setMode("typing"); + } + } + }; + + var setEventHandlers = function () { + util.addEvent(panels.input, "keypress", function (event) { + // keyCode 89: y + // keyCode 90: z + if ((event.ctrlKey || event.metaKey) && (event.keyCode == 89 || event.keyCode == 90)) { + event.preventDefault(); + } + }); + + var handlePaste = function () { + if (uaSniffed.isIE || (inputStateObj && inputStateObj.text != panels.input.value)) { + if (timer == undefined) { + mode = "paste"; + saveState(); + refreshState(); + } + } + }; + + util.addEvent(panels.input, "keydown", handleCtrlYZ); + util.addEvent(panels.input, "keydown", handleModeChange); + util.addEvent(panels.input, "mousedown", function () { + setMode("moving"); + }); + + panels.input.onpaste = handlePaste; + panels.input.ondrop = handlePaste; + }; + + var init = function () { + setEventHandlers(); + refreshState(true); + saveState(); + }; + + init(); + } + + // end of UndoManager + + // The input textarea state/contents. + // This is used to implement undo/redo by the undo manager. + function TextareaState(panels, isInitialState) { + + // Aliases + var stateObj = this; + var inputArea = panels.input; + this.init = function () { + if (!util.isVisible(inputArea)) { + return; + } + if (!isInitialState && doc.activeElement && doc.activeElement !== inputArea) { // this happens when tabbing out of the input box + return; + } + + this.setInputAreaSelectionStartEnd(); + this.scrollTop = inputArea.scrollTop; + if (!this.text && inputArea.selectionStart || inputArea.selectionStart === 0) { + this.text = inputArea.value; + } + + } + + // Sets the selected text in the input box after we've performed an + // operation. + this.setInputAreaSelection = function () { + + if (!util.isVisible(inputArea)) { + return; + } + + if (inputArea.selectionStart !== undefined && !uaSniffed.isOpera) { + + inputArea.focus(); + inputArea.selectionStart = stateObj.start; + inputArea.selectionEnd = stateObj.end; + inputArea.scrollTop = stateObj.scrollTop; + } + else if (doc.selection) { + + if (doc.activeElement && doc.activeElement !== inputArea) { + return; + } + + inputArea.focus(); + var range = inputArea.createTextRange(); + range.moveStart("character", -inputArea.value.length); + range.moveEnd("character", -inputArea.value.length); + range.moveEnd("character", stateObj.end); + range.moveStart("character", stateObj.start); + range.select(); + } + }; + + this.setInputAreaSelectionStartEnd = function () { + + if (!panels.ieCachedRange && (inputArea.selectionStart || inputArea.selectionStart === 0)) { + + stateObj.start = inputArea.selectionStart; + stateObj.end = inputArea.selectionEnd; + } + else if (doc.selection) { + + stateObj.text = util.fixEolChars(inputArea.value); + + // IE loses the selection in the textarea when buttons are + // clicked. On IE we cache the selection. Here, if something is cached, + // we take it. + var range = panels.ieCachedRange || doc.selection.createRange(); + + var fixedRange = util.fixEolChars(range.text); + var marker = "\x07"; + var markedRange = marker + fixedRange + marker; + range.text = markedRange; + var inputText = util.fixEolChars(inputArea.value); + + range.moveStart("character", -markedRange.length); + range.text = fixedRange; + + stateObj.start = inputText.indexOf(marker); + stateObj.end = inputText.lastIndexOf(marker) - marker.length; + + var len = stateObj.text.length - util.fixEolChars(inputArea.value).length; + + if (len) { + range.moveStart("character", -fixedRange.length); + while (len--) { + fixedRange += "\n"; + stateObj.end += 1; + } + range.text = fixedRange; + } + + if (panels.ieCachedRange) + stateObj.scrollTop = panels.ieCachedScrollTop; // this is set alongside with ieCachedRange + + panels.ieCachedRange = null; + + this.setInputAreaSelection(); + } + }; + + // Restore this state into the input area. + this.restore = function () { + + if (stateObj.text != undefined && stateObj.text != inputArea.value) { + inputArea.value = stateObj.text; + } + this.setInputAreaSelection(); + inputArea.scrollTop = stateObj.scrollTop; + }; + + // Gets a collection of HTML chunks from the inptut textarea. + this.getChunks = function () { + + var chunk = new Chunks(); + chunk.before = util.fixEolChars(stateObj.text.substring(0, stateObj.start)); + chunk.startTag = ""; + chunk.selection = util.fixEolChars(stateObj.text.substring(stateObj.start, stateObj.end)); + chunk.endTag = ""; + chunk.after = util.fixEolChars(stateObj.text.substring(stateObj.end)); + chunk.scrollTop = stateObj.scrollTop; + + return chunk; + }; + + // Sets the TextareaState properties given a chunk of markdown. + this.setChunks = function (chunk) { + + chunk.before = chunk.before + chunk.startTag; + chunk.after = chunk.endTag + chunk.after; + + this.start = chunk.before.length; + this.end = chunk.before.length + chunk.selection.length; + this.text = chunk.before + chunk.selection + chunk.after; + this.scrollTop = chunk.scrollTop; + }; + this.init(); + }; + + function PreviewManager(converter, panels, previewRefreshCallback) { + + var managerObj = this; + var timeout; + var elapsedTime; + var oldInputText; + var maxDelay = 3000; + var startType = "delayed"; // The other legal value is "manual" + + // Adds event listeners to elements + var setupEvents = function (inputElem, listener) { + + util.addEvent(inputElem, "input", listener); + inputElem.onpaste = listener; + inputElem.ondrop = listener; + + util.addEvent(inputElem, "keypress", listener); + util.addEvent(inputElem, "keydown", listener); + }; + + var getDocScrollTop = function () { + + var result = 0; + + if (window.innerHeight) { + result = window.pageYOffset; + } + else + if (doc.documentElement && doc.documentElement.scrollTop) { + result = doc.documentElement.scrollTop; + } + else + if (doc.body) { + result = doc.body.scrollTop; + } + + return result; + }; + + var makePreviewHtml = function () { + + // If there is no registered preview panel + // there is nothing to do. + if (!panels.preview) + return; + + + var text = panels.input.value; + if (text && text == oldInputText) { + return; // Input text hasn't changed. + } + else { + oldInputText = text; + } + + var prevTime = new Date().getTime(); + + text = converter.makeHtml(text); + + // Calculate the processing time of the HTML creation. + // It's used as the delay time in the event listener. + var currTime = new Date().getTime(); + elapsedTime = currTime - prevTime; + + pushPreviewHtml(text); + }; + + // makePreviewHtml = window.probes.measure(makePreviewHtml, { + // before: function(){ window.probes.clear(); }, + // name: "makePreview", + // after: function(p) { window.probes.clear(); console.log("Total time to preview: " + p.time); } + // }); + + + // TODO allow us to inject this in (its our debouncer) + var debounce = function(func,wait,trickle) { + var timeout = null; + return function(){ + var context = this; + var args = arguments; + + later = function(){ + timeout = null; + func.apply(context, args); + }; + + if (timeout!=null && trickle) { + return; + } + + var currentWait; + if (typeof wait == "function") { + currentWait = wait(); + } else { + currentWait = wait; + } + + //console.log(currentWait); + if (timeout) { clearTimeout(timeout); } + timeout = setTimeout(later, currentWait); + } + } + + makePreviewHtml = debounce(makePreviewHtml, function(){ + return Math.min(Math.max((elapsedTime || 1) * 10, 80),1000); + }, true); + + + // setTimeout is already used. Used as an event listener. + var applyTimeout = function () { + + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + + if (startType !== "manual") { + + var delay = 0; + + if (startType === "delayed") { + delay = elapsedTime; + } + + if (delay > maxDelay) { + delay = maxDelay; + } + timeout = setTimeout(makePreviewHtml, delay); + } + }; + + var getScaleFactor = function (panel) { + if (panel.scrollHeight <= panel.clientHeight) { + return 1; + } + return panel.scrollTop / (panel.scrollHeight - panel.clientHeight); + }; + + var setPanelScrollTops = function () { + if (panels.preview) { + panels.preview.scrollTop = (panels.preview.scrollHeight - panels.preview.clientHeight) * getScaleFactor(panels.preview); + } + }; + + this.refresh = function (requiresRefresh) { + if (requiresRefresh) { + oldInputText = ""; + makePreviewHtml(); + } + else { + applyTimeout(); + } + }; + + this.processingTime = function () { + return elapsedTime; + }; + + var isFirstTimeFilled = true; + + // IE doesn't let you use innerHTML if the element is contained somewhere in a table + // (which is the case for inline editing) -- in that case, detach the element, set the + // value, and reattach. Yes, that *is* ridiculous. + var ieSafePreviewSet = function (text) { + var preview = panels.preview; + var parent = preview.parentNode; + var sibling = preview.nextSibling; + parent.removeChild(preview); + preview.innerHTML = text; + if (!sibling) + parent.appendChild(preview); + else + parent.insertBefore(preview, sibling); + } + + var nonSuckyBrowserPreviewSet = function (text) { + panels.preview.innerHTML = text; + } + + var previewSetter; + + var previewSet = function (text) { + if (previewSetter) + return previewSetter(text); + + try { + nonSuckyBrowserPreviewSet(text); + previewSetter = nonSuckyBrowserPreviewSet; + } catch (e) { + previewSetter = ieSafePreviewSet; + previewSetter(text); + } + }; + + var pushPreviewHtml = function (text) { + + var emptyTop = position.getTop(panels.input) - getDocScrollTop(); + + if (panels.preview) { + previewSet(text); + previewRefreshCallback(); + } + + setPanelScrollTops(); + + if (isFirstTimeFilled) { + isFirstTimeFilled = false; + return; + } + + var fullTop = position.getTop(panels.input) - getDocScrollTop(); + + if (uaSniffed.isIE) { + setTimeout(function () { + window.scrollBy(0, fullTop - emptyTop); + }, 0); + } + else { + window.scrollBy(0, fullTop - emptyTop); + } + }; + + var init = function () { + + // TODO: make option to disable. We don't need this in discourse + // setupEvents(panels.input, applyTimeout); + + makePreviewHtml(); + + if (panels.preview) { + panels.preview.scrollTop = 0; + } + }; + + init(); + }; + + // Creates the background behind the hyperlink text entry box. + // And download dialog + // Most of this has been moved to CSS but the div creation and + // browser-specific hacks remain here. + ui.createBackground = function () { + + var background = doc.createElement("div"), + style = background.style; + + background.className = "wmd-prompt-background"; + + style.position = "absolute"; + style.top = "0"; + + style.zIndex = "2000"; + + if (uaSniffed.isIE) { + style.filter = "alpha(opacity=50)"; + } + else { + style.opacity = "0.5"; + } + + var pageSize = position.getPageSize(); + style.height = pageSize[1] + "px"; + + if (uaSniffed.isIE) { + style.left = doc.documentElement.scrollLeft; + style.width = doc.documentElement.clientWidth; + } + else { + style.left = "0"; + style.width = "100%"; + } + + doc.body.appendChild(background); + return background; + }; + + // This simulates a modal dialog box and asks for the URL when you + // click the hyperlink or image buttons. + // + // text: The html for the input box. + // defaultInputText: The default value that appears in the input box. + // callback: The function which is executed when the prompt is dismissed, either via OK or Cancel. + // It receives a single argument; either the entered text (if OK was chosen) or null (if Cancel + // was chosen). + ui.prompt = function (text, defaultInputText, callback) { + + // These variables need to be declared at this level since they are used + // in multiple functions. + var dialog; // The dialog box. + var input; // The text box where you enter the hyperlink. + + + if (defaultInputText === undefined) { + defaultInputText = ""; + } + + // Used as a keydown event handler. Esc dismisses the prompt. + // Key code 27 is ESC. + var checkEscape = function (key) { + var code = (key.charCode || key.keyCode); + if (code === 27) { + close(true); + } + }; + + // Dismisses the hyperlink input box. + // isCancel is true if we don't care about the input text. + // isCancel is false if we are going to keep the text. + var close = function (isCancel) { + util.removeEvent(doc.body, "keydown", checkEscape); + var text = input.value; + + if (isCancel) { + text = null; + } + else { + // Fixes common pasting errors. + text = text.replace(/^http:\/\/(https?|ftp):\/\//, '$1://'); + if (!/^(?:https?|ftp):\/\//.test(text)) + text = 'http://' + text; + } + + dialog.parentNode.removeChild(dialog); + + callback(text); + return false; + }; + + + + // Create the text input box form/window. + var createDialog = function () { + + // The main dialog box. + dialog = doc.createElement("div"); + dialog.className = "wmd-prompt-dialog"; + dialog.style.padding = "10px;"; + dialog.style.position = "fixed"; + dialog.style.width = "400px"; + dialog.style.zIndex = "2001"; + + // The dialog text. + var question = doc.createElement("div"); + question.innerHTML = text; + question.style.padding = "5px"; + dialog.appendChild(question); + + // The web form container for the text box and buttons. + var form = doc.createElement("form"), + style = form.style; + form.onsubmit = function () { return close(false); }; + style.padding = "0"; + style.margin = "0"; + style.cssFloat = "left"; + style.width = "100%"; + style.textAlign = "center"; + style.position = "relative"; + dialog.appendChild(form); + + // The input text box + input = doc.createElement("input"); + input.type = "text"; + input.value = defaultInputText; + style = input.style; + style.display = "block"; + style.width = "80%"; + style.marginLeft = style.marginRight = "auto"; + form.appendChild(input); + + // The ok button + var okButton = doc.createElement("input"); + okButton.type = "button"; + okButton.onclick = function () { return close(false); }; + okButton.value = "OK"; + style = okButton.style; + style.margin = "10px"; + style.display = "inline"; + style.width = "7em"; + + + // The cancel button + var cancelButton = doc.createElement("input"); + cancelButton.type = "button"; + cancelButton.onclick = function () { return close(true); }; + cancelButton.value = "Cancel"; + style = cancelButton.style; + style.margin = "10px"; + style.display = "inline"; + style.width = "7em"; + + form.appendChild(okButton); + form.appendChild(cancelButton); + + util.addEvent(doc.body, "keydown", checkEscape); + dialog.style.top = "50%"; + dialog.style.left = "50%"; + dialog.style.display = "block"; + if (uaSniffed.isIE_5or6) { + dialog.style.position = "absolute"; + dialog.style.top = doc.documentElement.scrollTop + 200 + "px"; + dialog.style.left = "50%"; + } + doc.body.appendChild(dialog); + + // This has to be done AFTER adding the dialog to the form if you + // want it to be centered. + dialog.style.marginTop = -(position.getHeight(dialog) / 2) + "px"; + dialog.style.marginLeft = -(position.getWidth(dialog) / 2) + "px"; + + }; + + // Why is this in a zero-length timeout? + // Is it working around a browser bug? + setTimeout(function () { + + createDialog(); + + var defTextLen = defaultInputText.length; + if (input.selectionStart !== undefined) { + input.selectionStart = 0; + input.selectionEnd = defTextLen; + } + else if (input.createTextRange) { + var range = input.createTextRange(); + range.collapse(false); + range.moveStart("character", -defTextLen); + range.moveEnd("character", defTextLen); + range.select(); + } + + input.focus(); + }, 0); + }; + + function UIManager(postfix, panels, undoManager, previewManager, commandManager, helpOptions) { + + var inputBox = panels.input, + buttons = {}; // buttons.undo, buttons.link, etc. The actual DOM elements. + + makeSpritedButtonRow(); + + var keyEvent = "keydown"; + if (uaSniffed.isOpera) { + keyEvent = "keypress"; + } + + util.addEvent(inputBox, keyEvent, function (key) { + + // Check to see if we have a button key and, if so execute the callback. + if ((key.ctrlKey || key.metaKey) && !key.altKey && !key.shiftKey) { + + var keyCode = key.charCode || key.keyCode; + var keyCodeStr = String.fromCharCode(keyCode).toLowerCase(); + + switch (keyCodeStr) { + case "b": + doClick(buttons.bold); + break; + case "i": + doClick(buttons.italic); + break; + case "l": + doClick(buttons.link); + break; + case "q": + doClick(buttons.quote); + break; + case "k": + doClick(buttons.code); + break; + case "g": + doClick(buttons.image); + break; + case "o": + doClick(buttons.olist); + break; + case "u": + doClick(buttons.ulist); + break; + case "h": + doClick(buttons.heading); + break; + case "y": + doClick(buttons.redo); + break; + case "z": + if (key.shiftKey) { + doClick(buttons.redo); + } + else { + doClick(buttons.undo); + } + break; + default: + return; + } + + + if (key.preventDefault) { + key.preventDefault(); + } + + if (window.event) { + window.event.returnValue = false; + } + } + }); + + // Auto-indent on shift-enter + util.addEvent(inputBox, "keyup", function (key) { + if (key.shiftKey && !key.ctrlKey && !key.metaKey) { + var keyCode = key.charCode || key.keyCode; + // Character 13 is Enter + if (keyCode === 13) { + var fakeButton = {}; + fakeButton.textOp = bindCommand("doAutoindent"); + doClick(fakeButton); + } + } + }); + + // special handler because IE clears the context of the textbox on ESC + if (uaSniffed.isIE) { + util.addEvent(inputBox, "keydown", function (key) { + var code = key.keyCode; + if (code === 27) { + return false; + } + }); + } + + + // Perform the button's action. + function doClick(button) { + + inputBox.focus(); + + if (button.textOp) { + + if (undoManager) { + undoManager.setCommandMode(); + } + + var state = new TextareaState(panels); + + if (!state) { + return; + } + + var chunks = state.getChunks(); + + // Some commands launch a "modal" prompt dialog. Javascript + // can't really make a modal dialog box and the WMD code + // will continue to execute while the dialog is displayed. + // This prevents the dialog pattern I'm used to and means + // I can't do something like this: + // + // var link = CreateLinkDialog(); + // makeMarkdownLink(link); + // + // Instead of this straightforward method of handling a + // dialog I have to pass any code which would execute + // after the dialog is dismissed (e.g. link creation) + // in a function parameter. + // + // Yes this is awkward and I think it sucks, but there's + // no real workaround. Only the image and link code + // create dialogs and require the function pointers. + var fixupInputArea = function () { + + inputBox.focus(); + + if (chunks) { + state.setChunks(chunks); + } + + state.restore(); + previewManager.refresh(); + }; + + var noCleanup = button.textOp(chunks, fixupInputArea); + + if (!noCleanup) { + fixupInputArea(); + } + + } + + if (button.execute) { + button.execute(undoManager); + } + }; + + function setupButton(button, isEnabled) { + + if (isEnabled) { + button.disabled = false + + // IE tries to select the background image "button" text (it's + // implemented in a list item) so we have to cache the selection + // on mousedown. + if (uaSniffed.isIE) { + button.onmousedown = function () { + if (doc.activeElement && doc.activeElement !== panels.input) { // we're not even in the input box, so there's no selection + return; + } + panels.ieCachedRange = document.selection.createRange(); + panels.ieCachedScrollTop = panels.input.scrollTop; + }; + } + + if (!button.isHelp) { + button.onclick = function () { + if (this.onmouseout) { + this.onmouseout(); + } + doClick(this); + return false; + } + } + } + else { + button.disabled = true + button.onmouseover = button.onmouseout = button.onclick = function () { }; + } + } + + function bindCommand(method) { + if (typeof method === "string") + method = commandManager[method]; + return function () { method.apply(commandManager, arguments); } + } + + function makeSpritedButtonRow() { + + var buttonBar = panels.buttonBar; + var buttonRow = document.createElement("div"); + buttonRow.id = "wmd-button-row" + postfix; + buttonRow.className = 'wmd-button-row'; + buttonRow = buttonBar.appendChild(buttonRow); + var xPosition = 0; + var makeButton = function (id, title, textOp) { + var button = document.createElement("button"); + button.className = "wmd-button"; + xPosition += 25; + button.id = id + postfix; + button.title = title; + if (textOp) + button.textOp = textOp; + setupButton(button, true); + buttonRow.appendChild(button); + return button; + }; + + var makeSpacer = function (num) { + var spacer = document.createElement("div"); + spacer.className = "wmd-spacer"; + spacer.id = "wmd-spacer" + num + postfix; + buttonRow.appendChild(spacer); + xPosition += 25; + } + + // If we have any buttons to insert, do it! + if (typeof PagedownCustom != "undefined") { + insertButtons = PagedownCustom.insertButtons + if (insertButtons && (insertButtons.length > 0)) { + for (var i=0; i Ctrl+B", bindCommand("doBold")); + buttons.italic = makeButton("wmd-italic-button", "Emphasis Ctrl+I", bindCommand("doItalic")); + makeSpacer(1); + buttons.link = makeButton("wmd-link-button", "Hyperlink Ctrl+L", bindCommand(function (chunk, postProcessing) { + return this.doLinkOrImage(chunk, postProcessing, false); + })); + buttons.quote = makeButton("wmd-quote-button", "Blockquote
    Ctrl+Q",bindCommand("doBlockquote")); + buttons.code = makeButton("wmd-code-button", "Preformatted text
     Ctrl+K", bindCommand("doCode"));
    +            buttons.image = makeButton("wmd-image-button", "Image  Ctrl+G", bindCommand(function (chunk, postProcessing) {
    +                return this.doLinkOrImage(chunk, postProcessing, true);
    +            }));
    +            makeSpacer(2);
    +            buttons.olist = makeButton("wmd-olist-button", "Numbered List 
      Ctrl+O", bindCommand(function (chunk, postProcessing) { + this.doList(chunk, postProcessing, true); + })); + buttons.ulist = makeButton("wmd-ulist-button", "Bulleted List
        Ctrl+U", bindCommand(function (chunk, postProcessing) { + this.doList(chunk, postProcessing, false); + })); + buttons.heading = makeButton("wmd-heading-button", "Heading

        /

        Ctrl+H", bindCommand("doHeading")); + buttons.hr = makeButton("wmd-hr-button", "Horizontal Rule
        Ctrl+R", bindCommand("doHorizontalRule")); + makeSpacer(3); + buttons.undo = makeButton("wmd-undo-button", "Undo - Ctrl+Z", null); + buttons.undo.execute = function (manager) { if (manager) manager.undo(); }; + + var redoTitle = /win/.test(nav.platform.toLowerCase()) ? + "Redo - Ctrl+Y" : + "Redo - Ctrl+Shift+Z"; // mac and other non-Windows platforms + + buttons.redo = makeButton("wmd-redo-button", redoTitle, null); + buttons.redo.execute = function (manager) { if (manager) manager.redo(); }; + + if (helpOptions) { + var helpButton = document.createElement("li"); + var helpButtonImage = document.createElement("span"); + helpButton.appendChild(helpButtonImage); + helpButton.className = "wmd-button wmd-help-button"; + helpButton.id = "wmd-help-button" + postfix; + helpButton.isHelp = true; + helpButton.style.right = "0px"; + helpButton.title = helpOptions.title || defaultHelpHoverTitle; + helpButton.onclick = helpOptions.handler; + + setupButton(helpButton, true); + buttonRow.appendChild(helpButton); + buttons.help = helpButton; + } + + setUndoRedoButtonStates(); + } + + function setUndoRedoButtonStates() { + if (undoManager) { + setupButton(buttons.undo, undoManager.canUndo()); + setupButton(buttons.redo, undoManager.canRedo()); + } + }; + + this.setUndoRedoButtonStates = setUndoRedoButtonStates; + + } + + function CommandManager(pluginHooks) { + this.hooks = pluginHooks; + } + + var commandProto = CommandManager.prototype; + + // The markdown symbols - 4 spaces = code, > = blockquote, etc. + commandProto.prefixes = "(?:\\s{4,}|\\s*>|\\s*-\\s+|\\s*\\d+\\.|=|\\+|-|_|\\*|#|\\s*\\[[^\n]]+\\]:)"; + + // Remove markdown symbols from the chunk selection. + commandProto.unwrap = function (chunk) { + var txt = new re("([^\\n])\\n(?!(\\n|" + this.prefixes + "))", "g"); + chunk.selection = chunk.selection.replace(txt, "$1 $2"); + }; + + commandProto.wrap = function (chunk, len) { + this.unwrap(chunk); + var regex = new re("(.{1," + len + "})( +|$\\n?)", "gm"), + that = this; + + chunk.selection = chunk.selection.replace(regex, function (line, marked) { + if (new re("^" + that.prefixes, "").test(line)) { + return line; + } + return marked + "\n"; + }); + + chunk.selection = chunk.selection.replace(/\s+$/, ""); + }; + + commandProto.doBold = function (chunk, postProcessing) { + return this.doBorI(chunk, postProcessing, 2, "strong text"); + }; + + commandProto.doItalic = function (chunk, postProcessing) { + return this.doBorI(chunk, postProcessing, 1, "emphasized text"); + }; + + // chunk: The selected region that will be enclosed with */** + // nStars: 1 for italics, 2 for bold + // insertText: If you just click the button without highlighting text, this gets inserted + commandProto.doBorI = function (chunk, postProcessing, nStars, insertText) { + + // Get rid of whitespace and fixup newlines. + chunk.trimWhitespace(); + chunk.selection = chunk.selection.replace(/\n{2,}/g, "\n"); + + // Look for stars before and after. Is the chunk already marked up? + // note that these regex matches cannot fail + var starsBefore = /(\**$)/.exec(chunk.before)[0]; + var starsAfter = /(^\**)/.exec(chunk.after)[0]; + + var prevStars = Math.min(starsBefore.length, starsAfter.length); + + // Remove stars if we have to since the button acts as a toggle. + if ((prevStars >= nStars) && (prevStars != 2 || nStars != 1)) { + chunk.before = chunk.before.replace(re("[*]{" + nStars + "}$", ""), ""); + chunk.after = chunk.after.replace(re("^[*]{" + nStars + "}", ""), ""); + } + else if (!chunk.selection && starsAfter) { + // It's not really clear why this code is necessary. It just moves + // some arbitrary stuff around. + chunk.after = chunk.after.replace(/^([*_]*)/, ""); + chunk.before = chunk.before.replace(/(\s?)$/, ""); + var whitespace = re.$1; + chunk.before = chunk.before + starsAfter + whitespace; + } + else { + + // In most cases, if you don't have any selected text and click the button + // you'll get a selected, marked up region with the default text inserted. + if (!chunk.selection && !starsAfter) { + chunk.selection = insertText; + } + + // Add the true markup. + var markup = nStars <= 1 ? "*" : "**"; // shouldn't the test be = ? + chunk.before = chunk.before + markup; + chunk.after = markup + chunk.after; + } + + return; + }; + + commandProto.stripLinkDefs = function (text, defsToAdd) { + + text = text.replace(/^[ ]{0,3}\[(\d+)\]:[ \t]*\n?[ \t]*?[ \t]*\n?[ \t]*(?:(\n*)["(](.+?)[")][ \t]*)?(?:\n+|$)/gm, + function (totalMatch, id, link, newlines, title) { + defsToAdd[id] = totalMatch.replace(/\s*$/, ""); + if (newlines) { + // Strip the title and return that separately. + defsToAdd[id] = totalMatch.replace(/["(](.+?)[")]$/, ""); + return newlines + title; + } + return ""; + }); + + return text; + }; + + commandProto.addLinkDef = function (chunk, linkDef) { + + var refNumber = 0; // The current reference number + var defsToAdd = {}; // + // Start with a clean slate by removing all previous link definitions. + chunk.before = this.stripLinkDefs(chunk.before, defsToAdd); + chunk.selection = this.stripLinkDefs(chunk.selection, defsToAdd); + chunk.after = this.stripLinkDefs(chunk.after, defsToAdd); + + var defs = ""; + var regex = /(\[)((?:\[[^\]]*\]|[^\[\]])*)(\][ ]?(?:\n[ ]*)?\[)(\d+)(\])/g; + + var addDefNumber = function (def) { + refNumber++; + def = def.replace(/^[ ]{0,3}\[(\d+)\]:/, " [" + refNumber + "]:"); + defs += "\n" + def; + }; + + // note that + // a) the recursive call to getLink cannot go infinite, because by definition + // of regex, inner is always a proper substring of wholeMatch, and + // b) more than one level of nesting is neither supported by the regex + // nor making a lot of sense (the only use case for nesting is a linked image) + var getLink = function (wholeMatch, before, inner, afterInner, id, end) { + inner = inner.replace(regex, getLink); + if (defsToAdd[id]) { + addDefNumber(defsToAdd[id]); + return before + inner + afterInner + refNumber + end; + } + return wholeMatch; + }; + + chunk.before = chunk.before.replace(regex, getLink); + + if (linkDef) { + addDefNumber(linkDef); + } + else { + chunk.selection = chunk.selection.replace(regex, getLink); + } + + var refOut = refNumber; + + chunk.after = chunk.after.replace(regex, getLink); + + if (chunk.after) { + chunk.after = chunk.after.replace(/\n*$/, ""); + } + if (!chunk.after) { + chunk.selection = chunk.selection.replace(/\n*$/, ""); + } + + chunk.after += "\n\n" + defs; + + return refOut; + }; + + // takes the line as entered into the add link/as image dialog and makes + // sure the URL and the optinal title are "nice". + function properlyEncoded(linkdef) { + return linkdef.replace(/^\s*(.*?)(?:\s+"(.+)")?\s*$/, function (wholematch, link, title) { + link = link.replace(/\?.*$/, function (querypart) { + return querypart.replace(/\+/g, " "); // in the query string, a plus and a space are identical + }); + link = decodeURIComponent(link); // unencode first, to prevent double encoding + link = encodeURI(link).replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29'); + link = link.replace(/\?.*$/, function (querypart) { + return querypart.replace(/\+/g, "%2b"); // since we replaced plus with spaces in the query part, all pluses that now appear where originally encoded + }); + if (title) { + title = title.trim ? title.trim() : title.replace(/^\s*/, "").replace(/\s*$/, ""); + title = $.trim(title).replace(/"/g, "quot;").replace(/\(/g, "(").replace(/\)/g, ")").replace(//g, ">"); + } + return title ? link + ' "' + title + '"' : link; + }); + } + + commandProto.doLinkOrImage = function (chunk, postProcessing, isImage) { + + chunk.trimWhitespace(); + chunk.findTags(/\s*!?\[/, /\][ ]?(?:\n[ ]*)?(\[.*?\])?/); + var background; + + if (chunk.endTag.length > 1 && chunk.startTag.length > 0) { + + chunk.startTag = chunk.startTag.replace(/!?\[/, ""); + chunk.endTag = ""; + this.addLinkDef(chunk, null); + + } + else { + + // We're moving start and end tag back into the selection, since (as we're in the else block) we're not + // *removing* a link, but *adding* one, so whatever findTags() found is now back to being part of the + // link text. linkEnteredCallback takes care of escaping any brackets. + chunk.selection = chunk.startTag + chunk.selection + chunk.endTag; + chunk.startTag = chunk.endTag = ""; + + if (/\n\n/.test(chunk.selection)) { + this.addLinkDef(chunk, null); + return; + } + var that = this; + // The function to be executed when you enter a link and press OK or Cancel. + // Marks up the link and adds the ref. + var linkEnteredCallback = function (link) { + + background.parentNode.removeChild(background); + + if (link !== null) { + // ( $1 + // [^\\] anything that's not a backslash + // (?:\\\\)* an even number (this includes zero) of backslashes + // ) + // (?= followed by + // [[\]] an opening or closing bracket + // ) + // + // In other words, a non-escaped bracket. These have to be escaped now to make sure they + // don't count as the end of the link or similar. + // Note that the actual bracket has to be a lookahead, because (in case of to subsequent brackets), + // the bracket in one match may be the "not a backslash" character in the next match, so it + // should not be consumed by the first match. + // The "prepend a space and finally remove it" steps makes sure there is a "not a backslash" at the + // start of the string, so this also works if the selection begins with a bracket. We cannot solve + // this by anchoring with ^, because in the case that the selection starts with two brackets, this + // would mean a zero-width match at the start. Since zero-width matches advance the string position, + // the first bracket could then not act as the "not a backslash" for the second. + chunk.selection = (" " + chunk.selection).replace(/([^\\](?:\\\\)*)(?=[[\]])/g, "$1\\").substr(1); + + var linkDef = " [999]: " + properlyEncoded(link); + + var num = that.addLinkDef(chunk, linkDef); + chunk.startTag = isImage ? "![" : "["; + chunk.endTag = "][" + num + "]"; + + if (!chunk.selection) { + if (isImage) { + chunk.selection = "enter image description here"; + } + else { + chunk.selection = "enter link description here"; + } + } + } + postProcessing(); + }; + + background = ui.createBackground(); + + if (isImage) { + if (!this.hooks.insertImageDialog(linkEnteredCallback)) + ui.prompt(imageDialogText, imageDefaultText, linkEnteredCallback); + } + else { + ui.prompt(linkDialogText, linkDefaultText, linkEnteredCallback); + } + return true; + } + }; + + // When making a list, hitting shift-enter will put your cursor on the next line + // at the current indent level. + commandProto.doAutoindent = function (chunk, postProcessing) { + + var commandMgr = this, + fakeSelection = false; + + chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}([*+-]|\d+[.])[ \t]*\n$/, "\n\n"); + chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}>[ \t]*\n$/, "\n\n"); + chunk.before = chunk.before.replace(/(\n|^)[ \t]+\n$/, "\n\n"); + + // There's no selection, end the cursor wasn't at the end of the line: + // The user wants to split the current list item / code line / blockquote line + // (for the latter it doesn't really matter) in two. Temporarily select the + // (rest of the) line to achieve this. + if (!chunk.selection && !/^[ \t]*(?:\n|$)/.test(chunk.after)) { + chunk.after = chunk.after.replace(/^[^\n]*/, function (wholeMatch) { + chunk.selection = wholeMatch; + return ""; + }); + fakeSelection = true; + } + + if (/(\n|^)[ ]{0,3}([*+-]|\d+[.])[ \t]+.*\n$/.test(chunk.before)) { + if (commandMgr.doList) { + commandMgr.doList(chunk); + } + } + if (/(\n|^)[ ]{0,3}>[ \t]+.*\n$/.test(chunk.before)) { + if (commandMgr.doBlockquote) { + commandMgr.doBlockquote(chunk); + } + } + if (/(\n|^)(\t|[ ]{4,}).*\n$/.test(chunk.before)) { + if (commandMgr.doCode) { + commandMgr.doCode(chunk); + } + } + + if (fakeSelection) { + chunk.after = chunk.selection + chunk.after; + chunk.selection = ""; + } + }; + + commandProto.doBlockquote = function (chunk, postProcessing) { + + chunk.selection = chunk.selection.replace(/^(\n*)([^\r]+?)(\n*)$/, + function (totalMatch, newlinesBefore, text, newlinesAfter) { + chunk.before += newlinesBefore; + chunk.after = newlinesAfter + chunk.after; + return text; + }); + + chunk.before = chunk.before.replace(/(>[ \t]*)$/, + function (totalMatch, blankLine) { + chunk.selection = blankLine + chunk.selection; + return ""; + }); + + chunk.selection = chunk.selection.replace(/^(\s|>)+$/, ""); + chunk.selection = chunk.selection || "Blockquote"; + + // The original code uses a regular expression to find out how much of the + // text *directly before* the selection already was a blockquote: + + /* + if (chunk.before) { + chunk.before = chunk.before.replace(/\n?$/, "\n"); + } + chunk.before = chunk.before.replace(/(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*$)/, + function (totalMatch) { + chunk.startTag = totalMatch; + return ""; + }); + */ + + // This comes down to: + // Go backwards as many lines a possible, such that each line + // a) starts with ">", or + // b) is almost empty, except for whitespace, or + // c) is preceeded by an unbroken chain of non-empty lines + // leading up to a line that starts with ">" and at least one more character + // and in addition + // d) at least one line fulfills a) + // + // Since this is essentially a backwards-moving regex, it's susceptible to + // catstrophic backtracking and can cause the browser to hang; + // see e.g. http://meta.stackoverflow.com/questions/9807. + // + // Hence we replaced this by a simple state machine that just goes through the + // lines and checks for a), b), and c). + + var match = "", + leftOver = "", + line; + if (chunk.before) { + var lines = chunk.before.replace(/\n$/, "").split("\n"); + var inChain = false; + for (var i = 0; i < lines.length; i++) { + var good = false; + line = lines[i]; + inChain = inChain && line.length > 0; // c) any non-empty line continues the chain + if (/^>/.test(line)) { // a) + good = true; + if (!inChain && line.length > 1) // c) any line that starts with ">" and has at least one more character starts the chain + inChain = true; + } else if (/^[ \t]*$/.test(line)) { // b) + good = true; + } else { + good = inChain; // c) the line is not empty and does not start with ">", so it matches if and only if we're in the chain + } + if (good) { + match += line + "\n"; + } else { + leftOver += match + line; + match = "\n"; + } + } + if (!/(^|\n)>/.test(match)) { // d) + leftOver += match; + match = ""; + } + } + + chunk.startTag = match; + chunk.before = leftOver; + + // end of change + + if (chunk.after) { + chunk.after = chunk.after.replace(/^\n?/, "\n"); + } + + chunk.after = chunk.after.replace(/^(((\n|^)(\n[ \t]*)*>(.+\n)*.*)+(\n[ \t]*)*)/, + function (totalMatch) { + chunk.endTag = totalMatch; + return ""; + } + ); + + var replaceBlanksInTags = function (useBracket) { + + var replacement = useBracket ? "> " : ""; + + if (chunk.startTag) { + chunk.startTag = chunk.startTag.replace(/\n((>|\s)*)\n$/, + function (totalMatch, markdown) { + return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; + }); + } + if (chunk.endTag) { + chunk.endTag = chunk.endTag.replace(/^\n((>|\s)*)\n/, + function (totalMatch, markdown) { + return "\n" + markdown.replace(/^[ ]{0,3}>?[ \t]*$/gm, replacement) + "\n"; + }); + } + }; + + if (/^(?![ ]{0,3}>)/m.test(chunk.selection)) { + this.wrap(chunk, SETTINGS.lineLength - 2); + chunk.selection = chunk.selection.replace(/^/gm, "> "); + replaceBlanksInTags(true); + chunk.skipLines(); + } else { + chunk.selection = chunk.selection.replace(/^[ ]{0,3}> ?/gm, ""); + this.unwrap(chunk); + replaceBlanksInTags(false); + + if (!/^(\n|^)[ ]{0,3}>/.test(chunk.selection) && chunk.startTag) { + chunk.startTag = chunk.startTag.replace(/\n{0,2}$/, "\n\n"); + } + + if (!/(\n|^)[ ]{0,3}>.*$/.test(chunk.selection) && chunk.endTag) { + chunk.endTag = chunk.endTag.replace(/^\n{0,2}/, "\n\n"); + } + } + + chunk.selection = this.hooks.postBlockquoteCreation(chunk.selection); + + if (!/\n/.test(chunk.selection)) { + chunk.selection = chunk.selection.replace(/^(> *)/, + function (wholeMatch, blanks) { + chunk.startTag += blanks; + return ""; + }); + } + }; + + commandProto.doCode = function (chunk, postProcessing) { + + var hasTextBefore = /\S[ ]*$/.test(chunk.before); + var hasTextAfter = /^[ ]*\S/.test(chunk.after); + + // Use 'four space' markdown if the selection is on its own + // line or is multiline. + if ((!hasTextAfter && !hasTextBefore) || /\n/.test(chunk.selection)) { + + chunk.before = chunk.before.replace(/[ ]{4}$/, + function (totalMatch) { + chunk.selection = totalMatch + chunk.selection; + return ""; + }); + + var nLinesBack = 1; + var nLinesForward = 1; + + if (/(\n|^)(\t|[ ]{4,}).*\n$/.test(chunk.before)) { + nLinesBack = 0; + } + if (/^\n(\t|[ ]{4,})/.test(chunk.after)) { + nLinesForward = 0; + } + + chunk.skipLines(nLinesBack, nLinesForward); + + if (!chunk.selection) { + chunk.startTag = " "; + chunk.selection = "enter code here"; + } + else { + if (/^[ ]{0,3}\S/m.test(chunk.selection)) { + if (/\n/.test(chunk.selection)) + chunk.selection = chunk.selection.replace(/^/gm, " "); + else // if it's not multiline, do not select the four added spaces; this is more consistent with the doList behavior + chunk.before += " "; + } + else { + chunk.selection = chunk.selection.replace(/^[ ]{4}/gm, ""); + } + } + } + else { + // Use backticks (`) to delimit the code block. + + chunk.trimWhitespace(); + chunk.findTags(/`/, /`/); + + if (!chunk.startTag && !chunk.endTag) { + chunk.startTag = chunk.endTag = "`"; + if (!chunk.selection) { + chunk.selection = "enter code here"; + } + } + else if (chunk.endTag && !chunk.startTag) { + chunk.before += chunk.endTag; + chunk.endTag = ""; + } + else { + chunk.startTag = chunk.endTag = ""; + } + } + }; + + commandProto.doList = function (chunk, postProcessing, isNumberedList) { + + // These are identical except at the very beginning and end. + // Should probably use the regex extension function to make this clearer. + var previousItemsRegex = /(\n|^)(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*$/; + var nextItemsRegex = /^\n*(([ ]{0,3}([*+-]|\d+[.])[ \t]+.*)(\n.+|\n{2,}([*+-].*|\d+[.])[ \t]+.*|\n{2,}[ \t]+\S.*)*)\n*/; + + // The default bullet is a dash but others are possible. + // This has nothing to do with the particular HTML bullet, + // it's just a markdown bullet. + var bullet = "-"; + + // The number in a numbered list. + var num = 1; + + // Get the item prefix - e.g. " 1. " for a numbered list, " - " for a bulleted list. + var getItemPrefix = function () { + var prefix; + if (isNumberedList) { + prefix = " " + num + ". "; + num++; + } + else { + prefix = " " + bullet + " "; + } + return prefix; + }; + + // Fixes the prefixes of the other list items. + var getPrefixedItem = function (itemText) { + + // The numbering flag is unset when called by autoindent. + if (isNumberedList === undefined) { + isNumberedList = /^\s*\d/.test(itemText); + } + + // Renumber/bullet the list element. + itemText = itemText.replace(/^[ ]{0,3}([*+-]|\d+[.])\s/gm, + function (_) { + return getItemPrefix(); + }); + + return itemText; + }; + + chunk.findTags(/(\n|^)*[ ]{0,3}([*+-]|\d+[.])\s+/, null); + + if (chunk.before && !/\n$/.test(chunk.before) && !/^\n/.test(chunk.startTag)) { + chunk.before += chunk.startTag; + chunk.startTag = ""; + } + + if (chunk.startTag) { + + var hasDigits = /\d+[.]/.test(chunk.startTag); + chunk.startTag = ""; + chunk.selection = chunk.selection.replace(/\n[ ]{4}/g, "\n"); + this.unwrap(chunk); + chunk.skipLines(); + + if (hasDigits) { + // Have to renumber the bullet points if this is a numbered list. + chunk.after = chunk.after.replace(nextItemsRegex, getPrefixedItem); + } + if (isNumberedList == hasDigits) { + return; + } + } + + var nLinesUp = 1; + + chunk.before = chunk.before.replace(previousItemsRegex, + function (itemText) { + if (/^\s*([*+-])/.test(itemText)) { + bullet = re.$1; + } + nLinesUp = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; + return getPrefixedItem(itemText); + }); + + if (!chunk.selection) { + chunk.selection = "List item"; + } + + var prefix = getItemPrefix(); + + var nLinesDown = 1; + + chunk.after = chunk.after.replace(nextItemsRegex, + function (itemText) { + nLinesDown = /[^\n]\n\n[^\n]/.test(itemText) ? 1 : 0; + return getPrefixedItem(itemText); + }); + + chunk.trimWhitespace(true); + chunk.skipLines(nLinesUp, nLinesDown, true); + chunk.startTag = prefix; + var spaces = prefix.replace(/./g, " "); + this.wrap(chunk, SETTINGS.lineLength - spaces.length); + chunk.selection = chunk.selection.replace(/\n/g, "\n" + spaces); + + }; + + commandProto.doHeading = function (chunk, postProcessing) { + + // Remove leading/trailing whitespace and reduce internal spaces to single spaces. + chunk.selection = chunk.selection.replace(/\s+/g, " "); + chunk.selection = chunk.selection.replace(/(^\s+|\s+$)/g, ""); + + // If we clicked the button with no selected text, we just + // make a level 2 hash header around some default text. + if (!chunk.selection) { + chunk.startTag = "## "; + chunk.selection = "Heading"; + chunk.endTag = " ##"; + return; + } + + var headerLevel = 0; // The existing header level of the selected text. + + // Remove any existing hash heading markdown and save the header level. + chunk.findTags(/#+[ ]*/, /[ ]*#+/); + if (/#+/.test(chunk.startTag)) { + headerLevel = re.lastMatch.length; + } + chunk.startTag = chunk.endTag = ""; + + // Try to get the current header level by looking for - and = in the line + // below the selection. + chunk.findTags(null, /\s?(-+|=+)/); + if (/=+/.test(chunk.endTag)) { + headerLevel = 1; + } + if (/-+/.test(chunk.endTag)) { + headerLevel = 2; + } + + // Skip to the next line so we can create the header markdown. + chunk.startTag = chunk.endTag = ""; + chunk.skipLines(1, 1); + + // We make a level 2 header if there is no current header. + // If there is a header level, we substract one from the header level. + // If it's already a level 1 header, it's removed. + var headerLevelToCreate = headerLevel == 0 ? 2 : headerLevel - 1; + + if (headerLevelToCreate > 0) { + + // The button only creates level 1 and 2 underline headers. + // Why not have it iterate over hash header levels? Wouldn't that be easier and cleaner? + var headerChar = headerLevelToCreate >= 2 ? "-" : "="; + var len = chunk.selection.length; + if (len > SETTINGS.lineLength) { + len = SETTINGS.lineLength; + } + chunk.endTag = "\n"; + while (len--) { + chunk.endTag += headerChar; + } + } + }; + + commandProto.doHorizontalRule = function (chunk, postProcessing) { + chunk.startTag = "----------\n"; + chunk.selection = ""; + chunk.skipLines(2, 1, true); + } + + +})(); diff --git a/app/assets/javascripts/external/Markdown.Sanitizer.js b/app/assets/javascripts/external/Markdown.Sanitizer.js new file mode 100644 index 00000000000..cc5826fa8fa --- /dev/null +++ b/app/assets/javascripts/external/Markdown.Sanitizer.js @@ -0,0 +1,108 @@ +(function () { + var output, Converter; + if (typeof exports === "object" && typeof require === "function") { // we're in a CommonJS (e.g. Node.js) module + output = exports; + Converter = require("./Markdown.Converter").Converter; + } else { + output = window.Markdown; + Converter = output.Converter; + } + + output.getSanitizingConverter = function () { + var converter = new Converter(); + converter.hooks.chain("postConversion", sanitizeHtml); + converter.hooks.chain("postConversion", balanceTags); + return converter; + } + + function sanitizeHtml(html) { + return html.replace(/<[^>]*>?/gi, sanitizeTag); + } + + // (tags that can be opened/closed) | (tags that stand alone) + var basic_tag_whitelist = /^(<\/?(b|blockquote|code|del|dd|dl|dt|em|h1|h2|h3|i|kbd|li|ol|p|pre|s|sup|sub|strong|strike|ul)>|<(br|hr)\s?\/?>)$/i; + //
        | + var a_white = /^(]+")?\s?>|<\/a>)$/i; + + // ]*")?(\stitle="[^"<>]*")?\s?\/?>)$/i; + + function sanitizeTag(tag) { + if (tag.match(basic_tag_whitelist) || tag.match(a_white) || tag.match(img_white)) + return tag; + else + return ""; + } + + /// + /// attempt to balance HTML tags in the html string + /// by removing any unmatched opening or closing tags + /// IMPORTANT: we *assume* HTML has *already* been + /// sanitized and is safe/sane before balancing! + /// + /// adapted from CODESNIPPET: A8591DBA-D1D3-11DE-947C-BA5556D89593 + /// + function balanceTags(html) { + + if (html == "") + return ""; + + var re = /<\/?\w+[^>]*(\s|$|>)/g; + // convert everything to lower case; this makes + // our case insensitive comparisons easier + var tags = html.toLowerCase().match(re); + + // no HTML tags present? nothing to do; exit now + var tagcount = (tags || []).length; + if (tagcount == 0) + return html; + + var tagname, tag; + var ignoredtags = "



      • "; + var match; + var tagpaired = []; + var tagremove = []; + var needsRemoval = false; + + // loop through matched tags in forward order + for (var ctag = 0; ctag < tagcount; ctag++) { + tagname = tags[ctag].replace(/<\/?(\w+).*/, "$1"); + // skip any already paired tags + // and skip tags in our ignore list; assume they're self-closed + if (tagpaired[ctag] || ignoredtags.search("<" + tagname + ">") > -1) + continue; + + tag = tags[ctag]; + match = -1; + + if (!/^<\//.test(tag)) { + // this is an opening tag + // search forwards (next tags), look for closing tags + for (var ntag = ctag + 1; ntag < tagcount; ntag++) { + if (!tagpaired[ntag] && tags[ntag] == "") { + match = ntag; + break; + } + } + } + + if (match == -1) + needsRemoval = tagremove[ctag] = true; // mark for removal + else + tagpaired[match] = true; // mark paired + } + + if (!needsRemoval) + return html; + + // delete all orphaned tags from the string + + var ctag = 0; + html = html.replace(re, function (match) { + var res = tagremove[ctag] ? "" : match; + ctag++; + return res; + }); + return html; + } +})(); diff --git a/app/assets/javascripts/external/bootbox.js b/app/assets/javascripts/external/bootbox.js new file mode 100644 index 00000000000..3d811afc5c3 --- /dev/null +++ b/app/assets/javascripts/external/bootbox.js @@ -0,0 +1,511 @@ +/** + * bootbox.js v2.3.2 + * + * The MIT License + * + * Copyright (C) 2011-2012 by Nick Payne + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE + */ +var bootbox = window.bootbox || (function($) { + + var _locale = 'en', + _defaultLocale = 'en', + _animate = false, + _icons = {}, + /* last var should always be the public object we'll return */ + that = {}; + + /** + * standard locales. Please add more according to ISO 639-1 standard. Multiple language variants are + * unlikely to be required. If this gets too large it can be split out into separate JS files. + */ + var _locales = { + 'en' : { + OK : 'OK', + CANCEL : 'Cancel', + CONFIRM : 'OK' + }, + 'fr' : { + OK : 'OK', + CANCEL : 'Annuler', + CONFIRM : 'D\'accord' + }, + 'de' : { + OK : 'OK', + CANCEL : 'Abbrechen', + CONFIRM : 'Akzeptieren' + }, + 'es' : { + OK : 'OK', + CANCEL : 'Cancelar', + CONFIRM : 'Aceptar' + }, + 'br' : { + OK : 'OK', + CANCEL : 'Cancelar', + CONFIRM : 'Sim' + }, + 'nl' : { + OK : 'OK', + CANCEL : 'Annuleren', + CONFIRM : 'Accepteren' + }, + 'ru' : { + OK : 'OK', + CANCEL : 'Отмена', + CONFIRM : 'Применить' + }, + 'it' : { + OK : 'OK', + CANCEL : 'Annulla', + CONFIRM : 'Conferma' + } + }; + + function _translate(str, locale) { + // we assume if no target locale is probided then we should take it from current setting + if (locale == null) { + locale = _locale; + } + if (typeof _locales[locale][str] == 'string') { + return _locales[locale][str]; + } + + // if we couldn't find a lookup then try and fallback to a default translation + + if (locale != _defaultLocale) { + return _translate(str, _defaultLocale); + } + + // if we can't do anything then bail out with whatever string was passed in - last resort + return str; + } + + that.setLocale = function(locale) { + for (var i in _locales) { + if (i == locale) { + _locale = locale; + return; + } + } + throw new Error('Invalid locale: '+locale); + } + + that.addLocale = function(locale, translations) { + if (typeof _locales[locale] == 'undefined') { + _locales[locale] = {}; + } + for (var str in translations) { + _locales[locale][str] = translations[str]; + } + } + + that.setIcons = function(icons) { + _icons = icons; + if (typeof _icons !== 'object' || _icons == null) { + _icons = {}; + } + } + + that.alert = function(/*str, label, cb*/) { + var str = "", + label = _translate('OK'), + cb = null; + + switch (arguments.length) { + case 1: + // no callback, default button label + str = arguments[0]; + break; + case 2: + // callback *or* custom button label dependent on type + str = arguments[0]; + if (typeof arguments[1] == 'function') { + cb = arguments[1]; + } else { + label = arguments[1]; + } + break; + case 3: + // callback and custom button label + str = arguments[0]; + label = arguments[1]; + cb = arguments[2]; + break; + default: + throw new Error("Incorrect number of arguments: expected 1-3"); + break; + } + + return that.dialog(str, { + "label": label, + "icon" : _icons.OK, + "callback": cb + }, { + "onEscape": cb + }); + } + + that.confirm = function(/*str, labelCancel, labelOk, cb*/) { + var str = "", + labelCancel = _translate('CANCEL'), + labelOk = _translate('CONFIRM'), + cb = null; + + switch (arguments.length) { + case 1: + str = arguments[0]; + break; + case 2: + str = arguments[0]; + if (typeof arguments[1] == 'function') { + cb = arguments[1]; + } else { + labelCancel = arguments[1]; + } + break; + case 3: + str = arguments[0]; + labelCancel = arguments[1]; + if (typeof arguments[2] == 'function') { + cb = arguments[2]; + } else { + labelOk = arguments[2]; + } + break; + case 4: + str = arguments[0]; + labelCancel = arguments[1]; + labelOk = arguments[2]; + cb = arguments[3]; + break; + default: + throw new Error("Incorrect number of arguments: expected 1-4"); + break; + } + + return that.dialog(str, [{ + "label": labelCancel, + "icon" : _icons.CANCEL, + "callback": function() { + if (typeof cb == 'function') { + cb(false); + } + } + }, { + "label": labelOk, + "icon" : _icons.CONFIRM, + "callback": function() { + if (typeof cb == 'function') { + cb(true); + } + } + }]); + } + + that.prompt = function(/*str, labelCancel, labelOk, cb*/) { + var str = "", + labelCancel = _translate('CANCEL'), + labelOk = _translate('CONFIRM'), + cb = null; + + switch (arguments.length) { + case 1: + str = arguments[0]; + break; + case 2: + str = arguments[0]; + if (typeof arguments[1] == 'function') { + cb = arguments[1]; + } else { + labelCancel = arguments[1]; + } + break; + case 3: + str = arguments[0]; + labelCancel = arguments[1]; + if (typeof arguments[2] == 'function') { + cb = arguments[2]; + } else { + labelOk = arguments[2]; + } + break; + case 4: + str = arguments[0]; + labelCancel = arguments[1]; + labelOk = arguments[2]; + cb = arguments[3]; + break; + default: + throw new Error("Incorrect number of arguments: expected 1-4"); + break; + } + + var header = str; + + // let's keep a reference to the form object for later + var form = $("
        "); + form.append(""); + + var div = that.dialog(form, [{ + "label": labelCancel, + "icon" : _icons.CANCEL, + "callback": function() { + if (typeof cb == 'function') { + cb(null); + } + } + }, { + "label": labelOk, + "icon" : _icons.CONFIRM, + "callback": function() { + if (typeof cb == 'function') { + cb( + form.find("input[type=text]").val() + ); + } + } + }], { + "header": header + }); + + div.on("shown", function() { + form.find("input[type=text]").focus(); + + // ensure that submitting the form (e.g. with the enter key) + // replicates the behaviour of a normal prompt() + form.on("submit", function(e) { + e.preventDefault(); + div.find(".btn-primary").click(); + }); + }); + + return div; + } + + that.modal = function(/*str, label, options*/) { + var str; + var label; + var options; + + var defaultOptions = { + "onEscape": null, + "keyboard": true, + "backdrop": true + }; + + switch (arguments.length) { + case 1: + str = arguments[0]; + break; + case 2: + str = arguments[0]; + if (typeof arguments[1] == 'object') { + options = arguments[1]; + } else { + label = arguments[1]; + } + break; + case 3: + str = arguments[0]; + label = arguments[1]; + options = arguments[2]; + break; + default: + throw new Error("Incorrect number of arguments: expected 1-3"); + break; + } + + defaultOptions['header'] = label; + + if (typeof options == 'object') { + options = $.extend(defaultOptions, options); + } else { + options = defaultOptions; + } + + return that.dialog(str, [], options); + } + + that.dialog = function(str, handlers, options) { + var hideSource = null, + buttons = "", + callbacks = [], + options = options || {}; + + // check for single object and convert to array if necessary + if (handlers == null) { + handlers = []; + } else if (typeof handlers.length == 'undefined') { + handlers = [handlers]; + } + + var i = handlers.length; + while (i--) { + var label = null, + _class = null, + icon = '', + callback = null; + + if (typeof handlers[i]['label'] == 'undefined' && + typeof handlers[i]['class'] == 'undefined' && + typeof handlers[i]['callback'] == 'undefined') { + // if we've got nothing we expect, check for condensed format + + var propCount = 0, // condensed will only match if this == 1 + property = null; // save the last property we found + + // be nicer to count the properties without this, but don't think it's possible... + for (var j in handlers[i]) { + property = j; + if (++propCount > 1) { + // forget it, too many properties + break; + } + } + + if (propCount == 1 && typeof handlers[i][j] == 'function') { + // matches condensed format of label -> function + handlers[i]['label'] = property; + handlers[i]['callback'] = handlers[i][j]; + } + } + + if (typeof handlers[i]['callback']== 'function') { + callback = handlers[i]['callback']; + } + + if (handlers[i]['class']) { + _class = handlers[i]['class']; + } else if (i == handlers.length -1 && handlers.length <= 2) { + // always add a primary to the main option in a two-button dialog + _class = 'btn-primary'; + } + + if (handlers[i]['label']) { + label = handlers[i]['label']; + } else { + label = "Option "+(i+1); + } + + if (handlers[i]['icon']) { + icon = " "; + } + + buttons += ""+icon+""+label+""; + + callbacks[i] = callback; + } + + var parts = [""); + + var div = $(parts.join("\n")); + + // check whether we should fade in/out + var shouldFade = (typeof options.animate === 'undefined') ? _animate : options.animate; + + if (shouldFade) { + div.addClass("fade"); + } + + // now we've built up the div properly we can inject the content whether it was a string or a jQuery object + $(".modal-body", div).html(str); + + div.bind('hidden', function() { + div.remove(); + }); + + div.bind('hide', function() { + if (hideSource == 'escape' && + typeof options.onEscape == 'function') { + options.onEscape(); + } + }); + + // hook into the modal's keyup trigger to check for the escape key + $(document).bind('keyup.modal', function ( e ) { + if (e.which == 27) { + hideSource = 'escape'; + } + }); + + // well, *if* we have a primary - give the last dom element (first displayed) focus + div.bind('shown', function() { + $("a.btn-primary:last", div).focus(); + }); + + // wire up button handlers + div.on('click', '.modal-footer a, a.close', function(e) { + var handler = $(this).data("handler"), + cb = callbacks[handler], + hideModal = null; + + if (typeof cb == 'function') { + hideModal = cb(); + } + if (hideModal !== false){ + e.preventDefault(); + hideSource = 'button'; + div.modal("hide"); + } + }); + + if (options.keyboard == null) { + options.keyboard = (typeof options.onEscape == 'function'); + } + + $("body").append(div); + + div.modal({ + "backdrop" : options.backdrop || true, + "keyboard" : options.keyboard + }); + + return div; + } + + that.hideAll = function() { + $(".bootbox").modal("hide"); + } + + that.animate = function(animate) { + _animate = animate; + } + + return that; +})( window.jQuery ); diff --git a/app/assets/javascripts/external/bootstrap-alert.js b/app/assets/javascripts/external/bootstrap-alert.js new file mode 100644 index 00000000000..57890a9a281 --- /dev/null +++ b/app/assets/javascripts/external/bootstrap-alert.js @@ -0,0 +1,90 @@ +/* ========================================================== + * bootstrap-alert.js v2.0.4 + * http://twitter.github.com/bootstrap/javascript.html#alerts + * ========================================================== + * Copyright 2012 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================== */ + + +!function ($) { + + "use strict"; // jshint ;_; + + + /* ALERT CLASS DEFINITION + * ====================== */ + + var dismiss = '[data-dismiss="alert"]' + , Alert = function (el) { + $(el).on('click', dismiss, this.close) + } + + Alert.prototype.close = function (e) { + var $this = $(this) + , selector = $this.attr('data-target') + , $parent + + if (!selector) { + selector = $this.attr('href') + selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') //strip for ie7 + } + + $parent = $(selector) + + e && e.preventDefault() + + $parent.length || ($parent = $this.hasClass('alert') ? $this : $this.parent()) + + $parent.trigger(e = $.Event('close')) + + if (e.isDefaultPrevented()) return + + $parent.removeClass('in') + + function removeElement() { + $parent + .trigger('closed') + .remove() + } + + $.support.transition && $parent.hasClass('fade') ? + $parent.on($.support.transition.end, removeElement) : + removeElement() + } + + + /* ALERT PLUGIN DEFINITION + * ======================= */ + + $.fn.alert = function (option) { + return this.each(function () { + var $this = $(this) + , data = $this.data('alert') + if (!data) $this.data('alert', (data = new Alert(this))) + if (typeof option == 'string') data[option].call($this) + }) + } + + $.fn.alert.Constructor = Alert + + + /* ALERT DATA-API + * ============== */ + + $(function () { + $('body').on('click.alert.data-api', dismiss, Alert.prototype.close) + }) + +}(window.jQuery); \ No newline at end of file diff --git a/app/assets/javascripts/external/bootstrap-button.js b/app/assets/javascripts/external/bootstrap-button.js new file mode 100644 index 00000000000..11ecb9d6104 --- /dev/null +++ b/app/assets/javascripts/external/bootstrap-button.js @@ -0,0 +1,96 @@ +/* ============================================================ + * bootstrap-button.js v2.0.4 + * http://twitter.github.com/bootstrap/javascript.html#buttons + * ============================================================ + * Copyright 2012 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============================================================ */ + + +!function ($) { + + "use strict"; // jshint ;_; + + + /* BUTTON PUBLIC CLASS DEFINITION + * ============================== */ + + var Button = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, $.fn.button.defaults, options) + } + + Button.prototype.setState = function (state) { + var d = 'disabled' + , $el = this.$element + , data = $el.data() + , val = $el.is('input') ? 'val' : 'html' + + state = state + 'Text' + data.resetText || $el.data('resetText', $el[val]()) + + $el[val](data[state] || this.options[state]) + + // push to event loop to allow forms to submit + setTimeout(function () { + state == 'loadingText' ? + $el.addClass(d).attr(d, d) : + $el.removeClass(d).removeAttr(d) + }, 0) + } + + Button.prototype.toggle = function () { + var $parent = this.$element.parent('[data-toggle="buttons-radio"]') + + $parent && $parent + .find('.active') + .removeClass('active') + + this.$element.toggleClass('active') + } + + + /* BUTTON PLUGIN DEFINITION + * ======================== */ + + $.fn.button = function (option) { + return this.each(function () { + var $this = $(this) + , data = $this.data('button') + , options = typeof option == 'object' && option + if (!data) $this.data('button', (data = new Button(this, options))) + if (option == 'toggle') data.toggle() + else if (option) data.setState(option) + }) + } + + $.fn.button.defaults = { + loadingText: 'loading...' + } + + $.fn.button.Constructor = Button + + + /* BUTTON DATA-API + * =============== */ + + $(function () { + $('body').on('click.button.data-api', '[data-toggle^=button]', function ( e ) { + var $btn = $(e.target) + if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn') + $btn.button('toggle') + }) + }) + +}(window.jQuery); \ No newline at end of file diff --git a/app/assets/javascripts/external/bootstrap-dropdown.js b/app/assets/javascripts/external/bootstrap-dropdown.js new file mode 100644 index 00000000000..454a9684b52 --- /dev/null +++ b/app/assets/javascripts/external/bootstrap-dropdown.js @@ -0,0 +1,100 @@ +/* ============================================================ + * bootstrap-dropdown.js v2.0.4 + * http://twitter.github.com/bootstrap/javascript.html#dropdowns + * ============================================================ + * Copyright 2012 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============================================================ */ + + +!function ($) { + + "use strict"; // jshint ;_; + + + /* DROPDOWN CLASS DEFINITION + * ========================= */ + + var toggle = '[data-toggle="dropdown"]' + , Dropdown = function (element) { + var $el = $(element).on('click.dropdown.data-api', this.toggle) + $('html').on('click.dropdown.data-api', function () { + $el.parent().removeClass('open') + }) + } + + Dropdown.prototype = { + + constructor: Dropdown + + , toggle: function (e) { + var $this = $(this) + , $parent + , selector + , isActive + + if ($this.is('.disabled, :disabled')) return + + selector = $this.attr('data-target') + + if (!selector) { + selector = $this.attr('href') + selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') //strip for ie7 + } + + $parent = $(selector) + $parent.length || ($parent = $this.parent()) + + isActive = $parent.hasClass('open') + + clearMenus() + + if (!isActive) $parent.toggleClass('open') + + return false + } + + } + + function clearMenus() { + $(toggle).parent().removeClass('open') + } + + + /* DROPDOWN PLUGIN DEFINITION + * ========================== */ + + $.fn.dropdown = function (option) { + return this.each(function () { + var $this = $(this) + , data = $this.data('dropdown') + if (!data) $this.data('dropdown', (data = new Dropdown(this))) + if (typeof option == 'string') data[option].call($this) + }) + } + + $.fn.dropdown.Constructor = Dropdown + + + /* APPLY TO STANDARD DROPDOWN ELEMENTS + * =================================== */ + + $(function () { + $('html').on('click.dropdown.data-api', clearMenus) + $('body') + .on('click.dropdown', '.dropdown form', function (e) { e.stopPropagation() }) + .on('click.dropdown.data-api', toggle, Dropdown.prototype.toggle) + }) + +}(window.jQuery); \ No newline at end of file diff --git a/app/assets/javascripts/external/bootstrap-modal.js b/app/assets/javascripts/external/bootstrap-modal.js new file mode 100644 index 00000000000..c831de6b64b --- /dev/null +++ b/app/assets/javascripts/external/bootstrap-modal.js @@ -0,0 +1,218 @@ +/* ========================================================= + * bootstrap-modal.js v2.0.3 + * http://twitter.github.com/bootstrap/javascript.html#modals + * ========================================================= + * Copyright 2012 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= */ + + +!function ($) { + + "use strict"; // jshint ;_; + + + /* MODAL CLASS DEFINITION + * ====================== */ + + var Modal = function (content, options) { + this.options = options + this.$element = $(content) + .delegate('[data-dismiss="modal"]', 'click.dismiss.modal', $.proxy(this.hide, this)) + } + + Modal.prototype = { + + constructor: Modal + + , toggle: function () { + return this[!this.isShown ? 'show' : 'hide']() + } + + , show: function () { + var that = this + , e = $.Event('show') + + this.$element.trigger(e) + + if (this.isShown || e.isDefaultPrevented()) return + + $('body').addClass('modal-open') + + this.isShown = true + + escape.call(this) + backdrop.call(this, function () { + var transition = $.support.transition && that.$element.hasClass('fade') + + if (!that.$element.parent().length) { + that.$element.appendTo(document.body) //don't move modals dom position + } + + that.$element + .show() + + if (transition) { + that.$element[0].offsetWidth // force reflow + } + + that.$element.addClass('in') + + transition ? + that.$element.one($.support.transition.end, function () { that.$element.trigger('shown') }) : + that.$element.trigger('shown') + + }) + } + + , hide: function (e) { + e && e.preventDefault() + + var that = this + + e = $.Event('hide') + + this.$element.trigger(e) + + if (!this.isShown || e.isDefaultPrevented()) return + + this.isShown = false + + $('body').removeClass('modal-open') + + escape.call(this) + + this.$element.removeClass('in') + + $.support.transition && this.$element.hasClass('fade') ? + hideWithTransition.call(this) : + hideModal.call(this) + } + + } + + + /* MODAL PRIVATE METHODS + * ===================== */ + + function hideWithTransition() { + var that = this + , timeout = setTimeout(function () { + that.$element.off($.support.transition.end) + hideModal.call(that) + }, 500) + + this.$element.one($.support.transition.end, function () { + clearTimeout(timeout) + hideModal.call(that) + }) + } + + function hideModal(that) { + this.$element + .hide() + .trigger('hidden') + + backdrop.call(this) + } + + function backdrop(callback) { + var that = this + , animate = this.$element.hasClass('fade') ? 'fade' : '' + + if (this.isShown && this.options.backdrop) { + var doAnimate = $.support.transition && animate + + this.$backdrop = $('
      • ' + html + '
      • '; + } else { + return ""; + } + }; + + AbstractChosen.prototype.results_update_field = function() { + if (!this.is_multiple) { + this.results_reset_cleanup(); + } + this.result_clear_highlight(); + this.result_single_selected = null; + return this.results_build(); + }; + + AbstractChosen.prototype.results_toggle = function() { + if (this.results_showing) { + return this.results_hide(); + } else { + return this.results_show(); + } + }; + + AbstractChosen.prototype.results_search = function(evt) { + if (this.results_showing) { + return this.winnow_results(); + } else { + return this.results_show(); + } + }; + + AbstractChosen.prototype.keyup_checker = function(evt) { + var stroke, _ref; + stroke = (_ref = evt.which) != null ? _ref : evt.keyCode; + this.search_field_scale(); + switch (stroke) { + case 8: + if (this.is_multiple && this.backstroke_length < 1 && this.choices > 0) { + return this.keydown_backstroke(); + } else if (!this.pending_backstroke) { + this.result_clear_highlight(); + return this.results_search(); + } + break; + case 13: + evt.preventDefault(); + if (this.results_showing) { + return this.result_select(evt); + } + break; + case 27: + if (this.results_showing) { + this.results_hide(); + } + return true; + case 9: + case 38: + case 40: + case 16: + case 91: + case 17: + break; + default: + return this.results_search(); + } + }; + + AbstractChosen.prototype.generate_field_id = function() { + var new_id; + new_id = this.generate_random_id(); + this.form_field.id = new_id; + return new_id; + }; + + AbstractChosen.prototype.generate_random_char = function() { + var chars, newchar, rand; + chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + rand = Math.floor(Math.random() * chars.length); + return newchar = chars.substring(rand, rand + 1); + }; + + return AbstractChosen; + + })(); + + root.AbstractChosen = AbstractChosen; + +}).call(this); + +/* +Chosen source: generate output using 'cake build' +Copyright (c) 2011 by Harvest +*/ + + +(function() { + var $, Chosen, get_side_border_padding, root, + __hasProp = {}.hasOwnProperty, + __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; + + root = this; + + $ = jQuery; + + $.fn.extend({ + chosen: function(options) { + if ($.browser.msie && ($.browser.version === "6.0" || $.browser.version === "7.0")) { + return this; + } + return this.each(function(input_field) { + var $this; + $this = $(this); + if (!$this.hasClass("chzn-done")) { + return $this.data('chosen', new Chosen(this, options)); + } + }); + } + }); + + Chosen = (function(_super) { + + __extends(Chosen, _super); + + function Chosen() { + return Chosen.__super__.constructor.apply(this, arguments); + } + + Chosen.prototype.setup = function() { + this.form_field_jq = $(this.form_field); + this.current_value = this.form_field_jq.val(); + return this.is_rtl = this.form_field_jq.hasClass("chzn-rtl"); + }; + + Chosen.prototype.finish_setup = function() { + return this.form_field_jq.addClass("chzn-done"); + }; + + Chosen.prototype.set_up_html = function() { + var container_div, dd_top, dd_width, sf_width; + this.container_id = this.form_field.id.length ? this.form_field.id.replace(/[^\w]/g, '_') : this.generate_field_id(); + this.container_id += "_chzn"; + this.f_width = this.form_field_jq.outerWidth(); + container_div = $("
        ", { + id: this.container_id, + "class": "chzn-container" + (this.is_rtl ? ' chzn-rtl' : ''), + style: 'width: ' + this.f_width + 'px;' + }); + if (this.is_multiple) { + container_div.html('
          '); + } else { + container_div.html('' + this.default_text + '
            '); + } + this.form_field_jq.hide().after(container_div); + this.container = $('#' + this.container_id); + this.container.addClass("chzn-container-" + (this.is_multiple ? "multi" : "single")); + this.dropdown = this.container.find('div.chzn-drop').first(); + dd_top = this.container.height(); + dd_width = this.f_width - get_side_border_padding(this.dropdown); + this.dropdown.css({ + "width": dd_width + "px", + "top": dd_top + "px" + }); + this.search_field = this.container.find('input').first(); + this.search_results = this.container.find('ul.chzn-results').first(); + this.search_field_scale(); + this.search_no_results = this.container.find('li.no-results').first(); + if (this.is_multiple) { + this.search_choices = this.container.find('ul.chzn-choices').first(); + this.search_container = this.container.find('li.search-field').first(); + } else { + this.search_container = this.container.find('div.chzn-search').first(); + this.selected_item = this.container.find('.chzn-single').first(); + sf_width = dd_width - get_side_border_padding(this.search_container) - get_side_border_padding(this.search_field); + this.search_field.css({ + "width": sf_width + "px" + }); + } + this.results_build(); + this.set_tab_index(); + return this.form_field_jq.trigger("liszt:ready", { + chosen: this + }); + }; + + Chosen.prototype.register_observers = function() { + var _this = this; + this.container.mousedown(function(evt) { + return _this.container_mousedown(evt); + }); + this.container.mouseup(function(evt) { + return _this.container_mouseup(evt); + }); + this.container.mouseenter(function(evt) { + return _this.mouse_enter(evt); + }); + this.container.mouseleave(function(evt) { + return _this.mouse_leave(evt); + }); + this.search_results.mouseup(function(evt) { + return _this.search_results_mouseup(evt); + }); + this.search_results.mouseover(function(evt) { + return _this.search_results_mouseover(evt); + }); + this.search_results.mouseout(function(evt) { + return _this.search_results_mouseout(evt); + }); + this.form_field_jq.bind("liszt:updated", function(evt) { + return _this.results_update_field(evt); + }); + this.search_field.blur(function(evt) { + return _this.input_blur(evt); + }); + this.search_field.keyup(function(evt) { + return _this.keyup_checker(evt); + }); + this.search_field.keydown(function(evt) { + return _this.keydown_checker(evt); + }); + if (this.is_multiple) { + this.search_choices.click(function(evt) { + return _this.choices_click(evt); + }); + return this.search_field.focus(function(evt) { + return _this.input_focus(evt); + }); + } else { + return this.container.click(function(evt) { + return evt.preventDefault(); + }); + } + }; + + Chosen.prototype.search_field_disabled = function() { + this.is_disabled = this.form_field_jq[0].disabled; + if (this.is_disabled) { + this.container.addClass('chzn-disabled'); + this.search_field[0].disabled = true; + if (!this.is_multiple) { + this.selected_item.unbind("focus", this.activate_action); + } + return this.close_field(); + } else { + this.container.removeClass('chzn-disabled'); + this.search_field[0].disabled = false; + if (!this.is_multiple) { + return this.selected_item.bind("focus", this.activate_action); + } + } + }; + + Chosen.prototype.container_mousedown = function(evt) { + var target_closelink; + if (!this.is_disabled) { + target_closelink = evt != null ? ($(evt.target)).hasClass("search-choice-close") : false; + if (evt && evt.type === "mousedown" && !this.results_showing) { + evt.stopPropagation(); + } + if (!this.pending_destroy_click && !target_closelink) { + if (!this.active_field) { + if (this.is_multiple) { + this.search_field.val(""); + } + $(document).click(this.click_test_action); + this.results_show(); + } else if (!this.is_multiple && evt && (($(evt.target)[0] === this.selected_item[0]) || $(evt.target).parents("a.chzn-single").length)) { + evt.preventDefault(); + this.results_toggle(); + } + return this.activate_field(); + } else { + return this.pending_destroy_click = false; + } + } + }; + + Chosen.prototype.container_mouseup = function(evt) { + if (evt.target.nodeName === "ABBR" && !this.is_disabled) { + return this.results_reset(evt); + } + }; + + Chosen.prototype.blur_test = function(evt) { + if (!this.active_field && this.container.hasClass("chzn-container-active")) { + return this.close_field(); + } + }; + + Chosen.prototype.close_field = function() { + $(document).unbind("click", this.click_test_action); + if (!this.is_multiple) { + this.selected_item.attr("tabindex", this.search_field.attr("tabindex")); + this.search_field.attr("tabindex", -1); + } + this.active_field = false; + this.results_hide(); + this.container.removeClass("chzn-container-active"); + this.winnow_results_clear(); + this.clear_backstroke(); + this.show_search_field_default(); + return this.search_field_scale(); + }; + + Chosen.prototype.activate_field = function() { + if (!this.is_multiple && !this.active_field) { + this.search_field.attr("tabindex", this.selected_item.attr("tabindex")); + this.selected_item.attr("tabindex", -1); + } + this.container.addClass("chzn-container-active"); + this.active_field = true; + this.search_field.val(this.search_field.val()); + return this.search_field.focus(); + }; + + Chosen.prototype.test_active_click = function(evt) { + if ($(evt.target).parents('#' + this.container_id).length) { + return this.active_field = true; + } else { + return this.close_field(); + } + }; + + Chosen.prototype.results_build = function() { + var content, data, _i, _len, _ref; + this.parsing = true; + this.results_data = root.SelectParser.select_to_array(this.form_field); + if (this.is_multiple && this.choices > 0) { + this.search_choices.find("li.search-choice").remove(); + this.choices = 0; + } else if (!this.is_multiple) { + this.selected_item.addClass("chzn-default").find("span").text(this.default_text); + if (this.form_field.options.length <= this.disable_search_threshold) { + this.container.addClass("chzn-container-single-nosearch"); + } else { + this.container.removeClass("chzn-container-single-nosearch"); + } + } + content = ''; + _ref = this.results_data; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + data = _ref[_i]; + if (data.group) { + content += this.result_add_group(data); + } else if (!data.empty) { + content += this.result_add_option(data); + if (data.selected && this.is_multiple) { + this.choice_build(data); + } else if (data.selected && !this.is_multiple) { + this.selected_item.removeClass("chzn-default").find("span").text(data.text); + if (this.allow_single_deselect) { + this.single_deselect_control_build(); + } + } + } + } + this.search_field_disabled(); + this.show_search_field_default(); + this.search_field_scale(); + this.search_results.html(content); + return this.parsing = false; + }; + + Chosen.prototype.result_add_group = function(group) { + if (!group.disabled) { + group.dom_id = this.container_id + "_g_" + group.array_index; + return '
          • ' + $("
            ").text(group.label).html() + '
          • '; + } else { + return ""; + } + }; + + Chosen.prototype.result_do_highlight = function(el) { + var high_bottom, high_top, maxHeight, visible_bottom, visible_top; + if (el.length) { + this.result_clear_highlight(); + this.result_highlight = el; + this.result_highlight.addClass("highlighted"); + maxHeight = parseInt(this.search_results.css("maxHeight"), 10); + visible_top = this.search_results.scrollTop(); + visible_bottom = maxHeight + visible_top; + high_top = this.result_highlight.position().top + this.search_results.scrollTop(); + high_bottom = high_top + this.result_highlight.outerHeight(); + if (high_bottom >= visible_bottom) { + return this.search_results.scrollTop((high_bottom - maxHeight) > 0 ? high_bottom - maxHeight : 0); + } else if (high_top < visible_top) { + return this.search_results.scrollTop(high_top); + } + } + }; + + Chosen.prototype.result_clear_highlight = function() { + if (this.result_highlight) { + this.result_highlight.removeClass("highlighted"); + } + return this.result_highlight = null; + }; + + Chosen.prototype.results_show = function() { + var dd_top; + if (!this.is_multiple) { + this.selected_item.addClass("chzn-single-with-drop"); + if (this.result_single_selected) { + this.result_do_highlight(this.result_single_selected); + } + } else if (this.max_selected_options <= this.choices) { + this.form_field_jq.trigger("liszt:maxselected", { + chosen: this + }); + return false; + } + dd_top = this.is_multiple ? this.container.height() : this.container.height() - 1; + this.form_field_jq.trigger("liszt:showing_dropdown", { + chosen: this + }); + this.dropdown.css({ + "top": dd_top + "px", + "left": 0 + }); + this.results_showing = true; + this.search_field.focus(); + this.search_field.val(this.search_field.val()); + return this.winnow_results(); + }; + + Chosen.prototype.results_hide = function() { + if (!this.is_multiple) { + this.selected_item.removeClass("chzn-single-with-drop"); + } + this.result_clear_highlight(); + this.form_field_jq.trigger("liszt:hiding_dropdown", { + chosen: this + }); + this.dropdown.css({ + "left": "-9000px" + }); + return this.results_showing = false; + }; + + Chosen.prototype.set_tab_index = function(el) { + var ti; + if (this.form_field_jq.attr("tabindex")) { + ti = this.form_field_jq.attr("tabindex"); + this.form_field_jq.attr("tabindex", -1); + if (this.is_multiple) { + return this.search_field.attr("tabindex", ti); + } else { + this.selected_item.attr("tabindex", ti); + return this.search_field.attr("tabindex", -1); + } + } + }; + + Chosen.prototype.show_search_field_default = function() { + if (this.is_multiple && this.choices < 1 && !this.active_field) { + this.search_field.val(this.default_text); + return this.search_field.addClass("default"); + } else { + this.search_field.val(""); + return this.search_field.removeClass("default"); + } + }; + + Chosen.prototype.search_results_mouseup = function(evt) { + var target; + target = $(evt.target).hasClass("active-result") ? $(evt.target) : $(evt.target).parents(".active-result").first(); + if (target.length) { + this.result_highlight = target; + return this.result_select(evt); + } + }; + + Chosen.prototype.search_results_mouseover = function(evt) { + var target; + target = $(evt.target).hasClass("active-result") ? $(evt.target) : $(evt.target).parents(".active-result").first(); + if (target) { + return this.result_do_highlight(target); + } + }; + + Chosen.prototype.search_results_mouseout = function(evt) { + if ($(evt.target).hasClass("active-result" || $(evt.target).parents('.active-result').first())) { + return this.result_clear_highlight(); + } + }; + + Chosen.prototype.choices_click = function(evt) { + evt.preventDefault(); + if (this.active_field && !($(evt.target).hasClass("search-choice" || $(evt.target).parents('.search-choice').first)) && !this.results_showing) { + return this.results_show(); + } + }; + + Chosen.prototype.choice_build = function(item) { + var choice_id, link, + _this = this; + if (this.is_multiple && this.max_selected_options <= this.choices) { + this.form_field_jq.trigger("liszt:maxselected", { + chosen: this + }); + return false; + } + choice_id = this.container_id + "_c_" + item.array_index; + this.choices += 1; + this.search_container.before('
          • ' + item.html + '
          • '); + link = $('#' + choice_id).find("a").first(); + return link.click(function(evt) { + return _this.choice_destroy_link_click(evt); + }); + }; + + Chosen.prototype.choice_destroy_link_click = function(evt) { + evt.preventDefault(); + if (!this.is_disabled) { + this.pending_destroy_click = true; + return this.choice_destroy($(evt.target)); + } else { + return evt.stopPropagation; + } + }; + + Chosen.prototype.choice_destroy = function(link) { + this.choices -= 1; + this.show_search_field_default(); + if (this.is_multiple && this.choices > 0 && this.search_field.val().length < 1) { + this.results_hide(); + } + this.result_deselect(link.attr("rel")); + return link.parents('li').first().remove(); + }; + + Chosen.prototype.results_reset = function() { + this.form_field.options[0].selected = true; + this.selected_item.find("span").text(this.default_text); + if (!this.is_multiple) { + this.selected_item.addClass("chzn-default"); + } + this.show_search_field_default(); + this.results_reset_cleanup(); + this.form_field_jq.trigger("change"); + if (this.active_field) { + return this.results_hide(); + } + }; + + Chosen.prototype.results_reset_cleanup = function() { + return this.selected_item.find("abbr").remove(); + }; + + Chosen.prototype.result_select = function(evt) { + var high, high_id, item, position; + if (this.result_highlight) { + high = this.result_highlight; + high_id = high.attr("id"); + this.result_clear_highlight(); + if (this.is_multiple) { + this.result_deactivate(high); + } else { + this.search_results.find(".result-selected").removeClass("result-selected"); + this.result_single_selected = high; + this.selected_item.removeClass("chzn-default"); + } + high.addClass("result-selected"); + position = high_id.substr(high_id.lastIndexOf("_") + 1); + item = this.results_data[position]; + item.selected = true; + this.form_field.options[item.options_index].selected = true; + if (this.is_multiple) { + this.choice_build(item); + } else { + this.selected_item.find("span").first().text(item.text); + if (this.allow_single_deselect) { + this.single_deselect_control_build(); + } + } + if (!(evt.metaKey && this.is_multiple)) { + this.results_hide(); + } + this.search_field.val(""); + if (this.is_multiple || this.form_field_jq.val() !== this.current_value) { + this.form_field_jq.trigger("change", { + 'selected': this.form_field.options[item.options_index].value + }); + } + this.current_value = this.form_field_jq.val(); + return this.search_field_scale(); + } + }; + + Chosen.prototype.result_activate = function(el) { + return el.addClass("active-result"); + }; + + Chosen.prototype.result_deactivate = function(el) { + return el.removeClass("active-result"); + }; + + Chosen.prototype.result_deselect = function(pos) { + var result, result_data; + result_data = this.results_data[pos]; + result_data.selected = false; + this.form_field.options[result_data.options_index].selected = false; + result = $("#" + this.container_id + "_o_" + pos); + result.removeClass("result-selected").addClass("active-result").show(); + this.result_clear_highlight(); + this.winnow_results(); + this.form_field_jq.trigger("change", { + deselected: this.form_field.options[result_data.options_index].value + }); + return this.search_field_scale(); + }; + + Chosen.prototype.single_deselect_control_build = function() { + if (this.allow_single_deselect && this.selected_item.find("abbr").length < 1) { + return this.selected_item.find("span").first().after(""); + } + }; + + Chosen.prototype.winnow_results = function() { + var found, option, part, parts, regex, regexAnchor, result, result_id, results, searchText, startpos, text, zregex, _i, _j, _len, _len1, _ref; + this.no_results_clear(); + results = 0; + searchText = this.search_field.val() === this.default_text ? "" : $('
            ').text($.trim(this.search_field.val())).html(); + regexAnchor = this.search_contains ? "" : "^"; + regex = new RegExp(regexAnchor + searchText.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"), 'i'); + zregex = new RegExp(searchText.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"), 'i'); + _ref = this.results_data; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + option = _ref[_i]; + if (!option.disabled && !option.empty) { + if (option.group) { + $('#' + option.dom_id).css('display', 'none'); + } else if (!(this.is_multiple && option.selected)) { + found = false; + result_id = option.dom_id; + result = $("#" + result_id); + if (regex.test(option.html)) { + found = true; + results += 1; + } else if (option.html.indexOf(" ") >= 0 || option.html.indexOf("[") === 0) { + parts = option.html.replace(/\[|\]/g, "").split(" "); + if (parts.length) { + for (_j = 0, _len1 = parts.length; _j < _len1; _j++) { + part = parts[_j]; + if (regex.test(part)) { + found = true; + results += 1; + } + } + } + } + if (found) { + if (searchText.length) { + startpos = option.html.search(zregex); + text = option.html.substr(0, startpos + searchText.length) + '' + option.html.substr(startpos + searchText.length); + text = text.substr(0, startpos) + '' + text.substr(startpos); + text = this.options.template ? this.options.template(text, option.template_data) : text; + } else { + text = this.options.template ? this.options.template(option.html, option.template_data) : option.html; + } + result.html(text); + this.result_activate(result); + if (option.group_array_index != null) { + $("#" + this.results_data[option.group_array_index].dom_id).css('display', 'list-item'); + } + } else { + if (this.result_highlight && result_id === this.result_highlight.attr('id')) { + this.result_clear_highlight(); + } + this.result_deactivate(result); + } + } + } + } + if (results < 1 && searchText.length) { + return this.no_results(searchText); + } else { + return this.winnow_results_set_highlight(); + } + }; + + Chosen.prototype.winnow_results_clear = function() { + var li, lis, _i, _len, _results; + this.search_field.val(""); + lis = this.search_results.find("li"); + _results = []; + for (_i = 0, _len = lis.length; _i < _len; _i++) { + li = lis[_i]; + li = $(li); + if (li.hasClass("group-result")) { + _results.push(li.css('display', 'auto')); + } else if (!this.is_multiple || !li.hasClass("result-selected")) { + _results.push(this.result_activate(li)); + } else { + _results.push(void 0); + } + } + return _results; + }; + + Chosen.prototype.winnow_results_set_highlight = function() { + var do_high, selected_results; + if (!this.result_highlight) { + selected_results = !this.is_multiple ? this.search_results.find(".result-selected.active-result") : []; + do_high = selected_results.length ? selected_results.first() : this.search_results.find(".active-result").first(); + if (do_high != null) { + return this.result_do_highlight(do_high); + } + } + }; + + Chosen.prototype.no_results = function(terms) { + var no_results_html; + no_results_html = $('
          • ' + this.results_none_found + ' ""
          • '); + no_results_html.find("span").first().html(terms); + return this.search_results.append(no_results_html); + }; + + Chosen.prototype.no_results_clear = function() { + return this.search_results.find(".no-results").remove(); + }; + + Chosen.prototype.keydown_arrow = function() { + var first_active, next_sib; + if (!this.result_highlight) { + first_active = this.search_results.find("li.active-result").first(); + if (first_active) { + this.result_do_highlight($(first_active)); + } + } else if (this.results_showing) { + next_sib = this.result_highlight.nextAll("li.active-result").first(); + if (next_sib) { + this.result_do_highlight(next_sib); + } + } + if (!this.results_showing) { + return this.results_show(); + } + }; + + Chosen.prototype.keyup_arrow = function() { + var prev_sibs; + if (!this.results_showing && !this.is_multiple) { + return this.results_show(); + } else if (this.result_highlight) { + prev_sibs = this.result_highlight.prevAll("li.active-result"); + if (prev_sibs.length) { + return this.result_do_highlight(prev_sibs.first()); + } else { + if (this.choices > 0) { + this.results_hide(); + } + return this.result_clear_highlight(); + } + } + }; + + Chosen.prototype.keydown_backstroke = function() { + if (this.pending_backstroke) { + this.choice_destroy(this.pending_backstroke.find("a").first()); + return this.clear_backstroke(); + } else { + this.pending_backstroke = this.search_container.siblings("li.search-choice").last(); + if (this.single_backstroke_delete) { + return this.keydown_backstroke(); + } else { + return this.pending_backstroke.addClass("search-choice-focus"); + } + } + }; + + Chosen.prototype.clear_backstroke = function() { + if (this.pending_backstroke) { + this.pending_backstroke.removeClass("search-choice-focus"); + } + return this.pending_backstroke = null; + }; + + Chosen.prototype.keydown_checker = function(evt) { + var stroke, _ref; + stroke = (_ref = evt.which) != null ? _ref : evt.keyCode; + this.search_field_scale(); + if (stroke !== 8 && this.pending_backstroke) { + this.clear_backstroke(); + } + switch (stroke) { + case 8: + this.backstroke_length = this.search_field.val().length; + break; + case 9: + if (this.results_showing && !this.is_multiple) { + this.result_select(evt); + } + this.mouse_on_container = false; + break; + case 13: + evt.preventDefault(); + break; + case 38: + evt.preventDefault(); + this.keyup_arrow(); + break; + case 40: + this.keydown_arrow(); + break; + } + }; + + Chosen.prototype.search_field_scale = function() { + var dd_top, div, h, style, style_block, styles, w, _i, _len; + if (this.is_multiple) { + h = 0; + w = 0; + style_block = "position:absolute; left: -1000px; top: -1000px; display:none;"; + styles = ['font-size', 'font-style', 'font-weight', 'font-family', 'line-height', 'text-transform', 'letter-spacing']; + for (_i = 0, _len = styles.length; _i < _len; _i++) { + style = styles[_i]; + style_block += style + ":" + this.search_field.css(style) + ";"; + } + div = $('
            ', { + 'style': style_block + }); + div.text(this.search_field.val()); + $('body').append(div); + w = div.width() + 25; + div.remove(); + if (w > this.f_width - 10) { + w = this.f_width - 10; + } + this.search_field.css({ + 'width': w + 'px' + }); + dd_top = this.container.height(); + return this.dropdown.css({ + "top": dd_top + "px" + }); + } + }; + + Chosen.prototype.generate_random_id = function() { + var string; + string = "sel" + this.generate_random_char() + this.generate_random_char() + this.generate_random_char(); + while ($("#" + string).length > 0) { + string += this.generate_random_char(); + } + return string; + }; + + return Chosen; + + })(AbstractChosen); + + get_side_border_padding = function(elmt) { + var side_border_padding; + return side_border_padding = elmt.outerWidth() - elmt.width(); + }; + + root.get_side_border_padding = get_side_border_padding; + +}).call(this); diff --git a/app/assets/javascripts/external/ember.js b/app/assets/javascripts/external/ember.js new file mode 100644 index 00000000000..0dec4a332c4 --- /dev/null +++ b/app/assets/javascripts/external/ember.js @@ -0,0 +1,26685 @@ +// Version: v1.0.0-pre.2-608-g538b7a0 +// Last commit: 538b7a0 (2013-02-03 17:48:00 -0800) + + +(function() { +/*global __fail__*/ + +/** +Ember Debug + +@module ember +@submodule ember-debug +*/ + +/** +@class Ember +*/ + +if ('undefined' === typeof Ember) { + Ember = {}; + + if ('undefined' !== typeof window) { + window.Em = window.Ember = Em = Ember; + } +} + +Ember.ENV = 'undefined' === typeof ENV ? {} : ENV; + +if (!('MANDATORY_SETTER' in Ember.ENV)) { + Ember.ENV.MANDATORY_SETTER = true; // default to true for debug dist +} + +/** + Define an assertion that will throw an exception if the condition is not + met. Ember build tools will remove any calls to `Ember.assert()` when + doing a production build. Example: + + ```javascript + // Test for truthiness + Ember.assert('Must pass a valid object', obj); + // Fail unconditionally + Ember.assert('This code path should never be run') + ``` + + @method assert + @param {String} desc A description of the assertion. This will become + the text of the Error thrown if the assertion fails. + @param {Boolean} test Must be truthy for the assertion to pass. If + falsy, an exception will be thrown. +*/ +Ember.assert = function(desc, test) { + if (!test) throw new Error("assertion failed: "+desc); +}; + + +/** + Display a warning with the provided message. Ember build tools will + remove any calls to `Ember.warn()` when doing a production build. + + @method warn + @param {String} message A warning to display. + @param {Boolean} test An optional boolean. If falsy, the warning + will be displayed. +*/ +Ember.warn = function(message, test) { + if (!test) { + Ember.Logger.warn("WARNING: "+message); + if ('trace' in Ember.Logger) Ember.Logger.trace(); + } +}; + +/** + Display a debug notice. Ember build tools will remove any calls to + `Ember.debug()` when doing a production build. + + ```javascript + Ember.debug("I'm a debug notice!"); + ``` + + @method debug + @param {String} message A debug message to display. +*/ +Ember.debug = function(message) { + Ember.Logger.debug("DEBUG: "+message); +}; + +/** + Display a deprecation warning with the provided message and a stack trace + (Chrome and Firefox only). Ember build tools will remove any calls to + `Ember.deprecate()` when doing a production build. + + @method deprecate + @param {String} message A description of the deprecation. + @param {Boolean} test An optional boolean. If falsy, the deprecation + will be displayed. +*/ +Ember.deprecate = function(message, test) { + if (Ember && Ember.TESTING_DEPRECATION) { return; } + + if (arguments.length === 1) { test = false; } + if (test) { return; } + + if (Ember && Ember.ENV.RAISE_ON_DEPRECATION) { throw new Error(message); } + + var error; + + // When using new Error, we can't do the arguments check for Chrome. Alternatives are welcome + try { __fail__.fail(); } catch (e) { error = e; } + + if (Ember.LOG_STACKTRACE_ON_DEPRECATION && error.stack) { + var stack, stackStr = ''; + if (error['arguments']) { + // Chrome + stack = error.stack.replace(/^\s+at\s+/gm, ''). + replace(/^([^\(]+?)([\n$])/gm, '{anonymous}($1)$2'). + replace(/^Object.\s*\(([^\)]+)\)/gm, '{anonymous}($1)').split('\n'); + stack.shift(); + } else { + // Firefox + stack = error.stack.replace(/(?:\n@:0)?\s+$/m, ''). + replace(/^\(/gm, '{anonymous}(').split('\n'); + } + + stackStr = "\n " + stack.slice(2).join("\n "); + message = message + stackStr; + } + + Ember.Logger.warn("DEPRECATION: "+message); +}; + + + +/** + Display a deprecation warning with the provided message and a stack trace + (Chrome and Firefox only) when the wrapped method is called. + + Ember build tools will not remove calls to `Ember.deprecateFunc()`, though + no warnings will be shown in production. + + @method deprecateFunc + @param {String} message A description of the deprecation. + @param {Function} func The function to be deprecated. +*/ +Ember.deprecateFunc = function(message, func) { + return function() { + Ember.deprecate(message); + return func.apply(this, arguments); + }; +}; + +})(); + +// Version: v1.0.0-pre.2-608-g538b7a0 +// Last commit: 538b7a0 (2013-02-03 17:48:00 -0800) + + +(function() { +var define, requireModule; + +(function() { + var registry = {}, seen = {}; + + define = function(name, deps, callback) { + registry[name] = { deps: deps, callback: callback }; + }; + + requireModule = function(name) { + if (seen[name]) { return seen[name]; } + seen[name] = {}; + + var mod = registry[name], + deps = mod.deps, + callback = mod.callback, + reified = [], + exports; + + for (var i=0, l=deps.length; i= 0) { + intersection.push(element); + } + }); + + return intersection; + } +}; + +})(); + + + +(function() { +/*jshint newcap:false*/ +/** +@module ember-metal +*/ + +// NOTE: There is a bug in jshint that doesn't recognize `Object()` without `new` +// as being ok unless both `newcap:false` and not `use strict`. +// https://github.com/jshint/jshint/issues/392 + +// Testing this is not ideal, but we want to use native functions +// if available, but not to use versions created by libraries like Prototype +var isNativeFunc = function(func) { + // This should probably work in all browsers likely to have ES5 array methods + return func && Function.prototype.toString.call(func).indexOf('[native code]') > -1; +}; + +// From: https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/array/map +var arrayMap = isNativeFunc(Array.prototype.map) ? Array.prototype.map : function(fun /*, thisp */) { + //"use strict"; + + if (this === void 0 || this === null) { + throw new TypeError(); + } + + var t = Object(this); + var len = t.length >>> 0; + if (typeof fun !== "function") { + throw new TypeError(); + } + + var res = new Array(len); + var thisp = arguments[1]; + for (var i = 0; i < len; i++) { + if (i in t) { + res[i] = fun.call(thisp, t[i], i, t); + } + } + + return res; +}; + +// From: https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/array/foreach +var arrayForEach = isNativeFunc(Array.prototype.forEach) ? Array.prototype.forEach : function(fun /*, thisp */) { + //"use strict"; + + if (this === void 0 || this === null) { + throw new TypeError(); + } + + var t = Object(this); + var len = t.length >>> 0; + if (typeof fun !== "function") { + throw new TypeError(); + } + + var thisp = arguments[1]; + for (var i = 0; i < len; i++) { + if (i in t) { + fun.call(thisp, t[i], i, t); + } + } +}; + +var arrayIndexOf = isNativeFunc(Array.prototype.indexOf) ? Array.prototype.indexOf : function (obj, fromIndex) { + if (fromIndex === null || fromIndex === undefined) { fromIndex = 0; } + else if (fromIndex < 0) { fromIndex = Math.max(0, this.length + fromIndex); } + for (var i = fromIndex, j = this.length; i < j; i++) { + if (this[i] === obj) { return i; } + } + return -1; +}; + +Ember.ArrayPolyfills = { + map: arrayMap, + forEach: arrayForEach, + indexOf: arrayIndexOf +}; + +if (Ember.SHIM_ES5) { + if (!Array.prototype.map) { + Array.prototype.map = arrayMap; + } + + if (!Array.prototype.forEach) { + Array.prototype.forEach = arrayForEach; + } + + if (!Array.prototype.indexOf) { + Array.prototype.indexOf = arrayIndexOf; + } +} + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +/* + JavaScript (before ES6) does not have a Map implementation. Objects, + which are often used as dictionaries, may only have Strings as keys. + + Because Ember has a way to get a unique identifier for every object + via `Ember.guidFor`, we can implement a performant Map with arbitrary + keys. Because it is commonly used in low-level bookkeeping, Map is + implemented as a pure JavaScript object for performance. + + This implementation follows the current iteration of the ES6 proposal for + maps (http://wiki.ecmascript.org/doku.php?id=harmony:simple_maps_and_sets), + with two exceptions. First, because we need our implementation to be pleasant + on older browsers, we do not use the `delete` name (using `remove` instead). + Second, as we do not have the luxury of in-VM iteration, we implement a + forEach method for iteration. + + Map is mocked out to look like an Ember object, so you can do + `Ember.Map.create()` for symmetry with other Ember classes. +*/ +var guidFor = Ember.guidFor, + indexOf = Ember.ArrayPolyfills.indexOf; + +var copy = function(obj) { + var output = {}; + + for (var prop in obj) { + if (obj.hasOwnProperty(prop)) { output[prop] = obj[prop]; } + } + + return output; +}; + +var copyMap = function(original, newObject) { + var keys = original.keys.copy(), + values = copy(original.values); + + newObject.keys = keys; + newObject.values = values; + + return newObject; +}; + +/** + This class is used internally by Ember and Ember Data. + Please do not use it at this time. We plan to clean it up + and add many tests soon. + + @class OrderedSet + @namespace Ember + @constructor + @private +*/ +var OrderedSet = Ember.OrderedSet = function() { + this.clear(); +}; + +/** + @method create + @static + @return {Ember.OrderedSet} +*/ +OrderedSet.create = function() { + return new OrderedSet(); +}; + + +OrderedSet.prototype = { + /** + @method clear + */ + clear: function() { + this.presenceSet = {}; + this.list = []; + }, + + /** + @method add + @param obj + */ + add: function(obj) { + var guid = guidFor(obj), + presenceSet = this.presenceSet, + list = this.list; + + if (guid in presenceSet) { return; } + + presenceSet[guid] = true; + list.push(obj); + }, + + /** + @method remove + @param obj + */ + remove: function(obj) { + var guid = guidFor(obj), + presenceSet = this.presenceSet, + list = this.list; + + delete presenceSet[guid]; + + var index = indexOf.call(list, obj); + if (index > -1) { + list.splice(index, 1); + } + }, + + /** + @method isEmpty + @return {Boolean} + */ + isEmpty: function() { + return this.list.length === 0; + }, + + /** + @method has + @param obj + @return {Boolean} + */ + has: function(obj) { + var guid = guidFor(obj), + presenceSet = this.presenceSet; + + return guid in presenceSet; + }, + + /** + @method forEach + @param {Function} function + @param target + */ + forEach: function(fn, self) { + // allow mutation during iteration + var list = this.list.slice(); + + for (var i = 0, j = list.length; i < j; i++) { + fn.call(self, list[i]); + } + }, + + /** + @method toArray + @return {Array} + */ + toArray: function() { + return this.list.slice(); + }, + + /** + @method copy + @return {Ember.OrderedSet} + */ + copy: function() { + var set = new OrderedSet(); + + set.presenceSet = copy(this.presenceSet); + set.list = this.list.slice(); + + return set; + } +}; + +/** + A Map stores values indexed by keys. Unlike JavaScript's + default Objects, the keys of a Map can be any JavaScript + object. + + Internally, a Map has two data structures: + + 1. `keys`: an OrderedSet of all of the existing keys + 2. `values`: a JavaScript Object indexed by the `Ember.guidFor(key)` + + When a key/value pair is added for the first time, we + add the key to the `keys` OrderedSet, and create or + replace an entry in `values`. When an entry is deleted, + we delete its entry in `keys` and `values`. + + @class Map + @namespace Ember + @private + @constructor +*/ +var Map = Ember.Map = function() { + this.keys = Ember.OrderedSet.create(); + this.values = {}; +}; + +/** + @method create + @static +*/ +Map.create = function() { + return new Map(); +}; + +Map.prototype = { + /** + Retrieve the value associated with a given key. + + @method get + @param {anything} key + @return {anything} the value associated with the key, or `undefined` + */ + get: function(key) { + var values = this.values, + guid = guidFor(key); + + return values[guid]; + }, + + /** + Adds a value to the map. If a value for the given key has already been + provided, the new value will replace the old value. + + @method set + @param {anything} key + @param {anything} value + */ + set: function(key, value) { + var keys = this.keys, + values = this.values, + guid = guidFor(key); + + keys.add(key); + values[guid] = value; + }, + + /** + Removes a value from the map for an associated key. + + @method remove + @param {anything} key + @return {Boolean} true if an item was removed, false otherwise + */ + remove: function(key) { + // don't use ES6 "delete" because it will be annoying + // to use in browsers that are not ES6 friendly; + var keys = this.keys, + values = this.values, + guid = guidFor(key), + value; + + if (values.hasOwnProperty(guid)) { + keys.remove(key); + value = values[guid]; + delete values[guid]; + return true; + } else { + return false; + } + }, + + /** + Check whether a key is present. + + @method has + @param {anything} key + @return {Boolean} true if the item was present, false otherwise + */ + has: function(key) { + var values = this.values, + guid = guidFor(key); + + return values.hasOwnProperty(guid); + }, + + /** + Iterate over all the keys and values. Calls the function once + for each key, passing in the key and value, in that order. + + The keys are guaranteed to be iterated over in insertion order. + + @method forEach + @param {Function} callback + @param {anything} self if passed, the `this` value inside the + callback. By default, `this` is the map. + */ + forEach: function(callback, self) { + var keys = this.keys, + values = this.values; + + keys.forEach(function(key) { + var guid = guidFor(key); + callback.call(self, key, values[guid]); + }); + }, + + /** + @method copy + @return {Ember.Map} + */ + copy: function() { + return copyMap(this, new Map()); + } +}; + +/** + @class MapWithDefault + @namespace Ember + @extends Ember.Map + @private + @constructor + @param [options] + @param {anything} [options.defaultValue] +*/ +var MapWithDefault = Ember.MapWithDefault = function(options) { + Map.call(this); + this.defaultValue = options.defaultValue; +}; + +/** + @method create + @static + @param [options] + @param {anything} [options.defaultValue] + @return {Ember.MapWithDefault|Ember.Map} If options are passed, returns + `Ember.MapWithDefault` otherwise returns `Ember.Map` +*/ +MapWithDefault.create = function(options) { + if (options) { + return new MapWithDefault(options); + } else { + return new Map(); + } +}; + +MapWithDefault.prototype = Ember.create(Map.prototype); + +/** + Retrieve the value associated with a given key. + + @method get + @param {anything} key + @return {anything} the value associated with the key, or the default value +*/ +MapWithDefault.prototype.get = function(key) { + var hasValue = this.has(key); + + if (hasValue) { + return Map.prototype.get.call(this, key); + } else { + var defaultValue = this.defaultValue(key); + this.set(key, defaultValue); + return defaultValue; + } +}; + +/** + @method copy + @return {Ember.MapWithDefault} +*/ +MapWithDefault.prototype.copy = function() { + return copyMap(this, new MapWithDefault({ + defaultValue: this.defaultValue + })); +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +var META_KEY = Ember.META_KEY, get, set; + +var MANDATORY_SETTER = Ember.ENV.MANDATORY_SETTER; + +var IS_GLOBAL = /^([A-Z$]|([0-9][A-Z$]))/; +var IS_GLOBAL_PATH = /^([A-Z$]|([0-9][A-Z$])).*[\.\*]/; +var HAS_THIS = /^this[\.\*]/; +var FIRST_KEY = /^([^\.\*]+)/; + +// .......................................................... +// GET AND SET +// +// If we are on a platform that supports accessors we can get use those. +// Otherwise simulate accessors by looking up the property directly on the +// object. + +/** + Gets the value of a property on an object. If the property is computed, + the function will be invoked. If the property is not defined but the + object implements the `unknownProperty` method then that will be invoked. + + If you plan to run on IE8 and older browsers then you should use this + method anytime you want to retrieve a property on an object that you don't + know for sure is private. (Properties beginning with an underscore '_' + are considered private.) + + On all newer browsers, you only need to use this method to retrieve + properties if the property might not be defined on the object and you want + to respect the `unknownProperty` handler. Otherwise you can ignore this + method. + + Note that if the object itself is `undefined`, this method will throw + an error. + + @method get + @for Ember + @param {Object} obj The object to retrieve from. + @param {String} keyName The property key to retrieve + @return {Object} the property value or `null`. +*/ +get = function get(obj, keyName) { + // Helpers that operate with 'this' within an #each + if (keyName === '') { + return obj; + } + + if (!keyName && 'string'===typeof obj) { + keyName = obj; + obj = null; + } + + if (!obj || keyName.indexOf('.') !== -1) { + Ember.assert("Cannot call get with '"+ keyName +"' on an undefined object.", obj !== undefined); + return getPath(obj, keyName); + } + + Ember.assert("You need to provide an object and key to `get`.", !!obj && keyName); + + var meta = obj[META_KEY], desc = meta && meta.descs[keyName], ret; + if (desc) { + return desc.get(obj, keyName); + } else { + if (MANDATORY_SETTER && meta && meta.watching[keyName] > 0) { + ret = meta.values[keyName]; + } else { + ret = obj[keyName]; + } + + if (ret === undefined && + 'object' === typeof obj && !(keyName in obj) && 'function' === typeof obj.unknownProperty) { + return obj.unknownProperty(keyName); + } + + return ret; + } +}; + +/** + Sets the value of a property on an object, respecting computed properties + and notifying observers and other listeners of the change. If the + property is not defined but the object implements the `unknownProperty` + method then that will be invoked as well. + + If you plan to run on IE8 and older browsers then you should use this + method anytime you want to set a property on an object that you don't + know for sure is private. (Properties beginning with an underscore '_' + are considered private.) + + On all newer browsers, you only need to use this method to set + properties if the property might not be defined on the object and you want + to respect the `unknownProperty` handler. Otherwise you can ignore this + method. + + @method set + @for Ember + @param {Object} obj The object to modify. + @param {String} keyName The property key to set + @param {Object} value The value to set + @return {Object} the passed value. +*/ +set = function set(obj, keyName, value, tolerant) { + if (typeof obj === 'string') { + Ember.assert("Path '" + obj + "' must be global if no obj is given.", IS_GLOBAL.test(obj)); + value = keyName; + keyName = obj; + obj = null; + } + + if (!obj || keyName.indexOf('.') !== -1) { + return setPath(obj, keyName, value, tolerant); + } + + Ember.assert("You need to provide an object and key to `set`.", !!obj && keyName !== undefined); + Ember.assert('calling set on destroyed object', !obj.isDestroyed); + + var meta = obj[META_KEY], desc = meta && meta.descs[keyName], + isUnknown, currentValue; + if (desc) { + desc.set(obj, keyName, value); + } else { + isUnknown = 'object' === typeof obj && !(keyName in obj); + + // setUnknownProperty is called if `obj` is an object, + // the property does not already exist, and the + // `setUnknownProperty` method exists on the object + if (isUnknown && 'function' === typeof obj.setUnknownProperty) { + obj.setUnknownProperty(keyName, value); + } else if (meta && meta.watching[keyName] > 0) { + if (MANDATORY_SETTER) { + currentValue = meta.values[keyName]; + } else { + currentValue = obj[keyName]; + } + // only trigger a change if the value has changed + if (value !== currentValue) { + Ember.propertyWillChange(obj, keyName); + if (MANDATORY_SETTER) { + if (currentValue === undefined && !(keyName in obj)) { + Ember.defineProperty(obj, keyName, null, value); // setup mandatory setter + } else { + meta.values[keyName] = value; + } + } else { + obj[keyName] = value; + } + Ember.propertyDidChange(obj, keyName); + } + } else { + obj[keyName] = value; + } + } + return value; +}; + +// Currently used only by Ember Data tests +if (Ember.config.overrideAccessors) { + Ember.get = get; + Ember.set = set; + Ember.config.overrideAccessors(); + get = Ember.get; + set = Ember.set; +} + +function firstKey(path) { + return path.match(FIRST_KEY)[0]; +} + +// assumes path is already normalized +function normalizeTuple(target, path) { + var hasThis = HAS_THIS.test(path), + isGlobal = !hasThis && IS_GLOBAL_PATH.test(path), + key; + + if (!target || isGlobal) target = Ember.lookup; + if (hasThis) path = path.slice(5); + + if (target === Ember.lookup) { + key = firstKey(path); + target = get(target, key); + path = path.slice(key.length+1); + } + + // must return some kind of path to be valid else other things will break. + if (!path || path.length===0) throw new Error('Invalid Path'); + + return [ target, path ]; +} + +function getPath(root, path) { + var hasThis, parts, tuple, idx, len; + + // If there is no root and path is a key name, return that + // property from the global object. + // E.g. get('Ember') -> Ember + if (root === null && path.indexOf('.') === -1) { return get(Ember.lookup, path); } + + // detect complicated paths and normalize them + hasThis = HAS_THIS.test(path); + + if (!root || hasThis) { + tuple = normalizeTuple(root, path); + root = tuple[0]; + path = tuple[1]; + tuple.length = 0; + } + + parts = path.split("."); + len = parts.length; + for (idx=0; root && idx 0; + + if (existingDesc instanceof Ember.Descriptor) { + existingDesc.teardown(obj, keyName); + } + + if (desc instanceof Ember.Descriptor) { + value = desc; + + descs[keyName] = desc; + if (MANDATORY_SETTER && watching) { + objectDefineProperty(obj, keyName, { + configurable: true, + enumerable: true, + writable: true, + value: undefined // make enumerable + }); + } else { + obj[keyName] = undefined; // make enumerable + } + desc.setup(obj, keyName); + } else { + descs[keyName] = undefined; // shadow descriptor in proto + if (desc == null) { + value = data; + + if (MANDATORY_SETTER && watching) { + meta.values[keyName] = data; + objectDefineProperty(obj, keyName, { + configurable: true, + enumerable: true, + set: MANDATORY_SETTER_FUNCTION, + get: DEFAULT_GETTER_FUNCTION(keyName) + }); + } else { + obj[keyName] = data; + } + } else { + value = desc; + + // compatibility with ES5 + objectDefineProperty(obj, keyName, desc); + } + } + + // if key is being watched, override chains that + // were initialized with the prototype + if (watching) { Ember.overrideChains(obj, keyName, meta); } + + // The `value` passed to the `didDefineProperty` hook is + // either the descriptor or data, whichever was passed. + if (obj.didDefineProperty) { obj.didDefineProperty(obj, keyName, value); } + + return this; +}; + + +})(); + + + +(function() { +// Ember.tryFinally +/** +@module ember-metal +*/ + +var AFTER_OBSERVERS = ':change'; +var BEFORE_OBSERVERS = ':before'; + +var guidFor = Ember.guidFor; + +var deferred = 0; + +/* + this.observerSet = { + [senderGuid]: { // variable name: `keySet` + [keyName]: listIndex + } + }, + this.observers = [ + { + sender: obj, + keyName: keyName, + eventName: eventName, + listeners: [ + [target, method, onceFlag, suspendedFlag] + ] + }, + ... + ] +*/ +function ObserverSet() { + this.clear(); +} + +ObserverSet.prototype.add = function(sender, keyName, eventName) { + var observerSet = this.observerSet, + observers = this.observers, + senderGuid = Ember.guidFor(sender), + keySet = observerSet[senderGuid], + index; + + if (!keySet) { + observerSet[senderGuid] = keySet = {}; + } + index = keySet[keyName]; + if (index === undefined) { + index = observers.push({ + sender: sender, + keyName: keyName, + eventName: eventName, + listeners: [] + }) - 1; + keySet[keyName] = index; + } + return observers[index].listeners; +}; + +ObserverSet.prototype.flush = function() { + var observers = this.observers, i, len, observer, sender; + this.clear(); + for (i=0, len=observers.length; i < len; ++i) { + observer = observers[i]; + sender = observer.sender; + if (sender.isDestroying || sender.isDestroyed) { continue; } + Ember.sendEvent(sender, observer.eventName, [sender, observer.keyName], observer.listeners); + } +}; + +ObserverSet.prototype.clear = function() { + this.observerSet = {}; + this.observers = []; +}; + +var beforeObserverSet = new ObserverSet(), observerSet = new ObserverSet(); + +/** + @method beginPropertyChanges + @chainable +*/ +Ember.beginPropertyChanges = function() { + deferred++; +}; + +/** + @method endPropertyChanges +*/ +Ember.endPropertyChanges = function() { + deferred--; + if (deferred<=0) { + beforeObserverSet.clear(); + observerSet.flush(); + } +}; + +/** + Make a series of property changes together in an + exception-safe way. + + ```javascript + Ember.changeProperties(function() { + obj1.set('foo', mayBlowUpWhenSet); + obj2.set('bar', baz); + }); + ``` + + @method changeProperties + @param {Function} callback + @param [binding] +*/ +Ember.changeProperties = function(cb, binding){ + Ember.beginPropertyChanges(); + Ember.tryFinally(cb, Ember.endPropertyChanges, binding); +}; + +/** + Set a list of properties on an object. These properties are set inside + a single `beginPropertyChanges` and `endPropertyChanges` batch, so + observers will be buffered. + + @method setProperties + @param target + @param {Hash} properties + @return target +*/ +Ember.setProperties = function(self, hash) { + Ember.changeProperties(function(){ + for(var prop in hash) { + if (hash.hasOwnProperty(prop)) Ember.set(self, prop, hash[prop]); + } + }); + return self; +}; + + +function changeEvent(keyName) { + return keyName+AFTER_OBSERVERS; +} + +function beforeEvent(keyName) { + return keyName+BEFORE_OBSERVERS; +} + +/** + @method addObserver + @param obj + @param {String} path + @param {Object|Function} targetOrMethod + @param {Function|String} [method] +*/ +Ember.addObserver = function(obj, path, target, method) { + Ember.addListener(obj, changeEvent(path), target, method); + Ember.watch(obj, path); + return this; +}; + +Ember.observersFor = function(obj, path) { + return Ember.listenersFor(obj, changeEvent(path)); +}; + +/** + @method removeObserver + @param obj + @param {String} path + @param {Object|Function} targetOrMethod + @param {Function|String} [method] +*/ +Ember.removeObserver = function(obj, path, target, method) { + Ember.unwatch(obj, path); + Ember.removeListener(obj, changeEvent(path), target, method); + return this; +}; + +/** + @method addBeforeObserver + @param obj + @param {String} path + @param {Object|Function} targetOrMethod + @param {Function|String} [method] +*/ +Ember.addBeforeObserver = function(obj, path, target, method) { + Ember.addListener(obj, beforeEvent(path), target, method); + Ember.watch(obj, path); + return this; +}; + +// Suspend observer during callback. +// +// This should only be used by the target of the observer +// while it is setting the observed path. +Ember._suspendBeforeObserver = function(obj, path, target, method, callback) { + return Ember._suspendListener(obj, beforeEvent(path), target, method, callback); +}; + +Ember._suspendObserver = function(obj, path, target, method, callback) { + return Ember._suspendListener(obj, changeEvent(path), target, method, callback); +}; + +var map = Ember.ArrayPolyfills.map; + +Ember._suspendBeforeObservers = function(obj, paths, target, method, callback) { + var events = map.call(paths, beforeEvent); + return Ember._suspendListeners(obj, events, target, method, callback); +}; + +Ember._suspendObservers = function(obj, paths, target, method, callback) { + var events = map.call(paths, changeEvent); + return Ember._suspendListeners(obj, events, target, method, callback); +}; + +Ember.beforeObserversFor = function(obj, path) { + return Ember.listenersFor(obj, beforeEvent(path)); +}; + +/** + @method removeBeforeObserver + @param obj + @param {String} path + @param {Object|Function} targetOrMethod + @param {Function|String} [method] +*/ +Ember.removeBeforeObserver = function(obj, path, target, method) { + Ember.unwatch(obj, path); + Ember.removeListener(obj, beforeEvent(path), target, method); + return this; +}; + +Ember.notifyBeforeObservers = function(obj, keyName) { + if (obj.isDestroying) { return; } + + var eventName = beforeEvent(keyName), listeners, listenersDiff; + if (deferred) { + listeners = beforeObserverSet.add(obj, keyName, eventName); + listenersDiff = Ember.listenersDiff(obj, eventName, listeners); + Ember.sendEvent(obj, eventName, [obj, keyName], listenersDiff); + } else { + Ember.sendEvent(obj, eventName, [obj, keyName]); + } +}; + +Ember.notifyObservers = function(obj, keyName) { + if (obj.isDestroying) { return; } + + var eventName = changeEvent(keyName), listeners; + if (deferred) { + listeners = observerSet.add(obj, keyName, eventName); + Ember.listenersUnion(obj, eventName, listeners); + } else { + Ember.sendEvent(obj, eventName, [obj, keyName]); + } +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +var guidFor = Ember.guidFor, // utils.js + metaFor = Ember.meta, // utils.js + get = Ember.get, // accessors.js + set = Ember.set, // accessors.js + normalizeTuple = Ember.normalizeTuple, // accessors.js + GUID_KEY = Ember.GUID_KEY, // utils.js + META_KEY = Ember.META_KEY, // utils.js + // circular reference observer depends on Ember.watch + // we should move change events to this file or its own property_events.js + notifyObservers = Ember.notifyObservers, // observer.js + forEach = Ember.ArrayPolyfills.forEach, // array.js + FIRST_KEY = /^([^\.\*]+)/, + IS_PATH = /[\.\*]/; + +var MANDATORY_SETTER = Ember.ENV.MANDATORY_SETTER, +o_defineProperty = Ember.platform.defineProperty; + +function firstKey(path) { + return path.match(FIRST_KEY)[0]; +} + +// returns true if the passed path is just a keyName +function isKeyName(path) { + return path==='*' || !IS_PATH.test(path); +} + +// .......................................................... +// DEPENDENT KEYS +// + +function iterDeps(method, obj, depKey, seen, meta) { + + var guid = guidFor(obj); + if (!seen[guid]) seen[guid] = {}; + if (seen[guid][depKey]) return; + seen[guid][depKey] = true; + + var deps = meta.deps; + deps = deps && deps[depKey]; + if (deps) { + for(var key in deps) { + var desc = meta.descs[key]; + if (desc && desc._suspended === obj) continue; + method(obj, key); + } + } +} + + +var WILL_SEEN, DID_SEEN; + +// called whenever a property is about to change to clear the cache of any dependent keys (and notify those properties of changes, etc...) +function dependentKeysWillChange(obj, depKey, meta) { + if (obj.isDestroying) { return; } + + var seen = WILL_SEEN, top = !seen; + if (top) { seen = WILL_SEEN = {}; } + iterDeps(propertyWillChange, obj, depKey, seen, meta); + if (top) { WILL_SEEN = null; } +} + +// called whenever a property has just changed to update dependent keys +function dependentKeysDidChange(obj, depKey, meta) { + if (obj.isDestroying) { return; } + + var seen = DID_SEEN, top = !seen; + if (top) { seen = DID_SEEN = {}; } + iterDeps(propertyDidChange, obj, depKey, seen, meta); + if (top) { DID_SEEN = null; } +} + +// .......................................................... +// CHAIN +// + +function addChainWatcher(obj, keyName, node) { + if (!obj || ('object' !== typeof obj)) { return; } // nothing to do + + var m = metaFor(obj), nodes = m.chainWatchers; + + if (!m.hasOwnProperty('chainWatchers')) { + nodes = m.chainWatchers = {}; + } + + if (!nodes[keyName]) { nodes[keyName] = []; } + nodes[keyName].push(node); + Ember.watch(obj, keyName); +} + +function removeChainWatcher(obj, keyName, node) { + if (!obj || 'object' !== typeof obj) { return; } // nothing to do + + var m = metaFor(obj, false); + if (!m.hasOwnProperty('chainWatchers')) { return; } // nothing to do + + var nodes = m.chainWatchers; + + if (nodes[keyName]) { + nodes = nodes[keyName]; + for (var i = 0, l = nodes.length; i < l; i++) { + if (nodes[i] === node) { nodes.splice(i, 1); } + } + } + Ember.unwatch(obj, keyName); +} + +var pendingQueue = []; + +// attempts to add the pendingQueue chains again. If some of them end up +// back in the queue and reschedule is true, schedules a timeout to try +// again. +function flushPendingChains() { + if (pendingQueue.length === 0) { return; } // nothing to do + + var queue = pendingQueue; + pendingQueue = []; + + forEach.call(queue, function(q) { q[0].add(q[1]); }); + + Ember.warn('Watching an undefined global, Ember expects watched globals to be setup by the time the run loop is flushed, check for typos', pendingQueue.length === 0); +} + +function isProto(pvalue) { + return metaFor(pvalue, false).proto === pvalue; +} + +// A ChainNode watches a single key on an object. If you provide a starting +// value for the key then the node won't actually watch it. For a root node +// pass null for parent and key and object for value. +var ChainNode = function(parent, key, value) { + var obj; + this._parent = parent; + this._key = key; + + // _watching is true when calling get(this._parent, this._key) will + // return the value of this node. + // + // It is false for the root of a chain (because we have no parent) + // and for global paths (because the parent node is the object with + // the observer on it) + this._watching = value===undefined; + + this._value = value; + this._paths = {}; + if (this._watching) { + this._object = parent.value(); + if (this._object) { addChainWatcher(this._object, this._key, this); } + } + + // Special-case: the EachProxy relies on immediate evaluation to + // establish its observers. + // + // TODO: Replace this with an efficient callback that the EachProxy + // can implement. + if (this._parent && this._parent._key === '@each') { + this.value(); + } +}; + +var ChainNodePrototype = ChainNode.prototype; + +ChainNodePrototype.value = function() { + if (this._value === undefined && this._watching) { + var obj = this._parent.value(); + this._value = (obj && !isProto(obj)) ? get(obj, this._key) : undefined; + } + return this._value; +}; + +ChainNodePrototype.destroy = function() { + if (this._watching) { + var obj = this._object; + if (obj) { removeChainWatcher(obj, this._key, this); } + this._watching = false; // so future calls do nothing + } +}; + +// copies a top level object only +ChainNodePrototype.copy = function(obj) { + var ret = new ChainNode(null, null, obj), + paths = this._paths, path; + for (path in paths) { + if (paths[path] <= 0) { continue; } // this check will also catch non-number vals. + ret.add(path); + } + return ret; +}; + +// called on the root node of a chain to setup watchers on the specified +// path. +ChainNodePrototype.add = function(path) { + var obj, tuple, key, src, paths; + + paths = this._paths; + paths[path] = (paths[path] || 0) + 1; + + obj = this.value(); + tuple = normalizeTuple(obj, path); + + // the path was a local path + if (tuple[0] && tuple[0] === obj) { + path = tuple[1]; + key = firstKey(path); + path = path.slice(key.length+1); + + // global path, but object does not exist yet. + // put into a queue and try to connect later. + } else if (!tuple[0]) { + pendingQueue.push([this, path]); + tuple.length = 0; + return; + + // global path, and object already exists + } else { + src = tuple[0]; + key = path.slice(0, 0-(tuple[1].length+1)); + path = tuple[1]; + } + + tuple.length = 0; + this.chain(key, path, src); +}; + +// called on the root node of a chain to teardown watcher on the specified +// path +ChainNodePrototype.remove = function(path) { + var obj, tuple, key, src, paths; + + paths = this._paths; + if (paths[path] > 0) { paths[path]--; } + + obj = this.value(); + tuple = normalizeTuple(obj, path); + if (tuple[0] === obj) { + path = tuple[1]; + key = firstKey(path); + path = path.slice(key.length+1); + } else { + src = tuple[0]; + key = path.slice(0, 0-(tuple[1].length+1)); + path = tuple[1]; + } + + tuple.length = 0; + this.unchain(key, path); +}; + +ChainNodePrototype.count = 0; + +ChainNodePrototype.chain = function(key, path, src) { + var chains = this._chains, node; + if (!chains) { chains = this._chains = {}; } + + node = chains[key]; + if (!node) { node = chains[key] = new ChainNode(this, key, src); } + node.count++; // count chains... + + // chain rest of path if there is one + if (path && path.length>0) { + key = firstKey(path); + path = path.slice(key.length+1); + node.chain(key, path); // NOTE: no src means it will observe changes... + } +}; + +ChainNodePrototype.unchain = function(key, path) { + var chains = this._chains, node = chains[key]; + + // unchain rest of path first... + if (path && path.length>1) { + key = firstKey(path); + path = path.slice(key.length+1); + node.unchain(key, path); + } + + // delete node if needed. + node.count--; + if (node.count<=0) { + delete chains[node._key]; + node.destroy(); + } + +}; + +ChainNodePrototype.willChange = function() { + var chains = this._chains; + if (chains) { + for(var key in chains) { + if (!chains.hasOwnProperty(key)) { continue; } + chains[key].willChange(); + } + } + + if (this._parent) { this._parent.chainWillChange(this, this._key, 1); } +}; + +ChainNodePrototype.chainWillChange = function(chain, path, depth) { + if (this._key) { path = this._key + '.' + path; } + + if (this._parent) { + this._parent.chainWillChange(this, path, depth+1); + } else { + if (depth > 1) { Ember.propertyWillChange(this.value(), path); } + path = 'this.' + path; + if (this._paths[path] > 0) { Ember.propertyWillChange(this.value(), path); } + } +}; + +ChainNodePrototype.chainDidChange = function(chain, path, depth) { + if (this._key) { path = this._key + '.' + path; } + if (this._parent) { + this._parent.chainDidChange(this, path, depth+1); + } else { + if (depth > 1) { Ember.propertyDidChange(this.value(), path); } + path = 'this.' + path; + if (this._paths[path] > 0) { Ember.propertyDidChange(this.value(), path); } + } +}; + +ChainNodePrototype.didChange = function(suppressEvent) { + // invalidate my own value first. + if (this._watching) { + var obj = this._parent.value(); + if (obj !== this._object) { + removeChainWatcher(this._object, this._key, this); + this._object = obj; + addChainWatcher(obj, this._key, this); + } + this._value = undefined; + + // Special-case: the EachProxy relies on immediate evaluation to + // establish its observers. + if (this._parent && this._parent._key === '@each') + this.value(); + } + + // then notify chains... + var chains = this._chains; + if (chains) { + for(var key in chains) { + if (!chains.hasOwnProperty(key)) { continue; } + chains[key].didChange(suppressEvent); + } + } + + if (suppressEvent) { return; } + + // and finally tell parent about my path changing... + if (this._parent) { this._parent.chainDidChange(this, this._key, 1); } +}; + +// get the chains for the current object. If the current object has +// chains inherited from the proto they will be cloned and reconfigured for +// the current object. +function chainsFor(obj) { + var m = metaFor(obj), ret = m.chains; + if (!ret) { + ret = m.chains = new ChainNode(null, null, obj); + } else if (ret.value() !== obj) { + ret = m.chains = ret.copy(obj); + } + return ret; +} + +Ember.overrideChains = function(obj, keyName, m) { + chainsDidChange(obj, keyName, m, true); +}; + +function chainsWillChange(obj, keyName, m, arg) { + if (!m.hasOwnProperty('chainWatchers')) { return; } // nothing to do + + var nodes = m.chainWatchers; + + nodes = nodes[keyName]; + if (!nodes) { return; } + + for(var i = 0, l = nodes.length; i < l; i++) { + nodes[i].willChange(arg); + } +} + +function chainsDidChange(obj, keyName, m, arg) { + if (!m.hasOwnProperty('chainWatchers')) { return; } // nothing to do + + var nodes = m.chainWatchers; + + nodes = nodes[keyName]; + if (!nodes) { return; } + + // looping in reverse because the chainWatchers array can be modified inside didChange + for (var i = nodes.length - 1; i >= 0; i--) { + nodes[i].didChange(arg); + } +} + +// .......................................................... +// WATCH +// + +/** + @private + + Starts watching a property on an object. Whenever the property changes, + invokes `Ember.propertyWillChange` and `Ember.propertyDidChange`. This is the + primitive used by observers and dependent keys; usually you will never call + this method directly but instead use higher level methods like + `Ember.addObserver()` + + @method watch + @for Ember + @param obj + @param {String} keyName +*/ +Ember.watch = function(obj, keyName) { + // can't watch length on Array - it is special... + if (keyName === 'length' && Ember.typeOf(obj) === 'array') { return this; } + + var m = metaFor(obj), watching = m.watching, desc; + + // activate watching first time + if (!watching[keyName]) { + watching[keyName] = 1; + if (isKeyName(keyName)) { + desc = m.descs[keyName]; + if (desc && desc.willWatch) { desc.willWatch(obj, keyName); } + + if ('function' === typeof obj.willWatchProperty) { + obj.willWatchProperty(keyName); + } + + if (MANDATORY_SETTER && keyName in obj) { + m.values[keyName] = obj[keyName]; + o_defineProperty(obj, keyName, { + configurable: true, + enumerable: true, + set: Ember.MANDATORY_SETTER_FUNCTION, + get: Ember.DEFAULT_GETTER_FUNCTION(keyName) + }); + } + } else { + chainsFor(obj).add(keyName); + } + + } else { + watching[keyName] = (watching[keyName] || 0) + 1; + } + return this; +}; + +Ember.isWatching = function isWatching(obj, key) { + var meta = obj[META_KEY]; + return (meta && meta.watching[key]) > 0; +}; + +Ember.watch.flushPending = flushPendingChains; + +Ember.unwatch = function(obj, keyName) { + // can't watch length on Array - it is special... + if (keyName === 'length' && Ember.typeOf(obj) === 'array') { return this; } + + var m = metaFor(obj), watching = m.watching, desc; + + if (watching[keyName] === 1) { + watching[keyName] = 0; + + if (isKeyName(keyName)) { + desc = m.descs[keyName]; + if (desc && desc.didUnwatch) { desc.didUnwatch(obj, keyName); } + + if ('function' === typeof obj.didUnwatchProperty) { + obj.didUnwatchProperty(keyName); + } + + if (MANDATORY_SETTER && keyName in obj) { + o_defineProperty(obj, keyName, { + configurable: true, + enumerable: true, + writable: true, + value: m.values[keyName] + }); + delete m.values[keyName]; + } + } else { + chainsFor(obj).remove(keyName); + } + + } else if (watching[keyName]>1) { + watching[keyName]--; + } + + return this; +}; + +/** + @private + + Call on an object when you first beget it from another object. This will + setup any chained watchers on the object instance as needed. This method is + safe to call multiple times. + + @method rewatch + @for Ember + @param obj +*/ +Ember.rewatch = function(obj) { + var m = metaFor(obj, false), chains = m.chains; + + // make sure the object has its own guid. + if (GUID_KEY in obj && !obj.hasOwnProperty(GUID_KEY)) { + Ember.generateGuid(obj, 'ember'); + } + + // make sure any chained watchers update. + if (chains && chains.value() !== obj) { + m.chains = chains.copy(obj); + } + + return this; +}; + +Ember.finishChains = function(obj) { + var m = metaFor(obj, false), chains = m.chains; + if (chains) { + if (chains.value() !== obj) { + m.chains = chains = chains.copy(obj); + } + chains.didChange(true); + } +}; + +// .......................................................... +// PROPERTY CHANGES +// + +/** + This function is called just before an object property is about to change. + It will notify any before observers and prepare caches among other things. + + Normally you will not need to call this method directly but if for some + reason you can't directly watch a property you can invoke this method + manually along with `Ember.propertyDidChange()` which you should call just + after the property value changes. + + @method propertyWillChange + @for Ember + @param {Object} obj The object with the property that will change + @param {String} keyName The property key (or path) that will change. + @return {void} +*/ +function propertyWillChange(obj, keyName, value) { + var m = metaFor(obj, false), + watching = m.watching[keyName] > 0 || keyName === 'length', + proto = m.proto, + desc = m.descs[keyName]; + + if (!watching) { return; } + if (proto === obj) { return; } + if (desc && desc.willChange) { desc.willChange(obj, keyName); } + dependentKeysWillChange(obj, keyName, m); + chainsWillChange(obj, keyName, m); + Ember.notifyBeforeObservers(obj, keyName); +} + +Ember.propertyWillChange = propertyWillChange; + +/** + This function is called just after an object property has changed. + It will notify any observers and clear caches among other things. + + Normally you will not need to call this method directly but if for some + reason you can't directly watch a property you can invoke this method + manually along with `Ember.propertyWilLChange()` which you should call just + before the property value changes. + + @method propertyDidChange + @for Ember + @param {Object} obj The object with the property that will change + @param {String} keyName The property key (or path) that will change. + @return {void} +*/ +function propertyDidChange(obj, keyName) { + var m = metaFor(obj, false), + watching = m.watching[keyName] > 0 || keyName === 'length', + proto = m.proto, + desc = m.descs[keyName]; + + if (proto === obj) { return; } + + // shouldn't this mean that we're watching this key? + if (desc && desc.didChange) { desc.didChange(obj, keyName); } + if (!watching && keyName !== 'length') { return; } + + dependentKeysDidChange(obj, keyName, m); + chainsDidChange(obj, keyName, m); + Ember.notifyObservers(obj, keyName); +} + +Ember.propertyDidChange = propertyDidChange; + +var NODE_STACK = []; + +/** + Tears down the meta on an object so that it can be garbage collected. + Multiple calls will have no effect. + + @method destroy + @for Ember + @param {Object} obj the object to destroy + @return {void} +*/ +Ember.destroy = function (obj) { + var meta = obj[META_KEY], node, nodes, key, nodeObject; + if (meta) { + obj[META_KEY] = null; + // remove chainWatchers to remove circular references that would prevent GC + node = meta.chains; + if (node) { + NODE_STACK.push(node); + // process tree + while (NODE_STACK.length > 0) { + node = NODE_STACK.pop(); + // push children + nodes = node._chains; + if (nodes) { + for (key in nodes) { + if (nodes.hasOwnProperty(key)) { + NODE_STACK.push(nodes[key]); + } + } + } + // remove chainWatcher in node object + if (node._watching) { + nodeObject = node._object; + if (nodeObject) { + removeChainWatcher(nodeObject, node._key, node); + } + } + } + } + } +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +Ember.warn("The CP_DEFAULT_CACHEABLE flag has been removed and computed properties are always cached by default. Use `volatile` if you don't want caching.", Ember.ENV.CP_DEFAULT_CACHEABLE !== false); + + +var get = Ember.get, + set = Ember.set, + metaFor = Ember.meta, + guidFor = Ember.guidFor, + a_slice = [].slice, + o_create = Ember.create, + META_KEY = Ember.META_KEY, + watch = Ember.watch, + unwatch = Ember.unwatch; + +// .......................................................... +// DEPENDENT KEYS +// + +// data structure: +// meta.deps = { +// 'depKey': { +// 'keyName': count, +// } +// } + +/* + This function returns a map of unique dependencies for a + given object and key. +*/ +function keysForDep(obj, depsMeta, depKey) { + var keys = depsMeta[depKey]; + if (!keys) { + // if there are no dependencies yet for a the given key + // create a new empty list of dependencies for the key + keys = depsMeta[depKey] = {}; + } else if (!depsMeta.hasOwnProperty(depKey)) { + // otherwise if the dependency list is inherited from + // a superclass, clone the hash + keys = depsMeta[depKey] = o_create(keys); + } + return keys; +} + +/* return obj[META_KEY].deps */ +function metaForDeps(obj, meta) { + var deps = meta.deps; + // If the current object has no dependencies... + if (!deps) { + // initialize the dependencies with a pointer back to + // the current object + deps = meta.deps = {}; + } else if (!meta.hasOwnProperty('deps')) { + // otherwise if the dependencies are inherited from the + // object's superclass, clone the deps + deps = meta.deps = o_create(deps); + } + return deps; +} + +function addDependentKeys(desc, obj, keyName, meta) { + // the descriptor has a list of dependent keys, so + // add all of its dependent keys. + var depKeys = desc._dependentKeys, depsMeta, idx, len, depKey, keys; + if (!depKeys) return; + + depsMeta = metaForDeps(obj, meta); + + for(idx = 0, len = depKeys.length; idx < len; idx++) { + depKey = depKeys[idx]; + // Lookup keys meta for depKey + keys = keysForDep(obj, depsMeta, depKey); + // Increment the number of times depKey depends on keyName. + keys[keyName] = (keys[keyName] || 0) + 1; + // Watch the depKey + watch(obj, depKey); + } +} + +function removeDependentKeys(desc, obj, keyName, meta) { + // the descriptor has a list of dependent keys, so + // add all of its dependent keys. + var depKeys = desc._dependentKeys, depsMeta, idx, len, depKey, keys; + if (!depKeys) return; + + depsMeta = metaForDeps(obj, meta); + + for(idx = 0, len = depKeys.length; idx < len; idx++) { + depKey = depKeys[idx]; + // Lookup keys meta for depKey + keys = keysForDep(obj, depsMeta, depKey); + // Increment the number of times depKey depends on keyName. + keys[keyName] = (keys[keyName] || 0) - 1; + // Watch the depKey + unwatch(obj, depKey); + } +} + +// .......................................................... +// COMPUTED PROPERTY +// + +/** + @class ComputedProperty + @namespace Ember + @extends Ember.Descriptor + @constructor +*/ +function ComputedProperty(func, opts) { + this.func = func; + this._cacheable = (opts && opts.cacheable !== undefined) ? opts.cacheable : true; + this._dependentKeys = opts && opts.dependentKeys; +} + +Ember.ComputedProperty = ComputedProperty; +ComputedProperty.prototype = new Ember.Descriptor(); + +var ComputedPropertyPrototype = ComputedProperty.prototype; + +/** + Call on a computed property to set it into cacheable mode. When in this + mode the computed property will automatically cache the return value of + your function until one of the dependent keys changes. + + ```javascript + MyApp.president = Ember.Object.create({ + fullName: function() { + return this.get('firstName') + ' ' + this.get('lastName'); + + // After calculating the value of this function, Ember will + // return that value without re-executing this function until + // one of the dependent properties change. + }.property('firstName', 'lastName') + }); + ``` + + Properties are cacheable by default. + + @method cacheable + @param {Boolean} aFlag optional set to `false` to disable caching + @chainable +*/ +ComputedPropertyPrototype.cacheable = function(aFlag) { + this._cacheable = aFlag !== false; + return this; +}; + +/** + Call on a computed property to set it into non-cached mode. When in this + mode the computed property will not automatically cache the return value. + + ```javascript + MyApp.outsideService = Ember.Object.create({ + value: function() { + return OutsideService.getValue(); + }.property().volatile() + }); + ``` + + @method volatile + @chainable +*/ +ComputedPropertyPrototype.volatile = function() { + return this.cacheable(false); +}; + +/** + Sets the dependent keys on this computed property. Pass any number of + arguments containing key paths that this computed property depends on. + + ```javascript + MyApp.president = Ember.Object.create({ + fullName: Ember.computed(function() { + return this.get('firstName') + ' ' + this.get('lastName'); + + // Tell Ember that this computed property depends on firstName + // and lastName + }).property('firstName', 'lastName') + }); + ``` + + @method property + @param {String} path* zero or more property paths + @chainable +*/ +ComputedPropertyPrototype.property = function() { + var args = []; + for (var i = 0, l = arguments.length; i < l; i++) { + args.push(arguments[i]); + } + this._dependentKeys = args; + return this; +}; + +/** + In some cases, you may want to annotate computed properties with additional + metadata about how they function or what values they operate on. For example, + computed property functions may close over variables that are then no longer + available for introspection. + + You can pass a hash of these values to a computed property like this: + + ``` + person: function() { + var personId = this.get('personId'); + return App.Person.create({ id: personId }); + }.property().meta({ type: App.Person }) + ``` + + The hash that you pass to the `meta()` function will be saved on the + computed property descriptor under the `_meta` key. Ember runtime + exposes a public API for retrieving these values from classes, + via the `metaForProperty()` function. + + @method meta + @param {Hash} meta + @chainable +*/ + +ComputedPropertyPrototype.meta = function(meta) { + if (arguments.length === 0) { + return this._meta || {}; + } else { + this._meta = meta; + return this; + } +}; + +/* impl descriptor API */ +ComputedPropertyPrototype.willWatch = function(obj, keyName) { + // watch already creates meta for this instance + var meta = obj[META_KEY]; + Ember.assert('watch should have setup meta to be writable', meta.source === obj); + if (!(keyName in meta.cache)) { + addDependentKeys(this, obj, keyName, meta); + } +}; + +ComputedPropertyPrototype.didUnwatch = function(obj, keyName) { + var meta = obj[META_KEY]; + Ember.assert('unwatch should have setup meta to be writable', meta.source === obj); + if (!(keyName in meta.cache)) { + // unwatch already creates meta for this instance + removeDependentKeys(this, obj, keyName, meta); + } +}; + +/* impl descriptor API */ +ComputedPropertyPrototype.didChange = function(obj, keyName) { + // _suspended is set via a CP.set to ensure we don't clear + // the cached value set by the setter + if (this._cacheable && this._suspended !== obj) { + var meta = metaFor(obj); + if (keyName in meta.cache) { + delete meta.cache[keyName]; + if (!meta.watching[keyName]) { + removeDependentKeys(this, obj, keyName, meta); + } + } + } +}; + +/* impl descriptor API */ +ComputedPropertyPrototype.get = function(obj, keyName) { + var ret, cache, meta; + if (this._cacheable) { + meta = metaFor(obj); + cache = meta.cache; + if (keyName in cache) { return cache[keyName]; } + ret = cache[keyName] = this.func.call(obj, keyName); + if (!meta.watching[keyName]) { + addDependentKeys(this, obj, keyName, meta); + } + } else { + ret = this.func.call(obj, keyName); + } + return ret; +}; + +/* impl descriptor API */ +ComputedPropertyPrototype.set = function(obj, keyName, value) { + var cacheable = this._cacheable, + func = this.func, + meta = metaFor(obj, cacheable), + watched = meta.watching[keyName], + oldSuspended = this._suspended, + hadCachedValue = false, + cache = meta.cache, + cachedValue, ret; + + this._suspended = obj; + + try { + if (cacheable && cache.hasOwnProperty(keyName)) { + cachedValue = cache[keyName]; + hadCachedValue = true; + } + + // Check if the CP has been wrapped + if (func.wrappedFunction) { func = func.wrappedFunction; } + + // For backwards-compatibility with computed properties + // that check for arguments.length === 2 to determine if + // they are being get or set, only pass the old cached + // value if the computed property opts into a third + // argument. + if (func.length === 3) { + ret = func.call(obj, keyName, value, cachedValue); + } else if (func.length === 2) { + ret = func.call(obj, keyName, value); + } else { + Ember.defineProperty(obj, keyName, null, cachedValue); + Ember.set(obj, keyName, value); + return; + } + + if (hadCachedValue && cachedValue === ret) { return; } + + if (watched) { Ember.propertyWillChange(obj, keyName); } + + if (hadCachedValue) { + delete cache[keyName]; + } + + if (cacheable) { + if (!watched && !hadCachedValue) { + addDependentKeys(this, obj, keyName, meta); + } + cache[keyName] = ret; + } + + if (watched) { Ember.propertyDidChange(obj, keyName); } + } finally { + this._suspended = oldSuspended; + } + return ret; +}; + +/* called when property is defined */ +ComputedPropertyPrototype.setup = function(obj, keyName) { + var meta = obj[META_KEY]; + if (meta && meta.watching[keyName]) { + addDependentKeys(this, obj, keyName, metaFor(obj)); + } +}; + +/* called before property is overridden */ +ComputedPropertyPrototype.teardown = function(obj, keyName) { + var meta = metaFor(obj); + + if (meta.watching[keyName] || keyName in meta.cache) { + removeDependentKeys(this, obj, keyName, meta); + } + + if (this._cacheable) { delete meta.cache[keyName]; } + + return null; // no value to restore +}; + + +/** + This helper returns a new property descriptor that wraps the passed + computed property function. You can use this helper to define properties + with mixins or via `Ember.defineProperty()`. + + The function you pass will be used to both get and set property values. + The function should accept two parameters, key and value. If value is not + undefined you should set the value first. In either case return the + current value of the property. + + @method computed + @for Ember + @param {Function} func The computed property function. + @return {Ember.ComputedProperty} property descriptor instance +*/ +Ember.computed = function(func) { + var args; + + if (arguments.length > 1) { + args = a_slice.call(arguments, 0, -1); + func = a_slice.call(arguments, -1)[0]; + } + + var cp = new ComputedProperty(func); + + if (args) { + cp.property.apply(cp, args); + } + + return cp; +}; + +/** + Returns the cached value for a property, if one exists. + This can be useful for peeking at the value of a computed + property that is generated lazily, without accidentally causing + it to be created. + + @method cacheFor + @for Ember + @param {Object} obj the object whose property you want to check + @param {String} key the name of the property whose cached value you want + to return +*/ +Ember.cacheFor = function cacheFor(obj, key) { + var cache = metaFor(obj, false).cache; + + if (cache && key in cache) { + return cache[key]; + } +}; + +/** + @method computed.not + @for Ember + @param {String} dependentKey +*/ +Ember.computed.not = function(dependentKey) { + return Ember.computed(dependentKey, function(key) { + return !get(this, dependentKey); + }); +}; + +/** + @method computed.empty + @for Ember + @param {String} dependentKey +*/ +Ember.computed.empty = function(dependentKey) { + return Ember.computed(dependentKey, function(key) { + var val = get(this, dependentKey); + return val === undefined || val === null || val === '' || (Ember.isArray(val) && get(val, 'length') === 0); + }); +}; + +/** + @method computed.bool + @for Ember + @param {String} dependentKey +*/ +Ember.computed.bool = function(dependentKey) { + return Ember.computed(dependentKey, function(key) { + return !!get(this, dependentKey); + }); +}; + +/** + @method computed.alias + @for Ember + @param {String} dependentKey +*/ +Ember.computed.alias = function(dependentKey) { + return Ember.computed(dependentKey, function(key, value){ + if (arguments.length === 1) { + return get(this, dependentKey); + } else { + set(this, dependentKey, value); + return value; + } + }); +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +var o_create = Ember.create, + metaFor = Ember.meta, + metaPath = Ember.metaPath, + META_KEY = Ember.META_KEY; + +/* + The event system uses a series of nested hashes to store listeners on an + object. When a listener is registered, or when an event arrives, these + hashes are consulted to determine which target and action pair to invoke. + + The hashes are stored in the object's meta hash, and look like this: + + // Object's meta hash + { + listeners: { // variable name: `listenerSet` + "foo:changed": [ // variable name: `actions` + [target, method, onceFlag, suspendedFlag] + ] + } + } + +*/ + +function indexOf(array, target, method) { + var index = -1; + for (var i = 0, l = array.length; i < l; i++) { + if (target === array[i][0] && method === array[i][1]) { index = i; break; } + } + return index; +} + +function actionsFor(obj, eventName) { + var meta = metaFor(obj, true), + actions; + + if (!meta.listeners) { meta.listeners = {}; } + + if (!meta.hasOwnProperty('listeners')) { + // setup inherited copy of the listeners object + meta.listeners = o_create(meta.listeners); + } + + actions = meta.listeners[eventName]; + + // if there are actions, but the eventName doesn't exist in our listeners, then copy them from the prototype + if (actions && !meta.listeners.hasOwnProperty(eventName)) { + actions = meta.listeners[eventName] = meta.listeners[eventName].slice(); + } else if (!actions) { + actions = meta.listeners[eventName] = []; + } + + return actions; +} + +function actionsUnion(obj, eventName, otherActions) { + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName]; + + if (!actions) { return; } + for (var i = actions.length - 1; i >= 0; i--) { + var target = actions[i][0], + method = actions[i][1], + once = actions[i][2], + suspended = actions[i][3], + actionIndex = indexOf(otherActions, target, method); + + if (actionIndex === -1) { + otherActions.push([target, method, once, suspended]); + } + } +} + +function actionsDiff(obj, eventName, otherActions) { + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName], + diffActions = []; + + if (!actions) { return; } + for (var i = actions.length - 1; i >= 0; i--) { + var target = actions[i][0], + method = actions[i][1], + once = actions[i][2], + suspended = actions[i][3], + actionIndex = indexOf(otherActions, target, method); + + if (actionIndex !== -1) { continue; } + + otherActions.push([target, method, once, suspended]); + diffActions.push([target, method, once, suspended]); + } + + return diffActions; +} + +/** + Add an event listener + + @method addListener + @for Ember + @param obj + @param {String} eventName + @param {Object|Function} targetOrMethod A target object or a function + @param {Function|String} method A function or the name of a function to be called on `target` +*/ +function addListener(obj, eventName, target, method, once) { + Ember.assert("You must pass at least an object and event name to Ember.addListener", !!obj && !!eventName); + + if (!method && 'function' === typeof target) { + method = target; + target = null; + } + + var actions = actionsFor(obj, eventName), + actionIndex = indexOf(actions, target, method); + + if (actionIndex !== -1) { return; } + + actions.push([target, method, once, undefined]); + + if ('function' === typeof obj.didAddListener) { + obj.didAddListener(eventName, target, method); + } +} + +/** + Remove an event listener + + Arguments should match those passed to {{#crossLink "Ember/addListener"}}{{/crossLink}} + + @method removeListener + @for Ember + @param obj + @param {String} eventName + @param {Object|Function} targetOrMethod A target object or a function + @param {Function|String} method A function or the name of a function to be called on `target` +*/ +function removeListener(obj, eventName, target, method) { + Ember.assert("You must pass at least an object and event name to Ember.removeListener", !!obj && !!eventName); + + if (!method && 'function' === typeof target) { + method = target; + target = null; + } + + function _removeListener(target, method, once) { + var actions = actionsFor(obj, eventName), + actionIndex = indexOf(actions, target, method); + + // action doesn't exist, give up silently + if (actionIndex === -1) { return; } + + actions.splice(actionIndex, 1); + + if ('function' === typeof obj.didRemoveListener) { + obj.didRemoveListener(eventName, target, method); + } + } + + if (method) { + _removeListener(target, method); + } else { + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName]; + + if (!actions) { return; } + for (var i = actions.length - 1; i >= 0; i--) { + _removeListener(actions[i][0], actions[i][1]); + } + } +} + +/** + @private + + Suspend listener during callback. + + This should only be used by the target of the event listener + when it is taking an action that would cause the event, e.g. + an object might suspend its property change listener while it is + setting that property. + + @method suspendListener + @for Ember + @param obj + @param {String} eventName + @param {Object|Function} targetOrMethod A target object or a function + @param {Function|String} method A function or the name of a function to be called on `target` + @param {Function} callback +*/ +function suspendListener(obj, eventName, target, method, callback) { + if (!method && 'function' === typeof target) { + method = target; + target = null; + } + + var actions = actionsFor(obj, eventName), + actionIndex = indexOf(actions, target, method), + action; + + if (actionIndex !== -1) { + action = actions[actionIndex].slice(); // copy it, otherwise we're modifying a shared object + action[3] = true; // mark the action as suspended + actions[actionIndex] = action; // replace the shared object with our copy + } + + function tryable() { return callback.call(target); } + function finalizer() { if (action) { action[3] = undefined; } } + + return Ember.tryFinally(tryable, finalizer); +} + +/** + @private + + Suspend listener during callback. + + This should only be used by the target of the event listener + when it is taking an action that would cause the event, e.g. + an object might suspend its property change listener while it is + setting that property. + + @method suspendListener + @for Ember + @param obj + @param {Array} eventName Array of event names + @param {Object|Function} targetOrMethod A target object or a function + @param {Function|String} method A function or the name of a function to be called on `target` + @param {Function} callback +*/ +function suspendListeners(obj, eventNames, target, method, callback) { + if (!method && 'function' === typeof target) { + method = target; + target = null; + } + + var suspendedActions = [], + eventName, actions, action, i, l; + + for (i=0, l=eventNames.length; i= 0; i--) { // looping in reverse for once listeners + if (!actions[i] || actions[i][3] === true) { continue; } + + var target = actions[i][0], + method = actions[i][1], + once = actions[i][2]; + + if (once) { removeListener(obj, eventName, target, method); } + if (!target) { target = obj; } + if ('string' === typeof method) { method = target[method]; } + if (params) { + method.apply(target, params); + } else { + method.apply(target); + } + } + return true; +} + +/** + @private + @method hasListeners + @for Ember + @param obj + @param {String} eventName +*/ +function hasListeners(obj, eventName) { + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName]; + + return !!(actions && actions.length); +} + +/** + @private + @method listenersFor + @for Ember + @param obj + @param {String} eventName +*/ +function listenersFor(obj, eventName) { + var ret = []; + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName]; + + if (!actions) { return ret; } + + for (var i = 0, l = actions.length; i < l; i++) { + var target = actions[i][0], + method = actions[i][1]; + ret.push([target, method]); + } + + return ret; +} + +Ember.addListener = addListener; +Ember.removeListener = removeListener; +Ember._suspendListener = suspendListener; +Ember._suspendListeners = suspendListeners; +Ember.sendEvent = sendEvent; +Ember.hasListeners = hasListeners; +Ember.watchedEvents = watchedEvents; +Ember.listenersFor = listenersFor; +Ember.listenersDiff = actionsDiff; +Ember.listenersUnion = actionsUnion; + +})(); + + + +(function() { +// Ember.Logger +// Ember.watch.flushPending +// Ember.beginPropertyChanges, Ember.endPropertyChanges +// Ember.guidFor, Ember.tryFinally + +/** +@module ember-metal +*/ + +// .......................................................... +// HELPERS +// + +var slice = [].slice, + forEach = Ember.ArrayPolyfills.forEach; + +// invokes passed params - normalizing so you can pass target/func, +// target/string or just func +function invoke(target, method, args, ignore) { + + if (method === undefined) { + method = target; + target = undefined; + } + + if ('string' === typeof method) { method = target[method]; } + if (args && ignore > 0) { + args = args.length > ignore ? slice.call(args, ignore) : null; + } + + return Ember.handleErrors(function() { + // IE8's Function.prototype.apply doesn't accept undefined/null arguments. + return method.apply(target || this, args || []); + }, this); +} + + +// .......................................................... +// RUNLOOP +// + +var timerMark; // used by timers... + +/** +Ember RunLoop (Private) + +@class RunLoop +@namespace Ember +@private +@constructor +*/ +var RunLoop = function(prev) { + this._prev = prev || null; + this.onceTimers = {}; +}; + +RunLoop.prototype = { + /** + @method end + */ + end: function() { + this.flush(); + }, + + /** + @method prev + */ + prev: function() { + return this._prev; + }, + + // .......................................................... + // Delayed Actions + // + + /** + @method schedule + @param {String} queueName + @param target + @param method + */ + schedule: function(queueName, target, method) { + var queues = this._queues, queue; + if (!queues) { queues = this._queues = {}; } + queue = queues[queueName]; + if (!queue) { queue = queues[queueName] = []; } + + var args = arguments.length > 3 ? slice.call(arguments, 3) : null; + queue.push({ target: target, method: method, args: args }); + return this; + }, + + /** + @method flush + @param {String} queueName + */ + flush: function(queueName) { + var queueNames, idx, len, queue, log; + + if (!this._queues) { return this; } // nothing to do + + function iter(item) { + invoke(item.target, item.method, item.args); + } + + function tryable() { + forEach.call(queue, iter); + } + + Ember.watch.flushPending(); // make sure all chained watchers are setup + + if (queueName) { + while (this._queues && (queue = this._queues[queueName])) { + this._queues[queueName] = null; + + // the sync phase is to allow property changes to propagate. don't + // invoke observers until that is finished. + if (queueName === 'sync') { + log = Ember.LOG_BINDINGS; + if (log) { Ember.Logger.log('Begin: Flush Sync Queue'); } + + Ember.beginPropertyChanges(); + + Ember.tryFinally(tryable, Ember.endPropertyChanges); + + if (log) { Ember.Logger.log('End: Flush Sync Queue'); } + + } else { + forEach.call(queue, iter); + } + } + + } else { + queueNames = Ember.run.queues; + len = queueNames.length; + idx = 0; + + outerloop: + while (idx < len) { + queueName = queueNames[idx]; + queue = this._queues && this._queues[queueName]; + delete this._queues[queueName]; + + if (queue) { + // the sync phase is to allow property changes to propagate. don't + // invoke observers until that is finished. + if (queueName === 'sync') { + log = Ember.LOG_BINDINGS; + if (log) { Ember.Logger.log('Begin: Flush Sync Queue'); } + + Ember.beginPropertyChanges(); + + Ember.tryFinally(tryable, Ember.endPropertyChanges); + + if (log) { Ember.Logger.log('End: Flush Sync Queue'); } + } else { + forEach.call(queue, iter); + } + } + + // Loop through prior queues + for (var i = 0; i <= idx; i++) { + if (this._queues && this._queues[queueNames[i]]) { + // Start over at the first queue with contents + idx = i; + continue outerloop; + } + } + + idx++; + } + } + + timerMark = null; + + return this; + } + +}; + +Ember.RunLoop = RunLoop; + +// .......................................................... +// Ember.run - this is ideally the only public API the dev sees +// + +/** + Runs the passed target and method inside of a RunLoop, ensuring any + deferred actions including bindings and views updates are flushed at the + end. + + Normally you should not need to invoke this method yourself. However if + you are implementing raw event handlers when interfacing with other + libraries or plugins, you should probably wrap all of your code inside this + call. + + ```javascript + Ember.run(function(){ + // code to be execute within a RunLoop + }); + ``` + + @class run + @namespace Ember + @static + @constructor + @param {Object} [target] target of method to call + @param {Function|String} method Method to invoke. + May be a function or a string. If you pass a string + then it will be looked up on the passed target. + @param {Object} [args*] Any additional arguments you wish to pass to the method. + @return {Object} return value from invoking the passed function. +*/ +Ember.run = function(target, method) { + var loop, + args = arguments; + run.begin(); + + function tryable() { + if (target || method) { + return invoke(target, method, args, 2); + } + } + + return Ember.tryFinally(tryable, run.end); +}; + +var run = Ember.run; + + +/** + Begins a new RunLoop. Any deferred actions invoked after the begin will + be buffered until you invoke a matching call to `Ember.run.end()`. This is + an lower-level way to use a RunLoop instead of using `Ember.run()`. + + ```javascript + Ember.run.begin(); + // code to be execute within a RunLoop + Ember.run.end(); + ``` + + @method begin + @return {void} +*/ +Ember.run.begin = function() { + run.currentRunLoop = new RunLoop(run.currentRunLoop); +}; + +/** + Ends a RunLoop. This must be called sometime after you call + `Ember.run.begin()` to flush any deferred actions. This is a lower-level way + to use a RunLoop instead of using `Ember.run()`. + + ```javascript + Ember.run.begin(); + // code to be execute within a RunLoop + Ember.run.end(); + ``` + + @method end + @return {void} +*/ +Ember.run.end = function() { + Ember.assert('must have a current run loop', run.currentRunLoop); + + function tryable() { run.currentRunLoop.end(); } + function finalizer() { run.currentRunLoop = run.currentRunLoop.prev(); } + + Ember.tryFinally(tryable, finalizer); +}; + +/** + Array of named queues. This array determines the order in which queues + are flushed at the end of the RunLoop. You can define your own queues by + simply adding the queue name to this array. Normally you should not need + to inspect or modify this property. + + @property queues + @type Array + @default ['sync', 'actions', 'destroy', 'timers'] +*/ +Ember.run.queues = ['sync', 'actions', 'destroy', 'timers']; + +/** + Adds the passed target/method and any optional arguments to the named + queue to be executed at the end of the RunLoop. If you have not already + started a RunLoop when calling this method one will be started for you + automatically. + + At the end of a RunLoop, any methods scheduled in this way will be invoked. + Methods will be invoked in an order matching the named queues defined in + the `run.queues` property. + + ```javascript + Ember.run.schedule('timers', this, function(){ + // this will be executed at the end of the RunLoop, when timers are run + console.log("scheduled on timers queue"); + }); + + Ember.run.schedule('sync', this, function(){ + // this will be executed at the end of the RunLoop, when bindings are synced + console.log("scheduled on sync queue"); + }); + + // Note the functions will be run in order based on the run queues order. Output would be: + // scheduled on sync queue + // scheduled on timers queue + ``` + + @method schedule + @param {String} queue The name of the queue to schedule against. + Default queues are 'sync' and 'actions' + @param {Object} [target] target object to use as the context when invoking a method. + @param {String|Function} method The method to invoke. If you pass a string it + will be resolved on the target object at the time the scheduled item is + invoked allowing you to change the target function. + @param {Object} [arguments*] Optional arguments to be passed to the queued method. + @return {void} +*/ +Ember.run.schedule = function(queue, target, method) { + var loop = run.autorun(); + loop.schedule.apply(loop, arguments); +}; + +var scheduledAutorun; +function autorun() { + scheduledAutorun = null; + if (run.currentRunLoop) { run.end(); } +} + +// Used by global test teardown +Ember.run.hasScheduledTimers = function() { + return !!(scheduledAutorun || scheduledLater || scheduledNext); +}; + +// Used by global test teardown +Ember.run.cancelTimers = function () { + if (scheduledAutorun) { + clearTimeout(scheduledAutorun); + scheduledAutorun = null; + } + if (scheduledLater) { + clearTimeout(scheduledLater); + scheduledLater = null; + } + if (scheduledNext) { + clearTimeout(scheduledNext); + scheduledNext = null; + } + timers = {}; +}; + +/** + Begins a new RunLoop if necessary and schedules a timer to flush the + RunLoop at a later time. This method is used by parts of Ember to + ensure the RunLoop always finishes. You normally do not need to call this + method directly. Instead use `Ember.run()` + + @method autorun + @example + Ember.run.autorun(); + @return {Ember.RunLoop} the new current RunLoop +*/ +Ember.run.autorun = function() { + if (!run.currentRunLoop) { + Ember.assert("You have turned on testing mode, which disabled the run-loop's autorun. You will need to wrap any code with asynchronous side-effects in an Ember.run", !Ember.testing); + + run.begin(); + + if (!scheduledAutorun) { + scheduledAutorun = setTimeout(autorun, 1); + } + } + + return run.currentRunLoop; +}; + +/** + Immediately flushes any events scheduled in the 'sync' queue. Bindings + use this queue so this method is a useful way to immediately force all + bindings in the application to sync. + + You should call this method anytime you need any changed state to propagate + throughout the app immediately without repainting the UI. + + ```javascript + Ember.run.sync(); + ``` + + @method sync + @return {void} +*/ +Ember.run.sync = function() { + run.autorun(); + run.currentRunLoop.flush('sync'); +}; + +// .......................................................... +// TIMERS +// + +var timers = {}; // active timers... + +var scheduledLater; +function invokeLaterTimers() { + scheduledLater = null; + var now = (+ new Date()), earliest = -1; + for (var key in timers) { + if (!timers.hasOwnProperty(key)) { continue; } + var timer = timers[key]; + if (timer && timer.expires) { + if (now >= timer.expires) { + delete timers[key]; + invoke(timer.target, timer.method, timer.args, 2); + } else { + if (earliest<0 || (timer.expires < earliest)) earliest=timer.expires; + } + } + } + + // schedule next timeout to fire... + if (earliest > 0) { scheduledLater = setTimeout(invokeLaterTimers, earliest-(+ new Date())); } +} + +/** + Invokes the passed target/method and optional arguments after a specified + period if time. The last parameter of this method must always be a number + of milliseconds. + + You should use this method whenever you need to run some action after a + period of time instead of using `setTimeout()`. This method will ensure that + items that expire during the same script execution cycle all execute + together, which is often more efficient than using a real setTimeout. + + ```javascript + Ember.run.later(myContext, function(){ + // code here will execute within a RunLoop in about 500ms with this == myContext + }, 500); + ``` + + @method later + @param {Object} [target] target of method to invoke + @param {Function|String} method The method to invoke. + If you pass a string it will be resolved on the + target at the time the method is invoked. + @param {Object} [args*] Optional arguments to pass to the timeout. + @param {Number} wait + Number of milliseconds to wait. + @return {String} a string you can use to cancel the timer in + {{#crossLink "Ember/run.cancel"}}{{/crossLink}} later. +*/ +Ember.run.later = function(target, method) { + var args, expires, timer, guid, wait; + + // setTimeout compatibility... + if (arguments.length===2 && 'function' === typeof target) { + wait = method; + method = target; + target = undefined; + args = [target, method]; + } else { + args = slice.call(arguments); + wait = args.pop(); + } + + expires = (+ new Date()) + wait; + timer = { target: target, method: method, expires: expires, args: args }; + guid = Ember.guidFor(timer); + timers[guid] = timer; + run.once(timers, invokeLaterTimers); + return guid; +}; + +function invokeOnceTimer(guid, onceTimers) { + if (onceTimers[this.tguid]) { delete onceTimers[this.tguid][this.mguid]; } + if (timers[guid]) { invoke(this.target, this.method, this.args); } + delete timers[guid]; +} + +function scheduleOnce(queue, target, method, args) { + var tguid = Ember.guidFor(target), + mguid = Ember.guidFor(method), + onceTimers = run.autorun().onceTimers, + guid = onceTimers[tguid] && onceTimers[tguid][mguid], + timer; + + if (guid && timers[guid]) { + timers[guid].args = args; // replace args + } else { + timer = { + target: target, + method: method, + args: args, + tguid: tguid, + mguid: mguid + }; + + guid = Ember.guidFor(timer); + timers[guid] = timer; + if (!onceTimers[tguid]) { onceTimers[tguid] = {}; } + onceTimers[tguid][mguid] = guid; // so it isn't scheduled more than once + + run.schedule(queue, timer, invokeOnceTimer, guid, onceTimers); + } + + return guid; +} + +/** + Schedules an item to run one time during the current RunLoop. Calling + this method with the same target/method combination will have no effect. + + Note that although you can pass optional arguments these will not be + considered when looking for duplicates. New arguments will replace previous + calls. + + ```javascript + Ember.run(function(){ + var doFoo = function() { foo(); } + Ember.run.once(myContext, doFoo); + Ember.run.once(myContext, doFoo); + // doFoo will only be executed once at the end of the RunLoop + }); + ``` + + @method once + @param {Object} [target] target of method to invoke + @param {Function|String} method The method to invoke. + If you pass a string it will be resolved on the + target at the time the method is invoked. + @param {Object} [args*] Optional arguments to pass to the timeout. + @return {Object} timer +*/ +Ember.run.once = function(target, method) { + return scheduleOnce('actions', target, method, slice.call(arguments, 2)); +}; + +Ember.run.scheduleOnce = function(queue, target, method, args) { + return scheduleOnce(queue, target, method, slice.call(arguments, 3)); +}; + +var scheduledNext; +function invokeNextTimers() { + scheduledNext = null; + for(var key in timers) { + if (!timers.hasOwnProperty(key)) { continue; } + var timer = timers[key]; + if (timer.next) { + delete timers[key]; + invoke(timer.target, timer.method, timer.args, 2); + } + } +} + +/** + Schedules an item to run after control has been returned to the system. + This is often equivalent to calling `setTimeout(function() {}, 1)`. + + ```javascript + Ember.run.next(myContext, function(){ + // code to be executed in the next RunLoop, which will be scheduled after the current one + }); + ``` + + @method next + @param {Object} [target] target of method to invoke + @param {Function|String} method The method to invoke. + If you pass a string it will be resolved on the + target at the time the method is invoked. + @param {Object} [args*] Optional arguments to pass to the timeout. + @return {Object} timer +*/ +Ember.run.next = function(target, method) { + var guid, + timer = { + target: target, + method: method, + args: slice.call(arguments), + next: true + }; + + guid = Ember.guidFor(timer); + timers[guid] = timer; + + if (!scheduledNext) { scheduledNext = setTimeout(invokeNextTimers, 1); } + return guid; +}; + +/** + Cancels a scheduled item. Must be a value returned by `Ember.run.later()`, + `Ember.run.once()`, or `Ember.run.next()`. + + ```javascript + var runNext = Ember.run.next(myContext, function(){ + // will not be executed + }); + Ember.run.cancel(runNext); + + var runLater = Ember.run.later(myContext, function(){ + // will not be executed + }, 500); + Ember.run.cancel(runLater); + + var runOnce = Ember.run.once(myContext, function(){ + // will not be executed + }); + Ember.run.cancel(runOnce); + ``` + + @method cancel + @param {Object} timer Timer object to cancel + @return {void} +*/ +Ember.run.cancel = function(timer) { + delete timers[timer]; +}; + +})(); + + + +(function() { +// Ember.Logger +// get, set, trySet +// guidFor, isArray, meta +// addObserver, removeObserver +// Ember.run.schedule +/** +@module ember-metal +*/ + +// .......................................................... +// CONSTANTS +// + +/** + Debug parameter you can turn on. This will log all bindings that fire to + the console. This should be disabled in production code. Note that you + can also enable this from the console or temporarily. + + @property LOG_BINDINGS + @for Ember + @type Boolean + @default false +*/ +Ember.LOG_BINDINGS = false || !!Ember.ENV.LOG_BINDINGS; + +var get = Ember.get, + set = Ember.set, + guidFor = Ember.guidFor, + isGlobalPath = Ember.isGlobalPath; + + +function getWithGlobals(obj, path) { + return get(isGlobalPath(path) ? Ember.lookup : obj, path); +} + +// .......................................................... +// BINDING +// + +var Binding = function(toPath, fromPath) { + this._direction = 'fwd'; + this._from = fromPath; + this._to = toPath; + this._directionMap = Ember.Map.create(); +}; + +/** +@class Binding +@namespace Ember +*/ + +Binding.prototype = { + /** + This copies the Binding so it can be connected to another object. + + @method copy + @return {Ember.Binding} + */ + copy: function () { + var copy = new Binding(this._to, this._from); + if (this._oneWay) { copy._oneWay = true; } + return copy; + }, + + // .......................................................... + // CONFIG + // + + /** + This will set `from` property path to the specified value. It will not + attempt to resolve this property path to an actual object until you + connect the binding. + + The binding will search for the property path starting at the root object + you pass when you `connect()` the binding. It follows the same rules as + `get()` - see that method for more information. + + @method from + @param {String} propertyPath the property path to connect to + @return {Ember.Binding} `this` + */ + from: function(path) { + this._from = path; + return this; + }, + + /** + This will set the `to` property path to the specified value. It will not + attempt to resolve this property path to an actual object until you + connect the binding. + + The binding will search for the property path starting at the root object + you pass when you `connect()` the binding. It follows the same rules as + `get()` - see that method for more information. + + @method to + @param {String|Tuple} propertyPath A property path or tuple + @return {Ember.Binding} `this` + */ + to: function(path) { + this._to = path; + return this; + }, + + /** + Configures the binding as one way. A one-way binding will relay changes + on the `from` side to the `to` side, but not the other way around. This + means that if you change the `to` side directly, the `from` side may have + a different value. + + @method oneWay + @return {Ember.Binding} `this` + */ + oneWay: function() { + this._oneWay = true; + return this; + }, + + toString: function() { + var oneWay = this._oneWay ? '[oneWay]' : ''; + return "Ember.Binding<" + guidFor(this) + ">(" + this._from + " -> " + this._to + ")" + oneWay; + }, + + // .......................................................... + // CONNECT AND SYNC + // + + /** + Attempts to connect this binding instance so that it can receive and relay + changes. This method will raise an exception if you have not set the + from/to properties yet. + + @method connect + @param {Object} obj The root object for this binding. + @return {Ember.Binding} `this` + */ + connect: function(obj) { + Ember.assert('Must pass a valid object to Ember.Binding.connect()', !!obj); + + var fromPath = this._from, toPath = this._to; + Ember.trySet(obj, toPath, getWithGlobals(obj, fromPath)); + + // add an observer on the object to be notified when the binding should be updated + Ember.addObserver(obj, fromPath, this, this.fromDidChange); + + // if the binding is a two-way binding, also set up an observer on the target + if (!this._oneWay) { Ember.addObserver(obj, toPath, this, this.toDidChange); } + + this._readyToSync = true; + + return this; + }, + + /** + Disconnects the binding instance. Changes will no longer be relayed. You + will not usually need to call this method. + + @method disconnect + @param {Object} obj The root object you passed when connecting the binding. + @return {Ember.Binding} `this` + */ + disconnect: function(obj) { + Ember.assert('Must pass a valid object to Ember.Binding.disconnect()', !!obj); + + var twoWay = !this._oneWay; + + // remove an observer on the object so we're no longer notified of + // changes that should update bindings. + Ember.removeObserver(obj, this._from, this, this.fromDidChange); + + // if the binding is two-way, remove the observer from the target as well + if (twoWay) { Ember.removeObserver(obj, this._to, this, this.toDidChange); } + + this._readyToSync = false; // disable scheduled syncs... + return this; + }, + + // .......................................................... + // PRIVATE + // + + /* called when the from side changes */ + fromDidChange: function(target) { + this._scheduleSync(target, 'fwd'); + }, + + /* called when the to side changes */ + toDidChange: function(target) { + this._scheduleSync(target, 'back'); + }, + + _scheduleSync: function(obj, dir) { + var directionMap = this._directionMap; + var existingDir = directionMap.get(obj); + + // if we haven't scheduled the binding yet, schedule it + if (!existingDir) { + Ember.run.schedule('sync', this, this._sync, obj); + directionMap.set(obj, dir); + } + + // If both a 'back' and 'fwd' sync have been scheduled on the same object, + // default to a 'fwd' sync so that it remains deterministic. + if (existingDir === 'back' && dir === 'fwd') { + directionMap.set(obj, 'fwd'); + } + }, + + _sync: function(obj) { + var log = Ember.LOG_BINDINGS; + + // don't synchronize destroyed objects or disconnected bindings + if (obj.isDestroyed || !this._readyToSync) { return; } + + // get the direction of the binding for the object we are + // synchronizing from + var directionMap = this._directionMap; + var direction = directionMap.get(obj); + + var fromPath = this._from, toPath = this._to; + + directionMap.remove(obj); + + // if we're synchronizing from the remote object... + if (direction === 'fwd') { + var fromValue = getWithGlobals(obj, this._from); + if (log) { + Ember.Logger.log(' ', this.toString(), '->', fromValue, obj); + } + if (this._oneWay) { + Ember.trySet(obj, toPath, fromValue); + } else { + Ember._suspendObserver(obj, toPath, this, this.toDidChange, function () { + Ember.trySet(obj, toPath, fromValue); + }); + } + // if we're synchronizing *to* the remote object + } else if (direction === 'back') { + var toValue = get(obj, this._to); + if (log) { + Ember.Logger.log(' ', this.toString(), '<-', toValue, obj); + } + Ember._suspendObserver(obj, fromPath, this, this.fromDidChange, function () { + Ember.trySet(Ember.isGlobalPath(fromPath) ? Ember.lookup : obj, fromPath, toValue); + }); + } + } + +}; + +function mixinProperties(to, from) { + for (var key in from) { + if (from.hasOwnProperty(key)) { + to[key] = from[key]; + } + } +} + +mixinProperties(Binding, { + + /** + See {{#crossLink "Ember.Binding/from"}}{{/crossLink}} + + @method from + @static + */ + from: function() { + var C = this, binding = new C(); + return binding.from.apply(binding, arguments); + }, + + /** + See {{#crossLink "Ember.Binding/to"}}{{/crossLink}} + + @method to + @static + */ + to: function() { + var C = this, binding = new C(); + return binding.to.apply(binding, arguments); + }, + + /** + Creates a new Binding instance and makes it apply in a single direction. + A one-way binding will relay changes on the `from` side object (supplied + as the `from` argument) the `to` side, but not the other way around. + This means that if you change the "to" side directly, the "from" side may have + a different value. + + See {{#crossLink "Binding/oneWay"}}{{/crossLink}} + + @method oneWay + @param {String} from from path. + @param {Boolean} [flag] (Optional) passing nothing here will make the + binding `oneWay`. You can instead pass `false` to disable `oneWay`, making the + binding two way again. + */ + oneWay: function(from, flag) { + var C = this, binding = new C(null, from); + return binding.oneWay(flag); + } + +}); + +/** + An `Ember.Binding` connects the properties of two objects so that whenever + the value of one property changes, the other property will be changed also. + + ## Automatic Creation of Bindings with `/^*Binding/`-named Properties + + You do not usually create Binding objects directly but instead describe + bindings in your class or object definition using automatic binding + detection. + + Properties ending in a `Binding` suffix will be converted to `Ember.Binding` + instances. The value of this property should be a string representing a path + to another object or a custom binding instanced created using Binding helpers + (see "Customizing Your Bindings"): + + ``` + valueBinding: "MyApp.someController.title" + ``` + + This will create a binding from `MyApp.someController.title` to the `value` + property of your object instance automatically. Now the two values will be + kept in sync. + + ## One Way Bindings + + One especially useful binding customization you can use is the `oneWay()` + helper. This helper tells Ember that you are only interested in + receiving changes on the object you are binding from. For example, if you + are binding to a preference and you want to be notified if the preference + has changed, but your object will not be changing the preference itself, you + could do: + + ``` + bigTitlesBinding: Ember.Binding.oneWay("MyApp.preferencesController.bigTitles") + ``` + + This way if the value of `MyApp.preferencesController.bigTitles` changes the + `bigTitles` property of your object will change also. However, if you + change the value of your `bigTitles` property, it will not update the + `preferencesController`. + + One way bindings are almost twice as fast to setup and twice as fast to + execute because the binding only has to worry about changes to one side. + + You should consider using one way bindings anytime you have an object that + may be created frequently and you do not intend to change a property; only + to monitor it for changes. (such as in the example above). + + ## Adding Bindings Manually + + All of the examples above show you how to configure a custom binding, but the + result of these customizations will be a binding template, not a fully active + Binding instance. The binding will actually become active only when you + instantiate the object the binding belongs to. It is useful however, to + understand what actually happens when the binding is activated. + + For a binding to function it must have at least a `from` property and a `to` + property. The `from` property path points to the object/key that you want to + bind from while the `to` path points to the object/key you want to bind to. + + When you define a custom binding, you are usually describing the property + you want to bind from (such as `MyApp.someController.value` in the examples + above). When your object is created, it will automatically assign the value + you want to bind `to` based on the name of your binding key. In the + examples above, during init, Ember objects will effectively call + something like this on your binding: + + ```javascript + binding = Ember.Binding.from(this.valueBinding).to("value"); + ``` + + This creates a new binding instance based on the template you provide, and + sets the to path to the `value` property of the new object. Now that the + binding is fully configured with a `from` and a `to`, it simply needs to be + connected to become active. This is done through the `connect()` method: + + ```javascript + binding.connect(this); + ``` + + Note that when you connect a binding you pass the object you want it to be + connected to. This object will be used as the root for both the from and + to side of the binding when inspecting relative paths. This allows the + binding to be automatically inherited by subclassed objects as well. + + Now that the binding is connected, it will observe both the from and to side + and relay changes. + + If you ever needed to do so (you almost never will, but it is useful to + understand this anyway), you could manually create an active binding by + using the `Ember.bind()` helper method. (This is the same method used by + to setup your bindings on objects): + + ```javascript + Ember.bind(MyApp.anotherObject, "value", "MyApp.someController.value"); + ``` + + Both of these code fragments have the same effect as doing the most friendly + form of binding creation like so: + + ```javascript + MyApp.anotherObject = Ember.Object.create({ + valueBinding: "MyApp.someController.value", + + // OTHER CODE FOR THIS OBJECT... + }); + ``` + + Ember's built in binding creation method makes it easy to automatically + create bindings for you. You should always use the highest-level APIs + available, even if you understand how it works underneath. + + @class Binding + @namespace Ember + @since Ember 0.9 +*/ +Ember.Binding = Binding; + + +/** + Global helper method to create a new binding. Just pass the root object + along with a `to` and `from` path to create and connect the binding. + + @method bind + @for Ember + @param {Object} obj The root object of the transform. + @param {String} to The path to the 'to' side of the binding. + Must be relative to obj. + @param {String} from The path to the 'from' side of the binding. + Must be relative to obj or a global path. + @return {Ember.Binding} binding instance +*/ +Ember.bind = function(obj, to, from) { + return new Ember.Binding(to, from).connect(obj); +}; + +/** + @method oneWay + @for Ember + @param {Object} obj The root object of the transform. + @param {String} to The path to the 'to' side of the binding. + Must be relative to obj. + @param {String} from The path to the 'from' side of the binding. + Must be relative to obj or a global path. + @return {Ember.Binding} binding instance +*/ +Ember.oneWay = function(obj, to, from) { + return new Ember.Binding(to, from).oneWay().connect(obj); +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +var Mixin, REQUIRED, Alias, + a_map = Ember.ArrayPolyfills.map, + a_indexOf = Ember.ArrayPolyfills.indexOf, + a_forEach = Ember.ArrayPolyfills.forEach, + a_slice = [].slice, + EMPTY_META = {}, // dummy for non-writable meta + o_create = Ember.create, + defineProperty = Ember.defineProperty, + guidFor = Ember.guidFor; + +function mixinsMeta(obj) { + var m = Ember.meta(obj, true), ret = m.mixins; + if (!ret) { + ret = m.mixins = {}; + } else if (!m.hasOwnProperty('mixins')) { + ret = m.mixins = o_create(ret); + } + return ret; +} + +function initMixin(mixin, args) { + if (args && args.length > 0) { + mixin.mixins = a_map.call(args, function(x) { + if (x instanceof Mixin) { return x; } + + // Note: Manually setup a primitive mixin here. This is the only + // way to actually get a primitive mixin. This way normal creation + // of mixins will give you combined mixins... + var mixin = new Mixin(); + mixin.properties = x; + return mixin; + }); + } + return mixin; +} + +function isMethod(obj) { + return 'function' === typeof obj && + obj.isMethod !== false && + obj !== Boolean && obj !== Object && obj !== Number && obj !== Array && obj !== Date && obj !== String; +} + +var CONTINUE = {}; + +function mixinProperties(mixinsMeta, mixin) { + var guid; + + if (mixin instanceof Mixin) { + guid = guidFor(mixin); + if (mixinsMeta[guid]) { return CONTINUE; } + mixinsMeta[guid] = mixin; + return mixin.properties; + } else { + return mixin; // apply anonymous mixin properties + } +} + +function concatenatedProperties(props, values, base) { + var concats; + + // reset before adding each new mixin to pickup concats from previous + concats = values.concatenatedProperties || base.concatenatedProperties; + if (props.concatenatedProperties) { + concats = concats ? concats.concat(props.concatenatedProperties) : props.concatenatedProperties; + } + + return concats; +} + +function giveDescriptorSuper(meta, key, property, values, descs) { + var superProperty; + + // Computed properties override methods, and do not call super to them + if (values[key] === undefined) { + // Find the original descriptor in a parent mixin + superProperty = descs[key]; + } + + // If we didn't find the original descriptor in a parent mixin, find + // it on the original object. + superProperty = superProperty || meta.descs[key]; + + if (!superProperty || !(superProperty instanceof Ember.ComputedProperty)) { + return property; + } + + // Since multiple mixins may inherit from the same parent, we need + // to clone the computed property so that other mixins do not receive + // the wrapped version. + property = o_create(property); + property.func = Ember.wrap(property.func, superProperty.func); + + return property; +} + +function giveMethodSuper(obj, key, method, values, descs) { + var superMethod; + + // Methods overwrite computed properties, and do not call super to them. + if (descs[key] === undefined) { + // Find the original method in a parent mixin + superMethod = values[key]; + } + + // If we didn't find the original value in a parent mixin, find it in + // the original object + superMethod = superMethod || obj[key]; + + // Only wrap the new method if the original method was a function + if ('function' !== typeof superMethod) { + return method; + } + + return Ember.wrap(method, superMethod); +} + +function applyConcatenatedProperties(obj, key, value, values) { + var baseValue = values[key] || obj[key]; + + if (baseValue) { + if ('function' === typeof baseValue.concat) { + return baseValue.concat(value); + } else { + return Ember.makeArray(baseValue).concat(value); + } + } else { + return Ember.makeArray(value); + } +} + +function addNormalizedProperty(base, key, value, meta, descs, values, concats) { + if (value instanceof Ember.Descriptor) { + if (value === REQUIRED && descs[key]) { return CONTINUE; } + + // Wrap descriptor function to implement + // _super() if needed + if (value.func) { + value = giveDescriptorSuper(meta, key, value, values, descs); + } + + descs[key] = value; + values[key] = undefined; + } else { + // impl super if needed... + if (isMethod(value)) { + value = giveMethodSuper(base, key, value, values, descs); + } else if ((concats && a_indexOf.call(concats, key) >= 0) || key === 'concatenatedProperties') { + value = applyConcatenatedProperties(base, key, value, values); + } + + descs[key] = undefined; + values[key] = value; + } +} + +function mergeMixins(mixins, m, descs, values, base) { + var mixin, props, key, concats, meta; + + function removeKeys(keyName) { + delete descs[keyName]; + delete values[keyName]; + } + + for(var i=0, l=mixins.length; i= 0) { + if (_detect(mixins[loc], targetMixin, seen)) { return true; } + } + return false; +} + +/** + @method detect + @param obj + @return {Boolean} +*/ +MixinPrototype.detect = function(obj) { + if (!obj) { return false; } + if (obj instanceof Mixin) { return _detect(obj, this, {}); } + var mixins = Ember.meta(obj, false).mixins; + if (mixins) { + return !!mixins[guidFor(this)]; + } + return false; +}; + +MixinPrototype.without = function() { + var ret = new Mixin(this); + ret._without = a_slice.call(arguments); + return ret; +}; + +function _keys(ret, mixin, seen) { + if (seen[guidFor(mixin)]) { return; } + seen[guidFor(mixin)] = true; + + if (mixin.properties) { + var props = mixin.properties; + for (var key in props) { + if (props.hasOwnProperty(key)) { ret[key] = true; } + } + } else if (mixin.mixins) { + a_forEach.call(mixin.mixins, function(x) { _keys(ret, x, seen); }); + } +} + +MixinPrototype.keys = function() { + var keys = {}, seen = {}, ret = []; + _keys(keys, this, seen); + for(var key in keys) { + if (keys.hasOwnProperty(key)) { ret.push(key); } + } + return ret; +}; + +// returns the mixins currently applied to the specified object +// TODO: Make Ember.mixin +Mixin.mixins = function(obj) { + var mixins = Ember.meta(obj, false).mixins, ret = []; + + if (!mixins) { return ret; } + + for (var key in mixins) { + var mixin = mixins[key]; + + // skip primitive mixins since these are always anonymous + if (!mixin.properties) { ret.push(mixin); } + } + + return ret; +}; + +REQUIRED = new Ember.Descriptor(); +REQUIRED.toString = function() { return '(Required Property)'; }; + +/** + Denotes a required property for a mixin + + @method required + @for Ember +*/ +Ember.required = function() { + return REQUIRED; +}; + +Alias = function(methodName) { + this.methodName = methodName; +}; +Alias.prototype = new Ember.Descriptor(); + +/** + Makes a property or method available via an additional name. + + ```javascript + App.PaintSample = Ember.Object.extend({ + color: 'red', + colour: Ember.alias('color'), + name: function(){ + return "Zed"; + }, + moniker: Ember.alias("name") + }); + + var paintSample = App.PaintSample.create() + paintSample.get('colour'); // 'red' + paintSample.moniker(); // 'Zed' + ``` + + @method alias + @for Ember + @param {String} methodName name of the method or property to alias + @return {Ember.Descriptor} + @deprecated Use `Ember.aliasMethod` or `Ember.computed.alias` instead +*/ +Ember.alias = function(methodName) { + return new Alias(methodName); +}; + +Ember.deprecateFunc("Ember.alias is deprecated. Please use Ember.aliasMethod or Ember.computed.alias instead.", Ember.alias); + +/** + Makes a method available via an additional name. + + ```javascript + App.Person = Ember.Object.extend({ + name: function(){ + return 'Tomhuda Katzdale'; + }, + moniker: Ember.aliasMethod('name') + }); + + var goodGuy = App.Person.create() + ``` + + @method aliasMethod + @for Ember + @param {String} methodName name of the method to alias + @return {Ember.Descriptor} +*/ +Ember.aliasMethod = function(methodName) { + return new Alias(methodName); +}; + +// .......................................................... +// OBSERVER HELPER +// + +/** + @method observer + @for Ember + @param {Function} func + @param {String} propertyNames* + @return func +*/ +Ember.observer = function(func) { + var paths = a_slice.call(arguments, 1); + func.__ember_observes__ = paths; + return func; +}; + +// If observers ever become asynchronous, Ember.immediateObserver +// must remain synchronous. +/** + @method immediateObserver + @for Ember + @param {Function} func + @param {String} propertyNames* + @return func +*/ +Ember.immediateObserver = function() { + for (var i=0, l=arguments.length; i w. +*/ +Ember.compare = function compare(v, w) { + if (v === w) { return 0; } + + var type1 = Ember.typeOf(v); + var type2 = Ember.typeOf(w); + + var Comparable = Ember.Comparable; + if (Comparable) { + if (type1==='instance' && Comparable.detect(v.constructor)) { + return v.constructor.compare(v, w); + } + + if (type2 === 'instance' && Comparable.detect(w.constructor)) { + return 1-w.constructor.compare(w, v); + } + } + + // If we haven't yet generated a reverse-mapping of Ember.ORDER_DEFINITION, + // do so now. + var mapping = Ember.ORDER_DEFINITION_MAPPING; + if (!mapping) { + var order = Ember.ORDER_DEFINITION; + mapping = Ember.ORDER_DEFINITION_MAPPING = {}; + var idx, len; + for (idx = 0, len = order.length; idx < len; ++idx) { + mapping[order[idx]] = idx; + } + + // We no longer need Ember.ORDER_DEFINITION. + delete Ember.ORDER_DEFINITION; + } + + var type1Index = mapping[type1]; + var type2Index = mapping[type2]; + + if (type1Index < type2Index) { return -1; } + if (type1Index > type2Index) { return 1; } + + // types are equal - so we have to check values now + switch (type1) { + case 'boolean': + case 'number': + if (v < w) { return -1; } + if (v > w) { return 1; } + return 0; + + case 'string': + var comp = v.localeCompare(w); + if (comp < 0) { return -1; } + if (comp > 0) { return 1; } + return 0; + + case 'array': + var vLen = v.length; + var wLen = w.length; + var l = Math.min(vLen, wLen); + var r = 0; + var i = 0; + while (r === 0 && i < l) { + r = compare(v[i],w[i]); + i++; + } + if (r !== 0) { return r; } + + // all elements are equal now + // shorter array should be ordered first + if (vLen < wLen) { return -1; } + if (vLen > wLen) { return 1; } + // arrays are equal now + return 0; + + case 'instance': + if (Ember.Comparable && Ember.Comparable.detect(v)) { + return v.compare(v, w); + } + return 0; + + case 'date': + var vNum = v.getTime(); + var wNum = w.getTime(); + if (vNum < wNum) { return -1; } + if (vNum > wNum) { return 1; } + return 0; + + default: + return 0; + } +}; + +function _copy(obj, deep, seen, copies) { + var ret, loc, key; + + // primitive data types are immutable, just return them. + if ('object' !== typeof obj || obj===null) return obj; + + // avoid cyclical loops + if (deep && (loc=indexOf(seen, obj))>=0) return copies[loc]; + + Ember.assert('Cannot clone an Ember.Object that does not implement Ember.Copyable', !(obj instanceof Ember.Object) || (Ember.Copyable && Ember.Copyable.detect(obj))); + + // IMPORTANT: this specific test will detect a native array only. Any other + // object will need to implement Copyable. + if (Ember.typeOf(obj) === 'array') { + ret = obj.slice(); + if (deep) { + loc = ret.length; + while(--loc>=0) ret[loc] = _copy(ret[loc], deep, seen, copies); + } + } else if (Ember.Copyable && Ember.Copyable.detect(obj)) { + ret = obj.copy(deep, seen, copies); + } else { + ret = {}; + for(key in obj) { + if (!obj.hasOwnProperty(key)) continue; + + // Prevents browsers that don't respect non-enumerability from + // copying internal Ember properties + if (key.substring(0,2) === '__') continue; + + ret[key] = deep ? _copy(obj[key], deep, seen, copies) : obj[key]; + } + } + + if (deep) { + seen.push(obj); + copies.push(ret); + } + + return ret; +} + +/** + Creates a clone of the passed object. This function can take just about + any type of object and create a clone of it, including primitive values + (which are not actually cloned because they are immutable). + + If the passed object implements the `clone()` method, then this function + will simply call that method and return the result. + + @method copy + @for Ember + @param {Object} object The object to clone + @param {Boolean} deep If true, a deep copy of the object is made + @return {Object} The cloned object +*/ +Ember.copy = function(obj, deep) { + // fast paths + if ('object' !== typeof obj || obj===null) return obj; // can't copy primitives + if (Ember.Copyable && Ember.Copyable.detect(obj)) return obj.copy(deep); + return _copy(obj, deep, deep ? [] : null, deep ? [] : null); +}; + +/** + Convenience method to inspect an object. This method will attempt to + convert the object into a useful string description. + + It is a pretty simple implementation. If you want something more robust, + use something like JSDump: https://github.com/NV/jsDump + + @method inspect + @for Ember + @param {Object} obj The object you want to inspect. + @return {String} A description of the object +*/ +Ember.inspect = function(obj) { + if (typeof obj !== 'object' || obj === null) { + return obj + ''; + } + + var v, ret = []; + for(var key in obj) { + if (obj.hasOwnProperty(key)) { + v = obj[key]; + if (v === 'toString') { continue; } // ignore useless items + if (Ember.typeOf(v) === 'function') { v = "function() { ... }"; } + ret.push(key + ": " + v); + } + } + return "{" + ret.join(", ") + "}"; +}; + +/** + Compares two objects, returning true if they are logically equal. This is + a deeper comparison than a simple triple equal. For sets it will compare the + internal objects. For any other object that implements `isEqual()` it will + respect that method. + + ```javascript + Ember.isEqual('hello', 'hello'); // true + Ember.isEqual(1, 2); // false + Ember.isEqual([4,2], [4,2]); // false + ``` + + @method isEqual + @for Ember + @param {Object} a first object to compare + @param {Object} b second object to compare + @return {Boolean} +*/ +Ember.isEqual = function(a, b) { + if (a && 'function'===typeof a.isEqual) return a.isEqual(b); + return a === b; +}; + +// Used by Ember.compare +Ember.ORDER_DEFINITION = Ember.ENV.ORDER_DEFINITION || [ + 'undefined', + 'null', + 'boolean', + 'number', + 'string', + 'array', + 'object', + 'instance', + 'function', + 'class', + 'date' +]; + +/** + Returns all of the keys defined on an object or hash. This is useful + when inspecting objects for debugging. On browsers that support it, this + uses the native `Object.keys` implementation. + + @method keys + @for Ember + @param {Object} obj + @return {Array} Array containing keys of obj +*/ +Ember.keys = Object.keys; + +if (!Ember.keys) { + Ember.keys = function(obj) { + var ret = []; + for(var key in obj) { + if (obj.hasOwnProperty(key)) { ret.push(key); } + } + return ret; + }; +} + +// .......................................................... +// ERROR +// + +var errorProps = ['description', 'fileName', 'lineNumber', 'message', 'name', 'number', 'stack']; + +/** + A subclass of the JavaScript Error object for use in Ember. + + @class Error + @namespace Ember + @extends Error + @constructor +*/ +Ember.Error = function() { + var tmp = Error.prototype.constructor.apply(this, arguments); + + // Unfortunately errors are not enumerable in Chrome (at least), so `for prop in tmp` doesn't work. + for (var idx = 0; idx < errorProps.length; idx++) { + this[errorProps[idx]] = tmp[errorProps[idx]]; + } +}; + +Ember.Error.prototype = Ember.create(Error.prototype); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var STRING_DASHERIZE_REGEXP = (/[ _]/g); +var STRING_DASHERIZE_CACHE = {}; +var STRING_DECAMELIZE_REGEXP = (/([a-z])([A-Z])/g); +var STRING_CAMELIZE_REGEXP = (/(\-|_|\.|\s)+(.)?/g); +var STRING_UNDERSCORE_REGEXP_1 = (/([a-z\d])([A-Z]+)/g); +var STRING_UNDERSCORE_REGEXP_2 = (/\-|\s+/g); + +/** + Defines the hash of localized strings for the current language. Used by + the `Ember.String.loc()` helper. To localize, add string values to this + hash. + + @property STRINGS + @for Ember + @type Hash +*/ +Ember.STRINGS = {}; + +/** + Defines string helper methods including string formatting and localization. + Unless `Ember.EXTEND_PROTOTYPES.String` is `false` these methods will also be + added to the `String.prototype` as well. + + @class String + @namespace Ember + @static +*/ +Ember.String = { + + /** + Apply formatting options to the string. This will look for occurrences + of "%@" in your string and substitute them with the arguments you pass into + this method. If you want to control the specific order of replacement, + you can add a number after the key as well to indicate which argument + you want to insert. + + Ordered insertions are most useful when building loc strings where values + you need to insert may appear in different orders. + + ```javascript + "Hello %@ %@".fmt('John', 'Doe'); // "Hello John Doe" + "Hello %@2, %@1".fmt('John', 'Doe'); // "Hello Doe, John" + ``` + + @method fmt + @param {Object...} [args] + @return {String} formatted string + */ + fmt: function(str, formats) { + // first, replace any ORDERED replacements. + var idx = 0; // the current index for non-numerical replacements + return str.replace(/%@([0-9]+)?/g, function(s, argIndex) { + argIndex = (argIndex) ? parseInt(argIndex,0) - 1 : idx++ ; + s = formats[argIndex]; + return ((s === null) ? '(null)' : (s === undefined) ? '' : s).toString(); + }) ; + }, + + /** + Formats the passed string, but first looks up the string in the localized + strings hash. This is a convenient way to localize text. See + `Ember.String.fmt()` for more information on formatting. + + Note that it is traditional but not required to prefix localized string + keys with an underscore or other character so you can easily identify + localized strings. + + ```javascript + Ember.STRINGS = { + '_Hello World': 'Bonjour le monde', + '_Hello %@ %@': 'Bonjour %@ %@' + }; + + Ember.String.loc("_Hello World"); // 'Bonjour le monde'; + Ember.String.loc("_Hello %@ %@", ["John", "Smith"]); // "Bonjour John Smith"; + ``` + + @method loc + @param {String} str The string to format + @param {Array} formats Optional array of parameters to interpolate into string. + @return {String} formatted string + */ + loc: function(str, formats) { + str = Ember.STRINGS[str] || str; + return Ember.String.fmt(str, formats) ; + }, + + /** + Splits a string into separate units separated by spaces, eliminating any + empty strings in the process. This is a convenience method for split that + is mostly useful when applied to the `String.prototype`. + + ```javascript + Ember.String.w("alpha beta gamma").forEach(function(key) { + console.log(key); + }); + + // > alpha + // > beta + // > gamma + ``` + + @method w + @param {String} str The string to split + @return {String} split string + */ + w: function(str) { return str.split(/\s+/); }, + + /** + Converts a camelized string into all lower case separated by underscores. + + ```javascript + 'innerHTML'.decamelize(); // 'inner_html' + 'action_name'.decamelize(); // 'action_name' + 'css-class-name'.decamelize(); // 'css-class-name' + 'my favorite items'.decamelize(); // 'my favorite items' + ``` + + @method decamelize + @param {String} str The string to decamelize. + @return {String} the decamelized string. + */ + decamelize: function(str) { + return str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase(); + }, + + /** + Replaces underscores or spaces with dashes. + + ```javascript + 'innerHTML'.dasherize(); // 'inner-html' + 'action_name'.dasherize(); // 'action-name' + 'css-class-name'.dasherize(); // 'css-class-name' + 'my favorite items'.dasherize(); // 'my-favorite-items' + ``` + + @method dasherize + @param {String} str The string to dasherize. + @return {String} the dasherized string. + */ + dasherize: function(str) { + var cache = STRING_DASHERIZE_CACHE, + ret = cache[str]; + + if (ret) { + return ret; + } else { + ret = Ember.String.decamelize(str).replace(STRING_DASHERIZE_REGEXP,'-'); + cache[str] = ret; + } + + return ret; + }, + + /** + Returns the lowerCaseCamel form of a string. + + ```javascript + 'innerHTML'.camelize(); // 'innerHTML' + 'action_name'.camelize(); // 'actionName' + 'css-class-name'.camelize(); // 'cssClassName' + 'my favorite items'.camelize(); // 'myFavoriteItems' + ``` + + @method camelize + @param {String} str The string to camelize. + @return {String} the camelized string. + */ + camelize: function(str) { + return str.replace(STRING_CAMELIZE_REGEXP, function(match, separator, chr) { + return chr ? chr.toUpperCase() : ''; + }); + }, + + /** + Returns the UpperCamelCase form of a string. + + ```javascript + 'innerHTML'.classify(); // 'InnerHTML' + 'action_name'.classify(); // 'ActionName' + 'css-class-name'.classify(); // 'CssClassName' + 'my favorite items'.classify(); // 'MyFavoriteItems' + ``` + + @method classify + @param {String} str the string to classify + @return {String} the classified string + */ + classify: function(str) { + var parts = str.split("."), + out = []; + + for (var i=0, l=parts.length; i 'InnerHTML' + 'action_name'.capitalize() => 'Action_name' + 'css-class-name'.capitalize() => 'Css-class-name' + 'my favorite items'.capitalize() => 'My favorite items' + + @method capitalize + @param {String} str + @return {String} + */ + capitalize: function(str) { + return str.charAt(0).toUpperCase() + str.substr(1); + } + +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + + +var fmt = Ember.String.fmt, + w = Ember.String.w, + loc = Ember.String.loc, + camelize = Ember.String.camelize, + decamelize = Ember.String.decamelize, + dasherize = Ember.String.dasherize, + underscore = Ember.String.underscore, + capitalize = Ember.String.capitalize, + classify = Ember.String.classify; + +if (Ember.EXTEND_PROTOTYPES === true || Ember.EXTEND_PROTOTYPES.String) { + + /** + See {{#crossLink "Ember.String/fmt"}}{{/crossLink}} + + @method fmt + @for String + */ + String.prototype.fmt = function() { + return fmt(this, arguments); + }; + + /** + See {{#crossLink "Ember.String/w"}}{{/crossLink}} + + @method w + @for String + */ + String.prototype.w = function() { + return w(this); + }; + + /** + See {{#crossLink "Ember.String/loc"}}{{/crossLink}} + + @method loc + @for String + */ + String.prototype.loc = function() { + return loc(this, arguments); + }; + + /** + See {{#crossLink "Ember.String/camelize"}}{{/crossLink}} + + @method camelize + @for String + */ + String.prototype.camelize = function() { + return camelize(this); + }; + + /** + See {{#crossLink "Ember.String/decamelize"}}{{/crossLink}} + + @method decamelize + @for String + */ + String.prototype.decamelize = function() { + return decamelize(this); + }; + + /** + See {{#crossLink "Ember.String/dasherize"}}{{/crossLink}} + + @method dasherize + @for String + */ + String.prototype.dasherize = function() { + return dasherize(this); + }; + + /** + See {{#crossLink "Ember.String/underscore"}}{{/crossLink}} + + @method underscore + @for String + */ + String.prototype.underscore = function() { + return underscore(this); + }; + + /** + See {{#crossLink "Ember.String/classify"}}{{/crossLink}} + + @method classify + @for String + */ + String.prototype.classify = function() { + return classify(this); + }; + + /** + See {{#crossLink "Ember.String/capitalize"}}{{/crossLink}} + + @method capitalize + @for String + */ + String.prototype.capitalize = function() { + return capitalize(this); + }; + +} + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var a_slice = Array.prototype.slice; + +if (Ember.EXTEND_PROTOTYPES === true || Ember.EXTEND_PROTOTYPES.Function) { + + /** + The `property` extension of Javascript's Function prototype is available + when `Ember.EXTEND_PROTOTYPES` or `Ember.EXTEND_PROTOTYPES.Function` is + `true`, which is the default. + + Computed properties allow you to treat a function like a property: + + ```javascript + MyApp.president = Ember.Object.create({ + firstName: "Barack", + lastName: "Obama", + + fullName: function() { + return this.get('firstName') + ' ' + this.get('lastName'); + + // Call this flag to mark the function as a property + }.property() + }); + + MyApp.president.get('fullName'); // "Barack Obama" + ``` + + Treating a function like a property is useful because they can work with + bindings, just like any other property. + + Many computed properties have dependencies on other properties. For + example, in the above example, the `fullName` property depends on + `firstName` and `lastName` to determine its value. You can tell Ember + about these dependencies like this: + + ```javascript + MyApp.president = Ember.Object.create({ + firstName: "Barack", + lastName: "Obama", + + fullName: function() { + return this.get('firstName') + ' ' + this.get('lastName'); + + // Tell Ember.js that this computed property depends on firstName + // and lastName + }.property('firstName', 'lastName') + }); + ``` + + Make sure you list these dependencies so Ember knows when to update + bindings that connect to a computed property. Changing a dependency + will not immediately trigger an update of the computed property, but + will instead clear the cache so that it is updated when the next `get` + is called on the property. + + See {{#crossLink "Ember.ComputedProperty"}}{{/crossLink}}, + {{#crossLink "Ember/computed"}}{{/crossLink}} + + @method property + @for Function + */ + Function.prototype.property = function() { + var ret = Ember.computed(this); + return ret.property.apply(ret, arguments); + }; + + /** + The `observes` extension of Javascript's Function prototype is available + when `Ember.EXTEND_PROTOTYPES` or `Ember.EXTEND_PROTOTYPES.Function` is + true, which is the default. + + You can observe property changes simply by adding the `observes` + call to the end of your method declarations in classes that you write. + For example: + + ```javascript + Ember.Object.create({ + valueObserver: function() { + // Executes whenever the "value" property changes + }.observes('value') + }); + ``` + + See {{#crossLink "Ember.Observable/observes"}}{{/crossLink}} + + @method observes + @for Function + */ + Function.prototype.observes = function() { + this.__ember_observes__ = a_slice.call(arguments); + return this; + }; + + /** + The `observesBefore` extension of Javascript's Function prototype is + available when `Ember.EXTEND_PROTOTYPES` or + `Ember.EXTEND_PROTOTYPES.Function` is true, which is the default. + + You can get notified when a property changes is about to happen by + by adding the `observesBefore` call to the end of your method + declarations in classes that you write. For example: + + ```javascript + Ember.Object.create({ + valueObserver: function() { + // Executes whenever the "value" property is about to change + }.observesBefore('value') + }); + ``` + + See {{#crossLink "Ember.Observable/observesBefore"}}{{/crossLink}} + + @method observesBefore + @for Function + */ + Function.prototype.observesBefore = function() { + this.__ember_observesBefore__ = a_slice.call(arguments); + return this; + }; + +} + + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +// .......................................................... +// HELPERS +// + +var get = Ember.get, set = Ember.set; +var a_slice = Array.prototype.slice; +var a_indexOf = Ember.EnumerableUtils.indexOf; + +var contexts = []; + +function popCtx() { + return contexts.length===0 ? {} : contexts.pop(); +} + +function pushCtx(ctx) { + contexts.push(ctx); + return null; +} + +function iter(key, value) { + var valueProvided = arguments.length === 2; + + function i(item) { + var cur = get(item, key); + return valueProvided ? value===cur : !!cur; + } + return i ; +} + +/** + This mixin defines the common interface implemented by enumerable objects + in Ember. Most of these methods follow the standard Array iteration + API defined up to JavaScript 1.8 (excluding language-specific features that + cannot be emulated in older versions of JavaScript). + + This mixin is applied automatically to the Array class on page load, so you + can use any of these methods on simple arrays. If Array already implements + one of these methods, the mixin will not override them. + + ## Writing Your Own Enumerable + + To make your own custom class enumerable, you need two items: + + 1. You must have a length property. This property should change whenever + the number of items in your enumerable object changes. If you using this + with an `Ember.Object` subclass, you should be sure to change the length + property using `set().` + + 2. If you must implement `nextObject().` See documentation. + + Once you have these two methods implement, apply the `Ember.Enumerable` mixin + to your class and you will be able to enumerate the contents of your object + like any other collection. + + ## Using Ember Enumeration with Other Libraries + + Many other libraries provide some kind of iterator or enumeration like + facility. This is often where the most common API conflicts occur. + Ember's API is designed to be as friendly as possible with other + libraries by implementing only methods that mostly correspond to the + JavaScript 1.8 API. + + @class Enumerable + @namespace Ember + @extends Ember.Mixin + @since Ember 0.9 +*/ +Ember.Enumerable = Ember.Mixin.create( + /** @scope Ember.Enumerable.prototype */ { + + // compatibility + isEnumerable: true, + + /** + Implement this method to make your class enumerable. + + This method will be call repeatedly during enumeration. The index value + will always begin with 0 and increment monotonically. You don't have to + rely on the index value to determine what object to return, but you should + always check the value and start from the beginning when you see the + requested index is 0. + + The `previousObject` is the object that was returned from the last call + to `nextObject` for the current iteration. This is a useful way to + manage iteration if you are tracing a linked list, for example. + + Finally the context parameter will always contain a hash you can use as + a "scratchpad" to maintain any other state you need in order to iterate + properly. The context object is reused and is not reset between + iterations so make sure you setup the context with a fresh state whenever + the index parameter is 0. + + Generally iterators will continue to call `nextObject` until the index + reaches the your current length-1. If you run out of data before this + time for some reason, you should simply return undefined. + + The default implementation of this method simply looks up the index. + This works great on any Array-like objects. + + @method nextObject + @param {Number} index the current index of the iteration + @param {Object} previousObject the value returned by the last call to + `nextObject`. + @param {Object} context a context object you can use to maintain state. + @return {Object} the next object in the iteration or undefined + */ + nextObject: Ember.required(Function), + + /** + Helper method returns the first object from a collection. This is usually + used by bindings and other parts of the framework to extract a single + object if the enumerable contains only one item. + + If you override this method, you should implement it so that it will + always return the same value each time it is called. If your enumerable + contains only one object, this method should always return that object. + If your enumerable is empty, this method should return `undefined`. + + ```javascript + var arr = ["a", "b", "c"]; + arr.firstObject(); // "a" + + var arr = []; + arr.firstObject(); // undefined + ``` + + @property firstObject + @return {Object} the object or undefined + */ + firstObject: Ember.computed(function() { + if (get(this, 'length')===0) return undefined ; + + // handle generic enumerables + var context = popCtx(), ret; + ret = this.nextObject(0, null, context); + pushCtx(context); + return ret ; + }).property('[]'), + + /** + Helper method returns the last object from a collection. If your enumerable + contains only one object, this method should always return that object. + If your enumerable is empty, this method should return `undefined`. + + ```javascript + var arr = ["a", "b", "c"]; + arr.lastObject(); // "c" + + var arr = []; + arr.lastObject(); // undefined + ``` + + @property lastObject + @return {Object} the last object or undefined + */ + lastObject: Ember.computed(function() { + var len = get(this, 'length'); + if (len===0) return undefined ; + var context = popCtx(), idx=0, cur, last = null; + do { + last = cur; + cur = this.nextObject(idx++, last, context); + } while (cur !== undefined); + pushCtx(context); + return last; + }).property('[]'), + + /** + Returns `true` if the passed object can be found in the receiver. The + default version will iterate through the enumerable until the object + is found. You may want to override this with a more efficient version. + + ```javascript + var arr = ["a", "b", "c"]; + arr.contains("a"); // true + arr.contains("z"); // false + ``` + + @method contains + @param {Object} obj The object to search for. + @return {Boolean} `true` if object is found in enumerable. + */ + contains: function(obj) { + return this.find(function(item) { return item===obj; }) !== undefined; + }, + + /** + Iterates through the enumerable, calling the passed function on each + item. This method corresponds to the `forEach()` method defined in + JavaScript 1.6. + + The callback method you provide should have the following signature (all + parameters are optional): + + ```javascript + function(item, index, enumerable); + ``` + + - `item` is the current item in the iteration. + - `index` is the current index in the iteration. + - `enumerable` is the enumerable object itself. + + Note that in addition to a callback, you can also pass an optional target + object that will be set as `this` on the context. This is a good way + to give your iterator function access to the current object. + + @method forEach + @param {Function} callback The callback to execute + @param {Object} [target] The target object to use + @return {Object} receiver + */ + forEach: function(callback, target) { + if (typeof callback !== "function") throw new TypeError() ; + var len = get(this, 'length'), last = null, context = popCtx(); + + if (target === undefined) target = null; + + for(var idx=0;idx1) args = a_slice.call(arguments, 1); + + this.forEach(function(x, idx) { + var method = x && x[methodName]; + if ('function' === typeof method) { + ret[idx] = args ? method.apply(x, args) : method.call(x); + } + }, this); + + return ret; + }, + + /** + Simply converts the enumerable into a genuine array. The order is not + guaranteed. Corresponds to the method implemented by Prototype. + + @method toArray + @return {Array} the enumerable as an array. + */ + toArray: function() { + var ret = []; + this.forEach(function(o, idx) { ret[idx] = o; }); + return ret ; + }, + + /** + Returns a copy of the array with all null elements removed. + + ```javascript + var arr = ["a", null, "c", null]; + arr.compact(); // ["a", "c"] + ``` + + @method compact + @return {Array} the array without null elements. + */ + compact: function() { return this.without(null); }, + + /** + Returns a new enumerable that excludes the passed value. The default + implementation returns an array regardless of the receiver type unless + the receiver does not contain the value. + + ```javascript + var arr = ["a", "b", "a", "c"]; + arr.without("a"); // ["b", "c"] + ``` + + @method without + @param {Object} value + @return {Ember.Enumerable} + */ + without: function(value) { + if (!this.contains(value)) return this; // nothing to do + var ret = [] ; + this.forEach(function(k) { + if (k !== value) ret[ret.length] = k; + }) ; + return ret ; + }, + + /** + Returns a new enumerable that contains only unique values. The default + implementation returns an array regardless of the receiver type. + + ```javascript + var arr = ["a", "a", "b", "b"]; + arr.uniq(); // ["a", "b"] + ``` + + @method uniq + @return {Ember.Enumerable} + */ + uniq: function() { + var ret = []; + this.forEach(function(k){ + if (a_indexOf(ret, k)<0) ret.push(k); + }); + return ret; + }, + + /** + This property will trigger anytime the enumerable's content changes. + You can observe this property to be notified of changes to the enumerables + content. + + For plain enumerables, this property is read only. `Ember.Array` overrides + this method. + + @property [] + @type Ember.Array + */ + '[]': Ember.computed(function(key, value) { + return this; + }), + + // .......................................................... + // ENUMERABLE OBSERVERS + // + + /** + Registers an enumerable observer. Must implement `Ember.EnumerableObserver` + mixin. + + @method addEnumerableObserver + @param target {Object} + @param opts {Hash} + */ + addEnumerableObserver: function(target, opts) { + var willChange = (opts && opts.willChange) || 'enumerableWillChange', + didChange = (opts && opts.didChange) || 'enumerableDidChange'; + + var hasObservers = get(this, 'hasEnumerableObservers'); + if (!hasObservers) Ember.propertyWillChange(this, 'hasEnumerableObservers'); + Ember.addListener(this, '@enumerable:before', target, willChange); + Ember.addListener(this, '@enumerable:change', target, didChange); + if (!hasObservers) Ember.propertyDidChange(this, 'hasEnumerableObservers'); + return this; + }, + + /** + Removes a registered enumerable observer. + + @method removeEnumerableObserver + @param target {Object} + @param [opts] {Hash} + */ + removeEnumerableObserver: function(target, opts) { + var willChange = (opts && opts.willChange) || 'enumerableWillChange', + didChange = (opts && opts.didChange) || 'enumerableDidChange'; + + var hasObservers = get(this, 'hasEnumerableObservers'); + if (hasObservers) Ember.propertyWillChange(this, 'hasEnumerableObservers'); + Ember.removeListener(this, '@enumerable:before', target, willChange); + Ember.removeListener(this, '@enumerable:change', target, didChange); + if (hasObservers) Ember.propertyDidChange(this, 'hasEnumerableObservers'); + return this; + }, + + /** + Becomes true whenever the array currently has observers watching changes + on the array. + + @property hasEnumerableObservers + @type Boolean + */ + hasEnumerableObservers: Ember.computed(function() { + return Ember.hasListeners(this, '@enumerable:change') || Ember.hasListeners(this, '@enumerable:before'); + }), + + + /** + Invoke this method just before the contents of your enumerable will + change. You can either omit the parameters completely or pass the objects + to be removed or added if available or just a count. + + @method enumerableContentWillChange + @param {Ember.Enumerable|Number} removing An enumerable of the objects to + be removed or the number of items to be removed. + @param {Ember.Enumerable|Number} adding An enumerable of the objects to be + added or the number of items to be added. + @chainable + */ + enumerableContentWillChange: function(removing, adding) { + + var removeCnt, addCnt, hasDelta; + + if ('number' === typeof removing) removeCnt = removing; + else if (removing) removeCnt = get(removing, 'length'); + else removeCnt = removing = -1; + + if ('number' === typeof adding) addCnt = adding; + else if (adding) addCnt = get(adding,'length'); + else addCnt = adding = -1; + + hasDelta = addCnt<0 || removeCnt<0 || addCnt-removeCnt!==0; + + if (removing === -1) removing = null; + if (adding === -1) adding = null; + + Ember.propertyWillChange(this, '[]'); + if (hasDelta) Ember.propertyWillChange(this, 'length'); + Ember.sendEvent(this, '@enumerable:before', [this, removing, adding]); + + return this; + }, + + /** + Invoke this method when the contents of your enumerable has changed. + This will notify any observers watching for content changes. If your are + implementing an ordered enumerable (such as an array), also pass the + start and end values where the content changed so that it can be used to + notify range observers. + + @method enumerableContentDidChange + @param {Number} [start] optional start offset for the content change. + For unordered enumerables, you should always pass -1. + @param {Ember.Enumerable|Number} removing An enumerable of the objects to + be removed or the number of items to be removed. + @param {Ember.Enumerable|Number} adding An enumerable of the objects to + be added or the number of items to be added. + @chainable + */ + enumerableContentDidChange: function(removing, adding) { + var notify = this.propertyDidChange, removeCnt, addCnt, hasDelta; + + if ('number' === typeof removing) removeCnt = removing; + else if (removing) removeCnt = get(removing, 'length'); + else removeCnt = removing = -1; + + if ('number' === typeof adding) addCnt = adding; + else if (adding) addCnt = get(adding, 'length'); + else addCnt = adding = -1; + + hasDelta = addCnt<0 || removeCnt<0 || addCnt-removeCnt!==0; + + if (removing === -1) removing = null; + if (adding === -1) adding = null; + + Ember.sendEvent(this, '@enumerable:change', [this, removing, adding]); + if (hasDelta) Ember.propertyDidChange(this, 'length'); + Ember.propertyDidChange(this, '[]'); + + return this ; + } + +}) ; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +// .......................................................... +// HELPERS +// + +var get = Ember.get, set = Ember.set, meta = Ember.meta, map = Ember.EnumerableUtils.map, cacheFor = Ember.cacheFor; + +function none(obj) { return obj===null || obj===undefined; } + +// .......................................................... +// ARRAY +// +/** + This module implements Observer-friendly Array-like behavior. This mixin is + picked up by the Array class as well as other controllers, etc. that want to + appear to be arrays. + + Unlike `Ember.Enumerable,` this mixin defines methods specifically for + collections that provide index-ordered access to their contents. When you + are designing code that needs to accept any kind of Array-like object, you + should use these methods instead of Array primitives because these will + properly notify observers of changes to the array. + + Although these methods are efficient, they do add a layer of indirection to + your application so it is a good idea to use them only when you need the + flexibility of using both true JavaScript arrays and "virtual" arrays such + as controllers and collections. + + You can use the methods defined in this module to access and modify array + contents in a KVO-friendly way. You can also be notified whenever the + membership if an array changes by changing the syntax of the property to + `.observes('*myProperty.[]')`. + + To support `Ember.Array` in your own class, you must override two + primitives to use it: `replace()` and `objectAt()`. + + Note that the Ember.Array mixin also incorporates the `Ember.Enumerable` + mixin. All `Ember.Array`-like objects are also enumerable. + + @class Array + @namespace Ember + @extends Ember.Mixin + @uses Ember.Enumerable + @since Ember 0.9.0 +*/ +Ember.Array = Ember.Mixin.create(Ember.Enumerable, /** @scope Ember.Array.prototype */ { + + // compatibility + isSCArray: true, + + /** + Your array must support the `length` property. Your replace methods should + set this property whenever it changes. + + @property {Number} length + */ + length: Ember.required(), + + /** + Returns the object at the given `index`. If the given `index` is negative + or is greater or equal than the array length, returns `undefined`. + + This is one of the primitives you must implement to support `Ember.Array`. + If your object supports retrieving the value of an array item using `get()` + (i.e. `myArray.get(0)`), then you do not need to implement this method + yourself. + + ```javascript + var arr = ['a', 'b', 'c', 'd']; + arr.objectAt(0); // "a" + arr.objectAt(3); // "d" + arr.objectAt(-1); // undefined + arr.objectAt(4); // undefined + arr.objectAt(5); // undefined + ``` + + @method objectAt + @param {Number} idx The index of the item to return. + */ + objectAt: function(idx) { + if ((idx < 0) || (idx>=get(this, 'length'))) return undefined ; + return get(this, idx); + }, + + /** + This returns the objects at the specified indexes, using `objectAt`. + + ```javascript + var arr = ['a', 'b', 'c', 'd']; + arr.objectsAt([0, 1, 2]); // ["a", "b", "c"] + arr.objectsAt([2, 3, 4]); // ["c", "d", undefined] + ``` + + @method objectsAt + @param {Array} indexes An array of indexes of items to return. + */ + objectsAt: function(indexes) { + var self = this; + return map(indexes, function(idx){ return self.objectAt(idx); }); + }, + + // overrides Ember.Enumerable version + nextObject: function(idx) { + return this.objectAt(idx); + }, + + /** + This is the handler for the special array content property. If you get + this property, it will return this. If you set this property it a new + array, it will replace the current content. + + This property overrides the default property defined in `Ember.Enumerable`. + + @property [] + */ + '[]': Ember.computed(function(key, value) { + if (value !== undefined) this.replace(0, get(this, 'length'), value) ; + return this ; + }), + + firstObject: Ember.computed(function() { + return this.objectAt(0); + }), + + lastObject: Ember.computed(function() { + return this.objectAt(get(this, 'length')-1); + }), + + // optimized version from Enumerable + contains: function(obj){ + return this.indexOf(obj) >= 0; + }, + + // Add any extra methods to Ember.Array that are native to the built-in Array. + /** + Returns a new array that is a slice of the receiver. This implementation + uses the observable array methods to retrieve the objects for the new + slice. + + ```javascript + var arr = ['red', 'green', 'blue']; + arr.slice(0); // ['red', 'green', 'blue'] + arr.slice(0, 2); // ['red', 'green'] + arr.slice(1, 100); // ['green', 'blue'] + ``` + + @method slice + @param beginIndex {Integer} (Optional) index to begin slicing from. + @param endIndex {Integer} (Optional) index to end the slice at. + @return {Array} New array with specified slice + */ + slice: function(beginIndex, endIndex) { + var ret = []; + var length = get(this, 'length') ; + if (none(beginIndex)) beginIndex = 0 ; + if (none(endIndex) || (endIndex > length)) endIndex = length ; + while(beginIndex < endIndex) { + ret[ret.length] = this.objectAt(beginIndex++) ; + } + return ret ; + }, + + /** + Returns the index of the given object's first occurrence. + If no `startAt` argument is given, the starting location to + search is 0. If it's negative, will count backward from + the end of the array. Returns -1 if no match is found. + + ```javascript + var arr = ["a", "b", "c", "d", "a"]; + arr.indexOf("a"); // 0 + arr.indexOf("z"); // -1 + arr.indexOf("a", 2); // 4 + arr.indexOf("a", -1); // 4 + arr.indexOf("b", 3); // -1 + arr.indexOf("a", 100); // -1 + ``` + + @method indexOf + @param {Object} object the item to search for + @param {Number} startAt optional starting location to search, default 0 + @return {Number} index or -1 if not found + */ + indexOf: function(object, startAt) { + var idx, len = get(this, 'length'); + + if (startAt === undefined) startAt = 0; + if (startAt < 0) startAt += len; + + for(idx=startAt;idx= len) startAt = len-1; + if (startAt < 0) startAt += len; + + for(idx=startAt;idx>=0;idx--) { + if (this.objectAt(idx) === object) return idx ; + } + return -1; + }, + + // .......................................................... + // ARRAY OBSERVERS + // + + /** + Adds an array observer to the receiving array. The array observer object + normally must implement two methods: + + * `arrayWillChange(start, removeCount, addCount)` - This method will be + called just before the array is modified. + * `arrayDidChange(start, removeCount, addCount)` - This method will be + called just after the array is modified. + + Both callbacks will be passed the starting index of the change as well a + a count of the items to be removed and added. You can use these callbacks + to optionally inspect the array during the change, clear caches, or do + any other bookkeeping necessary. + + In addition to passing a target, you can also include an options hash + which you can use to override the method names that will be invoked on the + target. + + @method addArrayObserver + @param {Object} target The observer object. + @param {Hash} opts Optional hash of configuration options including + `willChange`, `didChange`, and a `context` option. + @return {Ember.Array} receiver + */ + addArrayObserver: function(target, opts) { + var willChange = (opts && opts.willChange) || 'arrayWillChange', + didChange = (opts && opts.didChange) || 'arrayDidChange'; + + var hasObservers = get(this, 'hasArrayObservers'); + if (!hasObservers) Ember.propertyWillChange(this, 'hasArrayObservers'); + Ember.addListener(this, '@array:before', target, willChange); + Ember.addListener(this, '@array:change', target, didChange); + if (!hasObservers) Ember.propertyDidChange(this, 'hasArrayObservers'); + return this; + }, + + /** + Removes an array observer from the object if the observer is current + registered. Calling this method multiple times with the same object will + have no effect. + + @method removeArrayObserver + @param {Object} target The object observing the array. + @return {Ember.Array} receiver + */ + removeArrayObserver: function(target, opts) { + var willChange = (opts && opts.willChange) || 'arrayWillChange', + didChange = (opts && opts.didChange) || 'arrayDidChange'; + + var hasObservers = get(this, 'hasArrayObservers'); + if (hasObservers) Ember.propertyWillChange(this, 'hasArrayObservers'); + Ember.removeListener(this, '@array:before', target, willChange); + Ember.removeListener(this, '@array:change', target, didChange); + if (hasObservers) Ember.propertyDidChange(this, 'hasArrayObservers'); + return this; + }, + + /** + Becomes true whenever the array currently has observers watching changes + on the array. + + @property Boolean + */ + hasArrayObservers: Ember.computed(function() { + return Ember.hasListeners(this, '@array:change') || Ember.hasListeners(this, '@array:before'); + }), + + /** + If you are implementing an object that supports `Ember.Array`, call this + method just before the array content changes to notify any observers and + invalidate any related properties. Pass the starting index of the change + as well as a delta of the amounts to change. + + @method arrayContentWillChange + @param {Number} startIdx The starting index in the array that will change. + @param {Number} removeAmt The number of items that will be removed. If you + pass `null` assumes 0 + @param {Number} addAmt The number of items that will be added If you + pass `null` assumes 0. + @return {Ember.Array} receiver + */ + arrayContentWillChange: function(startIdx, removeAmt, addAmt) { + + // if no args are passed assume everything changes + if (startIdx===undefined) { + startIdx = 0; + removeAmt = addAmt = -1; + } else { + if (removeAmt === undefined) removeAmt=-1; + if (addAmt === undefined) addAmt=-1; + } + + // Make sure the @each proxy is set up if anyone is observing @each + if (Ember.isWatching(this, '@each')) { get(this, '@each'); } + + Ember.sendEvent(this, '@array:before', [this, startIdx, removeAmt, addAmt]); + + var removing, lim; + if (startIdx>=0 && removeAmt>=0 && get(this, 'hasEnumerableObservers')) { + removing = []; + lim = startIdx+removeAmt; + for(var idx=startIdx;idx=0 && addAmt>=0 && get(this, 'hasEnumerableObservers')) { + adding = []; + lim = startIdx+addAmt; + for(var idx=startIdx;idx b` + + Default implementation raises an exception. + + @method compare + @param a {Object} the first object to compare + @param b {Object} the second object to compare + @return {Integer} the result of the comparison + */ + compare: Ember.required(Function) + +}); + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + + +var get = Ember.get, set = Ember.set; + +/** + Implements some standard methods for copying an object. Add this mixin to + any object you create that can create a copy of itself. This mixin is + added automatically to the built-in array. + + You should generally implement the `copy()` method to return a copy of the + receiver. + + Note that `frozenCopy()` will only work if you also implement + `Ember.Freezable`. + + @class Copyable + @namespace Ember + @extends Ember.Mixin + @since Ember 0.9 +*/ +Ember.Copyable = Ember.Mixin.create( +/** @scope Ember.Copyable.prototype */ { + + /** + Override to return a copy of the receiver. Default implementation raises + an exception. + + @method copy + @param deep {Boolean} if `true`, a deep copy of the object should be made + @return {Object} copy of receiver + */ + copy: Ember.required(Function), + + /** + If the object implements `Ember.Freezable`, then this will return a new + copy if the object is not frozen and the receiver if the object is frozen. + + Raises an exception if you try to call this method on a object that does + not support freezing. + + You should use this method whenever you want a copy of a freezable object + since a freezable object can simply return itself without actually + consuming more memory. + + @method frozenCopy + @return {Object} copy of receiver or receiver + */ + frozenCopy: function() { + if (Ember.Freezable && Ember.Freezable.detect(this)) { + return get(this, 'isFrozen') ? this : this.copy().freeze(); + } else { + throw new Error(Ember.String.fmt("%@ does not support freezing", [this])); + } + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + +var get = Ember.get, set = Ember.set; + +/** + The `Ember.Freezable` mixin implements some basic methods for marking an + object as frozen. Once an object is frozen it should be read only. No changes + may be made the internal state of the object. + + ## Enforcement + + To fully support freezing in your subclass, you must include this mixin and + override any method that might alter any property on the object to instead + raise an exception. You can check the state of an object by checking the + `isFrozen` property. + + Although future versions of JavaScript may support language-level freezing + object objects, that is not the case today. Even if an object is freezable, + it is still technically possible to modify the object, even though it could + break other parts of your application that do not expect a frozen object to + change. It is, therefore, very important that you always respect the + `isFrozen` property on all freezable objects. + + ## Example Usage + + The example below shows a simple object that implement the `Ember.Freezable` + protocol. + + ```javascript + Contact = Ember.Object.extend(Ember.Freezable, { + firstName: null, + lastName: null, + + // swaps the names + swapNames: function() { + if (this.get('isFrozen')) throw Ember.FROZEN_ERROR; + var tmp = this.get('firstName'); + this.set('firstName', this.get('lastName')); + this.set('lastName', tmp); + return this; + } + + }); + + c = Context.create({ firstName: "John", lastName: "Doe" }); + c.swapNames(); // returns c + c.freeze(); + c.swapNames(); // EXCEPTION + ``` + + ## Copying + + Usually the `Ember.Freezable` protocol is implemented in cooperation with the + `Ember.Copyable` protocol, which defines a `frozenCopy()` method that will + return a frozen object, if the object implements this method as well. + + @class Freezable + @namespace Ember + @extends Ember.Mixin + @since Ember 0.9 +*/ +Ember.Freezable = Ember.Mixin.create( +/** @scope Ember.Freezable.prototype */ { + + /** + Set to `true` when the object is frozen. Use this property to detect + whether your object is frozen or not. + + @property isFrozen + @type Boolean + */ + isFrozen: false, + + /** + Freezes the object. Once this method has been called the object should + no longer allow any properties to be edited. + + @method freeze + @return {Object} receiver + */ + freeze: function() { + if (get(this, 'isFrozen')) return this; + set(this, 'isFrozen', true); + return this; + } + +}); + +Ember.FROZEN_ERROR = "Frozen object cannot be modified."; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var forEach = Ember.EnumerableUtils.forEach; + +/** + This mixin defines the API for modifying generic enumerables. These methods + can be applied to an object regardless of whether it is ordered or + unordered. + + Note that an Enumerable can change even if it does not implement this mixin. + For example, a MappedEnumerable cannot be directly modified but if its + underlying enumerable changes, it will change also. + + ## Adding Objects + + To add an object to an enumerable, use the `addObject()` method. This + method will only add the object to the enumerable if the object is not + already present and the object if of a type supported by the enumerable. + + ```javascript + set.addObject(contact); + ``` + + ## Removing Objects + + To remove an object form an enumerable, use the `removeObject()` method. This + will only remove the object if it is already in the enumerable, otherwise + this method has no effect. + + ```javascript + set.removeObject(contact); + ``` + + ## Implementing In Your Own Code + + If you are implementing an object and want to support this API, just include + this mixin in your class and implement the required methods. In your unit + tests, be sure to apply the Ember.MutableEnumerableTests to your object. + + @class MutableEnumerable + @namespace Ember + @extends Ember.Mixin + @uses Ember.Enumerable +*/ +Ember.MutableEnumerable = Ember.Mixin.create(Ember.Enumerable, + /** @scope Ember.MutableEnumerable.prototype */ { + + /** + __Required.__ You must implement this method to apply this mixin. + + Attempts to add the passed object to the receiver if the object is not + already present in the collection. If the object is present, this method + has no effect. + + If the passed object is of a type not supported by the receiver + then this method should raise an exception. + + @method addObject + @param {Object} object The object to add to the enumerable. + @return {Object} the passed object + */ + addObject: Ember.required(Function), + + /** + Adds each object in the passed enumerable to the receiver. + + @method addObjects + @param {Ember.Enumerable} objects the objects to add. + @return {Object} receiver + */ + addObjects: function(objects) { + Ember.beginPropertyChanges(this); + forEach(objects, function(obj) { this.addObject(obj); }, this); + Ember.endPropertyChanges(this); + return this; + }, + + /** + __Required.__ You must implement this method to apply this mixin. + + Attempts to remove the passed object from the receiver collection if the + object is in present in the collection. If the object is not present, + this method has no effect. + + If the passed object is of a type not supported by the receiver + then this method should raise an exception. + + @method removeObject + @param {Object} object The object to remove from the enumerable. + @return {Object} the passed object + */ + removeObject: Ember.required(Function), + + + /** + Removes each objects in the passed enumerable from the receiver. + + @method removeObjects + @param {Ember.Enumerable} objects the objects to remove + @return {Object} receiver + */ + removeObjects: function(objects) { + Ember.beginPropertyChanges(this); + forEach(objects, function(obj) { this.removeObject(obj); }, this); + Ember.endPropertyChanges(this); + return this; + } + +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ +// .......................................................... +// CONSTANTS +// + +var OUT_OF_RANGE_EXCEPTION = "Index out of range" ; +var EMPTY = []; + +// .......................................................... +// HELPERS +// + +var get = Ember.get, set = Ember.set, forEach = Ember.EnumerableUtils.forEach; + +/** + This mixin defines the API for modifying array-like objects. These methods + can be applied only to a collection that keeps its items in an ordered set. + + Note that an Array can change even if it does not implement this mixin. + For example, one might implement a SparseArray that cannot be directly + modified, but if its underlying enumerable changes, it will change also. + + @class MutableArray + @namespace Ember + @extends Ember.Mixin + @uses Ember.Array + @uses Ember.MutableEnumerable +*/ +Ember.MutableArray = Ember.Mixin.create(Ember.Array, Ember.MutableEnumerable, + /** @scope Ember.MutableArray.prototype */ { + + /** + __Required.__ You must implement this method to apply this mixin. + + This is one of the primitives you must implement to support `Ember.Array`. + You should replace amt objects started at idx with the objects in the + passed array. You should also call `this.enumerableContentDidChange()` + + @method replace + @param {Number} idx Starting index in the array to replace. If + idx >= length, then append to the end of the array. + @param {Number} amt Number of elements that should be removed from + the array, starting at *idx*. + @param {Array} objects An array of zero or more objects that should be + inserted into the array at *idx* + */ + replace: Ember.required(), + + /** + Remove all elements from self. This is useful if you + want to reuse an existing array without having to recreate it. + + ```javascript + var colors = ["red", "green", "blue"]; + color.length(); // 3 + colors.clear(); // [] + colors.length(); // 0 + ``` + + @method clear + @return {Ember.Array} An empty Array. + */ + clear: function () { + var len = get(this, 'length'); + if (len === 0) return this; + this.replace(0, len, EMPTY); + return this; + }, + + /** + This will use the primitive `replace()` method to insert an object at the + specified index. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.insertAt(2, "yellow"); // ["red", "green", "yellow", "blue"] + colors.insertAt(5, "orange"); // Error: Index out of range + ``` + + @method insertAt + @param {Number} idx index of insert the object at. + @param {Object} object object to insert + */ + insertAt: function(idx, object) { + if (idx > get(this, 'length')) throw new Error(OUT_OF_RANGE_EXCEPTION) ; + this.replace(idx, 0, [object]) ; + return this ; + }, + + /** + Remove an object at the specified index using the `replace()` primitive + method. You can pass either a single index, or a start and a length. + + If you pass a start and length that is beyond the + length this method will throw an `Ember.OUT_OF_RANGE_EXCEPTION` + + ```javascript + var colors = ["red", "green", "blue", "yellow", "orange"]; + colors.removeAt(0); // ["green", "blue", "yellow", "orange"] + colors.removeAt(2, 2); // ["green", "blue"] + colors.removeAt(4, 2); // Error: Index out of range + ``` + + @method removeAt + @param {Number} start index, start of range + @param {Number} len length of passing range + @return {Object} receiver + */ + removeAt: function(start, len) { + if ('number' === typeof start) { + + if ((start < 0) || (start >= get(this, 'length'))) { + throw new Error(OUT_OF_RANGE_EXCEPTION); + } + + // fast case + if (len === undefined) len = 1; + this.replace(start, len, EMPTY); + } + + return this ; + }, + + /** + Push the object onto the end of the array. Works just like `push()` but it + is KVO-compliant. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.pushObject("black"); // ["red", "green", "blue", "black"] + colors.pushObject(["yellow", "orange"]); // ["red", "green", "blue", "black", ["yellow", "orange"]] + ``` + + @method pushObject + @param {anything} obj object to push + */ + pushObject: function(obj) { + this.insertAt(get(this, 'length'), obj) ; + return obj ; + }, + + /** + Add the objects in the passed numerable to the end of the array. Defers + notifying observers of the change until all objects are added. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.pushObjects("black"); // ["red", "green", "blue", "black"] + colors.pushObjects(["yellow", "orange"]); // ["red", "green", "blue", "black", "yellow", "orange"] + ``` + + @method pushObjects + @param {Ember.Enumerable} objects the objects to add + @return {Ember.Array} receiver + */ + pushObjects: function(objects) { + this.replace(get(this, 'length'), 0, objects); + return this; + }, + + /** + Pop object from array or nil if none are left. Works just like `pop()` but + it is KVO-compliant. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.popObject(); // "blue" + console.log(colors); // ["red", "green"] + ``` + + @method popObject + @return object + */ + popObject: function() { + var len = get(this, 'length') ; + if (len === 0) return null ; + + var ret = this.objectAt(len-1) ; + this.removeAt(len-1, 1) ; + return ret ; + }, + + /** + Shift an object from start of array or nil if none are left. Works just + like `shift()` but it is KVO-compliant. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.shiftObject(); // "red" + console.log(colors); // ["green", "blue"] + ``` + + @method shiftObject + @return object + */ + shiftObject: function() { + if (get(this, 'length') === 0) return null ; + var ret = this.objectAt(0) ; + this.removeAt(0) ; + return ret ; + }, + + /** + Unshift an object to start of array. Works just like `unshift()` but it is + KVO-compliant. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.unshiftObject("yellow"); // ["yellow", "red", "green", "blue"] + colors.unshiftObject(["black", "white"]); // [["black", "white"], "yellow", "red", "green", "blue"] + ``` + + @method unshiftObject + @param {anything} obj object to unshift + */ + unshiftObject: function(obj) { + this.insertAt(0, obj) ; + return obj ; + }, + + /** + Adds the named objects to the beginning of the array. Defers notifying + observers until all objects have been added. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.unshiftObjects(["black", "white"]); // ["black", "white", "red", "green", "blue"] + colors.unshiftObjects("yellow"); // Type Error: 'undefined' is not a function + ``` + + @method unshiftObjects + @param {Ember.Enumerable} objects the objects to add + @return {Ember.Array} receiver + */ + unshiftObjects: function(objects) { + this.replace(0, 0, objects); + return this; + }, + + /** + Reverse objects in the array. Works just like `reverse()` but it is + KVO-compliant. + + @method reverseObjects + @return {Ember.Array} receiver + */ + reverseObjects: function() { + var len = get(this, 'length'); + if (len === 0) return this; + var objects = this.toArray().reverse(); + this.replace(0, len, objects); + return this; + }, + + /** + Replace all the the receiver's content with content of the argument. + If argument is an empty array receiver will be cleared. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.setObjects(["black", "white"]); // ["black", "white"] + colors.setObjects([]); // [] + ``` + + @method setObjects + @param {Ember.Array} objects array whose content will be used for replacing + the content of the receiver + @return {Ember.Array} receiver with the new content + */ + setObjects: function(objects) { + if (objects.length === 0) return this.clear(); + + var len = get(this, 'length'); + this.replace(0, len, objects); + return this; + }, + + // .......................................................... + // IMPLEMENT Ember.MutableEnumerable + // + + removeObject: function(obj) { + var loc = get(this, 'length') || 0; + while(--loc >= 0) { + var curObject = this.objectAt(loc) ; + if (curObject === obj) this.removeAt(loc) ; + } + return this ; + }, + + addObject: function(obj) { + if (!this.contains(obj)) this.pushObject(obj); + return this ; + } + +}); + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set, defineProperty = Ember.defineProperty; + +/** + ## Overview + + This mixin provides properties and property observing functionality, core + features of the Ember object model. + + Properties and observers allow one object to observe changes to a + property on another object. This is one of the fundamental ways that + models, controllers and views communicate with each other in an Ember + application. + + Any object that has this mixin applied can be used in observer + operations. That includes `Ember.Object` and most objects you will + interact with as you write your Ember application. + + Note that you will not generally apply this mixin to classes yourself, + but you will use the features provided by this module frequently, so it + is important to understand how to use it. + + ## Using `get()` and `set()` + + Because of Ember's support for bindings and observers, you will always + access properties using the get method, and set properties using the + set method. This allows the observing objects to be notified and + computed properties to be handled properly. + + More documentation about `get` and `set` are below. + + ## Observing Property Changes + + You typically observe property changes simply by adding the `observes` + call to the end of your method declarations in classes that you write. + For example: + + ```javascript + Ember.Object.create({ + valueObserver: function() { + // Executes whenever the "value" property changes + }.observes('value') + }); + ``` + + Although this is the most common way to add an observer, this capability + is actually built into the `Ember.Object` class on top of two methods + defined in this mixin: `addObserver` and `removeObserver`. You can use + these two methods to add and remove observers yourself if you need to + do so at runtime. + + To add an observer for a property, call: + + ```javascript + object.addObserver('propertyKey', targetObject, targetAction) + ``` + + This will call the `targetAction` method on the `targetObject` to be called + whenever the value of the `propertyKey` changes. + + Note that if `propertyKey` is a computed property, the observer will be + called when any of the property dependencies are changed, even if the + resulting value of the computed property is unchanged. This is necessary + because computed properties are not computed until `get` is called. + + @class Observable + @namespace Ember + @extends Ember.Mixin +*/ +Ember.Observable = Ember.Mixin.create(/** @scope Ember.Observable.prototype */ { + + /** + Retrieves the value of a property from the object. + + This method is usually similar to using `object[keyName]` or `object.keyName`, + however it supports both computed properties and the unknownProperty + handler. + + Because `get` unifies the syntax for accessing all these kinds + of properties, it can make many refactorings easier, such as replacing a + simple property with a computed property, or vice versa. + + ### Computed Properties + + Computed properties are methods defined with the `property` modifier + declared at the end, such as: + + ```javascript + fullName: function() { + return this.getEach('firstName', 'lastName').compact().join(' '); + }.property('firstName', 'lastName') + ``` + + When you call `get` on a computed property, the function will be + called and the return value will be returned instead of the function + itself. + + ### Unknown Properties + + Likewise, if you try to call `get` on a property whose value is + `undefined`, the `unknownProperty()` method will be called on the object. + If this method returns any value other than `undefined`, it will be returned + instead. This allows you to implement "virtual" properties that are + not defined upfront. + + @method get + @param {String} key The property to retrieve + @return {Object} The property value or undefined. + */ + get: function(keyName) { + return get(this, keyName); + }, + + /** + To get multiple properties at once, call `getProperties` + with a list of strings or an array: + + ```javascript + record.getProperties('firstName', 'lastName', 'zipCode'); // { firstName: 'John', lastName: 'Doe', zipCode: '10011' } + ``` + + is equivalent to: + + ```javascript + record.getProperties(['firstName', 'lastName', 'zipCode']); // { firstName: 'John', lastName: 'Doe', zipCode: '10011' } + ``` + + @method getProperties + @param {String...|Array} list of keys to get + @return {Hash} + */ + getProperties: function() { + var ret = {}; + var propertyNames = arguments; + if (arguments.length === 1 && Ember.typeOf(arguments[0]) === 'array') { + propertyNames = arguments[0]; + } + for(var i = 0; i < propertyNames.length; i++) { + ret[propertyNames[i]] = get(this, propertyNames[i]); + } + return ret; + }, + + /** + Sets the provided key or path to the value. + + This method is generally very similar to calling `object[key] = value` or + `object.key = value`, except that it provides support for computed + properties, the `unknownProperty()` method and property observers. + + ### Computed Properties + + If you try to set a value on a key that has a computed property handler + defined (see the `get()` method for an example), then `set()` will call + that method, passing both the value and key instead of simply changing + the value itself. This is useful for those times when you need to + implement a property that is composed of one or more member + properties. + + ### Unknown Properties + + If you try to set a value on a key that is undefined in the target + object, then the `unknownProperty()` handler will be called instead. This + gives you an opportunity to implement complex "virtual" properties that + are not predefined on the object. If `unknownProperty()` returns + undefined, then `set()` will simply set the value on the object. + + ### Property Observers + + In addition to changing the property, `set()` will also register a property + change with the object. Unless you have placed this call inside of a + `beginPropertyChanges()` and `endPropertyChanges(),` any "local" observers + (i.e. observer methods declared on the same object), will be called + immediately. Any "remote" observers (i.e. observer methods declared on + another object) will be placed in a queue and called at a later time in a + coalesced manner. + + ### Chaining + + In addition to property changes, `set()` returns the value of the object + itself so you can do chaining like this: + + ```javascript + record.set('firstName', 'Charles').set('lastName', 'Jolley'); + ``` + + @method set + @param {String} key The property to set + @param {Object} value The value to set or `null`. + @return {Ember.Observable} + */ + set: function(keyName, value) { + set(this, keyName, value); + return this; + }, + + /** + To set multiple properties at once, call `setProperties` + with a Hash: + + ```javascript + record.setProperties({ firstName: 'Charles', lastName: 'Jolley' }); + ``` + + @method setProperties + @param {Hash} hash the hash of keys and values to set + @return {Ember.Observable} + */ + setProperties: function(hash) { + return Ember.setProperties(this, hash); + }, + + /** + Begins a grouping of property changes. + + You can use this method to group property changes so that notifications + will not be sent until the changes are finished. If you plan to make a + large number of changes to an object at one time, you should call this + method at the beginning of the changes to begin deferring change + notifications. When you are done making changes, call + `endPropertyChanges()` to deliver the deferred change notifications and end + deferring. + + @method beginPropertyChanges + @return {Ember.Observable} + */ + beginPropertyChanges: function() { + Ember.beginPropertyChanges(); + return this; + }, + + /** + Ends a grouping of property changes. + + You can use this method to group property changes so that notifications + will not be sent until the changes are finished. If you plan to make a + large number of changes to an object at one time, you should call + `beginPropertyChanges()` at the beginning of the changes to defer change + notifications. When you are done making changes, call this method to + deliver the deferred change notifications and end deferring. + + @method endPropertyChanges + @return {Ember.Observable} + */ + endPropertyChanges: function() { + Ember.endPropertyChanges(); + return this; + }, + + /** + Notify the observer system that a property is about to change. + + Sometimes you need to change a value directly or indirectly without + actually calling `get()` or `set()` on it. In this case, you can use this + method and `propertyDidChange()` instead. Calling these two methods + together will notify all observers that the property has potentially + changed value. + + Note that you must always call `propertyWillChange` and `propertyDidChange` + as a pair. If you do not, it may get the property change groups out of + order and cause notifications to be delivered more often than you would + like. + + @method propertyWillChange + @param {String} key The property key that is about to change. + @return {Ember.Observable} + */ + propertyWillChange: function(keyName){ + Ember.propertyWillChange(this, keyName); + return this; + }, + + /** + Notify the observer system that a property has just changed. + + Sometimes you need to change a value directly or indirectly without + actually calling `get()` or `set()` on it. In this case, you can use this + method and `propertyWillChange()` instead. Calling these two methods + together will notify all observers that the property has potentially + changed value. + + Note that you must always call `propertyWillChange` and `propertyDidChange` + as a pair. If you do not, it may get the property change groups out of + order and cause notifications to be delivered more often than you would + like. + + @method propertyDidChange + @param {String} keyName The property key that has just changed. + @return {Ember.Observable} + */ + propertyDidChange: function(keyName) { + Ember.propertyDidChange(this, keyName); + return this; + }, + + /** + Convenience method to call `propertyWillChange` and `propertyDidChange` in + succession. + + @method notifyPropertyChange + @param {String} keyName The property key to be notified about. + @return {Ember.Observable} + */ + notifyPropertyChange: function(keyName) { + this.propertyWillChange(keyName); + this.propertyDidChange(keyName); + return this; + }, + + addBeforeObserver: function(key, target, method) { + Ember.addBeforeObserver(this, key, target, method); + }, + + /** + Adds an observer on a property. + + This is the core method used to register an observer for a property. + + Once you call this method, anytime the key's value is set, your observer + will be notified. Note that the observers are triggered anytime the + value is set, regardless of whether it has actually changed. Your + observer should be prepared to handle that. + + You can also pass an optional context parameter to this method. The + context will be passed to your observer method whenever it is triggered. + Note that if you add the same target/method pair on a key multiple times + with different context parameters, your observer will only be called once + with the last context you passed. + + ### Observer Methods + + Observer methods you pass should generally have the following signature if + you do not pass a `context` parameter: + + ```javascript + fooDidChange: function(sender, key, value, rev) { }; + ``` + + The sender is the object that changed. The key is the property that + changes. The value property is currently reserved and unused. The rev + is the last property revision of the object when it changed, which you can + use to detect if the key value has really changed or not. + + If you pass a `context` parameter, the context will be passed before the + revision like so: + + ```javascript + fooDidChange: function(sender, key, value, context, rev) { }; + ``` + + Usually you will not need the value, context or revision parameters at + the end. In this case, it is common to write observer methods that take + only a sender and key value as parameters or, if you aren't interested in + any of these values, to write an observer that has no parameters at all. + + @method addObserver + @param {String} key The key to observer + @param {Object} target The target object to invoke + @param {String|Function} method The method to invoke. + @return {Ember.Object} self + */ + addObserver: function(key, target, method) { + Ember.addObserver(this, key, target, method); + }, + + /** + Remove an observer you have previously registered on this object. Pass + the same key, target, and method you passed to `addObserver()` and your + target will no longer receive notifications. + + @method removeObserver + @param {String} key The key to observer + @param {Object} target The target object to invoke + @param {String|Function} method The method to invoke. + @return {Ember.Observable} receiver + */ + removeObserver: function(key, target, method) { + Ember.removeObserver(this, key, target, method); + }, + + /** + Returns `true` if the object currently has observers registered for a + particular key. You can use this method to potentially defer performing + an expensive action until someone begins observing a particular property + on the object. + + @method hasObserverFor + @param {String} key Key to check + @return {Boolean} + */ + hasObserverFor: function(key) { + return Ember.hasListeners(this, key+':change'); + }, + + /** + @deprecated + @method getPath + @param {String} path The property path to retrieve + @return {Object} The property value or undefined. + */ + getPath: function(path) { + Ember.deprecate("getPath is deprecated since get now supports paths"); + return this.get(path); + }, + + /** + @deprecated + @method setPath + @param {String} path The path to the property that will be set + @param {Object} value The value to set or `null`. + @return {Ember.Observable} + */ + setPath: function(path, value) { + Ember.deprecate("setPath is deprecated since set now supports paths"); + return this.set(path, value); + }, + + /** + Retrieves the value of a property, or a default value in the case that the + property returns `undefined`. + + ```javascript + person.getWithDefault('lastName', 'Doe'); + ``` + + @method getWithDefault + @param {String} keyName The name of the property to retrieve + @param {Object} defaultValue The value to return if the property value is undefined + @return {Object} The property value or the defaultValue. + */ + getWithDefault: function(keyName, defaultValue) { + return Ember.getWithDefault(this, keyName, defaultValue); + }, + + /** + Set the value of a property to the current value plus some amount. + + ```javascript + person.incrementProperty('age'); + team.incrementProperty('score', 2); + ``` + + @method incrementProperty + @param {String} keyName The name of the property to increment + @param {Object} increment The amount to increment by. Defaults to 1 + @return {Object} The new property value + */ + incrementProperty: function(keyName, increment) { + if (!increment) { increment = 1; } + set(this, keyName, (get(this, keyName) || 0)+increment); + return get(this, keyName); + }, + + /** + Set the value of a property to the current value minus some amount. + + ```javascript + player.decrementProperty('lives'); + orc.decrementProperty('health', 5); + ``` + + @method decrementProperty + @param {String} keyName The name of the property to decrement + @param {Object} increment The amount to decrement by. Defaults to 1 + @return {Object} The new property value + */ + decrementProperty: function(keyName, increment) { + if (!increment) { increment = 1; } + set(this, keyName, (get(this, keyName) || 0)-increment); + return get(this, keyName); + }, + + /** + Set the value of a boolean property to the opposite of it's + current value. + + ```javascript + starship.toggleProperty('warpDriveEnaged'); + ``` + + @method toggleProperty + @param {String} keyName The name of the property to toggle + @return {Object} The new property value + */ + toggleProperty: function(keyName) { + set(this, keyName, !get(this, keyName)); + return get(this, keyName); + }, + + /** + Returns the cached value of a computed property, if it exists. + This allows you to inspect the value of a computed property + without accidentally invoking it if it is intended to be + generated lazily. + + @method cacheFor + @param {String} keyName + @return {Object} The cached value of the computed property, if any + */ + cacheFor: function(keyName) { + return Ember.cacheFor(this, keyName); + }, + + // intended for debugging purposes + observersForKey: function(keyName) { + return Ember.observersFor(this, keyName); + } +}); + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set; + +/** +@class TargetActionSupport +@namespace Ember +@extends Ember.Mixin +*/ +Ember.TargetActionSupport = Ember.Mixin.create({ + target: null, + action: null, + + targetObject: Ember.computed(function() { + var target = get(this, 'target'); + + if (Ember.typeOf(target) === "string") { + var value = get(this, target); + if (value === undefined) { value = get(Ember.lookup, target); } + return value; + } else { + return target; + } + }).property('target'), + + triggerAction: function() { + var action = get(this, 'action'), + target = get(this, 'targetObject'); + + if (target && action) { + var ret; + + if (typeof target.send === 'function') { + ret = target.send(action, this); + } else { + if (typeof action === 'string') { + action = target[action]; + } + ret = action.call(target, this); + } + if (ret !== false) ret = true; + + return ret; + } else { + return false; + } + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +/** + This mixin allows for Ember objects to subscribe to and emit events. + + ```javascript + App.Person = Ember.Object.extend(Ember.Evented, { + greet: function() { + // ... + this.trigger('greet'); + } + }); + + var person = App.Person.create(); + + person.on('greet', function() { + console.log('Our person has greeted'); + }); + + person.greet(); + + // outputs: 'Our person has greeted' + ``` + + @class Evented + @namespace Ember + @extends Ember.Mixin + */ +Ember.Evented = Ember.Mixin.create({ + + /** + Subscribes to a named event with given function. + + ```javascript + person.on('didLoad', function() { + // fired once the person has loaded + }); + ``` + + An optional target can be passed in as the 2nd argument that will + be set as the "this" for the callback. This is a good way to give your + function access to the object triggering the event. When the target + parameter is used the callback becomes the third argument. + + @method on + @param {String} name The name of the event + @param {Object} [target] The "this" binding for the callback + @param {Function} method The callback to execute + */ + on: function(name, target, method) { + Ember.addListener(this, name, target, method); + }, + + /** + Subscribes a function to a named event and then cancels the subscription + after the first time the event is triggered. It is good to use ``one`` when + you only care about the first time an event has taken place. + + This function takes an optional 2nd argument that will become the "this" + value for the callback. If this argument is passed then the 3rd argument + becomes the function. + + @method one + @param {String} name The name of the event + @param {Object} [target] The "this" binding for the callback + @param {Function} method The callback to execute + */ + one: function(name, target, method) { + if (!method) { + method = target; + target = null; + } + + Ember.addListener(this, name, target, method, true); + }, + + /** + Triggers a named event for the object. Any additional arguments + will be passed as parameters to the functions that are subscribed to the + event. + + ```javascript + person.on('didEat', food) { + console.log('person ate some ' + food); + }); + + person.trigger('didEat', 'broccoli'); + + // outputs: person ate some broccoli + ``` + @method trigger + @param {String} name The name of the event + @param {Object...} args Optional arguments to pass on + */ + trigger: function(name) { + var args = [], i, l; + for (i = 1, l = arguments.length; i < l; i++) { + args.push(arguments[i]); + } + Ember.sendEvent(this, name, args); + }, + + fire: function(name) { + Ember.deprecate("Ember.Evented#fire() has been deprecated in favor of trigger() for compatibility with jQuery. It will be removed in 1.0. Please update your code to call trigger() instead."); + this.trigger.apply(this, arguments); + }, + + /** + Cancels subscription for give name, target, and method. + + @method off + @param {String} name The name of the event + @param {Object} target The target of the subscription + @param {Function} method The function of the subscription + */ + off: function(name, target, method) { + Ember.removeListener(this, name, target, method); + }, + + /** + Checks to see if object has any subscriptions for named event. + + @method has + @param {String} name The name of the event + @return {Boolean} does the object have a subscription for event + */ + has: function(name) { + return Ember.hasListeners(this, name); + } +}); + +})(); + + + +(function() { +var RSVP = requireModule("rsvp"); + +RSVP.async = function(callback, binding) { + Ember.run.schedule('actions', binding, callback); +}; + +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, + slice = Array.prototype.slice; + +/** + @class Deferred + @namespace Ember + @extends Ember.Mixin + */ +Ember.DeferredMixin = Ember.Mixin.create({ + /** + Add handlers to be called when the Deferred object is resolved or rejected. + + @method then + @param {Function} doneCallback a callback function to be called when done + @param {Function} failCallback a callback function to be called when failed + */ + then: function(doneCallback, failCallback) { + var promise = get(this, 'promise'); + return promise.then.apply(promise, arguments); + }, + + /** + Resolve a Deferred object and call any `doneCallbacks` with the given args. + + @method resolve + */ + resolve: function(value) { + get(this, 'promise').resolve(value); + }, + + /** + Reject a Deferred object and call any `failCallbacks` with the given args. + + @method reject + */ + reject: function(value) { + get(this, 'promise').reject(value); + }, + + promise: Ember.computed(function() { + return new RSVP.Promise(); + }) +}); + + +})(); + + + +(function() { + +})(); + + + +(function() { +Ember.Container = requireModule('container'); +Ember.Container.set = Ember.set; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + +// NOTE: this object should never be included directly. Instead use Ember. +// Ember.Object. We only define this separately so that Ember.Set can depend on it + + +var set = Ember.set, get = Ember.get, + o_create = Ember.create, + o_defineProperty = Ember.platform.defineProperty, + a_slice = Array.prototype.slice, + GUID_KEY = Ember.GUID_KEY, + guidFor = Ember.guidFor, + generateGuid = Ember.generateGuid, + meta = Ember.meta, + rewatch = Ember.rewatch, + finishChains = Ember.finishChains, + destroy = Ember.destroy, + schedule = Ember.run.schedule, + Mixin = Ember.Mixin, + applyMixin = Mixin._apply, + finishPartial = Mixin.finishPartial, + reopen = Mixin.prototype.reopen, + MANDATORY_SETTER = Ember.ENV.MANDATORY_SETTER, + indexOf = Ember.EnumerableUtils.indexOf; + +var undefinedDescriptor = { + configurable: true, + writable: true, + enumerable: false, + value: undefined +}; + +function makeCtor() { + + // Note: avoid accessing any properties on the object since it makes the + // method a lot faster. This is glue code so we want it to be as fast as + // possible. + + var wasApplied = false, initMixins, initProperties; + + var Class = function() { + if (!wasApplied) { + Class.proto(); // prepare prototype... + } + o_defineProperty(this, GUID_KEY, undefinedDescriptor); + o_defineProperty(this, '_super', undefinedDescriptor); + var m = meta(this); + m.proto = this; + if (initMixins) { + // capture locally so we can clear the closed over variable + var mixins = initMixins; + initMixins = null; + this.reopen.apply(this, mixins); + } + if (initProperties) { + // capture locally so we can clear the closed over variable + var props = initProperties; + initProperties = null; + + var concatenatedProperties = this.concatenatedProperties; + + for (var i = 0, l = props.length; i < l; i++) { + var properties = props[i]; + for (var keyName in properties) { + if (!properties.hasOwnProperty(keyName)) { continue; } + + var value = properties[keyName], + IS_BINDING = Ember.IS_BINDING; + + if (IS_BINDING.test(keyName)) { + var bindings = m.bindings; + if (!bindings) { + bindings = m.bindings = {}; + } else if (!m.hasOwnProperty('bindings')) { + bindings = m.bindings = o_create(m.bindings); + } + bindings[keyName] = value; + } + + var desc = m.descs[keyName]; + + Ember.assert("Ember.Object.create no longer supports defining computed properties.", !(value instanceof Ember.ComputedProperty)); + Ember.assert("Ember.Object.create no longer supports defining methods that call _super.", !(typeof value === 'function' && value.toString().indexOf('._super') !== -1)); + + if (concatenatedProperties && indexOf(concatenatedProperties, keyName) >= 0) { + var baseValue = this[keyName]; + + if (baseValue) { + if ('function' === typeof baseValue.concat) { + value = baseValue.concat(value); + } else { + value = Ember.makeArray(baseValue).concat(value); + } + } else { + value = Ember.makeArray(value); + } + } + + if (desc) { + desc.set(this, keyName, value); + } else { + if (typeof this.setUnknownProperty === 'function' && !(keyName in this)) { + this.setUnknownProperty(keyName, value); + } else if (MANDATORY_SETTER) { + Ember.defineProperty(this, keyName, null, value); // setup mandatory setter + } else { + this[keyName] = value; + } + } + } + } + } + finishPartial(this, m); + delete m.proto; + finishChains(this); + this.init.apply(this, arguments); + }; + + Class.toString = Mixin.prototype.toString; + Class.willReopen = function() { + if (wasApplied) { + Class.PrototypeMixin = Mixin.create(Class.PrototypeMixin); + } + + wasApplied = false; + }; + Class._initMixins = function(args) { initMixins = args; }; + Class._initProperties = function(args) { initProperties = args; }; + + Class.proto = function() { + var superclass = Class.superclass; + if (superclass) { superclass.proto(); } + + if (!wasApplied) { + wasApplied = true; + Class.PrototypeMixin.applyPartial(Class.prototype); + rewatch(Class.prototype); + } + + return this.prototype; + }; + + return Class; + +} + +var CoreObject = makeCtor(); +CoreObject.toString = function() { return "Ember.CoreObject"; }; + +CoreObject.PrototypeMixin = Mixin.create({ + reopen: function() { + applyMixin(this, arguments, true); + return this; + }, + + isInstance: true, + + init: function() {}, + + /** + Defines the properties that will be concatenated from the superclass + (instead of overridden). + + By default, when you extend an Ember class a property defined in + the subclass overrides a property with the same name that is defined + in the superclass. However, there are some cases where it is preferable + to build up a property's value by combining the superclass' property + value with the subclass' value. An example of this in use within Ember + is the `classNames` property of `Ember.View`. + + Here is some sample code showing the difference between a concatenated + property and a normal one: + + ```javascript + App.BarView = Ember.View.extend({ + someNonConcatenatedProperty: ['bar'], + classNames: ['bar'] + }); + + App.FooBarView = App.BarView.extend({ + someNonConcatenatedProperty: ['foo'], + classNames: ['foo'], + }); + + var fooBarView = App.FooBarView.create(); + fooBarView.get('someNonConcatenatedProperty'); // ['foo'] + fooBarView.get('classNames'); // ['ember-view', 'bar', 'foo'] + ``` + + This behavior extends to object creation as well. Continuing the + above example: + + ```javascript + var view = App.FooBarView.create({ + someNonConcatenatedProperty: ['baz'], + classNames: ['baz'] + }) + view.get('someNonConcatenatedProperty'); // ['baz'] + view.get('classNames'); // ['ember-view', 'bar', 'foo', 'baz'] + ``` + Adding a single property that is not an array will just add it in the array: + + ```javascript + var view = App.FooBarView.create({ + classNames: 'baz' + }) + view.get('classNames'); // ['ember-view', 'bar', 'foo', 'baz'] + ``` + + Using the `concatenatedProperties` property, we can tell to Ember that mix + the content of the properties. + + In `Ember.View` the `classNameBindings` and `attributeBindings` properties + are also concatenated, in addition to `classNames`. + + This feature is available for you to use throughout the Ember object model, + although typical app developers are likely to use it infrequently. + + @property concatenatedProperties + @type Array + @default null + */ + concatenatedProperties: null, + + /** + @property isDestroyed + @default false + */ + isDestroyed: false, + + /** + @property isDestroying + @default false + */ + isDestroying: false, + + /** + Destroys an object by setting the `isDestroyed` flag and removing its + metadata, which effectively destroys observers and bindings. + + If you try to set a property on a destroyed object, an exception will be + raised. + + Note that destruction is scheduled for the end of the run loop and does not + happen immediately. + + @method destroy + @return {Ember.Object} receiver + */ + destroy: function() { + if (this._didCallDestroy) { return; } + + this.isDestroying = true; + this._didCallDestroy = true; + + if (this.willDestroy) { this.willDestroy(); } + + schedule('destroy', this, this._scheduledDestroy); + return this; + }, + + /** + @private + + Invoked by the run loop to actually destroy the object. This is + scheduled for execution by the `destroy` method. + + @method _scheduledDestroy + */ + _scheduledDestroy: function() { + destroy(this); + set(this, 'isDestroyed', true); + + if (this.didDestroy) { this.didDestroy(); } + }, + + bind: function(to, from) { + if (!(from instanceof Ember.Binding)) { from = Ember.Binding.from(from); } + from.to(to).connect(this); + return from; + }, + + /** + Returns a string representation which attempts to provide more information + than Javascript's `toString` typically does, in a generic way for all Ember + objects. + + App.Person = Em.Object.extend() + person = App.Person.create() + person.toString() //=> "" + + If the object's class is not defined on an Ember namespace, it will + indicate it is a subclass of the registered superclass: + + Student = App.Person.extend() + student = Student.create() + student.toString() //=> "<(subclass of App.Person):ember1025>" + + If the method `toStringExtension` is defined, its return value will be + included in the output. + + App.Teacher = App.Person.extend({ + toStringExtension: function(){ + return this.get('fullName'); + } + }); + teacher = App.Teacher.create() + teacher.toString(); // #=> "" + + @method toString + @return {String} string representation + */ + toString: function toString() { + var hasToStringExtension = typeof this.toStringExtension === 'function', + extension = hasToStringExtension ? ":" + this.toStringExtension() : ''; + var ret = '<'+this.constructor.toString()+':'+guidFor(this)+extension+'>'; + this.toString = makeToString(ret); + return ret; + } +}); + +CoreObject.PrototypeMixin.ownerConstructor = CoreObject; + +function makeToString(ret) { + return function() { return ret; }; +} + +if (Ember.config.overridePrototypeMixin) { + Ember.config.overridePrototypeMixin(CoreObject.PrototypeMixin); +} + +CoreObject.__super__ = null; + +var ClassMixin = Mixin.create({ + + ClassMixin: Ember.required(), + + PrototypeMixin: Ember.required(), + + isClass: true, + + isMethod: false, + + extend: function() { + var Class = makeCtor(), proto; + Class.ClassMixin = Mixin.create(this.ClassMixin); + Class.PrototypeMixin = Mixin.create(this.PrototypeMixin); + + Class.ClassMixin.ownerConstructor = Class; + Class.PrototypeMixin.ownerConstructor = Class; + + reopen.apply(Class.PrototypeMixin, arguments); + + Class.superclass = this; + Class.__super__ = this.prototype; + + proto = Class.prototype = o_create(this.prototype); + proto.constructor = Class; + generateGuid(proto, 'ember'); + meta(proto).proto = proto; // this will disable observers on prototype + + Class.ClassMixin.apply(Class); + return Class; + }, + + createWithMixins: function() { + var C = this; + if (arguments.length>0) { this._initMixins(arguments); } + return new C(); + }, + + create: function() { + var C = this; + if (arguments.length>0) { this._initProperties(arguments); } + return new C(); + }, + + reopen: function() { + this.willReopen(); + reopen.apply(this.PrototypeMixin, arguments); + return this; + }, + + reopenClass: function() { + reopen.apply(this.ClassMixin, arguments); + applyMixin(this, arguments, false); + return this; + }, + + detect: function(obj) { + if ('function' !== typeof obj) { return false; } + while(obj) { + if (obj===this) { return true; } + obj = obj.superclass; + } + return false; + }, + + detectInstance: function(obj) { + return obj instanceof this; + }, + + /** + In some cases, you may want to annotate computed properties with additional + metadata about how they function or what values they operate on. For + example, computed property functions may close over variables that are then + no longer available for introspection. + + You can pass a hash of these values to a computed property like this: + + ```javascript + person: function() { + var personId = this.get('personId'); + return App.Person.create({ id: personId }); + }.property().meta({ type: App.Person }) + ``` + + Once you've done this, you can retrieve the values saved to the computed + property from your class like this: + + ```javascript + MyClass.metaForProperty('person'); + ``` + + This will return the original hash that was passed to `meta()`. + + @method metaForProperty + @param key {String} property name + */ + metaForProperty: function(key) { + var desc = meta(this.proto(), false).descs[key]; + + Ember.assert("metaForProperty() could not find a computed property with key '"+key+"'.", !!desc && desc instanceof Ember.ComputedProperty); + return desc._meta || {}; + }, + + /** + Iterate over each computed property for the class, passing its name + and any associated metadata (see `metaForProperty`) to the callback. + + @method eachComputedProperty + @param {Function} callback + @param {Object} binding + */ + eachComputedProperty: function(callback, binding) { + var proto = this.proto(), + descs = meta(proto).descs, + empty = {}, + property; + + for (var name in descs) { + property = descs[name]; + + if (property instanceof Ember.ComputedProperty) { + callback.call(binding || this, name, property._meta || empty); + } + } + } + +}); + +ClassMixin.ownerConstructor = CoreObject; + +if (Ember.config.overrideClassMixin) { + Ember.config.overrideClassMixin(ClassMixin); +} + +CoreObject.ClassMixin = ClassMixin; +ClassMixin.apply(CoreObject); + +/** + @class CoreObject + @namespace Ember +*/ +Ember.CoreObject = CoreObject; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set, guidFor = Ember.guidFor, none = Ember.isNone; + +/** + An unordered collection of objects. + + A Set works a bit like an array except that its items are not ordered. You + can create a set to efficiently test for membership for an object. You can + also iterate through a set just like an array, even accessing objects by + index, however there is no guarantee as to their order. + + All Sets are observable via the Enumerable Observer API - which works + on any enumerable object including both Sets and Arrays. + + ## Creating a Set + + You can create a set like you would most objects using + `new Ember.Set()`. Most new sets you create will be empty, but you can + also initialize the set with some content by passing an array or other + enumerable of objects to the constructor. + + Finally, you can pass in an existing set and the set will be copied. You + can also create a copy of a set by calling `Ember.Set#copy()`. + + ```javascript + // creates a new empty set + var foundNames = new Ember.Set(); + + // creates a set with four names in it. + var names = new Ember.Set(["Charles", "Tom", "Juan", "Alex"]); // :P + + // creates a copy of the names set. + var namesCopy = new Ember.Set(names); + + // same as above. + var anotherNamesCopy = names.copy(); + ``` + + ## Adding/Removing Objects + + You generally add or remove objects from a set using `add()` or + `remove()`. You can add any type of object including primitives such as + numbers, strings, and booleans. + + Unlike arrays, objects can only exist one time in a set. If you call `add()` + on a set with the same object multiple times, the object will only be added + once. Likewise, calling `remove()` with the same object multiple times will + remove the object the first time and have no effect on future calls until + you add the object to the set again. + + NOTE: You cannot add/remove `null` or `undefined` to a set. Any attempt to do + so will be ignored. + + In addition to add/remove you can also call `push()`/`pop()`. Push behaves + just like `add()` but `pop()`, unlike `remove()` will pick an arbitrary + object, remove it and return it. This is a good way to use a set as a job + queue when you don't care which order the jobs are executed in. + + ## Testing for an Object + + To test for an object's presence in a set you simply call + `Ember.Set#contains()`. + + ## Observing changes + + When using `Ember.Set`, you can observe the `"[]"` property to be + alerted whenever the content changes. You can also add an enumerable + observer to the set to be notified of specific objects that are added and + removed from the set. See `Ember.Enumerable` for more information on + enumerables. + + This is often unhelpful. If you are filtering sets of objects, for instance, + it is very inefficient to re-filter all of the items each time the set + changes. It would be better if you could just adjust the filtered set based + on what was changed on the original set. The same issue applies to merging + sets, as well. + + ## Other Methods + + `Ember.Set` primary implements other mixin APIs. For a complete reference + on the methods you will use with `Ember.Set`, please consult these mixins. + The most useful ones will be `Ember.Enumerable` and + `Ember.MutableEnumerable` which implement most of the common iterator + methods you are used to on Array. + + Note that you can also use the `Ember.Copyable` and `Ember.Freezable` + APIs on `Ember.Set` as well. Once a set is frozen it can no longer be + modified. The benefit of this is that when you call `frozenCopy()` on it, + Ember will avoid making copies of the set. This allows you to write + code that can know with certainty when the underlying set data will or + will not be modified. + + @class Set + @namespace Ember + @extends Ember.CoreObject + @uses Ember.MutableEnumerable + @uses Ember.Copyable + @uses Ember.Freezable + @since Ember 0.9 +*/ +Ember.Set = Ember.CoreObject.extend(Ember.MutableEnumerable, Ember.Copyable, Ember.Freezable, + /** @scope Ember.Set.prototype */ { + + // .......................................................... + // IMPLEMENT ENUMERABLE APIS + // + + /** + This property will change as the number of objects in the set changes. + + @property length + @type number + @default 0 + */ + length: 0, + + /** + Clears the set. This is useful if you want to reuse an existing set + without having to recreate it. + + ```javascript + var colors = new Ember.Set(["red", "green", "blue"]); + colors.length; // 3 + colors.clear(); + colors.length; // 0 + ``` + + @method clear + @return {Ember.Set} An empty Set + */ + clear: function() { + if (this.isFrozen) { throw new Error(Ember.FROZEN_ERROR); } + + var len = get(this, 'length'); + if (len === 0) { return this; } + + var guid; + + this.enumerableContentWillChange(len, 0); + Ember.propertyWillChange(this, 'firstObject'); + Ember.propertyWillChange(this, 'lastObject'); + + for (var i=0; i < len; i++){ + guid = guidFor(this[i]); + delete this[guid]; + delete this[i]; + } + + set(this, 'length', 0); + + Ember.propertyDidChange(this, 'firstObject'); + Ember.propertyDidChange(this, 'lastObject'); + this.enumerableContentDidChange(len, 0); + + return this; + }, + + /** + Returns true if the passed object is also an enumerable that contains the + same objects as the receiver. + + ```javascript + var colors = ["red", "green", "blue"], + same_colors = new Ember.Set(colors); + + same_colors.isEqual(colors); // true + same_colors.isEqual(["purple", "brown"]); // false + ``` + + @method isEqual + @param {Ember.Set} obj the other object. + @return {Boolean} + */ + isEqual: function(obj) { + // fail fast + if (!Ember.Enumerable.detect(obj)) return false; + + var loc = get(this, 'length'); + if (get(obj, 'length') !== loc) return false; + + while(--loc >= 0) { + if (!obj.contains(this[loc])) return false; + } + + return true; + }, + + /** + Adds an object to the set. Only non-`null` objects can be added to a set + and those can only be added once. If the object is already in the set or + the passed value is null this method will have no effect. + + This is an alias for `Ember.MutableEnumerable.addObject()`. + + ```javascript + var colors = new Ember.Set(); + colors.add("blue"); // ["blue"] + colors.add("blue"); // ["blue"] + colors.add("red"); // ["blue", "red"] + colors.add(null); // ["blue", "red"] + colors.add(undefined); // ["blue", "red"] + ``` + + @method add + @param {Object} obj The object to add. + @return {Ember.Set} The set itself. + */ + add: Ember.aliasMethod('addObject'), + + /** + Removes the object from the set if it is found. If you pass a `null` value + or an object that is already not in the set, this method will have no + effect. This is an alias for `Ember.MutableEnumerable.removeObject()`. + + ```javascript + var colors = new Ember.Set(["red", "green", "blue"]); + colors.remove("red"); // ["blue", "green"] + colors.remove("purple"); // ["blue", "green"] + colors.remove(null); // ["blue", "green"] + ``` + + @method remove + @param {Object} obj The object to remove + @return {Ember.Set} The set itself. + */ + remove: Ember.aliasMethod('removeObject'), + + /** + Removes the last element from the set and returns it, or `null` if it's empty. + + ```javascript + var colors = new Ember.Set(["green", "blue"]); + colors.pop(); // "blue" + colors.pop(); // "green" + colors.pop(); // null + ``` + + @method pop + @return {Object} The removed object from the set or null. + */ + pop: function() { + if (get(this, 'isFrozen')) throw new Error(Ember.FROZEN_ERROR); + var obj = this.length > 0 ? this[this.length-1] : null; + this.remove(obj); + return obj; + }, + + /** + Inserts the given object on to the end of the set. It returns + the set itself. + + This is an alias for `Ember.MutableEnumerable.addObject()`. + + ```javascript + var colors = new Ember.Set(); + colors.push("red"); // ["red"] + colors.push("green"); // ["red", "green"] + colors.push("blue"); // ["red", "green", "blue"] + ``` + + @method push + @return {Ember.Set} The set itself. + */ + push: Ember.aliasMethod('addObject'), + + /** + Removes the last element from the set and returns it, or `null` if it's empty. + + This is an alias for `Ember.Set.pop()`. + + ```javascript + var colors = new Ember.Set(["green", "blue"]); + colors.shift(); // "blue" + colors.shift(); // "green" + colors.shift(); // null + ``` + + @method shift + @return {Object} The removed object from the set or null. + */ + shift: Ember.aliasMethod('pop'), + + /** + Inserts the given object on to the end of the set. It returns + the set itself. + + This is an alias of `Ember.Set.push()` + + ```javascript + var colors = new Ember.Set(); + colors.unshift("red"); // ["red"] + colors.unshift("green"); // ["red", "green"] + colors.unshift("blue"); // ["red", "green", "blue"] + ``` + + @method unshift + @return {Ember.Set} The set itself. + */ + unshift: Ember.aliasMethod('push'), + + /** + Adds each object in the passed enumerable to the set. + + This is an alias of `Ember.MutableEnumerable.addObjects()` + + ```javascript + var colors = new Ember.Set(); + colors.addEach(["red", "green", "blue"]); // ["red", "green", "blue"] + ``` + + @method addEach + @param {Ember.Enumerable} objects the objects to add. + @return {Ember.Set} The set itself. + */ + addEach: Ember.aliasMethod('addObjects'), + + /** + Removes each object in the passed enumerable to the set. + + This is an alias of `Ember.MutableEnumerable.removeObjects()` + + ```javascript + var colors = new Ember.Set(["red", "green", "blue"]); + colors.removeEach(["red", "blue"]); // ["green"] + ``` + + @method removeEach + @param {Ember.Enumerable} objects the objects to remove. + @return {Ember.Set} The set itself. + */ + removeEach: Ember.aliasMethod('removeObjects'), + + // .......................................................... + // PRIVATE ENUMERABLE SUPPORT + // + + init: function(items) { + this._super(); + if (items) this.addObjects(items); + }, + + // implement Ember.Enumerable + nextObject: function(idx) { + return this[idx]; + }, + + // more optimized version + firstObject: Ember.computed(function() { + return this.length > 0 ? this[0] : undefined; + }), + + // more optimized version + lastObject: Ember.computed(function() { + return this.length > 0 ? this[this.length-1] : undefined; + }), + + // implements Ember.MutableEnumerable + addObject: function(obj) { + if (get(this, 'isFrozen')) throw new Error(Ember.FROZEN_ERROR); + if (none(obj)) return this; // nothing to do + + var guid = guidFor(obj), + idx = this[guid], + len = get(this, 'length'), + added ; + + if (idx>=0 && idx=0 && idx=0; + }, + + copy: function() { + var C = this.constructor, ret = new C(), loc = get(this, 'length'); + set(ret, 'length', loc); + while(--loc>=0) { + ret[loc] = this[loc]; + ret[guidFor(this[loc])] = loc; + } + return ret; + }, + + toString: function() { + var len = this.length, idx, array = []; + for(idx = 0; idx < len; idx++) { + array[idx] = this[idx]; + } + return "Ember.Set<%@>".fmt(array.join(',')); + } + +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +/** + `Ember.Object` is the main base class for all Ember objects. It is a subclass + of `Ember.CoreObject` with the `Ember.Observable` mixin applied. For details, + see the documentation for each of these. + + @class Object + @namespace Ember + @extends Ember.CoreObject + @uses Ember.Observable +*/ +Ember.Object = Ember.CoreObject.extend(Ember.Observable); +Ember.Object.toString = function() { return "Ember.Object"; }; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, indexOf = Ember.ArrayPolyfills.indexOf; + +/** + A Namespace is an object usually used to contain other objects or methods + such as an application or framework. Create a namespace anytime you want + to define one of these new containers. + + # Example Usage + + ```javascript + MyFramework = Ember.Namespace.create({ + VERSION: '1.0.0' + }); + ``` + + @class Namespace + @namespace Ember + @extends Ember.Object +*/ +var Namespace = Ember.Namespace = Ember.Object.extend({ + isNamespace: true, + + init: function() { + Ember.Namespace.NAMESPACES.push(this); + Ember.Namespace.PROCESSED = false; + }, + + toString: function() { + var name = get(this, 'name'); + if (name) { return name; } + + findNamespaces(); + return this[Ember.GUID_KEY+'_name']; + }, + + nameClasses: function() { + processNamespace([this.toString()], this, {}); + }, + + destroy: function() { + var namespaces = Ember.Namespace.NAMESPACES; + Ember.lookup[this.toString()] = undefined; + namespaces.splice(indexOf.call(namespaces, this), 1); + this._super(); + } +}); + +Namespace.reopenClass({ + NAMESPACES: [Ember], + PROCESSED: false, + processAll: processAllNamespaces +}); + +var hasOwnProp = ({}).hasOwnProperty, + guidFor = Ember.guidFor; + +function processNamespace(paths, root, seen) { + var idx = paths.length; + + // Loop over all of the keys in the namespace, looking for classes + for(var key in root) { + if (!hasOwnProp.call(root, key)) { continue; } + var obj = root[key]; + + // If we are processing the `Ember` namespace, for example, the + // `paths` will start with `["Ember"]`. Every iteration through + // the loop will update the **second** element of this list with + // the key, so processing `Ember.View` will make the Array + // `['Ember', 'View']`. + paths[idx] = key; + + // If we have found an unprocessed class + if (obj && obj.toString === classToString) { + // Replace the class' `toString` with the dot-separated path + // and set its `NAME_KEY` + obj.toString = makeToString(paths.join('.')); + obj[NAME_KEY] = paths.join('.'); + + // Support nested namespaces + } else if (obj && obj.isNamespace) { + // Skip aliased namespaces + if (seen[guidFor(obj)]) { continue; } + seen[guidFor(obj)] = true; + + // Process the child namespace + processNamespace(paths, obj, seen); + } + } + + paths.length = idx; // cut out last item +} + +function findNamespaces() { + var Namespace = Ember.Namespace, lookup = Ember.lookup, obj, isNamespace; + + if (Namespace.PROCESSED) { return; } + + for (var prop in lookup) { + // These don't raise exceptions but can cause warnings + if (prop === "parent" || prop === "top" || prop === "frameElement") { continue; } + + // get(window.globalStorage, 'isNamespace') would try to read the storage for domain isNamespace and cause exception in Firefox. + // globalStorage is a storage obsoleted by the WhatWG storage specification. See https://developer.mozilla.org/en/DOM/Storage#globalStorage + if (prop === "globalStorage" && lookup.StorageList && lookup.globalStorage instanceof lookup.StorageList) { continue; } + // Unfortunately, some versions of IE don't support window.hasOwnProperty + if (lookup.hasOwnProperty && !lookup.hasOwnProperty(prop)) { continue; } + + // At times we are not allowed to access certain properties for security reasons. + // There are also times where even if we can access them, we are not allowed to access their properties. + try { + obj = Ember.lookup[prop]; + isNamespace = obj && obj.isNamespace; + } catch (e) { + continue; + } + + if (isNamespace) { + Ember.deprecate("Namespaces should not begin with lowercase.", /^[A-Z]/.test(prop)); + obj[NAME_KEY] = prop; + } + } +} + +var NAME_KEY = Ember.NAME_KEY = Ember.GUID_KEY + '_name'; + +function superClassString(mixin) { + var superclass = mixin.superclass; + if (superclass) { + if (superclass[NAME_KEY]) { return superclass[NAME_KEY]; } + else { return superClassString(superclass); } + } else { + return; + } +} + +function classToString() { + if (!Ember.BOOTED && !this[NAME_KEY]) { + processAllNamespaces(); + } + + var ret; + + if (this[NAME_KEY]) { + ret = this[NAME_KEY]; + } else { + var str = superClassString(this); + if (str) { + ret = "(subclass of " + str + ")"; + } else { + ret = "(unknown mixin)"; + } + this.toString = makeToString(ret); + } + + return ret; +} + +function processAllNamespaces() { + if (!Namespace.PROCESSED) { + findNamespaces(); + Namespace.PROCESSED = true; + } + + if (Ember.anyUnprocessedMixins) { + var namespaces = Namespace.NAMESPACES, namespace; + for (var i=0, l=namespaces.length; i=idx) { + var item = content.objectAt(loc); + if (item) { + Ember.addBeforeObserver(item, keyName, proxy, 'contentKeyWillChange'); + Ember.addObserver(item, keyName, proxy, 'contentKeyDidChange'); + + // keep track of the indicies each item was found at so we can map + // it back when the obj changes. + guid = guidFor(item); + if (!objects[guid]) objects[guid] = []; + objects[guid].push(loc); + } + } +} + +function removeObserverForContentKey(content, keyName, proxy, idx, loc) { + var objects = proxy._objects; + if (!objects) objects = proxy._objects = {}; + var indicies, guid; + + while(--loc>=idx) { + var item = content.objectAt(loc); + if (item) { + Ember.removeBeforeObserver(item, keyName, proxy, 'contentKeyWillChange'); + Ember.removeObserver(item, keyName, proxy, 'contentKeyDidChange'); + + guid = guidFor(item); + indicies = objects[guid]; + indicies[indicies.indexOf(loc)] = null; + } + } +} + +/** + This is the object instance returned when you get the `@each` property on an + array. It uses the unknownProperty handler to automatically create + EachArray instances for property names. + + @private + @class EachProxy + @namespace Ember + @extends Ember.Object +*/ +Ember.EachProxy = Ember.Object.extend({ + + init: function(content) { + this._super(); + this._content = content; + content.addArrayObserver(this); + + // in case someone is already observing some keys make sure they are + // added + forEach(Ember.watchedEvents(this), function(eventName) { + this.didAddListener(eventName); + }, this); + }, + + /** + You can directly access mapped properties by simply requesting them. + The `unknownProperty` handler will generate an EachArray of each item. + + @method unknownProperty + @param keyName {String} + @param value {anything} + */ + unknownProperty: function(keyName, value) { + var ret; + ret = new EachArray(this._content, keyName, this); + Ember.defineProperty(this, keyName, null, ret); + this.beginObservingContentKey(keyName); + return ret; + }, + + // .......................................................... + // ARRAY CHANGES + // Invokes whenever the content array itself changes. + + arrayWillChange: function(content, idx, removedCnt, addedCnt) { + var keys = this._keys, key, array, lim; + + lim = removedCnt>0 ? idx+removedCnt : -1; + Ember.beginPropertyChanges(this); + + for(key in keys) { + if (!keys.hasOwnProperty(key)) { continue; } + + if (lim>0) removeObserverForContentKey(content, key, this, idx, lim); + + Ember.propertyWillChange(this, key); + } + + Ember.propertyWillChange(this._content, '@each'); + Ember.endPropertyChanges(this); + }, + + arrayDidChange: function(content, idx, removedCnt, addedCnt) { + var keys = this._keys, key, array, lim; + + lim = addedCnt>0 ? idx+addedCnt : -1; + Ember.beginPropertyChanges(this); + + for(key in keys) { + if (!keys.hasOwnProperty(key)) { continue; } + + if (lim>0) addObserverForContentKey(content, key, this, idx, lim); + + Ember.propertyDidChange(this, key); + } + + Ember.propertyDidChange(this._content, '@each'); + Ember.endPropertyChanges(this); + }, + + // .......................................................... + // LISTEN FOR NEW OBSERVERS AND OTHER EVENT LISTENERS + // Start monitoring keys based on who is listening... + + didAddListener: function(eventName) { + if (IS_OBSERVER.test(eventName)) { + this.beginObservingContentKey(eventName.slice(0, -7)); + } + }, + + didRemoveListener: function(eventName) { + if (IS_OBSERVER.test(eventName)) { + this.stopObservingContentKey(eventName.slice(0, -7)); + } + }, + + // .......................................................... + // CONTENT KEY OBSERVING + // Actual watch keys on the source content. + + beginObservingContentKey: function(keyName) { + var keys = this._keys; + if (!keys) keys = this._keys = {}; + if (!keys[keyName]) { + keys[keyName] = 1; + var content = this._content, + len = get(content, 'length'); + addObserverForContentKey(content, keyName, this, 0, len); + } else { + keys[keyName]++; + } + }, + + stopObservingContentKey: function(keyName) { + var keys = this._keys; + if (keys && (keys[keyName]>0) && (--keys[keyName]<=0)) { + var content = this._content, + len = get(content, 'length'); + removeObserverForContentKey(content, keyName, this, 0, len); + } + }, + + contentKeyWillChange: function(obj, keyName) { + Ember.propertyWillChange(this, keyName); + }, + + contentKeyDidChange: function(obj, keyName) { + Ember.propertyDidChange(this, keyName); + } + +}); + + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + +var get = Ember.get, set = Ember.set; + +// Add Ember.Array to Array.prototype. Remove methods with native +// implementations and supply some more optimized versions of generic methods +// because they are so common. +var NativeArray = Ember.Mixin.create(Ember.MutableArray, Ember.Observable, Ember.Copyable, { + + // because length is a built-in property we need to know to just get the + // original property. + get: function(key) { + if (key==='length') return this.length; + else if ('number' === typeof key) return this[key]; + else return this._super(key); + }, + + objectAt: function(idx) { + return this[idx]; + }, + + // primitive for array support. + replace: function(idx, amt, objects) { + + if (this.isFrozen) throw Ember.FROZEN_ERROR ; + + // if we replaced exactly the same number of items, then pass only the + // replaced range. Otherwise, pass the full remaining array length + // since everything has shifted + var len = objects ? get(objects, 'length') : 0; + this.arrayContentWillChange(idx, amt, len); + + if (!objects || objects.length === 0) { + this.splice(idx, amt) ; + } else { + var args = [idx, amt].concat(objects) ; + this.splice.apply(this,args) ; + } + + this.arrayContentDidChange(idx, amt, len); + return this ; + }, + + // If you ask for an unknown property, then try to collect the value + // from member items. + unknownProperty: function(key, value) { + var ret;// = this.reducedProperty(key, value) ; + if ((value !== undefined) && ret === undefined) { + ret = this[key] = value; + } + return ret ; + }, + + // If browser did not implement indexOf natively, then override with + // specialized version + indexOf: function(object, startAt) { + var idx, len = this.length; + + if (startAt === undefined) startAt = 0; + else startAt = (startAt < 0) ? Math.ceil(startAt) : Math.floor(startAt); + if (startAt < 0) startAt += len; + + for(idx=startAt;idx=0;idx--) { + if (this[idx] === object) return idx ; + } + return -1; + }, + + copy: function(deep) { + if (deep) { + return this.map(function(item){ return Ember.copy(item, true); }); + } + + return this.slice(); + } +}); + +// Remove any methods implemented natively so we don't override them +var ignore = ['length']; +Ember.EnumerableUtils.forEach(NativeArray.keys(), function(methodName) { + if (Array.prototype[methodName]) ignore.push(methodName); +}); + +if (ignore.length>0) { + NativeArray = NativeArray.without.apply(NativeArray, ignore); +} + +/** + The NativeArray mixin contains the properties needed to to make the native + Array support Ember.MutableArray and all of its dependent APIs. Unless you + have `Ember.EXTEND_PROTOTYPES or `Ember.EXTEND_PROTOTYPES.Array` set to + false, this will be applied automatically. Otherwise you can apply the mixin + at anytime by calling `Ember.NativeArray.activate`. + + @class NativeArray + @namespace Ember + @extends Ember.Mixin + @uses Ember.MutableArray + @uses Ember.MutableEnumerable + @uses Ember.Copyable + @uses Ember.Freezable +*/ +Ember.NativeArray = NativeArray; + +/** + Creates an `Ember.NativeArray` from an Array like object. + Does not modify the original object. + + @method A + @for Ember + @return {Ember.NativeArray} +*/ +Ember.A = function(arr){ + if (arr === undefined) { arr = []; } + return Ember.Array.detect(arr) ? arr : Ember.NativeArray.apply(arr); +}; + +/** + Activates the mixin on the Array.prototype if not already applied. Calling + this method more than once is safe. + + @method activate + @for Ember.NativeArray + @static + @return {void} +*/ +Ember.NativeArray.activate = function() { + NativeArray.apply(Array.prototype); + + Ember.A = function(arr) { return arr || []; }; +}; + +if (Ember.EXTEND_PROTOTYPES === true || Ember.EXTEND_PROTOTYPES.Array) { + Ember.NativeArray.activate(); +} + + +})(); + + + +(function() { +var DeferredMixin = Ember.DeferredMixin, // mixins/deferred + EmberObject = Ember.Object, // system/object + get = Ember.get; + +var Deferred = Ember.Object.extend(DeferredMixin); + +Deferred.reopenClass({ + promise: function(callback, binding) { + var deferred = Deferred.create(); + callback.call(binding, deferred); + return get(deferred, 'promise'); + } +}); + +Ember.Deferred = Deferred; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var loadHooks = Ember.ENV.EMBER_LOAD_HOOKS || {}; +var loaded = {}; + +/** +@method onLoad +@for Ember +@param name {String} name of hook +@param callback {Function} callback to be called +*/ +Ember.onLoad = function(name, callback) { + var object; + + loadHooks[name] = loadHooks[name] || Ember.A(); + loadHooks[name].pushObject(callback); + + if (object = loaded[name]) { + callback(object); + } +}; + +/** +@method runLoadHooks +@for Ember +@param name {String} name of hook +@param object {Object} object to pass to callbacks +*/ +Ember.runLoadHooks = function(name, object) { + var hooks; + + loaded[name] = object; + + if (hooks = loadHooks[name]) { + loadHooks[name].forEach(function(callback) { + callback(object); + }); + } +}; + +})(); + + + +(function() { + +})(); + + + +(function() { +var get = Ember.get; + +/** +@module ember +@submodule ember-runtime +*/ + +/** + `Ember.ControllerMixin` provides a standard interface for all classes that + compose Ember's controller layer: `Ember.Controller`, + `Ember.ArrayController`, and `Ember.ObjectController`. + + Within an `Ember.Router`-managed application single shared instaces of every + Controller object in your application's namespace will be added to the + application's `Ember.Router` instance. See `Ember.Application#initialize` + for additional information. + + ## Views + + By default a controller instance will be the rendering context + for its associated `Ember.View.` This connection is made during calls to + `Ember.ControllerMixin#connectOutlet`. + + Within the view's template, the `Ember.View` instance can be accessed + through the controller with `{{view}}`. + + ## Target Forwarding + + By default a controller will target your application's `Ember.Router` + instance. Calls to `{{action}}` within the template of a controller's view + are forwarded to the router. See `Ember.Handlebars.helpers.action` for + additional information. + + @class ControllerMixin + @namespace Ember + @extends Ember.Mixin +*/ +Ember.ControllerMixin = Ember.Mixin.create({ + /* ducktype as a controller */ + isController: true, + + /** + The object to which events from the view should be sent. + + For example, when a Handlebars template uses the `{{action}}` helper, + it will attempt to send the event to the view's controller's `target`. + + By default, a controller's `target` is set to the router after it is + instantiated by `Ember.Application#initialize`. + + @property target + @default null + */ + target: null, + + container: null, + + store: null, + + model: Ember.computed.alias('content'), + + send: function(actionName) { + var args = [].slice.call(arguments, 1), target; + + if (this[actionName]) { + Ember.assert("The controller " + this + " does not have the action " + actionName, typeof this[actionName] === 'function'); + this[actionName].apply(this, args); + } else if(target = get(this, 'target')) { + Ember.assert("The target for controller " + this + " (" + target + ") did not define a `send` method", typeof target.send === 'function'); + target.send.apply(target, arguments); + } + } +}); + +/** + @class Controller + @namespace Ember + @extends Ember.Object + @uses Ember.ControllerMixin +*/ +Ember.Controller = Ember.Object.extend(Ember.ControllerMixin); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set, forEach = Ember.EnumerableUtils.forEach; + +/** + `Ember.SortableMixin` provides a standard interface for array proxies + to specify a sort order and maintain this sorting when objects are added, + removed, or updated without changing the implicit order of their underlying + content array: + + ```javascript + songs = [ + {trackNumber: 4, title: 'Ob-La-Di, Ob-La-Da'}, + {trackNumber: 2, title: 'Back in the U.S.S.R.'}, + {trackNumber: 3, title: 'Glass Onion'}, + ]; + + songsController = Ember.ArrayController.create({ + content: songs, + sortProperties: ['trackNumber'], + sortAscending: true + }); + + songsController.get('firstObject'); // {trackNumber: 2, title: 'Back in the U.S.S.R.'} + + songsController.addObject({trackNumber: 1, title: 'Dear Prudence'}); + songsController.get('firstObject'); // {trackNumber: 1, title: 'Dear Prudence'} + ``` + + @class SortableMixin + @namespace Ember + @extends Ember.Mixin + @uses Ember.MutableEnumerable +*/ +Ember.SortableMixin = Ember.Mixin.create(Ember.MutableEnumerable, { + + /** + Specifies which properties dictate the arrangedContent's sort order. + + @property {Array} sortProperties + */ + sortProperties: null, + + /** + Specifies the arrangedContent's sort direction + + @property {Boolean} sortAscending + */ + sortAscending: true, + + orderBy: function(item1, item2) { + var result = 0, + sortProperties = get(this, 'sortProperties'), + sortAscending = get(this, 'sortAscending'); + + Ember.assert("you need to define `sortProperties`", !!sortProperties); + + forEach(sortProperties, function(propertyName) { + if (result === 0) { + result = Ember.compare(get(item1, propertyName), get(item2, propertyName)); + if ((result !== 0) && !sortAscending) { + result = (-1) * result; + } + } + }); + + return result; + }, + + destroy: function() { + var content = get(this, 'content'), + sortProperties = get(this, 'sortProperties'); + + if (content && sortProperties) { + forEach(content, function(item) { + forEach(sortProperties, function(sortProperty) { + Ember.removeObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + } + + return this._super(); + }, + + isSorted: Ember.computed.bool('sortProperties'), + + arrangedContent: Ember.computed('content', 'sortProperties.@each', function(key, value) { + var content = get(this, 'content'), + isSorted = get(this, 'isSorted'), + sortProperties = get(this, 'sortProperties'), + self = this; + + if (content && isSorted) { + content = content.slice(); + content.sort(function(item1, item2) { + return self.orderBy(item1, item2); + }); + forEach(content, function(item) { + forEach(sortProperties, function(sortProperty) { + Ember.addObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + return Ember.A(content); + } + + return content; + }), + + _contentWillChange: Ember.beforeObserver(function() { + var content = get(this, 'content'), + sortProperties = get(this, 'sortProperties'); + + if (content && sortProperties) { + forEach(content, function(item) { + forEach(sortProperties, function(sortProperty) { + Ember.removeObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + } + + this._super(); + }, 'content'), + + sortAscendingWillChange: Ember.beforeObserver(function() { + this._lastSortAscending = get(this, 'sortAscending'); + }, 'sortAscending'), + + sortAscendingDidChange: Ember.observer(function() { + if (get(this, 'sortAscending') !== this._lastSortAscending) { + var arrangedContent = get(this, 'arrangedContent'); + arrangedContent.reverseObjects(); + } + }, 'sortAscending'), + + contentArrayWillChange: function(array, idx, removedCount, addedCount) { + var isSorted = get(this, 'isSorted'); + + if (isSorted) { + var arrangedContent = get(this, 'arrangedContent'); + var removedObjects = array.slice(idx, idx+removedCount); + var sortProperties = get(this, 'sortProperties'); + + forEach(removedObjects, function(item) { + arrangedContent.removeObject(item); + + forEach(sortProperties, function(sortProperty) { + Ember.removeObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + } + + return this._super(array, idx, removedCount, addedCount); + }, + + contentArrayDidChange: function(array, idx, removedCount, addedCount) { + var isSorted = get(this, 'isSorted'), + sortProperties = get(this, 'sortProperties'); + + if (isSorted) { + var addedObjects = array.slice(idx, idx+addedCount); + var arrangedContent = get(this, 'arrangedContent'); + + forEach(addedObjects, function(item) { + this.insertItemSorted(item); + + forEach(sortProperties, function(sortProperty) { + Ember.addObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + } + + return this._super(array, idx, removedCount, addedCount); + }, + + insertItemSorted: function(item) { + var arrangedContent = get(this, 'arrangedContent'); + var length = get(arrangedContent, 'length'); + + var idx = this._binarySearch(item, 0, length); + arrangedContent.insertAt(idx, item); + }, + + contentItemSortPropertyDidChange: function(item) { + var arrangedContent = get(this, 'arrangedContent'), + oldIndex = arrangedContent.indexOf(item), + leftItem = arrangedContent.objectAt(oldIndex - 1), + rightItem = arrangedContent.objectAt(oldIndex + 1), + leftResult = leftItem && this.orderBy(item, leftItem), + rightResult = rightItem && this.orderBy(item, rightItem); + + if (leftResult < 0 || rightResult > 0) { + arrangedContent.removeObject(item); + this.insertItemSorted(item); + } + }, + + _binarySearch: function(item, low, high) { + var mid, midItem, res, arrangedContent; + + if (low === high) { + return low; + } + + arrangedContent = get(this, 'arrangedContent'); + + mid = low + Math.floor((high - low) / 2); + midItem = arrangedContent.objectAt(mid); + + res = this.orderBy(midItem, item); + + if (res < 0) { + return this._binarySearch(item, mid+1, high); + } else if (res > 0) { + return this._binarySearch(item, low, mid); + } + + return mid; + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set, isGlobalPath = Ember.isGlobalPath, + forEach = Ember.EnumerableUtils.forEach, replace = Ember.EnumerableUtils.replace; + +/** + `Ember.ArrayController` provides a way for you to publish a collection of + objects so that you can easily bind to the collection from a Handlebars + `#each` helper, an `Ember.CollectionView`, or other controllers. + + The advantage of using an `ArrayController` is that you only have to set up + your view bindings once; to change what's displayed, simply swap out the + `content` property on the controller. + + For example, imagine you wanted to display a list of items fetched via an XHR + request. Create an `Ember.ArrayController` and set its `content` property: + + ```javascript + MyApp.listController = Ember.ArrayController.create(); + + $.get('people.json', function(data) { + MyApp.listController.set('content', data); + }); + ``` + + Then, create a view that binds to your new controller: + + ```handlebars + {{#each MyApp.listController}} + {{firstName}} {{lastName}} + {{/each}} + ``` + + Although you are binding to the controller, the behavior of this controller + is to pass through any methods or properties to the underlying array. This + capability comes from `Ember.ArrayProxy`, which this class inherits from. + + Sometimes you want to display computed properties within the body of an + `#each` helper that depend on the underlying items in `content`, but are not + present on those items. To do this, set `itemController` to the name of a + controller (probably an `ObjectController`) that will wrap each individual item. + + For example: + + ```handlebars + {{#each post in controller}} +
          • {{title}} ({{titleLength}} characters)
          • + {{/each}} + ``` + + ```javascript + App.PostsController = Ember.ArrayController.extend({ + itemController: 'post' + }); + + App.PostController = Ember.ObjectController.extend({ + // the `title` property will be proxied to the underlying post. + + titleLength: function() { + return this.get('title').length; + }.property('title') + }); + ``` + + In some cases it is helpful to return a different `itemController` depending + on the particular item. Subclasses can do this by overriding + `lookupItemController`. + + For example: + + ```javascript + App.MyArrayController = Ember.ArrayController.extend({ + lookupItemController: function( object ) { + if (object.get('isSpecial')) { + return "special"; // use App.SpecialController + } else { + return "regular"; // use App.RegularController + } + } + }); + ``` + + @class ArrayController + @namespace Ember + @extends Ember.ArrayProxy + @uses Ember.SortableMixin + @uses Ember.ControllerMixin +*/ + +Ember.ArrayController = Ember.ArrayProxy.extend(Ember.ControllerMixin, + Ember.SortableMixin, { + + /** + The controller used to wrap items, if any. + + @property itemController + @type String + @default null + */ + itemController: null, + + /** + Return the name of the controller to wrap items, or `null` if items should + be returned directly. The default implementation simply returns the + `itemController` property, but subclasses can override this method to return + different controllers for different objects. + + For example: + + ```javascript + App.MyArrayController = Ember.ArrayController.extend({ + lookupItemController: function( object ) { + if (object.get('isSpecial')) { + return "special"; // use App.SpecialController + } else { + return "regular"; // use App.RegularController + } + } + }); + ``` + + @method + @type String + @default null + */ + lookupItemController: function(object) { + return get(this, 'itemController'); + }, + + objectAtContent: function(idx) { + var length = get(this, 'length'), + object = get(this,'arrangedContent').objectAt(idx), + controllerClass = this.lookupItemController(object); + + if (controllerClass && idx < length) { + return this.controllerAt(idx, object, controllerClass); + } else { + // When controllerClass is falsy we have not opted in to using item + // controllers, so return the object directly. However, when + // controllerClass is defined but the index is out of range, we want to + // return the "out of range" value, whatever that might be. Rather than + // make assumptions (e.g. guessing `null` or `undefined`) we defer this to + // `arrangedContent`. + return object; + } + }, + + arrangedContentDidChange: function() { + this._super(); + this._resetSubContainers(); + }, + + arrayContentDidChange: function(idx, removedCnt, addedCnt) { + var subContainers = get(this, 'subContainers'), + subContainersToRemove = subContainers.slice(idx, idx+removedCnt); + + forEach(subContainersToRemove, function(subContainer) { + if (subContainer) { subContainer.destroy(); } + }); + + replace(subContainers, idx, removedCnt, new Array(addedCnt)); + + // The shadow array of subcontainers must be updated before we trigger + // observers, otherwise observers will get the wrong subcontainer when + // calling `objectAt` + this._super(idx, removedCnt, addedCnt); + }, + + init: function() { + this._super(); + this._resetSubContainers(); + }, + + controllerAt: function(idx, object, controllerClass) { + var container = get(this, 'container'), + subContainers = get(this, 'subContainers'), + subContainer = subContainers[idx], + controller; + + if (!subContainer) { + subContainer = subContainers[idx] = container.child(); + } + + controller = subContainer.lookup("controller:" + controllerClass); + if (!controller) { + throw new Error('Could not resolve itemController: "' + controllerClass + '"'); + } + + controller.set('target', this); + controller.set('content', object); + + return controller; + }, + + subContainers: null, + + _resetSubContainers: function() { + var subContainers = get(this, 'subContainers'); + + if (subContainers) { + forEach(subContainers, function(subContainer) { + if (subContainer) { subContainer.destroy(); } + }); + } + + this.set('subContainers', Ember.A()); + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +/** + `Ember.ObjectController` is part of Ember's Controller layer. A single shared + instance of each `Ember.ObjectController` subclass in your application's + namespace will be created at application initialization and be stored on your + application's `Ember.Router` instance. + + `Ember.ObjectController` derives its functionality from its superclass + `Ember.ObjectProxy` and the `Ember.ControllerMixin` mixin. + + @class ObjectController + @namespace Ember + @extends Ember.ObjectProxy + @uses Ember.ControllerMixin +**/ +Ember.ObjectController = Ember.ObjectProxy.extend(Ember.ControllerMixin); + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +Ember Runtime + +@module ember +@submodule ember-runtime +@requires ember-metal +*/ + +})(); + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var jQuery = Ember.imports.jQuery; +Ember.assert("Ember Views require jQuery 1.7 (>= 1.7.2), 1.8 or 1.9", jQuery && (jQuery().jquery.match(/^1\.(7(?!$)(?!\.[01])|8|9)(\.\d+)?(pre|rc\d?)?/) || Ember.ENV.FORCE_JQUERY)); + +/** + Alias for jQuery + + @method $ + @for Ember +*/ +Ember.$ = jQuery; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +// http://www.whatwg.org/specs/web-apps/current-work/multipage/dnd.html#dndevents +var dragEvents = Ember.String.w('dragstart drag dragenter dragleave dragover drop dragend'); + +// Copies the `dataTransfer` property from a browser event object onto the +// jQuery event object for the specified events +Ember.EnumerableUtils.forEach(dragEvents, function(eventName) { + Ember.$.event.fixHooks[eventName] = { props: ['dataTransfer'] }; +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +/*** BEGIN METAMORPH HELPERS ***/ + +// Internet Explorer prior to 9 does not allow setting innerHTML if the first element +// is a "zero-scope" element. This problem can be worked around by making +// the first node an invisible text node. We, like Modernizr, use ­ +var needsShy = (function(){ + var testEl = document.createElement('div'); + testEl.innerHTML = "
            "; + testEl.firstChild.innerHTML = ""; + return testEl.firstChild.innerHTML === ''; +})(); + +// IE 8 (and likely earlier) likes to move whitespace preceeding +// a script tag to appear after it. This means that we can +// accidentally remove whitespace when updating a morph. +var movesWhitespace = (function() { + var testEl = document.createElement('div'); + testEl.innerHTML = "Test: Value"; + return testEl.childNodes[0].nodeValue === 'Test:' && + testEl.childNodes[2].nodeValue === ' Value'; +})(); + +// Use this to find children by ID instead of using jQuery +var findChildById = function(element, id) { + if (element.getAttribute('id') === id) { return element; } + + var len = element.childNodes.length, idx, node, found; + for (idx=0; idx 0) { + var len = matches.length, idx; + for (idx=0; idxTest'); + canSet = el.options.length === 1; + } + + innerHTMLTags[tagName] = canSet; + + return canSet; +}; + +var setInnerHTML = function(element, html) { + var tagName = element.tagName; + + if (canSetInnerHTML(tagName)) { + setInnerHTMLWithoutFix(element, html); + } else { + Ember.assert("Can't set innerHTML on "+element.tagName+" in this browser", element.outerHTML); + + var startTag = element.outerHTML.match(new RegExp("<"+tagName+"([^>]*)>", 'i'))[0], + endTag = ''; + + var wrapper = document.createElement('div'); + setInnerHTMLWithoutFix(wrapper, startTag + html + endTag); + element = wrapper.firstChild; + while (element.tagName !== tagName) { + element = element.nextSibling; + } + } + + return element; +}; + +function isSimpleClick(event) { + var modifier = event.shiftKey || event.metaKey || event.altKey || event.ctrlKey, + secondaryClick = event.which > 1; // IE9 may return undefined + + return !modifier && !secondaryClick; +} + +Ember.ViewUtils = { + setInnerHTML: setInnerHTML, + isSimpleClick: isSimpleClick +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set; +var indexOf = Ember.ArrayPolyfills.indexOf; + + + + + +var ClassSet = function() { + this.seen = {}; + this.list = []; +}; + +ClassSet.prototype = { + add: function(string) { + if (string in this.seen) { return; } + this.seen[string] = true; + + this.list.push(string); + }, + + toDOM: function() { + return this.list.join(" "); + } +}; + +/** + `Ember.RenderBuffer` gathers information regarding the a view and generates the + final representation. `Ember.RenderBuffer` will generate HTML which can be pushed + to the DOM. + + @class RenderBuffer + @namespace Ember + @constructor +*/ +Ember.RenderBuffer = function(tagName) { + return new Ember._RenderBuffer(tagName); +}; + +Ember._RenderBuffer = function(tagName) { + this.tagNames = [tagName || null]; + this.buffer = []; +}; + +Ember._RenderBuffer.prototype = +/** @scope Ember.RenderBuffer.prototype */ { + + // The root view's element + _element: null, + + /** + @private + + An internal set used to de-dupe class names when `addClass()` is + used. After each call to `addClass()`, the `classes` property + will be updated. + + @property elementClasses + @type Array + @default [] + */ + elementClasses: null, + + /** + Array of class names which will be applied in the class attribute. + + You can use `setClasses()` to set this property directly. If you + use `addClass()`, it will be maintained for you. + + @property classes + @type Array + @default [] + */ + classes: null, + + /** + The id in of the element, to be applied in the id attribute. + + You should not set this property yourself, rather, you should use + the `id()` method of `Ember.RenderBuffer`. + + @property elementId + @type String + @default null + */ + elementId: null, + + /** + A hash keyed on the name of the attribute and whose value will be + applied to that attribute. For example, if you wanted to apply a + `data-view="Foo.bar"` property to an element, you would set the + elementAttributes hash to `{'data-view':'Foo.bar'}`. + + You should not maintain this hash yourself, rather, you should use + the `attr()` method of `Ember.RenderBuffer`. + + @property elementAttributes + @type Hash + @default {} + */ + elementAttributes: null, + + /** + The value for this attribute. Values cannot be set via attr after + jQuery 1.9, they need to be set with val() instead. + + You should not maintain this value yourself, rather, you should use + the `val()` method of `Ember.RenderBuffer`. + + @property elementValue + @type String + @default null + */ + elementValue: null, + + /** + The tagname of the element an instance of `Ember.RenderBuffer` represents. + + Usually, this gets set as the first parameter to `Ember.RenderBuffer`. For + example, if you wanted to create a `p` tag, then you would call + + ```javascript + Ember.RenderBuffer('p') + ``` + + @property elementTag + @type String + @default null + */ + elementTag: null, + + /** + A hash keyed on the name of the style attribute and whose value will + be applied to that attribute. For example, if you wanted to apply a + `background-color:black;` style to an element, you would set the + elementStyle hash to `{'background-color':'black'}`. + + You should not maintain this hash yourself, rather, you should use + the `style()` method of `Ember.RenderBuffer`. + + @property elementStyle + @type Hash + @default {} + */ + elementStyle: null, + + /** + Nested `RenderBuffers` will set this to their parent `RenderBuffer` + instance. + + @property parentBuffer + @type Ember._RenderBuffer + */ + parentBuffer: null, + + /** + Adds a string of HTML to the `RenderBuffer`. + + @method push + @param {String} string HTML to push into the buffer + @chainable + */ + push: function(string) { + this.buffer.push(string); + return this; + }, + + /** + Adds a class to the buffer, which will be rendered to the class attribute. + + @method addClass + @param {String} className Class name to add to the buffer + @chainable + */ + addClass: function(className) { + // lazily create elementClasses + var elementClasses = this.elementClasses = (this.elementClasses || new ClassSet()); + this.elementClasses.add(className); + this.classes = this.elementClasses.list; + + return this; + }, + + setClasses: function(classNames) { + this.classes = classNames; + }, + + /** + Sets the elementID to be used for the element. + + @method id + @param {String} id + @chainable + */ + id: function(id) { + this.elementId = id; + return this; + }, + + // duck type attribute functionality like jQuery so a render buffer + // can be used like a jQuery object in attribute binding scenarios. + + /** + Adds an attribute which will be rendered to the element. + + @method attr + @param {String} name The name of the attribute + @param {String} value The value to add to the attribute + @chainable + @return {Ember.RenderBuffer|String} this or the current attribute value + */ + attr: function(name, value) { + var attributes = this.elementAttributes = (this.elementAttributes || {}); + + if (arguments.length === 1) { + return attributes[name]; + } else { + attributes[name] = value; + } + + return this; + }, + + /** + Adds an value which will be rendered to the element. + + @method val + @param {String} value The value to set + @chainable + @return {Ember.RenderBuffer|String} this or the current value + */ + val: function(value) { + var elementValue = this.elementValue; + + if (arguments.length === 0) { + return elementValue; + } else { + this.elementValue = value; + } + + return this; + }, + + /** + Remove an attribute from the list of attributes to render. + + @method removeAttr + @param {String} name The name of the attribute + @chainable + */ + removeAttr: function(name) { + var attributes = this.elementAttributes; + if (attributes) { delete attributes[name]; } + + return this; + }, + + /** + Adds a style to the style attribute which will be rendered to the element. + + @method style + @param {String} name Name of the style + @param {String} value + @chainable + */ + style: function(name, value) { + var style = this.elementStyle = (this.elementStyle || {}); + + this.elementStyle[name] = value; + return this; + }, + + begin: function(tagName) { + this.tagNames.push(tagName || null); + return this; + }, + + pushOpeningTag: function() { + var tagName = this.currentTagName(); + if (!tagName) { return; } + + if (!this._element && this.buffer.length === 0) { + this._element = this.generateElement(); + return; + } + + var buffer = this.buffer, + id = this.elementId, + classes = this.classes, + attrs = this.elementAttributes, + value = this.elementValue, + style = this.elementStyle, + prop; + + buffer.push('<' + tagName); + + if (id) { + buffer.push(' id="' + this._escapeAttribute(id) + '"'); + this.elementId = null; + } + if (classes) { + buffer.push(' class="' + this._escapeAttribute(classes.join(' ')) + '"'); + this.classes = null; + } + + if (style) { + buffer.push(' style="'); + + for (prop in style) { + if (style.hasOwnProperty(prop)) { + buffer.push(prop + ':' + this._escapeAttribute(style[prop]) + ';'); + } + } + + buffer.push('"'); + + this.elementStyle = null; + } + + if (attrs) { + for (prop in attrs) { + if (attrs.hasOwnProperty(prop)) { + buffer.push(' ' + prop + '="' + this._escapeAttribute(attrs[prop]) + '"'); + } + } + + this.elementAttributes = null; + } + + if (value) { + buffer.push(' value="' + this._escapeAttribute(value) + '"'); + + this.elementValue = null; + } + + buffer.push('>'); + }, + + pushClosingTag: function() { + var tagName = this.tagNames.pop(); + if (tagName) { this.buffer.push(''); } + }, + + currentTagName: function() { + return this.tagNames[this.tagNames.length-1]; + }, + + generateElement: function() { + var tagName = this.tagNames.pop(), // pop since we don't need to close + element = document.createElement(tagName), + $element = Ember.$(element), + id = this.elementId, + classes = this.classes, + attrs = this.elementAttributes, + value = this.elementValue, + style = this.elementStyle, + styleBuffer = '', prop; + + if (id) { + $element.attr('id', id); + this.elementId = null; + } + if (classes) { + $element.attr('class', classes.join(' ')); + this.classes = null; + } + + if (style) { + for (prop in style) { + if (style.hasOwnProperty(prop)) { + styleBuffer += (prop + ':' + style[prop] + ';'); + } + } + + $element.attr('style', styleBuffer); + + this.elementStyle = null; + } + + if (attrs) { + for (prop in attrs) { + if (attrs.hasOwnProperty(prop)) { + $element.attr(prop, attrs[prop]); + } + } + + this.elementAttributes = null; + } + + if (value) { + $element.val(value); + + this.elementValue = null; + } + + return element; + }, + + /** + @method element + @return {DOMElement} The element corresponding to the generated HTML + of this buffer + */ + element: function() { + var html = this.innerString(); + + if (html) { + this._element = Ember.ViewUtils.setInnerHTML(this._element, html); + } + + return this._element; + }, + + /** + Generates the HTML content for this buffer. + + @method string + @return {String} The generated HTML + */ + string: function() { + if (this._element) { + return this.element().outerHTML; + } else { + return this.innerString(); + } + }, + + innerString: function() { + return this.buffer.join(''); + }, + + _escapeAttribute: function(value) { + // Stolen shamelessly from Handlebars + + var escape = { + "<": "<", + ">": ">", + '"': """, + "'": "'", + "`": "`" + }; + + var badChars = /&(?!\w+;)|[<>"'`]/g; + var possible = /[&<>"'`]/; + + var escapeChar = function(chr) { + return escape[chr] || "&"; + }; + + var string = value.toString(); + + if(!possible.test(string)) { return string; } + return string.replace(badChars, escapeChar); + } + +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, fmt = Ember.String.fmt; + +/** + `Ember.EventDispatcher` handles delegating browser events to their + corresponding `Ember.Views.` For example, when you click on a view, + `Ember.EventDispatcher` ensures that that view's `mouseDown` method gets + called. + + @class EventDispatcher + @namespace Ember + @private + @extends Ember.Object +*/ +Ember.EventDispatcher = Ember.Object.extend( +/** @scope Ember.EventDispatcher.prototype */{ + + /** + @private + + The root DOM element to which event listeners should be attached. Event + listeners will be attached to the document unless this is overridden. + + Can be specified as a DOMElement or a selector string. + + The default body is a string since this may be evaluated before document.body + exists in the DOM. + + @property rootElement + @type DOMElement + @default 'body' + */ + rootElement: 'body', + + /** + @private + + Sets up event listeners for standard browser events. + + This will be called after the browser sends a `DOMContentReady` event. By + default, it will set up all of the listeners on the document body. If you + would like to register the listeners on a different element, set the event + dispatcher's `root` property. + + @method setup + @param addedEvents {Hash} + */ + setup: function(addedEvents) { + var event, events = { + touchstart : 'touchStart', + touchmove : 'touchMove', + touchend : 'touchEnd', + touchcancel : 'touchCancel', + keydown : 'keyDown', + keyup : 'keyUp', + keypress : 'keyPress', + mousedown : 'mouseDown', + mouseup : 'mouseUp', + contextmenu : 'contextMenu', + click : 'click', + dblclick : 'doubleClick', + mousemove : 'mouseMove', + focusin : 'focusIn', + focusout : 'focusOut', + mouseenter : 'mouseEnter', + mouseleave : 'mouseLeave', + submit : 'submit', + input : 'input', + change : 'change', + dragstart : 'dragStart', + drag : 'drag', + dragenter : 'dragEnter', + dragleave : 'dragLeave', + dragover : 'dragOver', + drop : 'drop', + dragend : 'dragEnd' + }; + + Ember.$.extend(events, addedEvents || {}); + + var rootElement = Ember.$(get(this, 'rootElement')); + + Ember.assert(fmt('You cannot use the same root element (%@) multiple times in an Ember.Application', [rootElement.selector || rootElement[0].tagName]), !rootElement.is('.ember-application')); + Ember.assert('You cannot make a new Ember.Application using a root element that is a descendent of an existing Ember.Application', !rootElement.closest('.ember-application').length); + Ember.assert('You cannot make a new Ember.Application using a root element that is an ancestor of an existing Ember.Application', !rootElement.find('.ember-application').length); + + rootElement.addClass('ember-application'); + + Ember.assert('Unable to add "ember-application" class to rootElement. Make sure you set rootElement to the body or an element in the body.', rootElement.is('.ember-application')); + + for (event in events) { + if (events.hasOwnProperty(event)) { + this.setupHandler(rootElement, event, events[event]); + } + } + }, + + /** + @private + + Registers an event listener on the document. If the given event is + triggered, the provided event handler will be triggered on the target view. + + If the target view does not implement the event handler, or if the handler + returns `false`, the parent view will be called. The event will continue to + bubble to each successive parent view until it reaches the top. + + For example, to have the `mouseDown` method called on the target view when + a `mousedown` event is received from the browser, do the following: + + ```javascript + setupHandler('mousedown', 'mouseDown'); + ``` + + @method setupHandler + @param {Element} rootElement + @param {String} event the browser-originated event to listen to + @param {String} eventName the name of the method to call on the view + */ + setupHandler: function(rootElement, event, eventName) { + var self = this; + + rootElement.delegate('.ember-view', event + '.ember', function(evt, triggeringManager) { + return Ember.handleErrors(function() { + var view = Ember.View.views[this.id], + result = true, manager = null; + + manager = self._findNearestEventManager(view,eventName); + + if (manager && manager !== triggeringManager) { + result = self._dispatchEvent(manager, evt, eventName, view); + } else if (view) { + result = self._bubbleEvent(view,evt,eventName); + } else { + evt.stopPropagation(); + } + + return result; + }, this); + }); + + rootElement.delegate('[data-ember-action]', event + '.ember', function(evt) { + return Ember.handleErrors(function() { + var actionId = Ember.$(evt.currentTarget).attr('data-ember-action'), + action = Ember.Handlebars.ActionHelper.registeredActions[actionId]; + + // We have to check for action here since in some cases, jQuery will trigger + // an event on `removeChild` (i.e. focusout) after we've already torn down the + // action handlers for the view. + if (action && action.eventName === eventName) { + return action.handler(evt); + } + }, this); + }); + }, + + _findNearestEventManager: function(view, eventName) { + var manager = null; + + while (view) { + manager = get(view, 'eventManager'); + if (manager && manager[eventName]) { break; } + + view = get(view, 'parentView'); + } + + return manager; + }, + + _dispatchEvent: function(object, evt, eventName, view) { + var result = true; + + var handler = object[eventName]; + if (Ember.typeOf(handler) === 'function') { + result = handler.call(object, evt, view); + // Do not preventDefault in eventManagers. + evt.stopPropagation(); + } + else { + result = this._bubbleEvent(view, evt, eventName); + } + + return result; + }, + + _bubbleEvent: function(view, evt, eventName) { + return Ember.run(function() { + return view.handleEvent(eventName, evt); + }); + }, + + destroy: function() { + var rootElement = get(this, 'rootElement'); + Ember.$(rootElement).undelegate('.ember').removeClass('ember-application'); + return this._super(); + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +// Add a new named queue for rendering views that happens +// after bindings have synced, and a queue for scheduling actions +// that that should occur after view rendering. +var queues = Ember.run.queues; +queues.splice(Ember.$.inArray('actions', queues)+1, 0, 'render', 'afterRender'); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set; + +// Original class declaration and documentation in runtime/lib/controllers/controller.js +// NOTE: It may be possible with YUIDoc to combine docs in two locations + +/** +Additional methods for the ControllerMixin + +@class ControllerMixin +@namespace Ember +*/ +Ember.ControllerMixin.reopen({ + target: null, + namespace: null, + view: null, + container: null, + _childContainers: null, + + init: function() { + this._super(); + set(this, '_childContainers', {}); + }, + + _modelDidChange: Ember.observer(function() { + var containers = get(this, '_childContainers'), + container; + + for (var prop in containers) { + if (!containers.hasOwnProperty(prop)) { continue; } + containers[prop].destroy(); + } + + set(this, '_childContainers', {}); + }, 'model') +}); + +})(); + + + +(function() { + +})(); + + + +(function() { +var states = {}; + +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, addObserver = Ember.addObserver, removeObserver = Ember.removeObserver; +var meta = Ember.meta, guidFor = Ember.guidFor, fmt = Ember.String.fmt; +var a_slice = [].slice; +var a_forEach = Ember.EnumerableUtils.forEach; +var a_addObject = Ember.EnumerableUtils.addObject; + +var childViewsProperty = Ember.computed(function() { + var childViews = this._childViews, ret = Ember.A(), view = this; + + a_forEach(childViews, function(view) { + if (view.isVirtual) { + ret.pushObjects(get(view, 'childViews')); + } else { + ret.push(view); + } + }); + + ret.replace = function (idx, removedCount, addedViews) { + if (view instanceof Ember.ContainerView) { + Ember.deprecate("Manipulating a Ember.ContainerView through its childViews property is deprecated. Please use the ContainerView instance itself as an Ember.MutableArray."); + return view.replace(idx, removedCount, addedViews); + } + throw new Error("childViews is immutable"); + }; + + return ret; +}); + +Ember.warn("The VIEW_PRESERVES_CONTEXT flag has been removed and the functionality can no longer be disabled.", Ember.ENV.VIEW_PRESERVES_CONTEXT !== false); + +/** + Global hash of shared templates. This will automatically be populated + by the build tools so that you can store your Handlebars templates in + separate files that get loaded into JavaScript at buildtime. + + @property TEMPLATES + @for Ember + @type Hash +*/ +Ember.TEMPLATES = {}; + +Ember.CoreView = Ember.Object.extend(Ember.Evented, { + isView: true, + + states: states, + + init: function() { + this._super(); + + // Register the view for event handling. This hash is used by + // Ember.EventDispatcher to dispatch incoming events. + if (!this.isVirtual) { + Ember.assert("Attempted to register a view with an id already in use: "+this.elementId, !Ember.View.views[this.elementId]); + Ember.View.views[this.elementId] = this; + } + + this.addBeforeObserver('elementId', function() { + throw new Error("Changing a view's elementId after creation is not allowed"); + }); + + this.transitionTo('preRender'); + }, + + /** + If the view is currently inserted into the DOM of a parent view, this + property will point to the parent of the view. + + @property parentView + @type Ember.View + @default null + */ + parentView: Ember.computed(function() { + var parent = this._parentView; + + if (parent && parent.isVirtual) { + return get(parent, 'parentView'); + } else { + return parent; + } + }).property('_parentView'), + + state: null, + + _parentView: null, + + // return the current view, not including virtual views + concreteView: Ember.computed(function() { + if (!this.isVirtual) { return this; } + else { return get(this, 'parentView'); } + }).property('parentView').volatile(), + + instrumentName: 'core_view', + + instrumentDetails: function(hash) { + hash.object = this.toString(); + }, + + /** + @private + + Invoked by the view system when this view needs to produce an HTML + representation. This method will create a new render buffer, if needed, + then apply any default attributes, such as class names and visibility. + Finally, the `render()` method is invoked, which is responsible for + doing the bulk of the rendering. + + You should not need to override this method; instead, implement the + `template` property, or if you need more control, override the `render` + method. + + @method renderToBuffer + @param {Ember.RenderBuffer} buffer the render buffer. If no buffer is + passed, a default buffer, using the current view's `tagName`, will + be used. + */ + renderToBuffer: function(parentBuffer, bufferOperation) { + var name = 'render.' + this.instrumentName, + details = {}; + + this.instrumentDetails(details); + + return Ember.instrument(name, details, function() { + return this._renderToBuffer(parentBuffer, bufferOperation); + }, this); + }, + + _renderToBuffer: function(parentBuffer, bufferOperation) { + Ember.run.sync(); + + // If this is the top-most view, start a new buffer. Otherwise, + // create a new buffer relative to the original using the + // provided buffer operation (for example, `insertAfter` will + // insert a new buffer after the "parent buffer"). + var tagName = this.tagName; + + if (tagName === null || tagName === undefined) { + tagName = 'div'; + } + + var buffer = this.buffer = parentBuffer && parentBuffer.begin(tagName) || Ember.RenderBuffer(tagName); + this.transitionTo('inBuffer', false); + + this.beforeRender(buffer); + this.render(buffer); + this.afterRender(buffer); + + return buffer; + }, + + /** + @private + + Override the default event firing from `Ember.Evented` to + also call methods with the given name. + + @method trigger + @param name {String} + */ + trigger: function(name) { + this._super.apply(this, arguments); + var method = this[name]; + if (method) { + var args = [], i, l; + for (i = 1, l = arguments.length; i < l; i++) { + args.push(arguments[i]); + } + return method.apply(this, args); + } + }, + + has: function(name) { + return Ember.typeOf(this[name]) === 'function' || this._super(name); + }, + + willDestroy: function() { + var parent = this._parentView; + + // destroy the element -- this will avoid each child view destroying + // the element over and over again... + if (!this.removedFromDOM) { this.destroyElement(); } + + // remove from parent if found. Don't call removeFromParent, + // as removeFromParent will try to remove the element from + // the DOM again. + if (parent) { parent.removeChild(this); } + + this.transitionTo('destroyed'); + + // next remove view from global hash + if (!this.isVirtual) delete Ember.View.views[this.elementId]; + }, + + clearRenderedChildren: Ember.K, + triggerRecursively: Ember.K, + invokeRecursively: Ember.K, + transitionTo: Ember.K, + destroyElement: Ember.K +}); + +/** + `Ember.View` is the class in Ember responsible for encapsulating templates of + HTML content, combining templates with data to render as sections of a page's + DOM, and registering and responding to user-initiated events. + + ## HTML Tag + + The default HTML tag name used for a view's DOM representation is `div`. This + can be customized by setting the `tagName` property. The following view +class: + + ```javascript + ParagraphView = Ember.View.extend({ + tagName: 'em' + }); + ``` + + Would result in instances with the following HTML: + + ```html + + ``` + + ## HTML `class` Attribute + + The HTML `class` attribute of a view's tag can be set by providing a + `classNames` property that is set to an array of strings: + + ```javascript + MyView = Ember.View.extend({ + classNames: ['my-class', 'my-other-class'] + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + `class` attribute values can also be set by providing a `classNameBindings` + property set to an array of properties names for the view. The return value + of these properties will be added as part of the value for the view's `class` + attribute. These properties can be computed properties: + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['propertyA', 'propertyB'], + propertyA: 'from-a', + propertyB: function(){ + if(someLogic){ return 'from-b'; } + }.property() + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + If the value of a class name binding returns a boolean the property name + itself will be used as the class name if the property is true. The class name + will not be added if the value is `false` or `undefined`. + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['hovered'], + hovered: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + When using boolean class name bindings you can supply a string value other + than the property name for use as the `class` HTML attribute by appending the + preferred value after a ":" character when defining the binding: + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['awesome:so-very-cool'], + awesome: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + Boolean value class name bindings whose property names are in a + camelCase-style format will be converted to a dasherized format: + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['isUrgent'], + isUrgent: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + Class name bindings can also refer to object values that are found by + traversing a path relative to the view itself: + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['messages.empty'] + messages: Ember.Object.create({ + empty: true + }) + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + If you want to add a class name for a property which evaluates to true and + and a different class name if it evaluates to false, you can pass a binding + like this: + + ```javascript + // Applies 'enabled' class when isEnabled is true and 'disabled' when isEnabled is false + Ember.View.create({ + classNameBindings: ['isEnabled:enabled:disabled'] + isEnabled: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + When isEnabled is `false`, the resulting HTML reprensentation looks like + this: + + ```html +
            + ``` + + This syntax offers the convenience to add a class if a property is `false`: + + ```javascript + // Applies no class when isEnabled is true and class 'disabled' when isEnabled is false + Ember.View.create({ + classNameBindings: ['isEnabled::disabled'] + isEnabled: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + When the `isEnabled` property on the view is set to `false`, it will result + in view instances with an HTML representation of: + + ```html +
            + ``` + + Updates to the the value of a class name binding will result in automatic + update of the HTML `class` attribute in the view's rendered HTML + representation. If the value becomes `false` or `undefined` the class name + will be removed. + + Both `classNames` and `classNameBindings` are concatenated properties. See + `Ember.Object` documentation for more information about concatenated + properties. + + ## HTML Attributes + + The HTML attribute section of a view's tag can be set by providing an + `attributeBindings` property set to an array of property names on the view. + The return value of these properties will be used as the value of the view's + HTML associated attribute: + + ```javascript + AnchorView = Ember.View.extend({ + tagName: 'a', + attributeBindings: ['href'], + href: 'http://google.com' + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html + + ``` + + If the return value of an `attributeBindings` monitored property is a boolean + the property will follow HTML's pattern of repeating the attribute's name as + its value: + + ```javascript + MyTextInput = Ember.View.extend({ + tagName: 'input', + attributeBindings: ['disabled'], + disabled: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html + + ``` + + `attributeBindings` can refer to computed properties: + + ```javascript + MyTextInput = Ember.View.extend({ + tagName: 'input', + attributeBindings: ['disabled'], + disabled: function(){ + if (someLogic) { + return true; + } else { + return false; + } + }.property() + }); + ``` + + Updates to the the property of an attribute binding will result in automatic + update of the HTML attribute in the view's rendered HTML representation. + + `attributeBindings` is a concatenated property. See `Ember.Object` + documentation for more information about concatenated properties. + + ## Templates + + The HTML contents of a view's rendered representation are determined by its + template. Templates can be any function that accepts an optional context + parameter and returns a string of HTML that will be inserted within the + view's tag. Most typically in Ember this function will be a compiled + `Ember.Handlebars` template. + + ```javascript + AView = Ember.View.extend({ + template: Ember.Handlebars.compile('I am the template') + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            I am the template
            + ``` + + Within an Ember application is more common to define a Handlebars templates as + part of a page: + + ```html + + ``` + + And associate it by name using a view's `templateName` property: + + ```javascript + AView = Ember.View.extend({ + templateName: 'some-template' + }); + ``` + + Using a value for `templateName` that does not have a Handlebars template + with a matching `data-template-name` attribute will throw an error. + + Assigning a value to both `template` and `templateName` properties will throw + an error. + + For views classes that may have a template later defined (e.g. as the block + portion of a `{{view}}` Handlebars helper call in another template or in + a subclass), you can provide a `defaultTemplate` property set to compiled + template function. If a template is not later provided for the view instance + the `defaultTemplate` value will be used: + + ```javascript + AView = Ember.View.extend({ + defaultTemplate: Ember.Handlebars.compile('I was the default'), + template: null, + templateName: null + }); + ``` + + Will result in instances with an HTML representation of: + + ```html +
            I was the default
            + ``` + + If a `template` or `templateName` is provided it will take precedence over + `defaultTemplate`: + + ```javascript + AView = Ember.View.extend({ + defaultTemplate: Ember.Handlebars.compile('I was the default') + }); + + aView = AView.create({ + template: Ember.Handlebars.compile('I was the template, not default') + }); + ``` + + Will result in the following HTML representation when rendered: + + ```html +
            I was the template, not default
            + ``` + + ## View Context + + The default context of the compiled template is the view's controller: + + ```javascript + AView = Ember.View.extend({ + template: Ember.Handlebars.compile('Hello {{excitedGreeting}}') + }); + + aController = Ember.Object.create({ + firstName: 'Barry', + excitedGreeting: function(){ + return this.get("content.firstName") + "!!!" + }.property() + }); + + aView = AView.create({ + controller: aController, + }); + ``` + + Will result in an HTML representation of: + + ```html +
            Hello Barry!!!
            + ``` + + A context can also be explicitly supplied through the view's `context` + property. If the view has neither `context` nor `controller` properties, the + `parentView`'s context will be used. + + ## Layouts + + Views can have a secondary template that wraps their main template. Like + primary templates, layouts can be any function that accepts an optional + context parameter and returns a string of HTML that will be inserted inside + view's tag. Views whose HTML element is self closing (e.g. ``) + cannot have a layout and this property will be ignored. + + Most typically in Ember a layout will be a compiled `Ember.Handlebars` + template. + + A view's layout can be set directly with the `layout` property or reference + an existing Handlebars template by name with the `layoutName` property. + + A template used as a layout must contain a single use of the Handlebars + `{{yield}}` helper. The HTML contents of a view's rendered `template` will be + inserted at this location: + + ```javascript + AViewWithLayout = Ember.View.extend({ + layout: Ember.Handlebars.compile("
            {{yield}}
            ") + template: Ember.Handlebars.compile("I got wrapped"), + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            +
            + I got wrapped +
            +
            + ``` + + See `Handlebars.helpers.yield` for more information. + + ## Responding to Browser Events + + Views can respond to user-initiated events in one of three ways: method + implementation, through an event manager, and through `{{action}}` helper use + in their template or layout. + + ### Method Implementation + + Views can respond to user-initiated events by implementing a method that + matches the event name. A `jQuery.Event` object will be passed as the + argument to this method. + + ```javascript + AView = Ember.View.extend({ + click: function(event){ + // will be called when when an instance's + // rendered element is clicked + } + }); + ``` + + ### Event Managers + + Views can define an object as their `eventManager` property. This object can + then implement methods that match the desired event names. Matching events + that occur on the view's rendered HTML or the rendered HTML of any of its DOM + descendants will trigger this method. A `jQuery.Event` object will be passed + as the first argument to the method and an `Ember.View` object as the + second. The `Ember.View` will be the view whose rendered HTML was interacted + with. This may be the view with the `eventManager` property or one of its + descendent views. + + ```javascript + AView = Ember.View.extend({ + eventManager: Ember.Object.create({ + doubleClick: function(event, view){ + // will be called when when an instance's + // rendered element or any rendering + // of this views's descendent + // elements is clicked + } + }) + }); + ``` + + An event defined for an event manager takes precedence over events of the + same name handled through methods on the view. + + ```javascript + AView = Ember.View.extend({ + mouseEnter: function(event){ + // will never trigger. + }, + eventManager: Ember.Object.create({ + mouseEnter: function(event, view){ + // takes presedence over AView#mouseEnter + } + }) + }); + ``` + + Similarly a view's event manager will take precedence for events of any views + rendered as a descendent. A method name that matches an event name will not + be called if the view instance was rendered inside the HTML representation of + a view that has an `eventManager` property defined that handles events of the + name. Events not handled by the event manager will still trigger method calls + on the descendent. + + ```javascript + OuterView = Ember.View.extend({ + template: Ember.Handlebars.compile("outer {{#view InnerView}}inner{{/view}} outer"), + eventManager: Ember.Object.create({ + mouseEnter: function(event, view){ + // view might be instance of either + // OutsideView or InnerView depending on + // where on the page the user interaction occured + } + }) + }); + + InnerView = Ember.View.extend({ + click: function(event){ + // will be called if rendered inside + // an OuterView because OuterView's + // eventManager doesn't handle click events + }, + mouseEnter: function(event){ + // will never be called if rendered inside + // an OuterView. + } + }); + ``` + + ### Handlebars `{{action}}` Helper + + See `Handlebars.helpers.action`. + + ### Event Names + + Possible events names for any of the responding approaches described above + are: + + Touch events: + + * `touchStart` + * `touchMove` + * `touchEnd` + * `touchCancel` + + Keyboard events + + * `keyDown` + * `keyUp` + * `keyPress` + + Mouse events + + * `mouseDown` + * `mouseUp` + * `contextMenu` + * `click` + * `doubleClick` + * `mouseMove` + * `focusIn` + * `focusOut` + * `mouseEnter` + * `mouseLeave` + + Form events: + + * `submit` + * `change` + * `focusIn` + * `focusOut` + * `input` + + HTML5 drag and drop events: + + * `dragStart` + * `drag` + * `dragEnter` + * `dragLeave` + * `drop` + * `dragEnd` + + ## Handlebars `{{view}}` Helper + + Other `Ember.View` instances can be included as part of a view's template by + using the `{{view}}` Handlebars helper. See `Handlebars.helpers.view` for + additional information. + + @class View + @namespace Ember + @extends Ember.Object + @uses Ember.Evented +*/ +Ember.View = Ember.CoreView.extend( +/** @scope Ember.View.prototype */ { + + concatenatedProperties: ['classNames', 'classNameBindings', 'attributeBindings'], + + /** + @property isView + @type Boolean + @default true + @final + */ + isView: true, + + // .......................................................... + // TEMPLATE SUPPORT + // + + /** + The name of the template to lookup if no template is provided. + + `Ember.View` will look for a template with this name in this view's + `templates` object. By default, this will be a global object + shared in `Ember.TEMPLATES`. + + @property templateName + @type String + @default null + */ + templateName: null, + + /** + The name of the layout to lookup if no layout is provided. + + `Ember.View` will look for a template with this name in this view's + `templates` object. By default, this will be a global object + shared in `Ember.TEMPLATES`. + + @property layoutName + @type String + @default null + */ + layoutName: null, + + /** + The hash in which to look for `templateName`. + + @property templates + @type Ember.Object + @default Ember.TEMPLATES + */ + templates: Ember.TEMPLATES, + + /** + The template used to render the view. This should be a function that + accepts an optional context parameter and returns a string of HTML that + will be inserted into the DOM relative to its parent view. + + In general, you should set the `templateName` property instead of setting + the template yourself. + + @property template + @type Function + */ + template: Ember.computed(function(key, value) { + if (value !== undefined) { return value; } + + var templateName = get(this, 'templateName'), + template = this.templateForName(templateName, 'template'); + + Ember.assert("You specified the templateName " + templateName + " for " + this + ", but it did not exist.", !templateName || template); + + return template || get(this, 'defaultTemplate'); + }).property('templateName'), + + container: Ember.computed(function() { + var parentView = get(this, '_parentView'); + + if (parentView) { return get(parentView, 'container'); } + + return Ember.Container && Ember.Container.defaultContainer; + }), + + /** + The controller managing this view. If this property is set, it will be + made available for use by the template. + + @property controller + @type Object + */ + controller: Ember.computed(function(key) { + var parentView = get(this, '_parentView'); + return parentView ? get(parentView, 'controller') : null; + }).property('_parentView'), + + /** + A view may contain a layout. A layout is a regular template but + supersedes the `template` property during rendering. It is the + responsibility of the layout template to retrieve the `template` + property from the view (or alternatively, call `Handlebars.helpers.yield`, + `{{yield}}`) to render it in the correct location. + + This is useful for a view that has a shared wrapper, but which delegates + the rendering of the contents of the wrapper to the `template` property + on a subclass. + + @property layout + @type Function + */ + layout: Ember.computed(function(key) { + var layoutName = get(this, 'layoutName'), + layout = this.templateForName(layoutName, 'layout'); + + Ember.assert("You specified the layoutName " + layoutName + " for " + this + ", but it did not exist.", !layoutName || layout); + + return layout || get(this, 'defaultLayout'); + }).property('layoutName'), + + templateForName: function(name, type) { + if (!name) { return; } + + Ember.assert("templateNames are not allowed to contain periods: "+name, name.indexOf('.') === -1); + + var container = get(this, 'container'); + + if (container) { + return container.lookup('template:' + name); + } + }, + + /** + The object from which templates should access properties. + + This object will be passed to the template function each time the render + method is called, but it is up to the individual function to decide what + to do with it. + + By default, this will be the view's controller. + + @property context + @type Object + */ + context: Ember.computed(function(key, value) { + if (arguments.length === 2) { + set(this, '_context', value); + return value; + } else { + return get(this, '_context'); + } + }).volatile(), + + /** + @private + + Private copy of the view's template context. This can be set directly + by Handlebars without triggering the observer that causes the view + to be re-rendered. + + The context of a view is looked up as follows: + + 1. Supplied context (usually by Handlebars) + 2. Specified controller + 3. `parentView`'s context (for a child of a ContainerView) + + The code in Handlebars that overrides the `_context` property first + checks to see whether the view has a specified controller. This is + something of a hack and should be revisited. + + @property _context + */ + _context: Ember.computed(function(key) { + var parentView, controller; + + if (controller = get(this, 'controller')) { + return controller; + } + + parentView = this._parentView; + if (parentView) { + return get(parentView, '_context'); + } + + return null; + }), + + /** + @private + + If a value that affects template rendering changes, the view should be + re-rendered to reflect the new value. + + @method _displayPropertyDidChange + */ + _contextDidChange: Ember.observer(function() { + this.rerender(); + }, 'context'), + + /** + If `false`, the view will appear hidden in DOM. + + @property isVisible + @type Boolean + @default null + */ + isVisible: true, + + /** + @private + + Array of child views. You should never edit this array directly. + Instead, use `appendChild` and `removeFromParent`. + + @property childViews + @type Array + @default [] + */ + childViews: childViewsProperty, + + _childViews: [], + + // When it's a virtual view, we need to notify the parent that their + // childViews will change. + _childViewsWillChange: Ember.beforeObserver(function() { + if (this.isVirtual) { + var parentView = get(this, 'parentView'); + if (parentView) { Ember.propertyWillChange(parentView, 'childViews'); } + } + }, 'childViews'), + + // When it's a virtual view, we need to notify the parent that their + // childViews did change. + _childViewsDidChange: Ember.observer(function() { + if (this.isVirtual) { + var parentView = get(this, 'parentView'); + if (parentView) { Ember.propertyDidChange(parentView, 'childViews'); } + } + }, 'childViews'), + + /** + Return the nearest ancestor that is an instance of the provided + class. + + @property nearestInstanceOf + @param {Class} klass Subclass of Ember.View (or Ember.View itself) + @return Ember.View + @deprecated + */ + nearestInstanceOf: function(klass) { + Ember.deprecate("nearestInstanceOf is deprecated and will be removed from future releases. Use nearestOfType."); + var view = get(this, 'parentView'); + + while (view) { + if(view instanceof klass) { return view; } + view = get(view, 'parentView'); + } + }, + + /** + Return the nearest ancestor that is an instance of the provided + class or mixin. + + @property nearestOfType + @param {Class,Mixin} klass Subclass of Ember.View (or Ember.View itself), + or an instance of Ember.Mixin. + @return Ember.View + */ + nearestOfType: function(klass) { + var view = get(this, 'parentView'), + isOfType = klass instanceof Ember.Mixin ? + function(view) { return klass.detect(view); } : + function(view) { return klass.detect(view.constructor); }; + + while (view) { + if( isOfType(view) ) { return view; } + view = get(view, 'parentView'); + } + }, + + /** + Return the nearest ancestor that has a given property. + + @property nearestWithProperty + @param {String} property A property name + @return Ember.View + */ + nearestWithProperty: function(property) { + var view = get(this, 'parentView'); + + while (view) { + if (property in view) { return view; } + view = get(view, 'parentView'); + } + }, + + /** + Return the nearest ancestor whose parent is an instance of + `klass`. + + @property nearestChildOf + @param {Class} klass Subclass of Ember.View (or Ember.View itself) + @return Ember.View + */ + nearestChildOf: function(klass) { + var view = get(this, 'parentView'); + + while (view) { + if(get(view, 'parentView') instanceof klass) { return view; } + view = get(view, 'parentView'); + } + }, + + /** + @private + + When the parent view changes, recursively invalidate `controller` + + @method _parentViewDidChange + */ + _parentViewDidChange: Ember.observer(function() { + if (this.isDestroying) { return; } + + if (get(this, 'parentView.controller') && !get(this, 'controller')) { + this.notifyPropertyChange('controller'); + } + }, '_parentView'), + + _controllerDidChange: Ember.observer(function() { + if (this.isDestroying) { return; } + + this.rerender(); + + this.forEachChildView(function(view) { + view.propertyDidChange('controller'); + }); + }, 'controller'), + + cloneKeywords: function() { + var templateData = get(this, 'templateData'); + + var keywords = templateData ? Ember.copy(templateData.keywords) : {}; + set(keywords, 'view', get(this, 'concreteView')); + set(keywords, '_view', this); + set(keywords, 'controller', get(this, 'controller')); + + return keywords; + }, + + /** + Called on your view when it should push strings of HTML into a + `Ember.RenderBuffer`. Most users will want to override the `template` + or `templateName` properties instead of this method. + + By default, `Ember.View` will look for a function in the `template` + property and invoke it with the value of `context`. The value of + `context` will be the view's controller unless you override it. + + @method render + @param {Ember.RenderBuffer} buffer The render buffer + */ + render: function(buffer) { + // If this view has a layout, it is the responsibility of the + // the layout to render the view's template. Otherwise, render the template + // directly. + var template = get(this, 'layout') || get(this, 'template'); + + if (template) { + var context = get(this, 'context'); + var keywords = this.cloneKeywords(); + var output; + + var data = { + view: this, + buffer: buffer, + isRenderData: true, + keywords: keywords, + insideGroup: get(this, 'templateData.insideGroup') + }; + + // Invoke the template with the provided template context, which + // is the view's controller by default. A hash of data is also passed that provides + // the template with access to the view and render buffer. + + Ember.assert('template must be a function. Did you mean to call Ember.Handlebars.compile("...") or specify templateName instead?', typeof template === 'function'); + // The template should write directly to the render buffer instead + // of returning a string. + output = template(context, { data: data }); + + // If the template returned a string instead of writing to the buffer, + // push the string onto the buffer. + if (output !== undefined) { buffer.push(output); } + } + }, + + /** + Renders the view again. This will work regardless of whether the + view is already in the DOM or not. If the view is in the DOM, the + rendering process will be deferred to give bindings a chance + to synchronize. + + If children were added during the rendering process using `appendChild`, + `rerender` will remove them, because they will be added again + if needed by the next `render`. + + In general, if the display of your view changes, you should modify + the DOM element directly instead of manually calling `rerender`, which can + be slow. + + @method rerender + */ + rerender: function() { + return this.currentState.rerender(this); + }, + + clearRenderedChildren: function() { + var lengthBefore = this.lengthBeforeRender, + lengthAfter = this.lengthAfterRender; + + // If there were child views created during the last call to render(), + // remove them under the assumption that they will be re-created when + // we re-render. + + // VIEW-TODO: Unit test this path. + var childViews = this._childViews; + for (var i=lengthAfter-1; i>=lengthBefore; i--) { + if (childViews[i]) { childViews[i].destroy(); } + } + }, + + /** + @private + + Iterates over the view's `classNameBindings` array, inserts the value + of the specified property into the `classNames` array, then creates an + observer to update the view's element if the bound property ever changes + in the future. + + @method _applyClassNameBindings + */ + _applyClassNameBindings: function(classBindings) { + var classNames = this.classNames, + elem, newClass, dasherizedClass; + + // Loop through all of the configured bindings. These will be either + // property names ('isUrgent') or property paths relative to the view + // ('content.isUrgent') + a_forEach(classBindings, function(binding) { + + // Variable in which the old class value is saved. The observer function + // closes over this variable, so it knows which string to remove when + // the property changes. + var oldClass; + // Extract just the property name from bindings like 'foo:bar' + var parsedPath = Ember.View._parsePropertyPath(binding); + + // Set up an observer on the context. If the property changes, toggle the + // class name. + var observer = function() { + // Get the current value of the property + newClass = this._classStringForProperty(binding); + elem = this.$(); + + // If we had previously added a class to the element, remove it. + if (oldClass) { + elem.removeClass(oldClass); + // Also remove from classNames so that if the view gets rerendered, + // the class doesn't get added back to the DOM. + classNames.removeObject(oldClass); + } + + // If necessary, add a new class. Make sure we keep track of it so + // it can be removed in the future. + if (newClass) { + elem.addClass(newClass); + oldClass = newClass; + } else { + oldClass = null; + } + }; + + // Get the class name for the property at its current value + dasherizedClass = this._classStringForProperty(binding); + + if (dasherizedClass) { + // Ensure that it gets into the classNames array + // so it is displayed when we render. + a_addObject(classNames, dasherizedClass); + + // Save a reference to the class name so we can remove it + // if the observer fires. Remember that this variable has + // been closed over by the observer. + oldClass = dasherizedClass; + } + + this.registerObserver(this, parsedPath.path, observer); + // Remove className so when the view is rerendered, + // the className is added based on binding reevaluation + this.one('willClearRender', function() { + if (oldClass) { + classNames.removeObject(oldClass); + oldClass = null; + } + }); + + }, this); + }, + + /** + @private + + Iterates through the view's attribute bindings, sets up observers for each, + then applies the current value of the attributes to the passed render buffer. + + @method _applyAttributeBindings + @param {Ember.RenderBuffer} buffer + */ + _applyAttributeBindings: function(buffer, attributeBindings) { + var attributeValue, elem, type; + + a_forEach(attributeBindings, function(binding) { + var split = binding.split(':'), + property = split[0], + attributeName = split[1] || property; + + // Create an observer to add/remove/change the attribute if the + // JavaScript property changes. + var observer = function() { + elem = this.$(); + if (!elem) { return; } + + attributeValue = get(this, property); + + Ember.View.applyAttributeBindings(elem, attributeName, attributeValue); + }; + + this.registerObserver(this, property, observer); + + // Determine the current value and add it to the render buffer + // if necessary. + attributeValue = get(this, property); + Ember.View.applyAttributeBindings(buffer, attributeName, attributeValue); + }, this); + }, + + /** + @private + + Given a property name, returns a dasherized version of that + property name if the property evaluates to a non-falsy value. + + For example, if the view has property `isUrgent` that evaluates to true, + passing `isUrgent` to this method will return `"is-urgent"`. + + @method _classStringForProperty + @param property + */ + _classStringForProperty: function(property) { + var parsedPath = Ember.View._parsePropertyPath(property); + var path = parsedPath.path; + + var val = get(this, path); + if (val === undefined && Ember.isGlobalPath(path)) { + val = get(Ember.lookup, path); + } + + return Ember.View._classStringForValue(path, val, parsedPath.className, parsedPath.falsyClassName); + }, + + // .......................................................... + // ELEMENT SUPPORT + // + + /** + Returns the current DOM element for the view. + + @property element + @type DOMElement + */ + element: Ember.computed(function(key, value) { + if (value !== undefined) { + return this.currentState.setElement(this, value); + } else { + return this.currentState.getElement(this); + } + }).property('_parentView'), + + /** + Returns a jQuery object for this view's element. If you pass in a selector + string, this method will return a jQuery object, using the current element + as its buffer. + + For example, calling `view.$('li')` will return a jQuery object containing + all of the `li` elements inside the DOM element of this view. + + @property $ + @param {String} [selector] a jQuery-compatible selector string + @return {jQuery} the CoreQuery object for the DOM node + */ + $: function(sel) { + return this.currentState.$(this, sel); + }, + + mutateChildViews: function(callback) { + var childViews = this._childViews, + idx = childViews.length, + view; + + while(--idx >= 0) { + view = childViews[idx]; + callback.call(this, view, idx); + } + + return this; + }, + + forEachChildView: function(callback) { + var childViews = this._childViews; + + if (!childViews) { return this; } + + var len = childViews.length, + view, idx; + + for(idx = 0; idx < len; idx++) { + view = childViews[idx]; + callback.call(this, view); + } + + return this; + }, + + /** + Appends the view's element to the specified parent element. + + If the view does not have an HTML representation yet, `createElement()` + will be called automatically. + + Note that this method just schedules the view to be appended; the DOM + element will not be appended to the given element until all bindings have + finished synchronizing. + + This is not typically a function that you will need to call directly when + building your application. You might consider using `Ember.ContainerView` + instead. If you do need to use `appendTo`, be sure that the target element + you are providing is associated with an `Ember.Application` and does not + have an ancestor element that is associated with an Ember view. + + @method appendTo + @param {String|DOMElement|jQuery} A selector, element, HTML string, or jQuery object + @return {Ember.View} receiver + */ + appendTo: function(target) { + // Schedule the DOM element to be created and appended to the given + // element after bindings have synchronized. + this._insertElementLater(function() { + Ember.assert("You cannot append to an existing Ember.View. Consider using Ember.ContainerView instead.", !Ember.$(target).is('.ember-view') && !Ember.$(target).parents().is('.ember-view')); + this.$().appendTo(target); + }); + + return this; + }, + + /** + Replaces the content of the specified parent element with this view's + element. If the view does not have an HTML representation yet, + `createElement()` will be called automatically. + + Note that this method just schedules the view to be appended; the DOM + element will not be appended to the given element until all bindings have + finished synchronizing + + @method replaceIn + @param {String|DOMElement|jQuery} A selector, element, HTML string, or jQuery object + @return {Ember.View} received + */ + replaceIn: function(target) { + Ember.assert("You cannot replace an existing Ember.View. Consider using Ember.ContainerView instead.", !Ember.$(target).is('.ember-view') && !Ember.$(target).parents().is('.ember-view')); + + this._insertElementLater(function() { + Ember.$(target).empty(); + this.$().appendTo(target); + }); + + return this; + }, + + /** + @private + + Schedules a DOM operation to occur during the next render phase. This + ensures that all bindings have finished synchronizing before the view is + rendered. + + To use, pass a function that performs a DOM operation. + + Before your function is called, this view and all child views will receive + the `willInsertElement` event. After your function is invoked, this view + and all of its child views will receive the `didInsertElement` event. + + ```javascript + view._insertElementLater(function() { + this.createElement(); + this.$().appendTo('body'); + }); + ``` + + @method _insertElementLater + @param {Function} fn the function that inserts the element into the DOM + */ + _insertElementLater: function(fn) { + this._scheduledInsert = Ember.run.scheduleOnce('render', this, '_insertElement', fn); + }, + + _insertElement: function (fn) { + this._scheduledInsert = null; + this.currentState.insertElement(this, fn); + }, + + /** + Appends the view's element to the document body. If the view does + not have an HTML representation yet, `createElement()` will be called + automatically. + + Note that this method just schedules the view to be appended; the DOM + element will not be appended to the document body until all bindings have + finished synchronizing. + + @method append + @return {Ember.View} receiver + */ + append: function() { + return this.appendTo(document.body); + }, + + /** + Removes the view's element from the element to which it is attached. + + @method remove + @return {Ember.View} receiver + */ + remove: function() { + // What we should really do here is wait until the end of the run loop + // to determine if the element has been re-appended to a different + // element. + // In the interim, we will just re-render if that happens. It is more + // important than elements get garbage collected. + if (!this.removedFromDOM) { this.destroyElement(); } + this.invokeRecursively(function(view) { + if (view.clearRenderedChildren) { view.clearRenderedChildren(); } + }); + }, + + elementId: null, + + /** + Attempts to discover the element in the parent element. The default + implementation looks for an element with an ID of `elementId` (or the + view's guid if `elementId` is null). You can override this method to + provide your own form of lookup. For example, if you want to discover your + element using a CSS class name instead of an ID. + + @method findElementInParentElement + @param {DOMElement} parentElement The parent's DOM element + @return {DOMElement} The discovered element + */ + findElementInParentElement: function(parentElem) { + var id = "#" + this.elementId; + return Ember.$(id)[0] || Ember.$(id, parentElem)[0]; + }, + + /** + Creates a DOM representation of the view and all of its + child views by recursively calling the `render()` method. + + After the element has been created, `didInsertElement` will + be called on this view and all of its child views. + + @method createElement + @return {Ember.View} receiver + */ + createElement: function() { + if (get(this, 'element')) { return this; } + + var buffer = this.renderToBuffer(); + set(this, 'element', buffer.element()); + + return this; + }, + + /** + Called when a view is going to insert an element into the DOM. + + @event willInsertElement + */ + willInsertElement: Ember.K, + + /** + Called when the element of the view has been inserted into the DOM. + Override this function to do any set up that requires an element in the + document body. + + @event didInsertElement + */ + didInsertElement: Ember.K, + + /** + Called when the view is about to rerender, but before anything has + been torn down. This is a good opportunity to tear down any manual + observers you have installed based on the DOM state + + @event willClearRender + */ + willClearRender: Ember.K, + + /** + @private + + Run this callback on the current view and recursively on child views. + + @method invokeRecursively + @param fn {Function} + */ + invokeRecursively: function(fn) { + var childViews = [this], currentViews, view; + + while (childViews.length) { + currentViews = childViews.slice(); + childViews = []; + + for (var i=0, l=currentViews.length; i` tag for views. + + @property tagName + @type String + @default null + */ + + // We leave this null by default so we can tell the difference between + // the default case and a user-specified tag. + tagName: null, + + /** + The WAI-ARIA role of the control represented by this view. For example, a + button may have a role of type 'button', or a pane may have a role of + type 'alertdialog'. This property is used by assistive software to help + visually challenged users navigate rich web applications. + + The full list of valid WAI-ARIA roles is available at: + http://www.w3.org/TR/wai-aria/roles#roles_categorization + + @property ariaRole + @type String + @default null + */ + ariaRole: null, + + /** + Standard CSS class names to apply to the view's outer element. This + property automatically inherits any class names defined by the view's + superclasses as well. + + @property classNames + @type Array + @default ['ember-view'] + */ + classNames: ['ember-view'], + + /** + A list of properties of the view to apply as class names. If the property + is a string value, the value of that string will be applied as a class + name. + + ```javascript + // Applies the 'high' class to the view element + Ember.View.create({ + classNameBindings: ['priority'] + priority: 'high' + }); + ``` + + If the value of the property is a Boolean, the name of that property is + added as a dasherized class name. + + ```javascript + // Applies the 'is-urgent' class to the view element + Ember.View.create({ + classNameBindings: ['isUrgent'] + isUrgent: true + }); + ``` + + If you would prefer to use a custom value instead of the dasherized + property name, you can pass a binding like this: + + ```javascript + // Applies the 'urgent' class to the view element + Ember.View.create({ + classNameBindings: ['isUrgent:urgent'] + isUrgent: true + }); + ``` + + This list of properties is inherited from the view's superclasses as well. + + @property classNameBindings + @type Array + @default [] + */ + classNameBindings: [], + + /** + A list of properties of the view to apply as attributes. If the property is + a string value, the value of that string will be applied as the attribute. + + ```javascript + // Applies the type attribute to the element + // with the value "button", like
            + Ember.View.create({ + attributeBindings: ['type'], + type: 'button' + }); + ``` + + If the value of the property is a Boolean, the name of that property is + added as an attribute. + + ```javascript + // Renders something like
            + Ember.View.create({ + attributeBindings: ['enabled'], + enabled: true + }); + ``` + + @property attributeBindings + */ + attributeBindings: [], + + // ....................................................... + // CORE DISPLAY METHODS + // + + /** + @private + + Setup a view, but do not finish waking it up. + - configure `childViews` + - register the view with the global views hash, which is used for event + dispatch + + @method init + */ + init: function() { + this.elementId = this.elementId || guidFor(this); + + this._super(); + + // setup child views. be sure to clone the child views array first + this._childViews = this._childViews.slice(); + + Ember.assert("Only arrays are allowed for 'classNameBindings'", Ember.typeOf(this.classNameBindings) === 'array'); + this.classNameBindings = Ember.A(this.classNameBindings.slice()); + + Ember.assert("Only arrays are allowed for 'classNames'", Ember.typeOf(this.classNames) === 'array'); + this.classNames = Ember.A(this.classNames.slice()); + + var viewController = get(this, 'viewController'); + if (viewController) { + viewController = get(viewController); + if (viewController) { + set(viewController, 'view', this); + } + } + }, + + appendChild: function(view, options) { + return this.currentState.appendChild(this, view, options); + }, + + /** + Removes the child view from the parent view. + + @method removeChild + @param {Ember.View} view + @return {Ember.View} receiver + */ + removeChild: function(view) { + // If we're destroying, the entire subtree will be + // freed, and the DOM will be handled separately, + // so no need to mess with childViews. + if (this.isDestroying) { return; } + + // update parent node + set(view, '_parentView', null); + + // remove view from childViews array. + var childViews = this._childViews; + + Ember.EnumerableUtils.removeObject(childViews, view); + + this.propertyDidChange('childViews'); // HUH?! what happened to will change? + + return this; + }, + + /** + Removes all children from the `parentView`. + + @method removeAllChildren + @return {Ember.View} receiver + */ + removeAllChildren: function() { + return this.mutateChildViews(function(view) { + this.removeChild(view); + }); + }, + + destroyAllChildren: function() { + return this.mutateChildViews(function(view) { + view.destroy(); + }); + }, + + /** + Removes the view from its `parentView`, if one is found. Otherwise + does nothing. + + @method removeFromParent + @return {Ember.View} receiver + */ + removeFromParent: function() { + var parent = this._parentView; + + // Remove DOM element from parent + this.remove(); + + if (parent) { parent.removeChild(this); } + return this; + }, + + /** + You must call `destroy` on a view to destroy the view (and all of its + child views). This will remove the view from any parent node, then make + sure that the DOM element managed by the view can be released by the + memory manager. + + @method willDestroy + */ + willDestroy: function() { + // calling this._super() will nuke computed properties and observers, + // so collect any information we need before calling super. + var childViews = this._childViews, + parent = this._parentView, + childLen, i; + + // destroy the element -- this will avoid each child view destroying + // the element over and over again... + if (!this.removedFromDOM) { this.destroyElement(); } + + childLen = childViews.length; + for (i=childLen-1; i>=0; i--) { + childViews[i].removedFromDOM = true; + } + + // remove from non-virtual parent view if viewName was specified + if (this.viewName) { + var nonVirtualParentView = get(this, 'parentView'); + if (nonVirtualParentView) { + set(nonVirtualParentView, this.viewName, null); + } + } + + // remove from parent if found. Don't call removeFromParent, + // as removeFromParent will try to remove the element from + // the DOM again. + if (parent) { parent.removeChild(this); } + + this.transitionTo('destroyed'); + + childLen = childViews.length; + for (i=childLen-1; i>=0; i--) { + childViews[i].destroy(); + } + + // next remove view from global hash + if (!this.isVirtual) delete Ember.View.views[get(this, 'elementId')]; + }, + + /** + Instantiates a view to be added to the childViews array during view + initialization. You generally will not call this method directly unless + you are overriding `createChildViews()`. Note that this method will + automatically configure the correct settings on the new view instance to + act as a child of the parent. + + @method createChildView + @param {Class} viewClass + @param {Hash} [attrs] Attributes to add + @return {Ember.View} new instance + */ + createChildView: function(view, attrs) { + if (view.isView && view._parentView === this) { return view; } + + if (Ember.CoreView.detect(view)) { + attrs = attrs || {}; + attrs._parentView = this; + attrs.templateData = attrs.templateData || get(this, 'templateData'); + + view = view.create(attrs); + + // don't set the property on a virtual view, as they are invisible to + // consumers of the view API + if (view.viewName) { set(get(this, 'concreteView'), view.viewName, view); } + } else { + Ember.assert('You must pass instance or subclass of View', view.isView); + + if (attrs) { + view.setProperties(attrs); + } + + if (!get(view, 'templateData')) { + set(view, 'templateData', get(this, 'templateData')); + } + + set(view, '_parentView', this); + } + + return view; + }, + + becameVisible: Ember.K, + becameHidden: Ember.K, + + /** + @private + + When the view's `isVisible` property changes, toggle the visibility + element of the actual DOM element. + + @method _isVisibleDidChange + */ + _isVisibleDidChange: Ember.observer(function() { + var $el = this.$(); + if (!$el) { return; } + + var isVisible = get(this, 'isVisible'); + + $el.toggle(isVisible); + + if (this._isAncestorHidden()) { return; } + + if (isVisible) { + this._notifyBecameVisible(); + } else { + this._notifyBecameHidden(); + } + }, 'isVisible'), + + _notifyBecameVisible: function() { + this.trigger('becameVisible'); + + this.forEachChildView(function(view) { + var isVisible = get(view, 'isVisible'); + + if (isVisible || isVisible === null) { + view._notifyBecameVisible(); + } + }); + }, + + _notifyBecameHidden: function() { + this.trigger('becameHidden'); + this.forEachChildView(function(view) { + var isVisible = get(view, 'isVisible'); + + if (isVisible || isVisible === null) { + view._notifyBecameHidden(); + } + }); + }, + + _isAncestorHidden: function() { + var parent = get(this, 'parentView'); + + while (parent) { + if (get(parent, 'isVisible') === false) { return true; } + + parent = get(parent, 'parentView'); + } + + return false; + }, + + clearBuffer: function() { + this.invokeRecursively(function(view) { + view.buffer = null; + }); + }, + + transitionTo: function(state, children) { + this.currentState = this.states[state]; + this.state = state; + + if (children !== false) { + this.forEachChildView(function(view) { + view.transitionTo(state); + }); + } + }, + + // ....................................................... + // EVENT HANDLING + // + + /** + @private + + Handle events from `Ember.EventDispatcher` + + @method handleEvent + @param eventName {String} + @param evt {Event} + */ + handleEvent: function(eventName, evt) { + return this.currentState.handleEvent(this, eventName, evt); + }, + + registerObserver: function(root, path, target, observer) { + Ember.addObserver(root, path, target, observer); + + this.one('willClearRender', function() { + Ember.removeObserver(root, path, target, observer); + }); + } + +}); + +/* + Describe how the specified actions should behave in the various + states that a view can exist in. Possible states: + + * preRender: when a view is first instantiated, and after its + element was destroyed, it is in the preRender state + * inBuffer: once a view has been rendered, but before it has + been inserted into the DOM, it is in the inBuffer state + * inDOM: once a view has been inserted into the DOM it is in + the inDOM state. A view spends the vast majority of its + existence in this state. + * destroyed: once a view has been destroyed (using the destroy + method), it is in this state. No further actions can be invoked + on a destroyed view. +*/ + + // in the destroyed state, everything is illegal + + // before rendering has begun, all legal manipulations are noops. + + // inside the buffer, legal manipulations are done on the buffer + + // once the view has been inserted into the DOM, legal manipulations + // are done on the DOM element. + +var DOMManager = { + prepend: function(view, html) { + view.$().prepend(html); + }, + + after: function(view, html) { + view.$().after(html); + }, + + html: function(view, html) { + view.$().html(html); + }, + + replace: function(view) { + var element = get(view, 'element'); + + set(view, 'element', null); + + view._insertElementLater(function() { + Ember.$(element).replaceWith(get(view, 'element')); + }); + }, + + remove: function(view) { + view.$().remove(); + }, + + empty: function(view) { + view.$().empty(); + } +}; + +Ember.View.reopen({ + domManager: DOMManager +}); + +Ember.View.reopenClass({ + + /** + @private + + Parse a path and return an object which holds the parsed properties. + + For example a path like "content.isEnabled:enabled:disabled" wil return the + following object: + + ```javascript + { + path: "content.isEnabled", + className: "enabled", + falsyClassName: "disabled", + classNames: ":enabled:disabled" + } + ``` + + @method _parsePropertyPath + @static + */ + _parsePropertyPath: function(path) { + var split = path.split(':'), + propertyPath = split[0], + classNames = "", + className, + falsyClassName; + + // check if the property is defined as prop:class or prop:trueClass:falseClass + if (split.length > 1) { + className = split[1]; + if (split.length === 3) { falsyClassName = split[2]; } + + classNames = ':' + className; + if (falsyClassName) { classNames += ":" + falsyClassName; } + } + + return { + path: propertyPath, + classNames: classNames, + className: (className === '') ? undefined : className, + falsyClassName: falsyClassName + }; + }, + + /** + @private + + Get the class name for a given value, based on the path, optional + `className` and optional `falsyClassName`. + + - if a `className` or `falsyClassName` has been specified: + - if the value is truthy and `className` has been specified, + `className` is returned + - if the value is falsy and `falsyClassName` has been specified, + `falsyClassName` is returned + - otherwise `null` is returned + - if the value is `true`, the dasherized last part of the supplied path + is returned + - if the value is not `false`, `undefined` or `null`, the `value` + is returned + - if none of the above rules apply, `null` is returned + + @method _classStringForValue + @param path + @param val + @param className + @param falsyClassName + @static + */ + _classStringForValue: function(path, val, className, falsyClassName) { + // When using the colon syntax, evaluate the truthiness or falsiness + // of the value to determine which className to return + if (className || falsyClassName) { + if (className && !!val) { + return className; + + } else if (falsyClassName && !val) { + return falsyClassName; + + } else { + return null; + } + + // If value is a Boolean and true, return the dasherized property + // name. + } else if (val === true) { + // Normalize property path to be suitable for use + // as a class name. For exaple, content.foo.barBaz + // becomes bar-baz. + var parts = path.split('.'); + return Ember.String.dasherize(parts[parts.length-1]); + + // If the value is not false, undefined, or null, return the current + // value of the property. + } else if (val !== false && val !== undefined && val !== null) { + return val; + + // Nothing to display. Return null so that the old class is removed + // but no new class is added. + } else { + return null; + } + } +}); + +/** + Global views hash + + @property views + @static + @type Hash +*/ +Ember.View.views = {}; + +// If someone overrides the child views computed property when +// defining their class, we want to be able to process the user's +// supplied childViews and then restore the original computed property +// at view initialization time. This happens in Ember.ContainerView's init +// method. +Ember.View.childViewsProperty = childViewsProperty; + +Ember.View.applyAttributeBindings = function(elem, name, value) { + if (name === 'value') { + Ember.View.applyValueBinding(elem, value); + } else { + Ember.View.applyAttributeBinding(elem, name, value); + } +}; + +Ember.View.applyAttributeBinding = function(elem, name, value) { + var type = Ember.typeOf(value); + var currentValue = elem.attr(name); + + // if this changes, also change the logic in ember-handlebars/lib/helpers/binding.js + if ( + ( + ( type === 'string' ) || + ( type === 'number' && !isNaN(value) ) || + ( type === 'boolean' && value ) + ) && ( + value !== currentValue + ) + ) { + elem.attr(name, value); + } else if (!value) { + elem.removeAttr(name); + } +}; + +Ember.View.applyValueBinding = function(elem, value) { + var type = Ember.typeOf(value); + var currentValue = elem.val(); + + // if this changes, also change the logic in ember-handlebars/lib/helpers/binding.js + if ( + ( + ( type === 'string' ) || + ( type === 'number' && !isNaN(value) ) || + ( type === 'boolean' && value ) + ) && ( + value !== currentValue + ) + ) { + if (elem.caretPosition) { + var caretPosition = elem.caretPosition(); + elem.val(value); + elem.setCaretPosition(caretPosition); + } else { + elem.val(value); + } + } else if (!value) { + elem.val(''); + } +}; + +Ember.View.states = states; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set; + +Ember.View.states._default = { + // appendChild is only legal while rendering the buffer. + appendChild: function() { + throw "You can't use appendChild outside of the rendering process"; + }, + + $: function() { + return undefined; + }, + + getElement: function() { + return null; + }, + + // Handle events from `Ember.EventDispatcher` + handleEvent: function() { + return true; // continue event propagation + }, + + destroyElement: function(view) { + set(view, 'element', null); + if (view._scheduledInsert) { + Ember.run.cancel(view._scheduledInsert); + view._scheduledInsert = null; + } + return view; + }, + + renderToBufferIfNeeded: function () { + return false; + }, + + rerender: Ember.K +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var preRender = Ember.View.states.preRender = Ember.create(Ember.View.states._default); + +Ember.merge(preRender, { + // a view leaves the preRender state once its element has been + // created (createElement). + insertElement: function(view, fn) { + view.createElement(); + view.triggerRecursively('willInsertElement'); + // after createElement, the view will be in the hasElement state. + fn.call(view); + view.transitionTo('inDOM'); + view.triggerRecursively('didInsertElement'); + }, + + renderToBufferIfNeeded: function(view) { + return view.renderToBuffer(); + }, + + empty: Ember.K, + + setElement: function(view, value) { + if (value !== null) { + view.transitionTo('hasElement'); + } + return value; + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, meta = Ember.meta; + +var inBuffer = Ember.View.states.inBuffer = Ember.create(Ember.View.states._default); + +Ember.merge(inBuffer, { + $: function(view, sel) { + // if we don't have an element yet, someone calling this.$() is + // trying to update an element that isn't in the DOM. Instead, + // rerender the view to allow the render method to reflect the + // changes. + view.rerender(); + return Ember.$(); + }, + + // when a view is rendered in a buffer, rerendering it simply + // replaces the existing buffer with a new one + rerender: function(view) { + throw new Ember.Error("Something you did caused a view to re-render after it rendered but before it was inserted into the DOM."); + }, + + // when a view is rendered in a buffer, appending a child + // view will render that view and append the resulting + // buffer into its buffer. + appendChild: function(view, childView, options) { + var buffer = view.buffer; + + childView = view.createChildView(childView, options); + view._childViews.push(childView); + + childView.renderToBuffer(buffer); + + view.propertyDidChange('childViews'); + + return childView; + }, + + // when a view is rendered in a buffer, destroying the + // element will simply destroy the buffer and put the + // state back into the preRender state. + destroyElement: function(view) { + view.clearBuffer(); + view._notifyWillDestroyElement(); + view.transitionTo('preRender'); + + return view; + }, + + empty: function() { + Ember.assert("Emptying a view in the inBuffer state is not allowed and should not happen under normal circumstances. Most likely there is a bug in your application. This may be due to excessive property change notifications."); + }, + + renderToBufferIfNeeded: function (view) { + return view.buffer; + }, + + // It should be impossible for a rendered view to be scheduled for + // insertion. + insertElement: function() { + throw "You can't insert an element that has already been rendered"; + }, + + setElement: function(view, value) { + if (value === null) { + view.transitionTo('preRender'); + } else { + view.clearBuffer(); + view.transitionTo('hasElement'); + } + + return value; + } +}); + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, meta = Ember.meta; + +var hasElement = Ember.View.states.hasElement = Ember.create(Ember.View.states._default); + +Ember.merge(hasElement, { + $: function(view, sel) { + var elem = get(view, 'element'); + return sel ? Ember.$(sel, elem) : Ember.$(elem); + }, + + getElement: function(view) { + var parent = get(view, 'parentView'); + if (parent) { parent = get(parent, 'element'); } + if (parent) { return view.findElementInParentElement(parent); } + return Ember.$("#" + get(view, 'elementId'))[0]; + }, + + setElement: function(view, value) { + if (value === null) { + view.transitionTo('preRender'); + } else { + throw "You cannot set an element to a non-null value when the element is already in the DOM."; + } + + return value; + }, + + // once the view has been inserted into the DOM, rerendering is + // deferred to allow bindings to synchronize. + rerender: function(view) { + view.triggerRecursively('willClearRender'); + + view.clearRenderedChildren(); + + view.domManager.replace(view); + return view; + }, + + // once the view is already in the DOM, destroying it removes it + // from the DOM, nukes its element, and puts it back into the + // preRender state if inDOM. + + destroyElement: function(view) { + view._notifyWillDestroyElement(); + view.domManager.remove(view); + set(view, 'element', null); + if (view._scheduledInsert) { + Ember.run.cancel(view._scheduledInsert); + view._scheduledInsert = null; + } + return view; + }, + + empty: function(view) { + var _childViews = view._childViews, len, idx; + if (_childViews) { + len = _childViews.length; + for (idx = 0; idx < len; idx++) { + _childViews[idx]._notifyWillDestroyElement(); + } + } + view.domManager.empty(view); + }, + + // Handle events from `Ember.EventDispatcher` + handleEvent: function(view, eventName, evt) { + if (view.has(eventName)) { + // Handler should be able to re-dispatch events, so we don't + // preventDefault or stopPropagation. + return view.trigger(eventName, evt); + } else { + return true; // continue event propagation + } + } +}); + +var inDOM = Ember.View.states.inDOM = Ember.create(hasElement); + +Ember.merge(inDOM, { + insertElement: function(view, fn) { + throw "You can't insert an element into the DOM that has already been inserted"; + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var destroyedError = "You can't call %@ on a destroyed view", fmt = Ember.String.fmt; + +var destroyed = Ember.View.states.destroyed = Ember.create(Ember.View.states._default); + +Ember.merge(destroyed, { + appendChild: function() { + throw fmt(destroyedError, ['appendChild']); + }, + rerender: function() { + throw fmt(destroyedError, ['rerender']); + }, + destroyElement: function() { + throw fmt(destroyedError, ['destroyElement']); + }, + empty: function() { + throw fmt(destroyedError, ['empty']); + }, + + setElement: function() { + throw fmt(destroyedError, ["set('element', ...)"]); + }, + + renderToBufferIfNeeded: function() { + throw fmt(destroyedError, ["renderToBufferIfNeeded"]); + }, + + // Since element insertion is scheduled, don't do anything if + // the view has been destroyed between scheduling and execution + insertElement: Ember.K +}); + + +})(); + + + +(function() { +Ember.View.cloneStates = function(from) { + var into = {}; + + into._default = {}; + into.preRender = Ember.create(into._default); + into.destroyed = Ember.create(into._default); + into.inBuffer = Ember.create(into._default); + into.hasElement = Ember.create(into._default); + into.inDOM = Ember.create(into.hasElement); + + var viewState; + + for (var stateName in from) { + if (!from.hasOwnProperty(stateName)) { continue; } + Ember.merge(into[stateName], from[stateName]); + } + + return into; +}; + +})(); + + + +(function() { +var states = Ember.View.cloneStates(Ember.View.states); + +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, meta = Ember.meta; +var forEach = Ember.EnumerableUtils.forEach; + +/** + A `ContainerView` is an `Ember.View` subclass that implements `Ember.MutableArray` + allowing programatic management of its child views. + + ## Setting Initial Child Views + + The initial array of child views can be set in one of two ways. You can + provide a `childViews` property at creation time that contains instance of + `Ember.View`: + + ```javascript + aContainer = Ember.ContainerView.create({ + childViews: [Ember.View.create(), Ember.View.create()] + }); + ``` + + You can also provide a list of property names whose values are instances of + `Ember.View`: + + ```javascript + aContainer = Ember.ContainerView.create({ + childViews: ['aView', 'bView', 'cView'], + aView: Ember.View.create(), + bView: Ember.View.create(), + cView: Ember.View.create() + }); + ``` + + The two strategies can be combined: + + ```javascript + aContainer = Ember.ContainerView.create({ + childViews: ['aView', Ember.View.create()], + aView: Ember.View.create() + }); + ``` + + Each child view's rendering will be inserted into the container's rendered + HTML in the same order as its position in the `childViews` property. + + ## Adding and Removing Child Views + + The container view implements `Ember.MutableArray` allowing programatic management of its child views. + + To remove a view, pass that view into a `removeObject` call on the container view. + + Given an empty `` the following code + + ```javascript + aContainer = Ember.ContainerView.create({ + classNames: ['the-container'], + childViews: ['aView', 'bView'], + aView: Ember.View.create({ + template: Ember.Handlebars.compile("A") + }), + bView: Ember.View.create({ + template: Ember.Handlebars.compile("B") + }) + }); + + aContainer.appendTo('body'); + ``` + + Results in the HTML + + ```html +
            +
            A
            +
            B
            +
            + ``` + + Removing a view + + ```javascript + aContainer.toArray(); // [aContainer.aView, aContainer.bView] + aContainer.removeObject(aContainer.get('bView')); + aContainer.toArray(); // [aContainer.aView] + ``` + + Will result in the following HTML + + ```html +
            +
            A
            +
            + ``` + + Similarly, adding a child view is accomplished by adding `Ember.View` instances to the + container view. + + Given an empty `` the following code + + ```javascript + aContainer = Ember.ContainerView.create({ + classNames: ['the-container'], + childViews: ['aView', 'bView'], + aView: Ember.View.create({ + template: Ember.Handlebars.compile("A") + }), + bView: Ember.View.create({ + template: Ember.Handlebars.compile("B") + }) + }); + + aContainer.appendTo('body'); + ``` + + Results in the HTML + + ```html +
            +
            A
            +
            B
            +
            + ``` + + Adding a view + + ```javascript + AnotherViewClass = Ember.View.extend({ + template: Ember.Handlebars.compile("Another view") + }); + + aContainer.toArray(); // [aContainer.aView, aContainer.bView] + aContainer.pushObject(AnotherViewClass.create()); + aContainer.toArray(); // [aContainer.aView, aContainer.bView, ] + ``` + + Will result in the following HTML + + ```html +
            +
            A
            +
            B
            +
            Another view
            +
            + ``` + + ## Templates and Layout + + A `template`, `templateName`, `defaultTemplate`, `layout`, `layoutName` or + `defaultLayout` property on a container view will not result in the template + or layout being rendered. The HTML contents of a `Ember.ContainerView`'s DOM + representation will only be the rendered HTML of its child views. + + ## Binding a View to Display + + If you would like to display a single view in your ContainerView, you can set + its `currentView` property. When the `currentView` property is set to a view + instance, it will be added to the ContainerView. If the `currentView` property + is later changed to a different view, the new view will replace the old view. + If `currentView` is set to `null`, the last `currentView` will be removed. + + This functionality is useful for cases where you want to bind the display of + a ContainerView to a controller or state manager. For example, you can bind + the `currentView` of a container to a controller like this: + + ```javascript + App.appController = Ember.Object.create({ + view: Ember.View.create({ + templateName: 'person_template' + }) + }); + ``` + + ```handlebars + {{view Ember.ContainerView currentViewBinding="App.appController.view"}} + ``` + + @class ContainerView + @namespace Ember + @extends Ember.View +*/ +Ember.ContainerView = Ember.View.extend(Ember.MutableArray, { + states: states, + + init: function() { + this._super(); + + var childViews = get(this, 'childViews'); + + // redefine view's childViews property that was obliterated + Ember.defineProperty(this, 'childViews', Ember.View.childViewsProperty); + + var _childViews = this._childViews; + + forEach(childViews, function(viewName, idx) { + var view; + + if ('string' === typeof viewName) { + view = get(this, viewName); + view = this.createChildView(view); + set(this, viewName, view); + } else { + view = this.createChildView(viewName); + } + + _childViews[idx] = view; + }, this); + + var currentView = get(this, 'currentView'); + if (currentView) { + _childViews.push(this.createChildView(currentView)); + } + }, + + replace: function(idx, removedCount, addedViews) { + var addedCount = addedViews ? get(addedViews, 'length') : 0; + + this.arrayContentWillChange(idx, removedCount, addedCount); + this.childViewsWillChange(this._childViews, idx, removedCount); + + if (addedCount === 0) { + this._childViews.splice(idx, removedCount) ; + } else { + var args = [idx, removedCount].concat(addedViews); + this._childViews.splice.apply(this._childViews, args); + } + + this.arrayContentDidChange(idx, removedCount, addedCount); + this.childViewsDidChange(this._childViews, idx, removedCount, addedCount); + + return this; + }, + + objectAt: function(idx) { + return this._childViews[idx]; + }, + + length: Ember.computed(function () { + return this._childViews.length; + }), + + /** + @private + + Instructs each child view to render to the passed render buffer. + + @method render + @param {Ember.RenderBuffer} buffer the buffer to render to + */ + render: function(buffer) { + this.forEachChildView(function(view) { + view.renderToBuffer(buffer); + }); + }, + + instrumentName: 'render.container', + + /** + @private + + When a child view is removed, destroy its element so that + it is removed from the DOM. + + The array observer that triggers this action is set up in the + `renderToBuffer` method. + + @method childViewsWillChange + @param {Ember.Array} views the child views array before mutation + @param {Number} start the start position of the mutation + @param {Number} removed the number of child views removed + **/ + childViewsWillChange: function(views, start, removed) { + this.propertyWillChange('childViews'); + + if (removed > 0) { + var changedViews = views.slice(start, start+removed); + // transition to preRender before clearing parentView + this.currentState.childViewsWillChange(this, views, start, removed); + this.initializeViews(changedViews, null, null); + } + }, + + removeChild: function(child) { + this.removeObject(child); + return this; + }, + + /** + @private + + When a child view is added, make sure the DOM gets updated appropriately. + + If the view has already rendered an element, we tell the child view to + create an element and insert it into the DOM. If the enclosing container + view has already written to a buffer, but not yet converted that buffer + into an element, we insert the string representation of the child into the + appropriate place in the buffer. + + @method childViewsDidChange + @param {Ember.Array} views the array of child views afte the mutation has occurred + @param {Number} start the start position of the mutation + @param {Number} removed the number of child views removed + @param {Number} the number of child views added + */ + childViewsDidChange: function(views, start, removed, added) { + if (added > 0) { + var changedViews = views.slice(start, start+added); + this.initializeViews(changedViews, this, get(this, 'templateData')); + this.currentState.childViewsDidChange(this, views, start, added); + } + this.propertyDidChange('childViews'); + }, + + initializeViews: function(views, parentView, templateData) { + forEach(views, function(view) { + set(view, '_parentView', parentView); + + if (!get(view, 'templateData')) { + set(view, 'templateData', templateData); + } + }); + }, + + currentView: null, + + _currentViewWillChange: Ember.beforeObserver(function() { + var currentView = get(this, 'currentView'); + if (currentView) { + currentView.destroy(); + } + }, 'currentView'), + + _currentViewDidChange: Ember.observer(function() { + var currentView = get(this, 'currentView'); + if (currentView) { + this.pushObject(currentView); + } + }, 'currentView'), + + _ensureChildrenAreInDOM: function () { + this.currentState.ensureChildrenAreInDOM(this); + } +}); + +Ember.merge(states._default, { + childViewsWillChange: Ember.K, + childViewsDidChange: Ember.K, + ensureChildrenAreInDOM: Ember.K +}); + +Ember.merge(states.inBuffer, { + childViewsDidChange: function(parentView, views, start, added) { + throw new Error('You cannot modify child views while in the inBuffer state'); + } +}); + +Ember.merge(states.hasElement, { + childViewsWillChange: function(view, views, start, removed) { + for (var i=start; i` and the following code: + + ```javascript + someItemsView = Ember.CollectionView.create({ + classNames: ['a-collection'], + content: ['A','B','C'], + itemViewClass: Ember.View.extend({ + template: Ember.Handlebars.compile("the letter: {{view.content}}") + }) + }); + + someItemsView.appendTo('body'); + ``` + + Will result in the following HTML structure + + ```html +
            +
            the letter: A
            +
            the letter: B
            +
            the letter: C
            +
            + ``` + + ## Automatic matching of parent/child tagNames + + Setting the `tagName` property of a `CollectionView` to any of + "ul", "ol", "table", "thead", "tbody", "tfoot", "tr", or "select" will result + in the item views receiving an appropriately matched `tagName` property. + + Given an empty `` and the following code: + + ```javascript + anUndorderedListView = Ember.CollectionView.create({ + tagName: 'ul', + content: ['A','B','C'], + itemViewClass: Ember.View.extend({ + template: Ember.Handlebars.compile("the letter: {{view.content}}") + }) + }); + + anUndorderedListView.appendTo('body'); + ``` + + Will result in the following HTML structure + + ```html +
              +
            • the letter: A
            • +
            • the letter: B
            • +
            • the letter: C
            • +
            + ``` + + Additional `tagName` pairs can be provided by adding to + `Ember.CollectionView.CONTAINER_MAP ` + + ```javascript + Ember.CollectionView.CONTAINER_MAP['article'] = 'section' + ``` + + ## Programatic creation of child views + + For cases where additional customization beyond the use of a single + `itemViewClass` or `tagName` matching is required CollectionView's + `createChildView` method can be overidden: + + ```javascript + CustomCollectionView = Ember.CollectionView.extend({ + createChildView: function(viewClass, attrs) { + if (attrs.content.kind == 'album') { + viewClass = App.AlbumView; + } else { + viewClass = App.SongView; + } + this._super(viewClass, attrs); + } + }); + ``` + + ## Empty View + + You can provide an `Ember.View` subclass to the `Ember.CollectionView` + instance as its `emptyView` property. If the `content` property of a + `CollectionView` is set to `null` or an empty array, an instance of this view + will be the `CollectionView`s only child. + + ```javascript + aListWithNothing = Ember.CollectionView.create({ + classNames: ['nothing'] + content: null, + emptyView: Ember.View.extend({ + template: Ember.Handlebars.compile("The collection is empty") + }) + }); + + aListWithNothing.appendTo('body'); + ``` + + Will result in the following HTML structure + + ```html +
            +
            + The collection is empty +
            +
            + ``` + + ## Adding and Removing items + + The `childViews` property of a `CollectionView` should not be directly + manipulated. Instead, add, remove, replace items from its `content` property. + This will trigger appropriate changes to its rendered HTML. + + ## Use in templates via the `{{collection}}` `Ember.Handlebars` helper + + `Ember.Handlebars` provides a helper specifically for adding + `CollectionView`s to templates. See `Ember.Handlebars.collection` for more + details + + @class CollectionView + @namespace Ember + @extends Ember.ContainerView + @since Ember 0.9 +*/ +Ember.CollectionView = Ember.ContainerView.extend( +/** @scope Ember.CollectionView.prototype */ { + + /** + A list of items to be displayed by the `Ember.CollectionView`. + + @property content + @type Ember.Array + @default null + */ + content: null, + + /** + @private + + This provides metadata about what kind of empty view class this + collection would like if it is being instantiated from another + system (like Handlebars) + + @property emptyViewClass + */ + emptyViewClass: Ember.View, + + /** + An optional view to display if content is set to an empty array. + + @property emptyView + @type Ember.View + @default null + */ + emptyView: null, + + /** + @property itemViewClass + @type Ember.View + @default Ember.View + */ + itemViewClass: Ember.View, + + init: function() { + var ret = this._super(); + this._contentDidChange(); + return ret; + }, + + _contentWillChange: Ember.beforeObserver(function() { + var content = this.get('content'); + + if (content) { content.removeArrayObserver(this); } + var len = content ? get(content, 'length') : 0; + this.arrayWillChange(content, 0, len); + }, 'content'), + + /** + @private + + Check to make sure that the content has changed, and if so, + update the children directly. This is always scheduled + asynchronously, to allow the element to be created before + bindings have synchronized and vice versa. + + @method _contentDidChange + */ + _contentDidChange: Ember.observer(function() { + var content = get(this, 'content'); + + if (content) { + Ember.assert(fmt("an Ember.CollectionView's content must implement Ember.Array. You passed %@", [content]), Ember.Array.detect(content)); + content.addArrayObserver(this); + } + + var len = content ? get(content, 'length') : 0; + this.arrayDidChange(content, 0, null, len); + }, 'content'), + + willDestroy: function() { + var content = get(this, 'content'); + if (content) { content.removeArrayObserver(this); } + + this._super(); + + if (this._createdEmptyView) { + this._createdEmptyView.destroy(); + } + }, + + arrayWillChange: function(content, start, removedCount) { + // If the contents were empty before and this template collection has an + // empty view remove it now. + var emptyView = get(this, 'emptyView'); + if (emptyView && emptyView instanceof Ember.View) { + emptyView.removeFromParent(); + } + + // Loop through child views that correspond with the removed items. + // Note that we loop from the end of the array to the beginning because + // we are mutating it as we go. + var childViews = this._childViews, childView, idx, len; + + len = this._childViews.length; + + var removingAll = removedCount === len; + + if (removingAll) { + this.currentState.empty(this); + } + + for (idx = start + removedCount - 1; idx >= start; idx--) { + childView = childViews[idx]; + if (removingAll) { childView.removedFromDOM = true; } + childView.destroy(); + } + }, + + /** + Called when a mutation to the underlying content array occurs. + + This method will replay that mutation against the views that compose the + `Ember.CollectionView`, ensuring that the view reflects the model. + + This array observer is added in `contentDidChange`. + + @method arrayDidChange + @param {Array} addedObjects the objects that were added to the content + @param {Array} removedObjects the objects that were removed from the content + @param {Number} changeIndex the index at which the changes occurred + */ + arrayDidChange: function(content, start, removed, added) { + var itemViewClass = get(this, 'itemViewClass'), + addedViews = [], view, item, idx, len, itemTagName; + + if ('string' === typeof itemViewClass) { + itemViewClass = get(itemViewClass); + } + + Ember.assert(fmt("itemViewClass must be a subclass of Ember.View, not %@", [itemViewClass]), Ember.View.detect(itemViewClass)); + + len = content ? get(content, 'length') : 0; + if (len) { + for (idx = start; idx < start+added; idx++) { + item = content.objectAt(idx); + + view = this.createChildView(itemViewClass, { + content: item, + contentIndex: idx + }); + + addedViews.push(view); + } + } else { + var emptyView = get(this, 'emptyView'); + if (!emptyView) { return; } + + var isClass = Ember.CoreView.detect(emptyView); + + emptyView = this.createChildView(emptyView); + addedViews.push(emptyView); + set(this, 'emptyView', emptyView); + + if (isClass) { this._createdEmptyView = emptyView; } + } + this.replace(start, 0, addedViews); + }, + + createChildView: function(view, attrs) { + view = this._super(view, attrs); + + var itemTagName = get(view, 'tagName'); + var tagName = (itemTagName === null || itemTagName === undefined) ? Ember.CollectionView.CONTAINER_MAP[get(this, 'tagName')] : itemTagName; + + set(view, 'tagName', tagName); + + return view; + } +}); + +/** + A map of parent tags to their default child tags. You can add + additional parent tags if you want collection views that use + a particular parent tag to default to a child tag. + + @property CONTAINER_MAP + @type Hash + @static + @final +*/ +Ember.CollectionView.CONTAINER_MAP = { + ul: 'li', + ol: 'li', + table: 'tr', + thead: 'tr', + tbody: 'tr', + tfoot: 'tr', + tr: 'td', + select: 'option' +}; + +})(); + + + +(function() { + +})(); + + + +(function() { +/*globals jQuery*/ +/** +Ember Views + +@module ember +@submodule ember-views +@requires ember-runtime +@main ember-views +*/ + +})(); + +(function() { +define("metamorph", + [], + function() { + "use strict"; + // ========================================================================== + // Project: metamorph + // Copyright: ©2011 My Company Inc. All rights reserved. + // ========================================================================== + + var K = function(){}, + guid = 0, + document = window.document, + + // Feature-detect the W3C range API, the extended check is for IE9 which only partially supports ranges + supportsRange = ('createRange' in document) && (typeof Range !== 'undefined') && Range.prototype.createContextualFragment, + + // Internet Explorer prior to 9 does not allow setting innerHTML if the first element + // is a "zero-scope" element. This problem can be worked around by making + // the first node an invisible text node. We, like Modernizr, use ­ + needsShy = (function(){ + var testEl = document.createElement('div'); + testEl.innerHTML = "
            "; + testEl.firstChild.innerHTML = ""; + return testEl.firstChild.innerHTML === ''; + })(), + + + // IE 8 (and likely earlier) likes to move whitespace preceeding + // a script tag to appear after it. This means that we can + // accidentally remove whitespace when updating a morph. + movesWhitespace = (function() { + var testEl = document.createElement('div'); + testEl.innerHTML = "Test: Value"; + return testEl.childNodes[0].nodeValue === 'Test:' && + testEl.childNodes[2].nodeValue === ' Value'; + })(); + + // Constructor that supports either Metamorph('foo') or new + // Metamorph('foo'); + // + // Takes a string of HTML as the argument. + + var Metamorph = function(html) { + var self; + + if (this instanceof Metamorph) { + self = this; + } else { + self = new K(); + } + + self.innerHTML = html; + var myGuid = 'metamorph-'+(guid++); + self.start = myGuid + '-start'; + self.end = myGuid + '-end'; + + return self; + }; + + K.prototype = Metamorph.prototype; + + var rangeFor, htmlFunc, removeFunc, outerHTMLFunc, appendToFunc, afterFunc, prependFunc, startTagFunc, endTagFunc; + + outerHTMLFunc = function() { + return this.startTag() + this.innerHTML + this.endTag(); + }; + + startTagFunc = function() { + /* + * We replace chevron by its hex code in order to prevent escaping problems. + * Check this thread for more explaination: + * http://stackoverflow.com/questions/8231048/why-use-x3c-instead-of-when-generating-html-from-javascript + */ + return "hi"; + * div.firstChild.firstChild.tagName //=> "" + * + * If our script markers are inside such a node, we need to find that + * node and use *it* as the marker. + **/ + var realNode = function(start) { + while (start.parentNode.tagName === "") { + start = start.parentNode; + } + + return start; + }; + + /** + * When automatically adding a tbody, Internet Explorer inserts the + * tbody immediately before the first . Other browsers create it + * before the first node, no matter what. + * + * This means the the following code: + * + * div = document.createElement("div"); + * div.innerHTML = "
            hi
            + * + * Generates the following DOM in IE: + * + * + div + * + table + * - script id='first' + * + tbody + * + tr + * + td + * - "hi" + * - script id='last' + * + * Which means that the two script tags, even though they were + * inserted at the same point in the hierarchy in the original + * HTML, now have different parents. + * + * This code reparents the first script tag by making it the tbody's + * first child. + **/ + var fixParentage = function(start, end) { + if (start.parentNode !== end.parentNode) { + end.parentNode.insertBefore(start, end.parentNode.firstChild); + } + }; + + htmlFunc = function(html, outerToo) { + // get the real starting node. see realNode for details. + var start = realNode(document.getElementById(this.start)); + var end = document.getElementById(this.end); + var parentNode = end.parentNode; + var node, nextSibling, last; + + // make sure that the start and end nodes share the same + // parent. If not, fix it. + fixParentage(start, end); + + // remove all of the nodes after the starting placeholder and + // before the ending placeholder. + node = start.nextSibling; + while (node) { + nextSibling = node.nextSibling; + last = node === end; + + // if this is the last node, and we want to remove it as well, + // set the `end` node to the next sibling. This is because + // for the rest of the function, we insert the new nodes + // before the end (note that insertBefore(node, null) is + // the same as appendChild(node)). + // + // if we do not want to remove it, just break. + if (last) { + if (outerToo) { end = node.nextSibling; } else { break; } + } + + node.parentNode.removeChild(node); + + // if this is the last node and we didn't break before + // (because we wanted to remove the outer nodes), break + // now. + if (last) { break; } + + node = nextSibling; + } + + // get the first node for the HTML string, even in cases like + // tables and lists where a simple innerHTML on a div would + // swallow some of the content. + node = firstNodeFor(start.parentNode, html); + + // copy the nodes for the HTML between the starting and ending + // placeholder. + while (node) { + nextSibling = node.nextSibling; + parentNode.insertBefore(node, end); + node = nextSibling; + } + }; + + // remove the nodes in the DOM representing this metamorph. + // + // this includes the starting and ending placeholders. + removeFunc = function() { + var start = realNode(document.getElementById(this.start)); + var end = document.getElementById(this.end); + + this.html(''); + start.parentNode.removeChild(start); + end.parentNode.removeChild(end); + }; + + appendToFunc = function(parentNode) { + var node = firstNodeFor(parentNode, this.outerHTML()); + var nextSibling; + + while (node) { + nextSibling = node.nextSibling; + parentNode.appendChild(node); + node = nextSibling; + } + }; + + afterFunc = function(html) { + // get the real starting node. see realNode for details. + var end = document.getElementById(this.end); + var insertBefore = end.nextSibling; + var parentNode = end.parentNode; + var nextSibling; + var node; + + // get the first node for the HTML string, even in cases like + // tables and lists where a simple innerHTML on a div would + // swallow some of the content. + node = firstNodeFor(parentNode, html); + + // copy the nodes for the HTML between the starting and ending + // placeholder. + while (node) { + nextSibling = node.nextSibling; + parentNode.insertBefore(node, insertBefore); + node = nextSibling; + } + }; + + prependFunc = function(html) { + var start = document.getElementById(this.start); + var parentNode = start.parentNode; + var nextSibling; + var node; + + node = firstNodeFor(parentNode, html); + var insertBefore = start.nextSibling; + + while (node) { + nextSibling = node.nextSibling; + parentNode.insertBefore(node, insertBefore); + node = nextSibling; + } + }; + } + + Metamorph.prototype.html = function(html) { + this.checkRemoved(); + if (html === undefined) { return this.innerHTML; } + + htmlFunc.call(this, html); + + this.innerHTML = html; + }; + + Metamorph.prototype.replaceWith = function(html) { + this.checkRemoved(); + htmlFunc.call(this, html, true); + }; + + Metamorph.prototype.remove = removeFunc; + Metamorph.prototype.outerHTML = outerHTMLFunc; + Metamorph.prototype.appendTo = appendToFunc; + Metamorph.prototype.after = afterFunc; + Metamorph.prototype.prepend = prependFunc; + Metamorph.prototype.startTag = startTagFunc; + Metamorph.prototype.endTag = endTagFunc; + + Metamorph.prototype.isRemoved = function() { + var before = document.getElementById(this.start); + var after = document.getElementById(this.end); + + return !before || !after; + }; + + Metamorph.prototype.checkRemoved = function() { + if (this.isRemoved()) { + throw new Error("Cannot perform operations on a Metamorph that is not in the DOM."); + } + }; + + return Metamorph; + }); + +})(); + +(function() { +/** +@module ember +@submodule ember-handlebars +*/ + +// Eliminate dependency on any Ember to simplify precompilation workflow +var objectCreate = Object.create || function(parent) { + function F() {} + F.prototype = parent; + return new F(); +}; + +var Handlebars = this.Handlebars || Ember.imports.Handlebars; +Ember.assert("Ember Handlebars requires Handlebars 1.0.rc.2 or greater", Handlebars && Handlebars.VERSION.match(/^1\.0\.rc\.[23456789]+/)); + +/** + Prepares the Handlebars templating library for use inside Ember's view + system. + + The `Ember.Handlebars` object is the standard Handlebars library, extended to + use Ember's `get()` method instead of direct property access, which allows + computed properties to be used inside templates. + + To create an `Ember.Handlebars` template, call `Ember.Handlebars.compile()`. + This will return a function that can be used by `Ember.View` for rendering. + + @class Handlebars + @namespace Ember +*/ +Ember.Handlebars = objectCreate(Handlebars); + +/** +@class helpers +@namespace Ember.Handlebars +*/ +Ember.Handlebars.helpers = objectCreate(Handlebars.helpers); + +/** + Override the the opcode compiler and JavaScript compiler for Handlebars. + + @class Compiler + @namespace Ember.Handlebars + @private + @constructor +*/ +Ember.Handlebars.Compiler = function() {}; + +// Handlebars.Compiler doesn't exist in runtime-only +if (Handlebars.Compiler) { + Ember.Handlebars.Compiler.prototype = objectCreate(Handlebars.Compiler.prototype); +} + +Ember.Handlebars.Compiler.prototype.compiler = Ember.Handlebars.Compiler; + +/** + @class JavaScriptCompiler + @namespace Ember.Handlebars + @private + @constructor +*/ +Ember.Handlebars.JavaScriptCompiler = function() {}; + +// Handlebars.JavaScriptCompiler doesn't exist in runtime-only +if (Handlebars.JavaScriptCompiler) { + Ember.Handlebars.JavaScriptCompiler.prototype = objectCreate(Handlebars.JavaScriptCompiler.prototype); + Ember.Handlebars.JavaScriptCompiler.prototype.compiler = Ember.Handlebars.JavaScriptCompiler; +} + + +Ember.Handlebars.JavaScriptCompiler.prototype.namespace = "Ember.Handlebars"; + + +Ember.Handlebars.JavaScriptCompiler.prototype.initializeBuffer = function() { + return "''"; +}; + +/** + @private + + Override the default buffer for Ember Handlebars. By default, Handlebars + creates an empty String at the beginning of each invocation and appends to + it. Ember's Handlebars overrides this to append to a single shared buffer. + + @method appendToBuffer + @param string {String} +*/ +Ember.Handlebars.JavaScriptCompiler.prototype.appendToBuffer = function(string) { + return "data.buffer.push("+string+");"; +}; + +var prefix = "ember" + (+new Date()), incr = 1; + +/** + @private + + Rewrite simple mustaches from `{{foo}}` to `{{bind "foo"}}`. This means that + all simple mustaches in Ember's Handlebars will also set up an observer to + keep the DOM up to date when the underlying property changes. + + @method mustache + @for Ember.Handlebars.Compiler + @param mustache +*/ +Ember.Handlebars.Compiler.prototype.mustache = function(mustache) { + if (mustache.isHelper && mustache.id.string === 'control') { + mustache.hash = mustache.hash || new Handlebars.AST.HashNode([]); + mustache.hash.pairs.push(["controlID", new Handlebars.AST.StringNode(prefix + incr++)]); + } else if (mustache.params.length || mustache.hash) { + // no changes required + } else { + var id = new Handlebars.AST.IdNode(['_triageMustache']); + + // Update the mustache node to include a hash value indicating whether the original node + // was escaped. This will allow us to properly escape values when the underlying value + // changes and we need to re-render the value. + if(!mustache.escaped) { + mustache.hash = mustache.hash || new Handlebars.AST.HashNode([]); + mustache.hash.pairs.push(["unescaped", new Handlebars.AST.StringNode("true")]); + } + mustache = new Handlebars.AST.MustacheNode([id].concat([mustache.id]), mustache.hash, !mustache.escaped); + } + + return Handlebars.Compiler.prototype.mustache.call(this, mustache); +}; + +/** + Used for precompilation of Ember Handlebars templates. This will not be used + during normal app execution. + + @method precompile + @for Ember.Handlebars + @static + @param {String} string The template to precompile +*/ +Ember.Handlebars.precompile = function(string) { + var ast = Handlebars.parse(string); + + var options = { + knownHelpers: { + action: true, + unbound: true, + bindAttr: true, + template: true, + view: true, + _triageMustache: true + }, + data: true, + stringParams: true + }; + + var environment = new Ember.Handlebars.Compiler().compile(ast, options); + return new Ember.Handlebars.JavaScriptCompiler().compile(environment, options, undefined, true); +}; + +// We don't support this for Handlebars runtime-only +if (Handlebars.compile) { + /** + The entry point for Ember Handlebars. This replaces the default + `Handlebars.compile` and turns on template-local data and String + parameters. + + @method compile + @for Ember.Handlebars + @static + @param {String} string The template to compile + @return {Function} + */ + Ember.Handlebars.compile = function(string) { + var ast = Handlebars.parse(string); + var options = { data: true, stringParams: true }; + var environment = new Ember.Handlebars.Compiler().compile(ast, options); + var templateSpec = new Ember.Handlebars.JavaScriptCompiler().compile(environment, options, undefined, true); + + return Ember.Handlebars.template(templateSpec); + }; +} + + +})(); + +(function() { +var slice = Array.prototype.slice; + +/** + @private + + If a path starts with a reserved keyword, returns the root + that should be used. + + @method normalizePath + @for Ember + @param root {Object} + @param path {String} + @param data {Hash} +*/ +var normalizePath = Ember.Handlebars.normalizePath = function(root, path, data) { + var keywords = (data && data.keywords) || {}, + keyword, isKeyword; + + // Get the first segment of the path. For example, if the + // path is "foo.bar.baz", returns "foo". + keyword = path.split('.', 1)[0]; + + // Test to see if the first path is a keyword that has been + // passed along in the view's data hash. If so, we will treat + // that object as the new root. + if (keywords.hasOwnProperty(keyword)) { + // Look up the value in the template's data hash. + root = keywords[keyword]; + isKeyword = true; + + // Handle cases where the entire path is the reserved + // word. In that case, return the object itself. + if (path === keyword) { + path = ''; + } else { + // Strip the keyword from the path and look up + // the remainder from the newly found root. + path = path.substr(keyword.length+1); + } + } + + return { root: root, path: path, isKeyword: isKeyword }; +}; + + +/** + Lookup both on root and on window. If the path starts with + a keyword, the corresponding object will be looked up in the + template's data hash and used to resolve the path. + + @method get + @for Ember.Handlebars + @param {Object} root The object to look up the property on + @param {String} path The path to be lookedup + @param {Object} options The template's option hash +*/ +var handlebarsGet = Ember.Handlebars.get = function(root, path, options) { + var data = options && options.data, + normalizedPath = normalizePath(root, path, data), + value; + + // In cases where the path begins with a keyword, change the + // root to the value represented by that keyword, and ensure + // the path is relative to it. + root = normalizedPath.root; + path = normalizedPath.path; + + value = Ember.get(root, path); + + // If the path starts with a capital letter, look it up on Ember.lookup, + // which defaults to the `window` object in browsers. + if (value === undefined && root !== Ember.lookup && Ember.isGlobalPath(path)) { + value = Ember.get(Ember.lookup, path); + } + return value; +}; +Ember.Handlebars.getPath = Ember.deprecateFunc('`Ember.Handlebars.getPath` has been changed to `Ember.Handlebars.get` for consistency.', Ember.Handlebars.get); + +Ember.Handlebars.resolveParams = function(context, params, options) { + var resolvedParams = [], types = options.types, param, type; + + for (var i=0, l=params.length; i + ``` + + The above handlebars template will fill the ``'s `src` attribute will + the value of the property referenced with `"imageUrl"` and its `alt` + attribute with the value of the property referenced with `"imageTitle"`. + + If the rendering context of this template is the following object: + + ```javascript + { + imageUrl: 'http://lolcats.info/haz-a-funny', + imageTitle: 'A humorous image of a cat' + } + ``` + + The resulting HTML output will be: + + ```html + A humorous image of a cat + ``` + + `bindAttr` cannot redeclare existing DOM element attributes. The use of `src` + in the following `bindAttr` example will be ignored and the hard coded value + of `src="/failwhale.gif"` will take precedence: + + ```handlebars + imageTitle + ``` + + ### `bindAttr` and the `class` attribute + + `bindAttr` supports a special syntax for handling a number of cases unique + to the `class` DOM element attribute. The `class` attribute combines + multiple discreet values into a single attribute as a space-delimited + list of strings. Each string can be: + + * a string return value of an object's property. + * a boolean return value of an object's property + * a hard-coded value + + A string return value works identically to other uses of `bindAttr`. The + return value of the property will become the value of the attribute. For + example, the following view and template: + + ```javascript + AView = Ember.View.extend({ + someProperty: function(){ + return "aValue"; + }.property() + }) + ``` + + ```handlebars + + ``` + + A boolean return value will insert a specified class name if the property + returns `true` and remove the class name if the property returns `false`. + + A class name is provided via the syntax + `somePropertyName:class-name-if-true`. + + ```javascript + AView = Ember.View.extend({ + someBool: true + }) + ``` + + ```handlebars + + ``` + + Result in the following rendered output: + + ```html + + ``` + + An additional section of the binding can be provided if you want to + replace the existing class instead of removing it when the boolean + value changes: + + ```handlebars + + ``` + + A hard-coded value can be used by prepending `:` to the desired + class name: `:class-name-to-always-apply`. + + ```handlebars + + ``` + + Results in the following rendered output: + + ```html + + ``` + + All three strategies - string return value, boolean return value, and + hard-coded value – can be combined in a single declaration: + + ```handlebars + + ``` + + @method bindAttr + @for Ember.Handlebars.helpers + @param {Hash} options + @return {String} HTML string +*/ +EmberHandlebars.registerHelper('bindAttr', function(options) { + + var attrs = options.hash; + + Ember.assert("You must specify at least one hash argument to bindAttr", !!Ember.keys(attrs).length); + + var view = options.data.view; + var ret = []; + var ctx = this; + + // Generate a unique id for this element. This will be added as a + // data attribute to the element so it can be looked up when + // the bound property changes. + var dataId = ++Ember.uuid; + + // Handle classes differently, as we can bind multiple classes + var classBindings = attrs['class']; + if (classBindings !== null && classBindings !== undefined) { + var classResults = EmberHandlebars.bindClasses(this, classBindings, view, dataId, options); + + ret.push('class="' + Handlebars.Utils.escapeExpression(classResults.join(' ')) + '"'); + delete attrs['class']; + } + + var attrKeys = Ember.keys(attrs); + + // For each attribute passed, create an observer and emit the + // current value of the property as an attribute. + forEach.call(attrKeys, function(attr) { + var path = attrs[attr], + pathRoot, normalized; + + Ember.assert(fmt("You must provide a String for a bound attribute, not %@", [path]), typeof path === 'string'); + + normalized = normalizePath(ctx, path, options.data); + + pathRoot = normalized.root; + path = normalized.path; + + var value = (path === 'this') ? pathRoot : handlebarsGet(pathRoot, path, options), + type = Ember.typeOf(value); + + Ember.assert(fmt("Attributes must be numbers, strings or booleans, not %@", [value]), value === null || value === undefined || type === 'number' || type === 'string' || type === 'boolean'); + + var observer, invoker; + + observer = function observer() { + var result = handlebarsGet(pathRoot, path, options); + + Ember.assert(fmt("Attributes must be numbers, strings or booleans, not %@", [result]), result === null || result === undefined || typeof result === 'number' || typeof result === 'string' || typeof result === 'boolean'); + + var elem = view.$("[data-bindattr-" + dataId + "='" + dataId + "']"); + + // If we aren't able to find the element, it means the element + // to which we were bound has been removed from the view. + // In that case, we can assume the template has been re-rendered + // and we need to clean up the observer. + if (!elem || elem.length === 0) { + Ember.removeObserver(pathRoot, path, invoker); + return; + } + + Ember.View.applyAttributeBindings(elem, attr, result); + }; + + invoker = function() { + Ember.run.scheduleOnce('render', observer); + }; + + // Add an observer to the view for when the property changes. + // When the observer fires, find the element using the + // unique data id and update the attribute to the new value. + if (path !== 'this') { + view.registerObserver(pathRoot, path, invoker); + } + + // if this changes, also change the logic in ember-views/lib/views/view.js + if ((type === 'string' || (type === 'number' && !isNaN(value)))) { + ret.push(attr + '="' + Handlebars.Utils.escapeExpression(value) + '"'); + } else if (value && type === 'boolean') { + // The developer controls the attr name, so it should always be safe + ret.push(attr + '="' + attr + '"'); + } + }, this); + + // Add the unique identifier + // NOTE: We use all lower-case since Firefox has problems with mixed case in SVG + ret.push('data-bindattr-' + dataId + '="' + dataId + '"'); + return new EmberHandlebars.SafeString(ret.join(' ')); +}); + +/** + @private + + Helper that, given a space-separated string of property paths and a context, + returns an array of class names. Calling this method also has the side + effect of setting up observers at those property paths, such that if they + change, the correct class name will be reapplied to the DOM element. + + For example, if you pass the string "fooBar", it will first look up the + "fooBar" value of the context. If that value is true, it will add the + "foo-bar" class to the current element (i.e., the dasherized form of + "fooBar"). If the value is a string, it will add that string as the class. + Otherwise, it will not add any new class name. + + @method bindClasses + @for Ember.Handlebars + @param {Ember.Object} context The context from which to lookup properties + @param {String} classBindings A string, space-separated, of class bindings + to use + @param {Ember.View} view The view in which observers should look for the + element to update + @param {Srting} bindAttrId Optional bindAttr id used to lookup elements + @return {Array} An array of class names to add +*/ +EmberHandlebars.bindClasses = function(context, classBindings, view, bindAttrId, options) { + var ret = [], newClass, value, elem; + + // Helper method to retrieve the property from the context and + // determine which class string to return, based on whether it is + // a Boolean or not. + var classStringForPath = function(root, parsedPath, options) { + var val, + path = parsedPath.path; + + if (path === 'this') { + val = root; + } else if (path === '') { + val = true; + } else { + val = handlebarsGet(root, path, options); + } + + return Ember.View._classStringForValue(path, val, parsedPath.className, parsedPath.falsyClassName); + }; + + // For each property passed, loop through and setup + // an observer. + forEach.call(classBindings.split(' '), function(binding) { + + // Variable in which the old class value is saved. The observer function + // closes over this variable, so it knows which string to remove when + // the property changes. + var oldClass; + + var observer, invoker; + + var parsedPath = Ember.View._parsePropertyPath(binding), + path = parsedPath.path, + pathRoot = context, + normalized; + + if (path !== '' && path !== 'this') { + normalized = normalizePath(context, path, options.data); + + pathRoot = normalized.root; + path = normalized.path; + } + + // Set up an observer on the context. If the property changes, toggle the + // class name. + observer = function() { + // Get the current value of the property + newClass = classStringForPath(pathRoot, parsedPath, options); + elem = bindAttrId ? view.$("[data-bindattr-" + bindAttrId + "='" + bindAttrId + "']") : view.$(); + + // If we can't find the element anymore, a parent template has been + // re-rendered and we've been nuked. Remove the observer. + if (!elem || elem.length === 0) { + Ember.removeObserver(pathRoot, path, invoker); + } else { + // If we had previously added a class to the element, remove it. + if (oldClass) { + elem.removeClass(oldClass); + } + + // If necessary, add a new class. Make sure we keep track of it so + // it can be removed in the future. + if (newClass) { + elem.addClass(newClass); + oldClass = newClass; + } else { + oldClass = null; + } + } + }; + + invoker = function() { + Ember.run.scheduleOnce('render', observer); + }; + + if (path !== '' && path !== 'this') { + view.registerObserver(pathRoot, path, invoker); + } + + // We've already setup the observer; now we just need to figure out the + // correct behavior right now on the first pass through. + value = classStringForPath(pathRoot, parsedPath, options); + + if (value) { + ret.push(value); + + // Make sure we save the current value so that it can be removed if the + // observer fires. + oldClass = value; + } + }); + + return ret; +}; + + +})(); + + + +(function() { +/*globals Handlebars */ + +// TODO: Don't require the entire module +/** +@module ember +@submodule ember-handlebars +*/ + +var get = Ember.get, set = Ember.set; +var PARENT_VIEW_PATH = /^parentView\./; +var EmberHandlebars = Ember.Handlebars; + +EmberHandlebars.ViewHelper = Ember.Object.create({ + + propertiesFromHTMLOptions: function(options, thisContext) { + var hash = options.hash, data = options.data; + var extensions = {}, + classes = hash['class'], + dup = false; + + if (hash.id) { + extensions.elementId = hash.id; + dup = true; + } + + if (classes) { + classes = classes.split(' '); + extensions.classNames = classes; + dup = true; + } + + if (hash.classBinding) { + extensions.classNameBindings = hash.classBinding.split(' '); + dup = true; + } + + if (hash.classNameBindings) { + if (extensions.classNameBindings === undefined) extensions.classNameBindings = []; + extensions.classNameBindings = extensions.classNameBindings.concat(hash.classNameBindings.split(' ')); + dup = true; + } + + if (hash.attributeBindings) { + Ember.assert("Setting 'attributeBindings' via Handlebars is not allowed. Please subclass Ember.View and set it there instead."); + extensions.attributeBindings = null; + dup = true; + } + + if (dup) { + hash = Ember.$.extend({}, hash); + delete hash.id; + delete hash['class']; + delete hash.classBinding; + } + + // Set the proper context for all bindings passed to the helper. This applies to regular attribute bindings + // as well as class name bindings. If the bindings are local, make them relative to the current context + // instead of the view. + var path; + + // Evaluate the context of regular attribute bindings: + for (var prop in hash) { + if (!hash.hasOwnProperty(prop)) { continue; } + + // Test if the property ends in "Binding" + if (Ember.IS_BINDING.test(prop) && typeof hash[prop] === 'string') { + path = this.contextualizeBindingPath(hash[prop], data); + if (path) { hash[prop] = path; } + } + } + + // Evaluate the context of class name bindings: + if (extensions.classNameBindings) { + for (var b in extensions.classNameBindings) { + var full = extensions.classNameBindings[b]; + if (typeof full === 'string') { + // Contextualize the path of classNameBinding so this: + // + // classNameBinding="isGreen:green" + // + // is converted to this: + // + // classNameBinding="_parentView.context.isGreen:green" + var parsedPath = Ember.View._parsePropertyPath(full); + path = this.contextualizeBindingPath(parsedPath.path, data); + if (path) { extensions.classNameBindings[b] = path + parsedPath.classNames; } + } + } + } + + return Ember.$.extend(hash, extensions); + }, + + // Transform bindings from the current context to a context that can be evaluated within the view. + // Returns null if the path shouldn't be changed. + // + // TODO: consider the addition of a prefix that would allow this method to return `path`. + contextualizeBindingPath: function(path, data) { + var normalized = Ember.Handlebars.normalizePath(null, path, data); + if (normalized.isKeyword) { + return 'templateData.keywords.' + path; + } else if (Ember.isGlobalPath(path)) { + return null; + } else if (path === 'this') { + return '_parentView.context'; + } else { + return '_parentView.context.' + path; + } + }, + + helper: function(thisContext, path, options) { + var inverse = options.inverse, + data = options.data, + view = data.view, + fn = options.fn, + hash = options.hash, + newView; + + if ('string' === typeof path) { + newView = EmberHandlebars.get(thisContext, path, options); + Ember.assert("Unable to find view at path '" + path + "'", !!newView); + } else { + newView = path; + } + + Ember.assert(Ember.String.fmt('You must pass a view to the #view helper, not %@ (%@)', [path, newView]), Ember.View.detect(newView) || Ember.View.detectInstance(newView)); + + var viewOptions = this.propertiesFromHTMLOptions(options, thisContext); + var currentView = data.view; + viewOptions.templateData = options.data; + var newViewProto = newView.proto ? newView.proto() : newView; + + if (fn) { + Ember.assert("You cannot provide a template block if you also specified a templateName", !get(viewOptions, 'templateName') && !get(newViewProto, 'templateName')); + viewOptions.template = fn; + } + + // We only want to override the `_context` computed property if there is + // no specified controller. See View#_context for more information. + if (!newViewProto.controller && !newViewProto.controllerBinding && !viewOptions.controller && !viewOptions.controllerBinding) { + viewOptions._context = thisContext; + } + + currentView.appendChild(newView, viewOptions); + } +}); + +/** + `{{view}}` inserts a new instance of `Ember.View` into a template passing its + options to the `Ember.View`'s `create` method and using the supplied block as + the view's own template. + + An empty `` and the following template: + + ```handlebars + A span: + {{#view tagName="span"}} + hello. + {{/view}} + ``` + + Will result in HTML structure: + + ```html + + + +
            + A span: + + Hello. + +
            + + ``` + + ### `parentView` setting + + The `parentView` property of the new `Ember.View` instance created through + `{{view}}` will be set to the `Ember.View` instance of the template where + `{{view}}` was called. + + ```javascript + aView = Ember.View.create({ + template: Ember.Handlebars.compile("{{#view}} my parent: {{parentView.elementId}} {{/view}}") + }); + + aView.appendTo('body'); + ``` + + Will result in HTML structure: + + ```html +
            +
            + my parent: ember1 +
            +
            + ``` + + ### Setting CSS id and class attributes + + The HTML `id` attribute can be set on the `{{view}}`'s resulting element with + the `id` option. This option will _not_ be passed to `Ember.View.create`. + + ```handlebars + {{#view tagName="span" id="a-custom-id"}} + hello. + {{/view}} + ``` + + Results in the following HTML structure: + + ```html +
            + + hello. + +
            + ``` + + The HTML `class` attribute can be set on the `{{view}}`'s resulting element + with the `class` or `classNameBindings` options. The `class` option will + directly set the CSS `class` attribute and will not be passed to + `Ember.View.create`. `classNameBindings` will be passed to `create` and use + `Ember.View`'s class name binding functionality: + + ```handlebars + {{#view tagName="span" class="a-custom-class"}} + hello. + {{/view}} + ``` + + Results in the following HTML structure: + + ```html +
            + + hello. + +
            + ``` + + ### Supplying a different view class + + `{{view}}` can take an optional first argument before its supplied options to + specify a path to a custom view class. + + ```handlebars + {{#view "MyApp.CustomView"}} + hello. + {{/view}} + ``` + + The first argument can also be a relative path. Ember will search for the + view class starting at the `Ember.View` of the template where `{{view}}` was + used as the root object: + + ```javascript + MyApp = Ember.Application.create({}); + MyApp.OuterView = Ember.View.extend({ + innerViewClass: Ember.View.extend({ + classNames: ['a-custom-view-class-as-property'] + }), + template: Ember.Handlebars.compile('{{#view "innerViewClass"}} hi {{/view}}') + }); + + MyApp.OuterView.create().appendTo('body'); + ``` + + Will result in the following HTML: + + ```html +
            +
            + hi +
            +
            + ``` + + ### Blockless use + + If you supply a custom `Ember.View` subclass that specifies its own template + or provide a `templateName` option to `{{view}}` it can be used without + supplying a block. Attempts to use both a `templateName` option and supply a + block will throw an error. + + ```handlebars + {{view "MyApp.ViewWithATemplateDefined"}} + ``` + + ### `viewName` property + + You can supply a `viewName` option to `{{view}}`. The `Ember.View` instance + will be referenced as a property of its parent view by this name. + + ```javascript + aView = Ember.View.create({ + template: Ember.Handlebars.compile('{{#view viewName="aChildByName"}} hi {{/view}}') + }); + + aView.appendTo('body'); + aView.get('aChildByName') // the instance of Ember.View created by {{view}} helper + ``` + + @method view + @for Ember.Handlebars.helpers + @param {String} path + @param {Hash} options + @return {String} HTML string +*/ +EmberHandlebars.registerHelper('view', function(path, options) { + Ember.assert("The view helper only takes a single argument", arguments.length <= 2); + + // If no path is provided, treat path param as options. + if (path && path.data && path.data.isRenderData) { + options = path; + path = "Ember.View"; + } + + return EmberHandlebars.ViewHelper.helper(this, path, options); +}); + + +})(); + + + +(function() { +/*globals Handlebars */ + +// TODO: Don't require all of this module +/** +@module ember +@submodule ember-handlebars +*/ + +var get = Ember.get, handlebarsGet = Ember.Handlebars.get, fmt = Ember.String.fmt; + +/** + `{{collection}}` is a `Ember.Handlebars` helper for adding instances of + `Ember.CollectionView` to a template. See `Ember.CollectionView` for + additional information on how a `CollectionView` functions. + + `{{collection}}`'s primary use is as a block helper with a `contentBinding` + option pointing towards an `Ember.Array`-compatible object. An `Ember.View` + instance will be created for each item in its `content` property. Each view + will have its own `content` property set to the appropriate item in the + collection. + + The provided block will be applied as the template for each item's view. + + Given an empty `` the following template: + + ```handlebars + {{#collection contentBinding="App.items"}} + Hi {{view.content.name}} + {{/collection}} + ``` + + And the following application code + + ```javascript + App = Ember.Application.create() + App.items = [ + Ember.Object.create({name: 'Dave'}), + Ember.Object.create({name: 'Mary'}), + Ember.Object.create({name: 'Sara'}) + ] + ``` + + Will result in the HTML structure below + + ```html +
            +
            Hi Dave
            +
            Hi Mary
            +
            Hi Sara
            +
            + ``` + + ### Blockless Use + + If you provide an `itemViewClass` option that has its own `template` you can + omit the block. + + The following template: + + ```handlebars + {{collection contentBinding="App.items" itemViewClass="App.AnItemView"}} + ``` + + And application code + + ```javascript + App = Ember.Application.create(); + App.items = [ + Ember.Object.create({name: 'Dave'}), + Ember.Object.create({name: 'Mary'}), + Ember.Object.create({name: 'Sara'}) + ]; + + App.AnItemView = Ember.View.extend({ + template: Ember.Handlebars.compile("Greetings {{view.content.name}}") + }); + ``` + + Will result in the HTML structure below + + ```html +
            +
            Greetings Dave
            +
            Greetings Mary
            +
            Greetings Sara
            +
            + ``` + + ### Specifying a CollectionView subclass + + By default the `{{collection}}` helper will create an instance of + `Ember.CollectionView`. You can supply a `Ember.CollectionView` subclass to + the helper by passing it as the first argument: + + ```handlebars + {{#collection App.MyCustomCollectionClass contentBinding="App.items"}} + Hi {{view.content.name}} + {{/collection}} + ``` + + ### Forwarded `item.*`-named Options + + As with the `{{view}}`, helper options passed to the `{{collection}}` will be + set on the resulting `Ember.CollectionView` as properties. Additionally, + options prefixed with `item` will be applied to the views rendered for each + item (note the camelcasing): + + ```handlebars + {{#collection contentBinding="App.items" + itemTagName="p" + itemClassNames="greeting"}} + Howdy {{view.content.name}} + {{/collection}} + ``` + + Will result in the following HTML structure: + + ```html +
            +

            Howdy Dave

            +

            Howdy Mary

            +

            Howdy Sara

            +
            + ``` + + @method collection + @for Ember.Handlebars.helpers + @param {String} path + @param {Hash} options + @return {String} HTML string + @deprecated Use `{{each}}` helper instead. +*/ +Ember.Handlebars.registerHelper('collection', function(path, options) { + Ember.deprecate("Using the {{collection}} helper without specifying a class has been deprecated as the {{each}} helper now supports the same functionality.", path !== 'collection'); + + // If no path is provided, treat path param as options. + if (path && path.data && path.data.isRenderData) { + options = path; + path = undefined; + Ember.assert("You cannot pass more than one argument to the collection helper", arguments.length === 1); + } else { + Ember.assert("You cannot pass more than one argument to the collection helper", arguments.length === 2); + } + + var fn = options.fn; + var data = options.data; + var inverse = options.inverse; + var view = options.data.view; + + // If passed a path string, convert that into an object. + // Otherwise, just default to the standard class. + var collectionClass; + collectionClass = path ? handlebarsGet(this, path, options) : Ember.CollectionView; + Ember.assert(fmt("%@ #collection: Could not find collection class %@", [data.view, path]), !!collectionClass); + + var hash = options.hash, itemHash = {}, match; + + // Extract item view class if provided else default to the standard class + var itemViewClass, itemViewPath = hash.itemViewClass; + var collectionPrototype = collectionClass.proto(); + delete hash.itemViewClass; + itemViewClass = itemViewPath ? handlebarsGet(collectionPrototype, itemViewPath, options) : collectionPrototype.itemViewClass; + Ember.assert(fmt("%@ #collection: Could not find itemViewClass %@", [data.view, itemViewPath]), !!itemViewClass); + + // Go through options passed to the {{collection}} helper and extract options + // that configure item views instead of the collection itself. + for (var prop in hash) { + if (hash.hasOwnProperty(prop)) { + match = prop.match(/^item(.)(.*)$/); + + if(match && prop !== 'itemController') { + // Convert itemShouldFoo -> shouldFoo + itemHash[match[1].toLowerCase() + match[2]] = hash[prop]; + // Delete from hash as this will end up getting passed to the + // {{view}} helper method. + delete hash[prop]; + } + } + } + + var tagName = hash.tagName || collectionPrototype.tagName; + + if (fn) { + itemHash.template = fn; + delete options.fn; + } + + var emptyViewClass; + if (inverse && inverse !== Handlebars.VM.noop) { + emptyViewClass = get(collectionPrototype, 'emptyViewClass'); + emptyViewClass = emptyViewClass.extend({ + template: inverse, + tagName: itemHash.tagName + }); + } else if (hash.emptyViewClass) { + emptyViewClass = handlebarsGet(this, hash.emptyViewClass, options); + } + if (emptyViewClass) { hash.emptyView = emptyViewClass; } + + if(!hash.keyword){ + itemHash._context = Ember.computed.alias('content'); + } + + var viewString = view.toString(); + + var viewOptions = Ember.Handlebars.ViewHelper.propertiesFromHTMLOptions({ data: data, hash: itemHash }, this); + hash.itemViewClass = itemViewClass.extend(viewOptions); + + return Ember.Handlebars.helpers.view.call(this, collectionClass, options); +}); + + +})(); + + + +(function() { +/*globals Handlebars */ +/** +@module ember +@submodule ember-handlebars +*/ + +var handlebarsGet = Ember.Handlebars.get; + +/** + `unbound` allows you to output a property without binding. *Important:* The + output will not be updated if the property changes. Use with caution. + + ```handlebars +
            {{unbound somePropertyThatDoesntChange}}
            + ``` + + `unbound` can also be used in conjunction with a bound helper to + render it in its unbound form: + + ```handlebars +
            {{unbound helperName somePropertyThatDoesntChange}}
            + ``` + + @method unbound + @for Ember.Handlebars.helpers + @param {String} property + @return {String} HTML string +*/ +Ember.Handlebars.registerHelper('unbound', function(property, fn) { + var options = arguments[arguments.length - 1], helper, context, out; + + if(arguments.length > 2) { + // Unbound helper call. + options.data.isUnbound = true; + helper = Ember.Handlebars.helpers[arguments[0]] || Ember.Handlebars.helperMissing; + out = helper.apply(this, Array.prototype.slice.call(arguments, 1)); + delete options.data.isUnbound; + return out; + } + + context = (fn.contexts && fn.contexts[0]) || this; + return handlebarsGet(context, property, fn); +}); + +})(); + + + +(function() { +/*jshint debug:true*/ +/** +@module ember +@submodule ember-handlebars +*/ + +var handlebarsGet = Ember.Handlebars.get, normalizePath = Ember.Handlebars.normalizePath; + +/** + `log` allows you to output the value of a value in the current rendering + context. + + ```handlebars + {{log myVariable}} + ``` + + @method log + @for Ember.Handlebars.helpers + @param {String} property +*/ +Ember.Handlebars.registerHelper('log', function(property, options) { + var context = (options.contexts && options.contexts[0]) || this, + normalized = normalizePath(context, property, options.data), + pathRoot = normalized.root, + path = normalized.path, + value = (path === 'this') ? pathRoot : handlebarsGet(pathRoot, path, options); + Ember.Logger.log(value); +}); + +/** + Execute the `debugger` statement in the current context. + + ```handlebars + {{debugger}} + ``` + + @method debugger + @for Ember.Handlebars.helpers + @param {String} property +*/ +Ember.Handlebars.registerHelper('debugger', function() { + debugger; +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-handlebars +*/ + +var get = Ember.get, set = Ember.set; + +Ember.Handlebars.EachView = Ember.CollectionView.extend(Ember._Metamorph, { + init: function() { + var itemController = get(this, 'itemController'); + var binding; + + if (itemController) { + var controller = Ember.ArrayController.create(); + set(controller, 'itemController', itemController); + set(controller, 'container', get(this, 'controller.container')); + set(controller, '_eachView', this); + this.disableContentObservers(function() { + set(this, 'content', controller); + binding = new Ember.Binding('content', '_eachView.dataSource').oneWay(); + binding.connect(controller); + }); + + set(this, '_arrayController', controller); + } else { + this.disableContentObservers(function() { + binding = new Ember.Binding('content', 'dataSource').oneWay(); + binding.connect(this); + }); + } + + return this._super(); + }, + + disableContentObservers: function(callback) { + Ember.removeBeforeObserver(this, 'content', null, '_contentWillChange'); + Ember.removeObserver(this, 'content', null, '_contentDidChange'); + + callback.apply(this); + + Ember.addBeforeObserver(this, 'content', null, '_contentWillChange'); + Ember.addObserver(this, 'content', null, '_contentDidChange'); + }, + + itemViewClass: Ember._MetamorphView, + emptyViewClass: Ember._MetamorphView, + + createChildView: function(view, attrs) { + view = this._super(view, attrs); + + // At the moment, if a container view subclass wants + // to insert keywords, it is responsible for cloning + // the keywords hash. This will be fixed momentarily. + var keyword = get(this, 'keyword'); + var content = get(view, 'content'); + + if (keyword) { + var data = get(view, 'templateData'); + + data = Ember.copy(data); + data.keywords = view.cloneKeywords(); + set(view, 'templateData', data); + + // In this case, we do not bind, because the `content` of + // a #each item cannot change. + data.keywords[keyword] = content; + } + + // If {{#each}} is looping over an array of controllers, + // point each child view at their respective controller. + if (content && get(content, 'isController')) { + set(view, 'controller', content); + } + + return view; + }, + + willDestroy: function() { + var arrayController = get(this, '_arrayController'); + + if (arrayController) { + arrayController.destroy(); + } + + return this._super(); + } +}); + +var GroupedEach = Ember.Handlebars.GroupedEach = function(context, path, options) { + var self = this, + normalized = Ember.Handlebars.normalizePath(context, path, options.data); + + this.context = context; + this.path = path; + this.options = options; + this.template = options.fn; + this.containingView = options.data.view; + this.normalizedRoot = normalized.root; + this.normalizedPath = normalized.path; + this.content = this.lookupContent(); + + this.addContentObservers(); + this.addArrayObservers(); + + this.containingView.on('willClearRender', function() { + self.destroy(); + }); +}; + +GroupedEach.prototype = { + contentWillChange: function() { + this.removeArrayObservers(); + }, + + contentDidChange: function() { + this.content = this.lookupContent(); + this.addArrayObservers(); + this.rerenderContainingView(); + }, + + contentArrayWillChange: Ember.K, + + contentArrayDidChange: function() { + this.rerenderContainingView(); + }, + + lookupContent: function() { + return Ember.Handlebars.get(this.normalizedRoot, this.normalizedPath, this.options); + }, + + addArrayObservers: function() { + this.content.addArrayObserver(this, { + willChange: 'contentArrayWillChange', + didChange: 'contentArrayDidChange' + }); + }, + + removeArrayObservers: function() { + this.content.removeArrayObserver(this, { + willChange: 'contentArrayWillChange', + didChange: 'contentArrayDidChange' + }); + }, + + addContentObservers: function() { + Ember.addBeforeObserver(this.normalizedRoot, this.normalizedPath, this, this.contentWillChange); + Ember.addObserver(this.normalizedRoot, this.normalizedPath, this, this.contentDidChange); + }, + + removeContentObservers: function() { + Ember.removeBeforeObserver(this.normalizedRoot, this.normalizedPath, this.contentWillChange); + Ember.removeObserver(this.normalizedRoot, this.normalizedPath, this.contentDidChange); + }, + + render: function() { + var content = this.content, + contentLength = get(content, 'length'), + data = this.options.data, + template = this.template; + + data.insideEach = true; + for (var i = 0; i < contentLength; i++) { + template(content.objectAt(i), { data: data }); + } + }, + + rerenderContainingView: function() { + Ember.run.scheduleOnce('render', this.containingView, 'rerender'); + }, + + destroy: function() { + this.removeContentObservers(); + this.removeArrayObservers(); + } +}; + +/** + The `{{#each}}` helper loops over elements in a collection, rendering its + block once for each item. It is an extension of the base Handlebars `{{#each}}` + helper: + + ```javascript + Developers = [{name: 'Yehuda'},{name: 'Tom'}, {name: 'Paul'}]; + ``` + + ```handlebars + {{#each Developers}} + {{name}} + {{/each}} + ``` + + `{{each}}` supports an alternative syntax with element naming: + + ```handlebars + {{#each person in Developers}} + {{person.name}} + {{/each}} + ``` + + When looping over objects that do not have properties, `{{this}}` can be used + to render the object: + + ```javascript + DeveloperNames = ['Yehuda', 'Tom', 'Paul'] + ``` + + ```handlebars + {{#each DeveloperNames}} + {{this}} + {{/each}} + ``` + ### {{else}} condition + `{{#each}}` can have a matching `{{else}}`. The contents of this block will render + if the collection is empty. + + ``` + {{#each person in Developers}} + {{person.name}} + {{else}} +

            Sorry, nobody is available for this task.

            + {{/each}} + ``` + ### Specifying a View class for items + If you provide an `itemViewClass` option that references a view class + with its own `template` you can omit the block. + + The following template: + + ```handlebars + {{#view App.MyView }} + {{each view.items itemViewClass="App.AnItemView"}} + {{/view}} + ``` + + And application code + + ```javascript + App = Ember.Application.create({ + MyView: Ember.View.extend({ + items: [ + Ember.Object.create({name: 'Dave'}), + Ember.Object.create({name: 'Mary'}), + Ember.Object.create({name: 'Sara'}) + ] + }) + }); + + App.AnItemView = Ember.View.extend({ + template: Ember.Handlebars.compile("Greetings {{name}}") + }); + ``` + + Will result in the HTML structure below + + ```html +
            +
            Greetings Dave
            +
            Greetings Mary
            +
            Greetings Sara
            +
            + ``` + + ### Representing each item with a Controller. + By default the controller lookup within an `{{#each}}` block will be + the controller of the template where the `{{#each}}` was used. If each + item needs to be presented by a custom controller you can provide a + `itemController` option which references a controller by lookup name. + Each item in the loop will be wrapped in an instance of this controller + and the item itself will be set to the `content` property of that controller. + + This is useful in cases where properties of model objects need transformation + or synthesis for display: + + ```javascript + App.DeveloperController = Ember.ObjectController.extend({ + isAvailableForHire: function(){ + return !this.get('content.isEmployed') && this.get('content.isSeekingWork'); + }.property('isEmployed', 'isSeekingWork') + }) + ``` + + ```handlebars + {{#each person in Developers itemController="developer"}} + {{person.name}} {{#if person.isAvailableForHire}}Hire me!{{/if}} + {{/each}} + ``` + + @method each + @for Ember.Handlebars.helpers + @param [name] {String} name for item (used with `in`) + @param path {String} path + @param [options] {Object} Handlebars key/value pairs of options + @param [options.itemViewClass] {String} a path to a view class used for each item + @param [options.itemController] {String} name of a controller to be created for each item +*/ +Ember.Handlebars.registerHelper('each', function(path, options) { + if (arguments.length === 4) { + Ember.assert("If you pass more than one argument to the each helper, it must be in the form #each foo in bar", arguments[1] === "in"); + + var keywordName = arguments[0]; + + options = arguments[3]; + path = arguments[2]; + if (path === '') { path = "this"; } + + options.hash.keyword = keywordName; + } + + options.hash.dataSourceBinding = path; + // Set up emptyView as a metamorph with no tag + //options.hash.emptyViewClass = Ember._MetamorphView; + + if (options.data.insideGroup && !options.hash.groupedRows && !options.hash.itemViewClass) { + new Ember.Handlebars.GroupedEach(this, path, options).render(); + } else { + return Ember.Handlebars.helpers.collection.call(this, 'Ember.Handlebars.EachView', options); + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-handlebars +*/ + +/** + `template` allows you to render a template from inside another template. + This allows you to re-use the same template in multiple places. For example: + + ```html + + ``` + + ```html + + ``` + + This helper looks for templates in the global `Ember.TEMPLATES` hash. If you + add ` + ``` + + And application code + + ```javascript + AController = Ember.Controller.extend({ + anActionName: function() {} + }); + + AView = Ember.View.extend({ + controller: AController.create(), + templateName: 'a-template' + }); + + aView = AView.create(); + aView.appendTo('body'); + ``` + + Will results in the following rendered HTML + + ```html +
            +
            + click me +
            +
            + ``` + + Clicking "click me" will trigger the `anActionName` method of the + `AController`. In this case, no additional parameters will be passed. + + If you provide additional parameters to the helper: + + ```handlebars + + ``` + + Those parameters will be passed along as arguments to the JavaScript + function implementing the action. + + ### Event Propagation + + Events triggered through the action helper will automatically have + `.preventDefault()` called on them. You do not need to do so in your event + handlers. + + To also disable bubbling, pass `bubbles=false` to the helper: + + ```handlebars + + ``` + + If you need the default handler to trigger you should either register your + own event handler, or use event methods on your view class. See `Ember.View` + 'Responding to Browser Events' for more information. + + ### Specifying DOM event type + + By default the `{{action}}` helper registers for DOM `click` events. You can + supply an `on` option to the helper to specify a different DOM event name: + + ```handlebars + + ``` + + See `Ember.View` 'Responding to Browser Events' for a list of + acceptable DOM event names. + + NOTE: Because `{{action}}` depends on Ember's event dispatch system it will + only function if an `Ember.EventDispatcher` instance is available. An + `Ember.EventDispatcher` instance will be created when a new `Ember.Application` + is created. Having an instance of `Ember.Application` will satisfy this + requirement. + + ### Specifying a Target + + There are several possible target objects for `{{action}}` helpers: + + In a typical Ember application, where views are managed through use of the + `{{outlet}}` helper, actions will bubble to the current controller, then + to the current route, and then up the route hierarchy. + + Alternatively, a `target` option can be provided to the helper to change + which object will receive the method call. This option must be a path + path to an object, accessible in the current context: + + ```handlebars + + ``` + + Clicking "click me" in the rendered HTML of the above template will trigger + the `anActionName` method of the object at `MyApplication.someObject`. + + If an action's target does not implement a method that matches the supplied + action name an error will be thrown. + + ```handlebars + + ``` + + With the following application code + + ```javascript + AView = Ember.View.extend({ + templateName; 'a-template', + // note: no method 'aMethodNameThatIsMissing' + anActionName: function(event) {} + }); + + aView = AView.create(); + aView.appendTo('body'); + ``` + + Will throw `Uncaught TypeError: Cannot call method 'call' of undefined` when + "click me" is clicked. + + ### Additional Parameters + + You may specify additional parameters to the `{{action}}` helper. These + parameters are passed along as the arguments to the JavaScript function + implementing the action. + + ```handlebars + + ``` + + Clicking "click me" will trigger the `edit` method on the current view's + controller with the current person as a parameter. + + @method action + @for Ember.Handlebars.helpers + @param {String} actionName + @param {Object...} contexts + @param {Hash} options + */ + EmberHandlebars.registerHelper('action', function(actionName) { + var options = arguments[arguments.length - 1], + contexts = a_slice.call(arguments, 1, -1); + + var hash = options.hash, + view = options.data.view, + controller, link; + + // create a hash to pass along to registerAction + var action = { + eventName: hash.on || "click" + }; + + action.parameters = { + context: this, + options: options, + params: contexts + }; + + action.view = view = get(view, 'concreteView'); + + var root, target; + + if (hash.target) { + root = this; + target = hash.target; + } else if (controller = options.data.keywords.controller) { + root = controller; + } + + action.target = { root: root, target: target, options: options }; + action.bubbles = hash.bubbles; + + var actionId = ActionHelper.registerAction(actionName, action); + return new SafeString('data-ember-action="' + actionId + '"'); + }); + +}); + +})(); + + + +(function() { +var get = Ember.get, set = Ember.set; + +Ember.Handlebars.registerHelper('control', function(path, modelPath, options) { + if (arguments.length === 2) { + options = modelPath; + modelPath = undefined; + } + + var model; + + if (modelPath) { + model = Ember.Handlebars.get(this, modelPath, options); + } + + var controller = options.data.keywords.controller, + view = options.data.keywords.view, + children = get(controller, '_childContainers'), + controlID = options.hash.controlID, + container, subContainer; + + if (children.hasOwnProperty(controlID)) { + subContainer = children[controlID]; + } else { + container = get(controller, 'container'), + subContainer = container.child(); + children[controlID] = subContainer; + } + + var normalizedPath = path.replace(/\//g, '.'); + + var childView = subContainer.lookup('view:' + normalizedPath) || subContainer.lookup('view:default'), + childController = subContainer.lookup('controller:' + normalizedPath), + childTemplate = subContainer.lookup('template:' + path); + + Ember.assert("Could not find controller for path: " + normalizedPath, childController); + Ember.assert("Could not find view for path: " + normalizedPath, childView); + + set(childController, 'target', controller); + set(childController, 'model', model); + + options.hash.template = childTemplate; + options.hash.controller = childController; + + function observer() { + var model = Ember.Handlebars.get(this, modelPath, options); + set(childController, 'model', model); + childView.rerender(); + } + + Ember.addObserver(this, modelPath, observer); + childView.one('willDestroyElement', this, function() { + Ember.removeObserver(this, modelPath, observer); + }); + + Ember.Handlebars.helpers.view.call(this, childView, options); +}); + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +Ember.ControllerMixin.reopen({ + transitionToRoute: function() { + var target = get(this, 'target'); + + return target.transitionTo.apply(target, arguments); + }, + + // TODO: Deprecate this, see https://github.com/emberjs/ember.js/issues/1785 + transitionTo: function() { + return this.transitionToRoute.apply(this, arguments); + }, + + replaceRoute: function() { + var target = get(this, 'target'); + + return target.replaceWith.apply(target, arguments); + }, + + // TODO: Deprecate this, see https://github.com/emberjs/ember.js/issues/1785 + replaceWith: function() { + return this.replaceRoute.apply(this, arguments); + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +Ember.View.reopen({ + init: function() { + set(this, '_outlets', {}); + this._super(); + }, + + connectOutlet: function(outletName, view) { + var outlets = get(this, '_outlets'), + container = get(this, 'container'), + router = container && container.lookup('router:main'), + renderedName = get(view, 'renderedName'); + + set(outlets, outletName, view); + + if (router && renderedName) { + router._connectActiveView(renderedName, view); + } + }, + + disconnectOutlet: function(outletName) { + var outlets = get(this, '_outlets'); + + set(outlets, outletName, null); + } +}); + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +/* + This file implements the `location` API used by Ember's router. + + That API is: + + getURL: returns the current URL + setURL(path): sets the current URL + replaceURL(path): replace the current URL (optional) + onUpdateURL(callback): triggers the callback when the URL changes + formatURL(url): formats `url` to be placed into `href` attribute + + Calling setURL or replaceURL will not trigger onUpdateURL callbacks. + + TODO: This should perhaps be moved so that it's visible in the doc output. +*/ + +/** + Ember.Location returns an instance of the correct implementation of + the `location` API. + + You can pass it a `implementation` ('hash', 'history', 'none') to force a + particular implementation. + + @class Location + @namespace Ember + @static +*/ +Ember.Location = { + create: function(options) { + var implementation = options && options.implementation; + Ember.assert("Ember.Location.create: you must specify a 'implementation' option", !!implementation); + + var implementationClass = this.implementations[implementation]; + Ember.assert("Ember.Location.create: " + implementation + " is not a valid implementation", !!implementationClass); + + return implementationClass.create.apply(implementationClass, arguments); + }, + + registerImplementation: function(name, implementation) { + this.implementations[name] = implementation; + }, + + implementations: {} +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +/** + Ember.NoneLocation does not interact with the browser. It is useful for + testing, or when you need to manage state with your Router, but temporarily + don't want it to muck with the URL (for example when you embed your + application in a larger page). + + @class NoneLocation + @namespace Ember + @extends Ember.Object +*/ +Ember.NoneLocation = Ember.Object.extend({ + path: '', + + getURL: function() { + return get(this, 'path'); + }, + + setURL: function(path) { + set(this, 'path', path); + }, + + onUpdateURL: function(callback) { + // We are not wired up to the browser, so we'll never trigger the callback. + }, + + formatURL: function(url) { + // The return value is not overly meaningful, but we do not want to throw + // errors when test code renders templates containing {{action href=true}} + // helpers. + return url; + } +}); + +Ember.Location.registerImplementation('none', Ember.NoneLocation); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +/** + Ember.HashLocation implements the location API using the browser's + hash. At present, it relies on a hashchange event existing in the + browser. + + @class HashLocation + @namespace Ember + @extends Ember.Object +*/ +Ember.HashLocation = Ember.Object.extend({ + + init: function() { + set(this, 'location', get(this, 'location') || window.location); + }, + + /** + @private + + Returns the current `location.hash`, minus the '#' at the front. + + @method getURL + */ + getURL: function() { + return get(this, 'location').hash.substr(1); + }, + + /** + @private + + Set the `location.hash` and remembers what was set. This prevents + `onUpdateURL` callbacks from triggering when the hash was set by + `HashLocation`. + + @method setURL + @param path {String} + */ + setURL: function(path) { + get(this, 'location').hash = path; + set(this, 'lastSetURL', path); + }, + + /** + @private + + Register a callback to be invoked when the hash changes. These + callbacks will execute when the user presses the back or forward + button, but not after `setURL` is invoked. + + @method onUpdateURL + @param callback {Function} + */ + onUpdateURL: function(callback) { + var self = this; + var guid = Ember.guidFor(this); + + Ember.$(window).bind('hashchange.ember-location-'+guid, function() { + var path = location.hash.substr(1); + if (get(self, 'lastSetURL') === path) { return; } + + set(self, 'lastSetURL', null); + + callback(location.hash.substr(1)); + }); + }, + + /** + @private + + Given a URL, formats it to be placed into the page as part + of an element's `href` attribute. + + This is used, for example, when using the {{action}} helper + to generate a URL based on an event. + + @method formatURL + @param url {String} + */ + formatURL: function(url) { + return '#'+url; + }, + + willDestroy: function() { + var guid = Ember.guidFor(this); + + Ember.$(window).unbind('hashchange.ember-location-'+guid); + } +}); + +Ember.Location.registerImplementation('hash', Ember.HashLocation); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; +var popstateReady = false; + +/** + Ember.HistoryLocation implements the location API using the browser's + history.pushState API. + + @class HistoryLocation + @namespace Ember + @extends Ember.Object +*/ +Ember.HistoryLocation = Ember.Object.extend({ + + init: function() { + set(this, 'location', get(this, 'location') || window.location); + this.initState(); + }, + + /** + @private + + Used to set state on first call to setURL + + @method initState + */ + initState: function() { + this.replaceState(this.formatURL(this.getURL())); + set(this, 'history', window.history); + }, + + /** + Will be pre-pended to path upon state change + + @property rootURL + @default '/' + */ + rootURL: '/', + + /** + @private + + Returns the current `location.pathname` without rootURL + + @method getURL + */ + getURL: function() { + var rootURL = get(this, 'rootURL'), + url = get(this, 'location').pathname; + + rootURL = rootURL.replace(/\/$/, ''); + url = url.replace(rootURL, ''); + + return url; + }, + + /** + @private + + Uses `history.pushState` to update the url without a page reload. + + @method setURL + @param path {String} + */ + setURL: function(path) { + path = this.formatURL(path); + + if (this.getState() && this.getState().path !== path) { + popstateReady = true; + this.pushState(path); + } + }, + + /** + @private + + Uses `history.replaceState` to update the url without a page reload + or history modification. + + @method replaceURL + @param path {String} + */ + replaceURL: function(path) { + path = this.formatURL(path); + + if (this.getState() && this.getState().path !== path) { + popstateReady = true; + this.replaceState(path); + } + }, + + /** + @private + + Get the current `history.state` + + @method getState + */ + getState: function() { + return get(this, 'history').state; + }, + + /** + @private + + Pushes a new state + + @method pushState + @param path {String} + */ + pushState: function(path) { + window.history.pushState({ path: path }, null, path); + }, + + /** + @private + + Replaces the current state + + @method replaceState + @param path {String} + */ + replaceState: function(path) { + window.history.replaceState({ path: path }, null, path); + }, + + /** + @private + + Register a callback to be invoked whenever the browser + history changes, including using forward and back buttons. + + @method onUpdateURL + @param callback {Function} + */ + onUpdateURL: function(callback) { + var guid = Ember.guidFor(this), + self = this; + + Ember.$(window).bind('popstate.ember-location-'+guid, function(e) { + if(!popstateReady) { + return; + } + callback(self.getURL()); + }); + }, + + /** + @private + + Used when using `{{action}}` helper. The url is always appended to the rootURL. + + @method formatURL + @param url {String} + */ + formatURL: function(url) { + var rootURL = get(this, 'rootURL'); + + if (url !== '') { + rootURL = rootURL.replace(/\/$/, ''); + } + + return rootURL + url; + }, + + willDestroy: function() { + var guid = Ember.guidFor(this); + + Ember.$(window).unbind('popstate.ember-location-'+guid); + } +}); + +Ember.Location.registerImplementation('history', Ember.HistoryLocation); + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +Ember Routing + +@module ember +@submodule ember-routing +@requires ember-states +@requires ember-views +*/ + +})(); + +(function() { +function visit(vertex, fn, visited, path) { + var name = vertex.name, + vertices = vertex.incoming, + names = vertex.incomingNames, + len = names.length, + i; + if (!visited) { + visited = {}; + } + if (!path) { + path = []; + } + if (visited.hasOwnProperty(name)) { + return; + } + path.push(name); + visited[name] = true; + for (i = 0; i < len; i++) { + visit(vertices[names[i]], fn, visited, path); + } + fn(vertex, path); + path.pop(); +} + +function DAG() { + this.names = []; + this.vertices = {}; +} + +DAG.prototype.add = function(name) { + if (!name) { return; } + if (this.vertices.hasOwnProperty(name)) { + return this.vertices[name]; + } + var vertex = { + name: name, incoming: {}, incomingNames: [], hasOutgoing: false, value: null + }; + this.vertices[name] = vertex; + this.names.push(name); + return vertex; +}; + +DAG.prototype.map = function(name, value) { + this.add(name).value = value; +}; + +DAG.prototype.addEdge = function(fromName, toName) { + if (!fromName || !toName || fromName === toName) { + return; + } + var from = this.add(fromName), to = this.add(toName); + if (to.incoming.hasOwnProperty(fromName)) { + return; + } + function checkCycle(vertex, path) { + if (vertex.name === toName) { + throw new Error("cycle detected: " + toName + " <- " + path.join(" <- ")); + } + } + visit(from, checkCycle); + from.hasOutgoing = true; + to.incoming[fromName] = from; + to.incomingNames.push(fromName); +}; + +DAG.prototype.topsort = function(fn) { + var visited = {}, + vertices = this.vertices, + names = this.names, + len = names.length, + i, vertex; + for (i = 0; i < len; i++) { + vertex = vertices[names[i]]; + if (!vertex.hasOutgoing) { + visit(vertex, fn, visited); + } + } +}; + +DAG.prototype.addEdges = function(name, value, before, after) { + var i; + this.map(name, value); + if (before) { + if (typeof before === 'string') { + this.addEdge(name, before); + } else { + for (i = 0; i < before.length; i++) { + this.addEdge(name, before[i]); + } + } + } + if (after) { + if (typeof after === 'string') { + this.addEdge(after, name); + } else { + for (i = 0; i < after.length; i++) { + this.addEdge(after[i], name); + } + } + } +}; + +Ember.DAG = DAG; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-application +*/ + +var get = Ember.get, set = Ember.set, + classify = Ember.String.classify, + decamelize = Ember.String.decamelize; + +/** + An instance of `Ember.Application` is the starting point for every Ember + application. It helps to instantiate, initialize and coordinate the many + objects that make up your app. + + Each Ember app has one and only one `Ember.Application` object. In fact, the + very first thing you should do in your application is create the instance: + + ```javascript + window.App = Ember.Application.create(); + ``` + + Typically, the application object is the only global variable. All other + classes in your app should be properties on the `Ember.Application` instance, + which highlights its first role: a global namespace. + + For example, if you define a view class, it might look like this: + + ```javascript + App.MyView = Ember.View.extend(); + ``` + + By default, calling `Ember.Application.create()` will automatically initialize + your application by calling the `Ember.Application.initialize()` method. If + you need to delay initialization, you can call your app's `deferReadiness()` + method. When you are ready for your app to be initialized, call its + `advanceReadiness()` method. + + Because `Ember.Application` inherits from `Ember.Namespace`, any classes + you create will have useful string representations when calling `toString()`. + See the `Ember.Namespace` documentation for more information. + + While you can think of your `Ember.Application` as a container that holds the + other classes in your application, there are several other responsibilities + going on under-the-hood that you may want to understand. + + ### Event Delegation + + Ember uses a technique called _event delegation_. This allows the framework + to set up a global, shared event listener instead of requiring each view to + do it manually. For example, instead of each view registering its own + `mousedown` listener on its associated element, Ember sets up a `mousedown` + listener on the `body`. + + If a `mousedown` event occurs, Ember will look at the target of the event and + start walking up the DOM node tree, finding corresponding views and invoking + their `mouseDown` method as it goes. + + `Ember.Application` has a number of default events that it listens for, as + well as a mapping from lowercase events to camel-cased view method names. For + example, the `keypress` event causes the `keyPress` method on the view to be + called, the `dblclick` event causes `doubleClick` to be called, and so on. + + If there is a browser event that Ember does not listen for by default, you + can specify custom events and their corresponding view method names by + setting the application's `customEvents` property: + + ```javascript + App = Ember.Application.create({ + customEvents: { + // add support for the loadedmetadata media + // player event + 'loadedmetadata': "loadedMetadata" + } + }); + ``` + + By default, the application sets up these event listeners on the document + body. However, in cases where you are embedding an Ember application inside + an existing page, you may want it to set up the listeners on an element + inside the body. + + For example, if only events inside a DOM element with the ID of `ember-app` + should be delegated, set your application's `rootElement` property: + + ```javascript + window.App = Ember.Application.create({ + rootElement: '#ember-app' + }); + ``` + + The `rootElement` can be either a DOM element or a jQuery-compatible selector + string. Note that *views appended to the DOM outside the root element will + not receive events.* If you specify a custom root element, make sure you only + append views inside it! + + To learn more about the advantages of event delegation and the Ember view + layer, and a list of the event listeners that are setup by default, visit the + [Ember View Layer guide](http://emberjs.com/guides/view_layer#toc_event-delegation). + + ### Initializers + + Libraries on top of Ember can register additional initializers, like so: + + ```javascript + Ember.Application.initializer({ + name: "store", + + initialize: function(container, application) { + container.register('store', 'main', application.Store); + } + }); + ``` + + ### Routing + + In addition to creating your application's router, `Ember.Application` is + also responsible for telling the router when to start routing. + + By default, the router will begin trying to translate the current URL into + application state once the browser emits the `DOMContentReady` event. If you + need to defer routing, you can call the application's `deferReadiness()` + method. Once routing can begin, call the `advanceReadiness()` method. + + If there is any setup required before routing begins, you can implement a + `ready()` method on your app that will be invoked immediately before routing + begins: + + ```javascript + window.App = Ember.Application.create({ + ready: function() { + this.set('router.enableLogging', true); + } + }); + + To begin routing, you must have at a minimum a top-level controller and view. + You define these as `App.ApplicationController` and `App.ApplicationView`, + respectively. Your application will not work if you do not define these two + mandatory classes. For example: + + ```javascript + App.ApplicationView = Ember.View.extend({ + templateName: 'application' + }); + App.ApplicationController = Ember.Controller.extend(); + ``` + + @class Application + @namespace Ember + @extends Ember.Namespace +*/ +var Application = Ember.Application = Ember.Namespace.extend( +/** @scope Ember.Application.prototype */{ + + /** + The root DOM element of the Application. This can be specified as an + element or a + [jQuery-compatible selector string](http://api.jquery.com/category/selectors/). + + This is the element that will be passed to the Application's, + `eventDispatcher`, which sets up the listeners for event delegation. Every + view in your application should be a child of the element you specify here. + + @property rootElement + @type DOMElement + @default 'body' + */ + rootElement: 'body', + + /** + The `Ember.EventDispatcher` responsible for delegating events to this + application's views. + + The event dispatcher is created by the application at initialization time + and sets up event listeners on the DOM element described by the + application's `rootElement` property. + + See the documentation for `Ember.EventDispatcher` for more information. + + @property eventDispatcher + @type Ember.EventDispatcher + @default null + */ + eventDispatcher: null, + + /** + The DOM events for which the event dispatcher should listen. + + By default, the application's `Ember.EventDispatcher` listens + for a set of standard DOM events, such as `mousedown` and + `keyup`, and delegates them to your application's `Ember.View` + instances. + + If you would like additional events to be delegated to your + views, set your `Ember.Application`'s `customEvents` property + to a hash containing the DOM event name as the key and the + corresponding view method name as the value. For example: + + ```javascript + App = Ember.Application.create({ + customEvents: { + // add support for the loadedmetadata media + // player event + 'loadedmetadata': "loadedMetadata" + } + }); + ``` + + @property customEvents + @type Object + @default null + */ + customEvents: null, + + isInitialized: false, + + // Start off the number of deferrals at 1. This will be + // decremented by the Application's own `initialize` method. + _readinessDeferrals: 1, + + init: function() { + if (!this.$) { this.$ = Ember.$; } + this.__container__ = this.buildContainer(); + + this.Router = this.Router || this.defaultRouter(); + if (this.Router) { this.Router.namespace = this; } + + this._super(); + + this.deferUntilDOMReady(); + this.scheduleInitialize(); + + Ember.debug('-------------------------------'); + Ember.debug('Ember.VERSION : ' + Ember.VERSION); + Ember.debug('Handlebars.VERSION : ' + Ember.Handlebars.VERSION); + Ember.debug('jQuery.VERSION : ' + Ember.$().jquery); + Ember.debug('-------------------------------'); + }, + + /** + @private + + Build the container for the current application. + + Also register a default application view in case the application + itself does not. + + @method buildContainer + @return {Ember.Container} the configured container + */ + buildContainer: function() { + var container = this.__container__ = Application.buildContainer(this); + + return container; + }, + + /** + @private + + If the application has not opted out of routing and has not explicitly + defined a router, supply a default router for the application author + to configure. + + This allows application developers to do: + + ```javascript + App = Ember.Application.create(); + + App.Router.map(function(match) { + match("/").to("index"); + }); + ``` + + @method defaultRouter + @return {Ember.Router} the default router + */ + defaultRouter: function() { + // Create a default App.Router if one was not supplied to make + // it possible to do App.Router.map(...) without explicitly + // creating a router first. + if (this.router === undefined) { + return Ember.Router.extend(); + } + }, + + /** + @private + + Defer Ember readiness until DOM readiness. By default, Ember + will wait for both DOM readiness and application initialization, + as well as any deferrals registered by initializers. + + @method deferUntilDOMReady + */ + deferUntilDOMReady: function() { + this.deferReadiness(); + + var self = this; + this.$().ready(function() { + self.advanceReadiness(); + }); + }, + + /** + @private + + Automatically initialize the application once the DOM has + become ready. + + The initialization itself is deferred using Ember.run.once, + which ensures that application loading finishes before + booting. + + If you are asynchronously loading code, you should call + `deferReadiness()` to defer booting, and then call + `advanceReadiness()` once all of your code has finished + loading. + + @method scheduleInitialize + */ + scheduleInitialize: function() { + var self = this; + this.$().ready(function() { + if (self.isDestroyed || self.isInitialized) return; + Ember.run.once(self, 'initialize'); + }); + }, + + /** + Use this to defer readiness until some condition is true. + + Example: + + ```javascript + App = Ember.Application.create(); + App.deferReadiness(); + + jQuery.getJSON("/auth-token", function(token) { + App.token = token; + App.advanceReadiness(); + }); + ``` + + This allows you to perform asynchronous setup logic and defer + booting your application until the setup has finished. + + However, if the setup requires a loading UI, it might be better + to use the router for this purpose. + + @method deferReadiness + */ + deferReadiness: function() { + Ember.assert("You cannot defer readiness since the `ready()` hook has already been called.", this._readinessDeferrals > 0); + this._readinessDeferrals++; + }, + + /** + @method advanceReadiness + @see {Ember.Application#deferReadiness} + */ + advanceReadiness: function() { + this._readinessDeferrals--; + + if (this._readinessDeferrals === 0) { + Ember.run.once(this, this.didBecomeReady); + } + }, + + /** + registers a factory for later injection + + Example: + + ```javascript + App = Ember.Application.create(); + + App.Person = Ember.Object.extend({}); + App.Orange = Ember.Object.extend({}); + App.Email = Ember.Object.extend({}); + + App.register('model:user', App.Person, {singleton: false }); + App.register('fruit:favorite', App.Orange); + App.register('communication:main', App.Email, {singleton: false}); + ``` + + @method register + @param type {String} + @param name {String} + @param factory {String} + @param options {String} (optional) + **/ + register: function() { + var container = this.__container__; + container.register.apply(container, arguments); + }, + /** + defines an injection or typeInjection + + Example: + + ```javascript + App.inject(, , ) + App.inject('model:user', 'email', 'model:email') + App.inject('model', 'source', 'source:main') + ``` + + @method inject + @param factoryNameOrType {String} + @param property {String} + @param injectionName {String} + **/ + inject: function(){ + var container = this.__container__; + container.injection.apply(container, arguments); + }, + + /** + @private + + Initialize the application. This happens automatically. + + Run any initializers and run the application load hook. These hooks may + choose to defer readiness. For example, an authentication hook might want + to defer readiness until the auth token has been retrieved. + + @method initialize + */ + initialize: function() { + Ember.assert("Application initialize may only be called once", !this.isInitialized); + Ember.assert("Cannot initialize a destroyed application", !this.isDestroyed); + this.isInitialized = true; + + // At this point, the App.Router must already be assigned + this.__container__.register('router', 'main', this.Router); + + this.runInitializers(); + Ember.runLoadHooks('application', this); + + // At this point, any initializers or load hooks that would have wanted + // to defer readiness have fired. In general, advancing readiness here + // will proceed to didBecomeReady. + this.advanceReadiness(); + + return this; + }, + + /** + @private + @method runInitializers + */ + runInitializers: function() { + var initializers = get(this.constructor, 'initializers'), + container = this.__container__, + graph = new Ember.DAG(), + namespace = this, + properties, i, initializer; + + for (i=0; i` state will be added to the list of enter and exit + // states because its context has changed. + + while (contexts.length > 0) { + if (stateIdx >= 0) { + state = this.enterStates[stateIdx--]; + } else { + if (this.enterStates.length) { + state = get(this.enterStates[0], 'parentState'); + if (!state) { throw "Cannot match all contexts to states"; } + } else { + // If re-entering the current state with a context, the resolve + // state will be the current state. + state = this.resolveState; + } + + this.enterStates.unshift(state); + this.exitStates.unshift(state); + } + + // in routers, only states with dynamic segments have a context + if (get(state, 'hasContext')) { + context = contexts.pop(); + } else { + context = null; + } + + matchedContexts.unshift(context); + } + + this.contexts = matchedContexts; + }, + + /** + Add any `initialState`s to the list of enter states. + + @method addInitialStates + */ + addInitialStates: function() { + var finalState = this.finalState, initialState; + + while(true) { + initialState = get(finalState, 'initialState') || 'start'; + finalState = get(finalState, 'states.' + initialState); + + if (!finalState) { break; } + + this.finalState = finalState; + this.enterStates.push(finalState); + this.contexts.push(undefined); + } + }, + + /** + Remove any states that were added because the number of contexts + exceeded the number of explicit enter states, but the context has + not changed since the last time the state was entered. + + @method removeUnchangedContexts + @param {Ember.StateManager} manager passed in to look up the last + context for a states + */ + removeUnchangedContexts: function(manager) { + // Start from the beginning of the enter states. If the state was added + // to the list during the context matching phase, make sure the context + // has actually changed since the last time the state was entered. + while (this.enterStates.length > 0) { + if (this.enterStates[0] !== this.exitStates[0]) { break; } + + if (this.enterStates.length === this.contexts.length) { + if (manager.getStateMeta(this.enterStates[0], 'context') !== this.contexts[0]) { break; } + this.contexts.shift(); + } + + this.resolveState = this.enterStates.shift(); + this.exitStates.shift(); + } + } +}; + +var sendRecursively = function(event, currentState, isUnhandledPass) { + var log = this.enableLogging, + eventName = isUnhandledPass ? 'unhandledEvent' : event, + action = currentState[eventName], + contexts, sendRecursiveArguments, actionArguments; + + contexts = [].slice.call(arguments, 3); + + // Test to see if the action is a method that + // can be invoked. Don't blindly check just for + // existence, because it is possible the state + // manager has a child state of the given name, + // and we should still raise an exception in that + // case. + if (typeof action === 'function') { + if (log) { + if (isUnhandledPass) { + Ember.Logger.log(fmt("STATEMANAGER: Unhandled event '%@' being sent to state %@.", [event, get(currentState, 'path')])); + } else { + Ember.Logger.log(fmt("STATEMANAGER: Sending event '%@' to state %@.", [event, get(currentState, 'path')])); + } + } + + actionArguments = contexts; + if (isUnhandledPass) { + actionArguments.unshift(event); + } + actionArguments.unshift(this); + + return action.apply(currentState, actionArguments); + } else { + var parentState = get(currentState, 'parentState'); + if (parentState) { + + sendRecursiveArguments = contexts; + sendRecursiveArguments.unshift(event, parentState, isUnhandledPass); + + return sendRecursively.apply(this, sendRecursiveArguments); + } else if (!isUnhandledPass) { + return sendEvent.call(this, event, contexts, true); + } + } +}; + +var sendEvent = function(eventName, sendRecursiveArguments, isUnhandledPass) { + sendRecursiveArguments.unshift(eventName, get(this, 'currentState'), isUnhandledPass); + return sendRecursively.apply(this, sendRecursiveArguments); +}; + +/** + StateManager is part of Ember's implementation of a finite state machine. A + StateManager instance manages a number of properties that are instances of + `Ember.State`, + tracks the current active state, and triggers callbacks when states have changed. + + ## Defining States + + The states of StateManager can be declared in one of two ways. First, you can + define a `states` property that contains all the states: + + ```javascript + managerA = Ember.StateManager.create({ + states: { + stateOne: Ember.State.create(), + stateTwo: Ember.State.create() + } + }) + + managerA.get('states') + // { + // stateOne: Ember.State.create(), + // stateTwo: Ember.State.create() + // } + ``` + + You can also add instances of `Ember.State` (or an `Ember.State` subclass) + directly as properties of a StateManager. These states will be collected into + the `states` property for you. + + ```javascript + managerA = Ember.StateManager.create({ + stateOne: Ember.State.create(), + stateTwo: Ember.State.create() + }) + + managerA.get('states') + // { + // stateOne: Ember.State.create(), + // stateTwo: Ember.State.create() + // } + ``` + + ## The Initial State + + When created a StateManager instance will immediately enter into the state + defined as its `start` property or the state referenced by name in its + `initialState` property: + + ```javascript + managerA = Ember.StateManager.create({ + start: Ember.State.create({}) + }) + + managerA.get('currentState.name') // 'start' + + managerB = Ember.StateManager.create({ + initialState: 'beginHere', + beginHere: Ember.State.create({}) + }) + + managerB.get('currentState.name') // 'beginHere' + ``` + + Because it is a property you may also provide a computed function if you wish + to derive an `initialState` programmatically: + + ```javascript + managerC = Ember.StateManager.create({ + initialState: function(){ + if (someLogic) { + return 'active'; + } else { + return 'passive'; + } + }.property(), + active: Ember.State.create({}), + passive: Ember.State.create({}) + }) + ``` + + ## Moving Between States + + A StateManager can have any number of `Ember.State` objects as properties + and can have a single one of these states as its current state. + + Calling `transitionTo` transitions between states: + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({}), + poweredUp: Ember.State.create({}) + }) + + robotManager.get('currentState.name') // 'poweredDown' + robotManager.transitionTo('poweredUp') + robotManager.get('currentState.name') // 'poweredUp' + ``` + + Before transitioning into a new state the existing `currentState` will have + its `exit` method called with the StateManager instance as its first argument + and an object representing the transition as its second argument. + + After transitioning into a new state the new `currentState` will have its + `enter` method called with the StateManager instance as its first argument + and an object representing the transition as its second argument. + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({ + exit: function(stateManager){ + console.log("exiting the poweredDown state") + } + }), + poweredUp: Ember.State.create({ + enter: function(stateManager){ + console.log("entering the poweredUp state. Destroy all humans.") + } + }) + }) + + robotManager.get('currentState.name') // 'poweredDown' + robotManager.transitionTo('poweredUp') + + // will log + // 'exiting the poweredDown state' + // 'entering the poweredUp state. Destroy all humans.' + ``` + + Once a StateManager is already in a state, subsequent attempts to enter that + state will not trigger enter or exit method calls. Attempts to transition + into a state that the manager does not have will result in no changes in the + StateManager's current state: + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({ + exit: function(stateManager){ + console.log("exiting the poweredDown state") + } + }), + poweredUp: Ember.State.create({ + enter: function(stateManager){ + console.log("entering the poweredUp state. Destroy all humans.") + } + }) + }) + + robotManager.get('currentState.name') // 'poweredDown' + robotManager.transitionTo('poweredUp') + // will log + // 'exiting the poweredDown state' + // 'entering the poweredUp state. Destroy all humans.' + robotManager.transitionTo('poweredUp') // no logging, no state change + + robotManager.transitionTo('someUnknownState') // silently fails + robotManager.get('currentState.name') // 'poweredUp' + ``` + + Each state property may itself contain properties that are instances of + `Ember.State`. The StateManager can transition to specific sub-states in a + series of transitionTo method calls or via a single transitionTo with the + full path to the specific state. The StateManager will also keep track of the + full path to its currentState + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({ + charging: Ember.State.create(), + charged: Ember.State.create() + }), + poweredUp: Ember.State.create({ + mobile: Ember.State.create(), + stationary: Ember.State.create() + }) + }) + + robotManager.get('currentState.name') // 'poweredDown' + + robotManager.transitionTo('poweredUp') + robotManager.get('currentState.name') // 'poweredUp' + + robotManager.transitionTo('mobile') + robotManager.get('currentState.name') // 'mobile' + + // transition via a state path + robotManager.transitionTo('poweredDown.charging') + robotManager.get('currentState.name') // 'charging' + + robotManager.get('currentState.path') // 'poweredDown.charging' + ``` + + Enter transition methods will be called for each state and nested child state + in their hierarchical order. Exit methods will be called for each state and + its nested states in reverse hierarchical order. + + Exit transitions for a parent state are not called when entering into one of + its child states, only when transitioning to a new section of possible states + in the hierarchy. + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({ + enter: function(){}, + exit: function(){ + console.log("exited poweredDown state") + }, + charging: Ember.State.create({ + enter: function(){}, + exit: function(){} + }), + charged: Ember.State.create({ + enter: function(){ + console.log("entered charged state") + }, + exit: function(){ + console.log("exited charged state") + } + }) + }), + poweredUp: Ember.State.create({ + enter: function(){ + console.log("entered poweredUp state") + }, + exit: function(){}, + mobile: Ember.State.create({ + enter: function(){ + console.log("entered mobile state") + }, + exit: function(){} + }), + stationary: Ember.State.create({ + enter: function(){}, + exit: function(){} + }) + }) + }) + + + robotManager.get('currentState.path') // 'poweredDown' + robotManager.transitionTo('charged') + // logs 'entered charged state' + // but does *not* log 'exited poweredDown state' + robotManager.get('currentState.name') // 'charged + + robotManager.transitionTo('poweredUp.mobile') + // logs + // 'exited charged state' + // 'exited poweredDown state' + // 'entered poweredUp state' + // 'entered mobile state' + ``` + + During development you can set a StateManager's `enableLogging` property to + `true` to receive console messages of state transitions. + + ```javascript + robotManager = Ember.StateManager.create({ + enableLogging: true + }) + ``` + + ## Managing currentState with Actions + + To control which transitions are possible for a given state, and + appropriately handle external events, the StateManager can receive and + route action messages to its states via the `send` method. Calling to + `send` with an action name will begin searching for a method with the same + name starting at the current state and moving up through the parent states + in a state hierarchy until an appropriate method is found or the StateManager + instance itself is reached. + + If an appropriately named method is found it will be called with the state + manager as the first argument and an optional `context` object as the second + argument. + + ```javascript + managerA = Ember.StateManager.create({ + initialState: 'stateOne.substateOne.subsubstateOne', + stateOne: Ember.State.create({ + substateOne: Ember.State.create({ + anAction: function(manager, context){ + console.log("an action was called") + }, + subsubstateOne: Ember.State.create({}) + }) + }) + }) + + managerA.get('currentState.name') // 'subsubstateOne' + managerA.send('anAction') + // 'stateOne.substateOne.subsubstateOne' has no anAction method + // so the 'anAction' method of 'stateOne.substateOne' is called + // and logs "an action was called" + // with managerA as the first argument + // and no second argument + + someObject = {} + managerA.send('anAction', someObject) + // the 'anAction' method of 'stateOne.substateOne' is called again + // with managerA as the first argument and + // someObject as the second argument. + ``` + + If the StateManager attempts to send an action but does not find an appropriately named + method in the current state or while moving upwards through the state hierarchy, it will + repeat the process looking for a `unhandledEvent` method. If an `unhandledEvent` method is + found, it will be called with the original event name as the second argument. If an + `unhandledEvent` method is not found, the StateManager will throw a new Ember.Error. + + ```javascript + managerB = Ember.StateManager.create({ + initialState: 'stateOne.substateOne.subsubstateOne', + stateOne: Ember.State.create({ + substateOne: Ember.State.create({ + subsubstateOne: Ember.State.create({}), + unhandledEvent: function(manager, eventName, context) { + console.log("got an unhandledEvent with name " + eventName); + } + }) + }) + }) + + managerB.get('currentState.name') // 'subsubstateOne' + managerB.send('anAction') + // neither `stateOne.substateOne.subsubstateOne` nor any of it's + // parent states have a handler for `anAction`. `subsubstateOne` + // also does not have a `unhandledEvent` method, but its parent + // state, `substateOne`, does, and it gets fired. It will log + // "got an unhandledEvent with name anAction" + ``` + + Action detection only moves upwards through the state hierarchy from the current state. + It does not search in other portions of the hierarchy. + + ```javascript + managerC = Ember.StateManager.create({ + initialState: 'stateOne.substateOne.subsubstateOne', + stateOne: Ember.State.create({ + substateOne: Ember.State.create({ + subsubstateOne: Ember.State.create({}) + }) + }), + stateTwo: Ember.State.create({ + anAction: function(manager, context){ + // will not be called below because it is + // not a parent of the current state + } + }) + }) + + managerC.get('currentState.name') // 'subsubstateOne' + managerC.send('anAction') + // Error: could not + // respond to event anAction in state stateOne.substateOne.subsubstateOne. + ``` + + Inside of an action method the given state should delegate `transitionTo` calls on its + StateManager. + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown.charging', + poweredDown: Ember.State.create({ + charging: Ember.State.create({ + chargeComplete: function(manager, context){ + manager.transitionTo('charged') + } + }), + charged: Ember.State.create({ + boot: function(manager, context){ + manager.transitionTo('poweredUp') + } + }) + }), + poweredUp: Ember.State.create({ + beginExtermination: function(manager, context){ + manager.transitionTo('rampaging') + }, + rampaging: Ember.State.create() + }) + }) + + robotManager.get('currentState.name') // 'charging' + robotManager.send('boot') // throws error, no boot action + // in current hierarchy + robotManager.get('currentState.name') // remains 'charging' + + robotManager.send('beginExtermination') // throws error, no beginExtermination + // action in current hierarchy + robotManager.get('currentState.name') // remains 'charging' + + robotManager.send('chargeComplete') + robotManager.get('currentState.name') // 'charged' + + robotManager.send('boot') + robotManager.get('currentState.name') // 'poweredUp' + + robotManager.send('beginExtermination', allHumans) + robotManager.get('currentState.name') // 'rampaging' + ``` + + Transition actions can also be created using the `transitionTo` method of the `Ember.State` class. The + following example StateManagers are equivalent: + + ```javascript + aManager = Ember.StateManager.create({ + stateOne: Ember.State.create({ + changeToStateTwo: Ember.State.transitionTo('stateTwo') + }), + stateTwo: Ember.State.create({}) + }) + + bManager = Ember.StateManager.create({ + stateOne: Ember.State.create({ + changeToStateTwo: function(manager, context){ + manager.transitionTo('stateTwo', context) + } + }), + stateTwo: Ember.State.create({}) + }) + ``` + + @class StateManager + @namespace Ember + @extends Ember.State +**/ +Ember.StateManager = Ember.State.extend({ + /** + @private + + When creating a new statemanager, look for a default state to transition + into. This state can either be named `start`, or can be specified using the + `initialState` property. + + @method init + */ + init: function() { + this._super(); + + set(this, 'stateMeta', Ember.Map.create()); + + var initialState = get(this, 'initialState'); + + if (!initialState && get(this, 'states.start')) { + initialState = 'start'; + } + + if (initialState) { + this.transitionTo(initialState); + Ember.assert('Failed to transition to initial state "' + initialState + '"', !!get(this, 'currentState')); + } + }, + + stateMetaFor: function(state) { + var meta = get(this, 'stateMeta'), + stateMeta = meta.get(state); + + if (!stateMeta) { + stateMeta = {}; + meta.set(state, stateMeta); + } + + return stateMeta; + }, + + setStateMeta: function(state, key, value) { + return set(this.stateMetaFor(state), key, value); + }, + + getStateMeta: function(state, key) { + return get(this.stateMetaFor(state), key); + }, + + /** + The current state from among the manager's possible states. This property should + not be set directly. Use `transitionTo` to move between states by name. + + @property currentState + @type Ember.State + */ + currentState: null, + + /** + The path of the current state. Returns a string representation of the current + state. + + @property currentPath + @type String + */ + currentPath: Ember.computed.alias('currentState.path'), + + /** + The name of transitionEvent that this stateManager will dispatch + + @property transitionEvent + @type String + @default 'setup' + */ + transitionEvent: 'setup', + + /** + If set to true, `errorOnUnhandledEvents` will cause an exception to be + raised if you attempt to send an event to a state manager that is not + handled by the current state or any of its parent states. + + @property errorOnUnhandledEvents + @type Boolean + @default true + */ + errorOnUnhandledEvent: true, + + send: function(event) { + var contexts = [].slice.call(arguments, 1); + Ember.assert('Cannot send event "' + event + '" while currentState is ' + get(this, 'currentState'), get(this, 'currentState')); + return sendEvent.call(this, event, contexts, false); + }, + unhandledEvent: function(manager, event) { + if (get(this, 'errorOnUnhandledEvent')) { + throw new Ember.Error(this.toString() + " could not respond to event " + event + " in state " + get(this, 'currentState.path') + "."); + } + }, + + /** + Finds a state by its state path. + + Example: + + ```javascript + manager = Ember.StateManager.create({ + root: Ember.State.create({ + dashboard: Ember.State.create() + }) + }); + + manager.getStateByPath(manager, "root.dashboard") + + // returns the dashboard state + ``` + + @method getStateByPath + @param {Ember.State} root the state to start searching from + @param {String} path the state path to follow + @return {Ember.State} the state at the end of the path + */ + getStateByPath: function(root, path) { + var parts = path.split('.'), + state = root; + + for (var i=0, len=parts.length; i`, an attempt to + // transition to `comments.show` will match ``. + // + // First, this code will look for root.posts.show.comments.show. + // Next, it will look for root.posts.comments.show. Finally, + // it will look for `root.comments.show`, and find the state. + // + // After this process, the following variables will exist: + // + // * resolveState: a common parent state between the current + // and target state. In the above example, `` is the + // `resolveState`. + // * enterStates: a list of all of the states represented + // by the path from the `resolveState`. For example, for + // the path `root.comments.show`, `enterStates` would have + // `[, ]` + // * exitStates: a list of all of the states from the + // `resolveState` to the `currentState`. In the above + // example, `exitStates` would have + // `[`, `]`. + while (resolveState && !enterStates) { + exitStates.unshift(resolveState); + + resolveState = get(resolveState, 'parentState'); + if (!resolveState) { + enterStates = this.getStatesInPath(this, path); + if (!enterStates) { + Ember.assert('Could not find state for path: "'+path+'"'); + return; + } + } + enterStates = this.getStatesInPath(resolveState, path); + } + + // If the path contains some states that are parents of both the + // current state and the target state, remove them. + // + // For example, in the following hierarchy: + // + // |- root + // | |- post + // | | |- index (* current) + // | | |- show + // + // If the `path` is `root.post.show`, the three variables will + // be: + // + // * resolveState: `` + // * enterStates: `[, , ]` + // * exitStates: `[, , ]` + // + // The goal of this code is to remove the common states, so we + // have: + // + // * resolveState: `` + // * enterStates: `[]` + // * exitStates: `[]` + // + // This avoid unnecessary calls to the enter and exit transitions. + while (enterStates.length > 0 && enterStates[0] === exitStates[0]) { + resolveState = enterStates.shift(); + exitStates.shift(); + } + + // Cache the enterStates, exitStates, and resolveState for the + // current state and the `path`. + var transitions = currentState.pathsCache[path] = { + exitStates: exitStates, + enterStates: enterStates, + resolveState: resolveState + }; + + return transitions; + }, + + triggerSetupContext: function(transitions) { + var contexts = transitions.contexts, + offset = transitions.enterStates.length - contexts.length, + enterStates = transitions.enterStates, + transitionEvent = get(this, 'transitionEvent'); + + Ember.assert("More contexts provided than states", offset >= 0); + + arrayForEach.call(enterStates, function(state, idx) { + state.trigger(transitionEvent, this, contexts[idx-offset]); + }, this); + }, + + getState: function(name) { + var state = get(this, name), + parentState = get(this, 'parentState'); + + if (state) { + return state; + } else if (parentState) { + return parentState.getState(name); + } + }, + + enterState: function(transition) { + var log = this.enableLogging; + + var exitStates = transition.exitStates.slice(0).reverse(); + arrayForEach.call(exitStates, function(state) { + state.trigger('exit', this); + }, this); + + arrayForEach.call(transition.enterStates, function(state) { + if (log) { Ember.Logger.log("STATEMANAGER: Entering " + get(state, 'path')); } + state.trigger('enter', this); + }, this); + + set(this, 'currentState', transition.finalState); + } +}); + +})(); + + + +(function() { +/** +Ember States + +@module ember +@submodule ember-states +@requires ember-runtime +*/ + +})(); + + +})(); +// Version: v1.0.0-pre.2-608-g538b7a0 +// Last commit: 538b7a0 (2013-02-03 17:48:00 -0800) + + +(function() { +/** +Ember + +@module ember +*/ + +})(); + diff --git a/app/assets/javascripts/external/group-helper.js b/app/assets/javascripts/external/group-helper.js new file mode 100644 index 00000000000..1824ed3e9b9 --- /dev/null +++ b/app/assets/javascripts/external/group-helper.js @@ -0,0 +1,23 @@ +(function() { +var get = Ember.get, set = Ember.set, EmberHandlebars = Ember.Handlebars; + +EmberHandlebars.registerHelper('group', function(options) { + var data = options.data, + fn = options.fn, + view = data.view, + childView; + + childView = view.createChildView(Ember._MetamorphView, { + context: get(view, 'context'), + + template: function(context, options) { + options.data.insideGroup = true; + return fn(context, options); + } + }); + + view.appendChild(childView); +}); + +})(); + diff --git a/app/assets/javascripts/external/handlebars-1.0.rc.2.js b/app/assets/javascripts/external/handlebars-1.0.rc.2.js new file mode 100644 index 00000000000..aeea9260568 --- /dev/null +++ b/app/assets/javascripts/external/handlebars-1.0.rc.2.js @@ -0,0 +1,1993 @@ +// lib/handlebars/base.js + +/*jshint eqnull:true*/ +this.Handlebars = {}; + +(function(Handlebars) { + +Handlebars.VERSION = "1.0.rc.2"; + +Handlebars.helpers = {}; +Handlebars.partials = {}; + +Handlebars.registerHelper = function(name, fn, inverse) { + if(inverse) { fn.not = inverse; } + this.helpers[name] = fn; +}; + +Handlebars.registerPartial = function(name, str) { + this.partials[name] = str; +}; + +Handlebars.registerHelper('helperMissing', function(arg) { + if(arguments.length === 2) { + return undefined; + } else { + throw new Error("Could not find property '" + arg + "'"); + } +}); + +var toString = Object.prototype.toString, functionType = "[object Function]"; + +Handlebars.registerHelper('blockHelperMissing', function(context, options) { + var inverse = options.inverse || function() {}, fn = options.fn; + + + var ret = ""; + var type = toString.call(context); + + if(type === functionType) { context = context.call(this); } + + if(context === true) { + return fn(this); + } else if(context === false || context == null) { + return inverse(this); + } else if(type === "[object Array]") { + if(context.length > 0) { + return Handlebars.helpers.each(context, options); + } else { + return inverse(this); + } + } else { + return fn(context); + } +}); + +Handlebars.K = function() {}; + +Handlebars.createFrame = Object.create || function(object) { + Handlebars.K.prototype = object; + var obj = new Handlebars.K(); + Handlebars.K.prototype = null; + return obj; +}; + +Handlebars.logger = { + DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, level: 3, + + methodMap: {0: 'debug', 1: 'info', 2: 'warn', 3: 'error'}, + + // can be overridden in the host environment + log: function(level, obj) { + if (Handlebars.logger.level <= level) { + var method = Handlebars.logger.methodMap[level]; + if (typeof console !== 'undefined' && console[method]) { + console[method].call(console, obj); + } + } + } +}; + +Handlebars.log = function(level, obj) { Handlebars.logger.log(level, obj); }; + +Handlebars.registerHelper('each', function(context, options) { + var fn = options.fn, inverse = options.inverse; + var i = 0, ret = "", data; + + if (options.data) { + data = Handlebars.createFrame(options.data); + } + + if(context && typeof context === 'object') { + if(context instanceof Array){ + for(var j = context.length; i 2) { + expected.push("'" + this.terminals_[p] + "'"); + } + if (this.lexer.showPosition) { + errStr = "Parse error on line " + (yylineno + 1) + ":\n" + this.lexer.showPosition() + "\nExpecting " + expected.join(", ") + ", got '" + (this.terminals_[symbol] || symbol) + "'"; + } else { + errStr = "Parse error on line " + (yylineno + 1) + ": Unexpected " + (symbol == 1?"end of input":"'" + (this.terminals_[symbol] || symbol) + "'"); + } + this.parseError(errStr, {text: this.lexer.match, token: this.terminals_[symbol] || symbol, line: this.lexer.yylineno, loc: yyloc, expected: expected}); + } + } + if (action[0] instanceof Array && action.length > 1) { + throw new Error("Parse Error: multiple actions possible at state: " + state + ", token: " + symbol); + } + switch (action[0]) { + case 1: + stack.push(symbol); + vstack.push(this.lexer.yytext); + lstack.push(this.lexer.yylloc); + stack.push(action[1]); + symbol = null; + if (!preErrorSymbol) { + yyleng = this.lexer.yyleng; + yytext = this.lexer.yytext; + yylineno = this.lexer.yylineno; + yyloc = this.lexer.yylloc; + if (recovering > 0) + recovering--; + } else { + symbol = preErrorSymbol; + preErrorSymbol = null; + } + break; + case 2: + len = this.productions_[action[1]][1]; + yyval.$ = vstack[vstack.length - len]; + yyval._$ = {first_line: lstack[lstack.length - (len || 1)].first_line, last_line: lstack[lstack.length - 1].last_line, first_column: lstack[lstack.length - (len || 1)].first_column, last_column: lstack[lstack.length - 1].last_column}; + if (ranges) { + yyval._$.range = [lstack[lstack.length - (len || 1)].range[0], lstack[lstack.length - 1].range[1]]; + } + r = this.performAction.call(yyval, yytext, yyleng, yylineno, this.yy, action[1], vstack, lstack); + if (typeof r !== "undefined") { + return r; + } + if (len) { + stack = stack.slice(0, -1 * len * 2); + vstack = vstack.slice(0, -1 * len); + lstack = lstack.slice(0, -1 * len); + } + stack.push(this.productions_[action[1]][0]); + vstack.push(yyval.$); + lstack.push(yyval._$); + newState = table[stack[stack.length - 2]][stack[stack.length - 1]]; + stack.push(newState); + break; + case 3: + return true; + } + } + return true; +} +}; +/* Jison generated lexer */ +var lexer = (function(){ +var lexer = ({EOF:1, +parseError:function parseError(str, hash) { + if (this.yy.parser) { + this.yy.parser.parseError(str, hash); + } else { + throw new Error(str); + } + }, +setInput:function (input) { + this._input = input; + this._more = this._less = this.done = false; + this.yylineno = this.yyleng = 0; + this.yytext = this.matched = this.match = ''; + this.conditionStack = ['INITIAL']; + this.yylloc = {first_line:1,first_column:0,last_line:1,last_column:0}; + if (this.options.ranges) this.yylloc.range = [0,0]; + this.offset = 0; + return this; + }, +input:function () { + var ch = this._input[0]; + this.yytext += ch; + this.yyleng++; + this.offset++; + this.match += ch; + this.matched += ch; + var lines = ch.match(/(?:\r\n?|\n).*/g); + if (lines) { + this.yylineno++; + this.yylloc.last_line++; + } else { + this.yylloc.last_column++; + } + if (this.options.ranges) this.yylloc.range[1]++; + + this._input = this._input.slice(1); + return ch; + }, +unput:function (ch) { + var len = ch.length; + var lines = ch.split(/(?:\r\n?|\n)/g); + + this._input = ch + this._input; + this.yytext = this.yytext.substr(0, this.yytext.length-len-1); + //this.yyleng -= len; + this.offset -= len; + var oldLines = this.match.split(/(?:\r\n?|\n)/g); + this.match = this.match.substr(0, this.match.length-1); + this.matched = this.matched.substr(0, this.matched.length-1); + + if (lines.length-1) this.yylineno -= lines.length-1; + var r = this.yylloc.range; + + this.yylloc = {first_line: this.yylloc.first_line, + last_line: this.yylineno+1, + first_column: this.yylloc.first_column, + last_column: lines ? + (lines.length === oldLines.length ? this.yylloc.first_column : 0) + oldLines[oldLines.length - lines.length].length - lines[0].length: + this.yylloc.first_column - len + }; + + if (this.options.ranges) { + this.yylloc.range = [r[0], r[0] + this.yyleng - len]; + } + return this; + }, +more:function () { + this._more = true; + return this; + }, +less:function (n) { + this.unput(this.match.slice(n)); + }, +pastInput:function () { + var past = this.matched.substr(0, this.matched.length - this.match.length); + return (past.length > 20 ? '...':'') + past.substr(-20).replace(/\n/g, ""); + }, +upcomingInput:function () { + var next = this.match; + if (next.length < 20) { + next += this._input.substr(0, 20-next.length); + } + return (next.substr(0,20)+(next.length > 20 ? '...':'')).replace(/\n/g, ""); + }, +showPosition:function () { + var pre = this.pastInput(); + var c = new Array(pre.length + 1).join("-"); + return pre + this.upcomingInput() + "\n" + c+"^"; + }, +next:function () { + if (this.done) { + return this.EOF; + } + if (!this._input) this.done = true; + + var token, + match, + tempMatch, + index, + col, + lines; + if (!this._more) { + this.yytext = ''; + this.match = ''; + } + var rules = this._currentRules(); + for (var i=0;i < rules.length; i++) { + tempMatch = this._input.match(this.rules[rules[i]]); + if (tempMatch && (!match || tempMatch[0].length > match[0].length)) { + match = tempMatch; + index = i; + if (!this.options.flex) break; + } + } + if (match) { + lines = match[0].match(/(?:\r\n?|\n).*/g); + if (lines) this.yylineno += lines.length; + this.yylloc = {first_line: this.yylloc.last_line, + last_line: this.yylineno+1, + first_column: this.yylloc.last_column, + last_column: lines ? lines[lines.length-1].length-lines[lines.length-1].match(/\r?\n?/)[0].length : this.yylloc.last_column + match[0].length}; + this.yytext += match[0]; + this.match += match[0]; + this.matches = match; + this.yyleng = this.yytext.length; + if (this.options.ranges) { + this.yylloc.range = [this.offset, this.offset += this.yyleng]; + } + this._more = false; + this._input = this._input.slice(match[0].length); + this.matched += match[0]; + token = this.performAction.call(this, this.yy, this, rules[index],this.conditionStack[this.conditionStack.length-1]); + if (this.done && this._input) this.done = false; + if (token) return token; + else return; + } + if (this._input === "") { + return this.EOF; + } else { + return this.parseError('Lexical error on line '+(this.yylineno+1)+'. Unrecognized text.\n'+this.showPosition(), + {text: "", token: null, line: this.yylineno}); + } + }, +lex:function lex() { + var r = this.next(); + if (typeof r !== 'undefined') { + return r; + } else { + return this.lex(); + } + }, +begin:function begin(condition) { + this.conditionStack.push(condition); + }, +popState:function popState() { + return this.conditionStack.pop(); + }, +_currentRules:function _currentRules() { + return this.conditions[this.conditionStack[this.conditionStack.length-1]].rules; + }, +topState:function () { + return this.conditionStack[this.conditionStack.length-2]; + }, +pushState:function begin(condition) { + this.begin(condition); + }}); +lexer.options = {}; +lexer.performAction = function anonymous(yy,yy_,$avoiding_name_collisions,YY_START) { + +var YYSTATE=YY_START +switch($avoiding_name_collisions) { +case 0: + if(yy_.yytext.slice(-1) !== "\\") this.begin("mu"); + if(yy_.yytext.slice(-1) === "\\") yy_.yytext = yy_.yytext.substr(0,yy_.yyleng-1), this.begin("emu"); + if(yy_.yytext) return 14; + +break; +case 1: return 14; +break; +case 2: + if(yy_.yytext.slice(-1) !== "\\") this.popState(); + if(yy_.yytext.slice(-1) === "\\") yy_.yytext = yy_.yytext.substr(0,yy_.yyleng-1); + return 14; + +break; +case 3: yy_.yytext = yy_.yytext.substr(0, yy_.yyleng-4); this.popState(); return 15; +break; +case 4: this.begin("par"); return 24; +break; +case 5: return 16; +break; +case 6: return 20; +break; +case 7: return 19; +break; +case 8: return 19; +break; +case 9: return 23; +break; +case 10: return 23; +break; +case 11: this.popState(); this.begin('com'); +break; +case 12: yy_.yytext = yy_.yytext.substr(3,yy_.yyleng-5); this.popState(); return 15; +break; +case 13: return 22; +break; +case 14: return 36; +break; +case 15: return 35; +break; +case 16: return 35; +break; +case 17: return 39; +break; +case 18: /*ignore whitespace*/ +break; +case 19: this.popState(); return 18; +break; +case 20: this.popState(); return 18; +break; +case 21: yy_.yytext = yy_.yytext.substr(1,yy_.yyleng-2).replace(/\\"/g,'"'); return 30; +break; +case 22: yy_.yytext = yy_.yytext.substr(1,yy_.yyleng-2).replace(/\\'/g,"'"); return 30; +break; +case 23: yy_.yytext = yy_.yytext.substr(1); return 28; +break; +case 24: return 32; +break; +case 25: return 32; +break; +case 26: return 31; +break; +case 27: return 35; +break; +case 28: yy_.yytext = yy_.yytext.substr(1, yy_.yyleng-2); return 35; +break; +case 29: return 'INVALID'; +break; +case 30: /*ignore whitespace*/ +break; +case 31: this.popState(); return 37; +break; +case 32: return 5; +break; +} +}; +lexer.rules = [/^(?:[^\x00]*?(?=(\{\{)))/,/^(?:[^\x00]+)/,/^(?:[^\x00]{2,}?(?=(\{\{|$)))/,/^(?:[\s\S]*?--\}\})/,/^(?:\{\{>)/,/^(?:\{\{#)/,/^(?:\{\{\/)/,/^(?:\{\{\^)/,/^(?:\{\{\s*else\b)/,/^(?:\{\{\{)/,/^(?:\{\{&)/,/^(?:\{\{!--)/,/^(?:\{\{![\s\S]*?\}\})/,/^(?:\{\{)/,/^(?:=)/,/^(?:\.(?=[} ]))/,/^(?:\.\.)/,/^(?:[\/.])/,/^(?:\s+)/,/^(?:\}\}\})/,/^(?:\}\})/,/^(?:"(\\["]|[^"])*")/,/^(?:'(\\[']|[^'])*')/,/^(?:@[a-zA-Z]+)/,/^(?:true(?=[}\s]))/,/^(?:false(?=[}\s]))/,/^(?:[0-9]+(?=[}\s]))/,/^(?:[a-zA-Z0-9_$-]+(?=[=}\s\/.]))/,/^(?:\[[^\]]*\])/,/^(?:.)/,/^(?:\s+)/,/^(?:[a-zA-Z0-9_$-/]+)/,/^(?:$)/]; +lexer.conditions = {"mu":{"rules":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,32],"inclusive":false},"emu":{"rules":[2],"inclusive":false},"com":{"rules":[3],"inclusive":false},"par":{"rules":[30,31],"inclusive":false},"INITIAL":{"rules":[0,1,32],"inclusive":true}}; +return lexer;})() +parser.lexer = lexer; +function Parser () { this.yy = {}; }Parser.prototype = parser;parser.Parser = Parser; +return new Parser; +})();; +// lib/handlebars/compiler/base.js +Handlebars.Parser = handlebars; + +Handlebars.parse = function(string) { + Handlebars.Parser.yy = Handlebars.AST; + return Handlebars.Parser.parse(string); +}; + +Handlebars.print = function(ast) { + return new Handlebars.PrintVisitor().accept(ast); +};; +// lib/handlebars/compiler/ast.js +(function() { + + Handlebars.AST = {}; + + Handlebars.AST.ProgramNode = function(statements, inverse) { + this.type = "program"; + this.statements = statements; + if(inverse) { this.inverse = new Handlebars.AST.ProgramNode(inverse); } + }; + + Handlebars.AST.MustacheNode = function(rawParams, hash, unescaped) { + this.type = "mustache"; + this.escaped = !unescaped; + this.hash = hash; + + var id = this.id = rawParams[0]; + var params = this.params = rawParams.slice(1); + + // a mustache is an eligible helper if: + // * its id is simple (a single part, not `this` or `..`) + var eligibleHelper = this.eligibleHelper = id.isSimple; + + // a mustache is definitely a helper if: + // * it is an eligible helper, and + // * it has at least one parameter or hash segment + this.isHelper = eligibleHelper && (params.length || hash); + + // if a mustache is an eligible helper but not a definite + // helper, it is ambiguous, and will be resolved in a later + // pass or at runtime. + }; + + Handlebars.AST.PartialNode = function(partialName, context) { + this.type = "partial"; + this.partialName = partialName; + this.context = context; + }; + + var verifyMatch = function(open, close) { + if(open.original !== close.original) { + throw new Handlebars.Exception(open.original + " doesn't match " + close.original); + } + }; + + Handlebars.AST.BlockNode = function(mustache, program, inverse, close) { + verifyMatch(mustache.id, close); + this.type = "block"; + this.mustache = mustache; + this.program = program; + this.inverse = inverse; + + if (this.inverse && !this.program) { + this.isInverse = true; + } + }; + + Handlebars.AST.ContentNode = function(string) { + this.type = "content"; + this.string = string; + }; + + Handlebars.AST.HashNode = function(pairs) { + this.type = "hash"; + this.pairs = pairs; + }; + + Handlebars.AST.IdNode = function(parts) { + this.type = "ID"; + this.original = parts.join("."); + + var dig = [], depth = 0; + + for(var i=0,l=parts.length; i": ">", + '"': """, + "'": "'", + "`": "`" + }; + + var badChars = /[&<>"'`]/g; + var possible = /[&<>"'`]/; + + var escapeChar = function(chr) { + return escape[chr] || "&"; + }; + + Handlebars.Utils = { + escapeExpression: function(string) { + // don't escape SafeStrings, since they're already safe + if (string instanceof Handlebars.SafeString) { + return string.toString(); + } else if (string == null || string === false) { + return ""; + } + + if(!possible.test(string)) { return string; } + return string.replace(badChars, escapeChar); + }, + + isEmpty: function(value) { + if (!value && value !== 0) { + return true; + } else if(Object.prototype.toString.call(value) === "[object Array]" && value.length === 0) { + return true; + } else { + return false; + } + } + }; +})();; +// lib/handlebars/compiler/compiler.js + +/*jshint eqnull:true*/ +Handlebars.Compiler = function() {}; +Handlebars.JavaScriptCompiler = function() {}; + +(function(Compiler, JavaScriptCompiler) { + // the foundHelper register will disambiguate helper lookup from finding a + // function in a context. This is necessary for mustache compatibility, which + // requires that context functions in blocks are evaluated by blockHelperMissing, + // and then proceed as if the resulting value was provided to blockHelperMissing. + + Compiler.prototype = { + compiler: Compiler, + + disassemble: function() { + var opcodes = this.opcodes, opcode, out = [], params, param; + + for (var i=0, l=opcodes.length; i 0) { + this.source[1] = this.source[1] + ", " + locals.join(", "); + } + + // Generate minimizer alias mappings + if (!this.isChild) { + var aliases = []; + for (var alias in this.context.aliases) { + this.source[1] = this.source[1] + ', ' + alias + '=' + this.context.aliases[alias]; + } + } + + if (this.source[1]) { + this.source[1] = "var " + this.source[1].substring(2) + ";"; + } + + // Merge children + if (!this.isChild) { + this.source[1] += '\n' + this.context.programs.join('\n') + '\n'; + } + + if (!this.environment.isSimple) { + this.source.push("return buffer;"); + } + + var params = this.isChild ? ["depth0", "data"] : ["Handlebars", "depth0", "helpers", "partials", "data"]; + + for(var i=0, l=this.environment.depths.list.length; i this.stackVars.length) { this.stackVars.push("stack" + this.stackSlot); } + return "stack" + this.stackSlot; + }, + + popStack: function() { + var item = this.compileStack.pop(); + + if (item instanceof Literal) { + return item.value; + } else { + this.stackSlot--; + return item; + } + }, + + topStack: function() { + var item = this.compileStack[this.compileStack.length - 1]; + + if (item instanceof Literal) { + return item.value; + } else { + return item; + } + }, + + quotedString: function(str) { + return '"' + str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + '"'; + }, + + setupHelper: function(paramSize, name) { + var params = []; + this.setupParams(paramSize, params); + var foundHelper = this.nameLookup('helpers', name, 'helper'); + + return { + params: params, + name: foundHelper, + callParams: ["depth0"].concat(params).join(", "), + helperMissingParams: ["depth0", this.quotedString(name)].concat(params).join(", ") + }; + }, + + // the params and contexts arguments are passed in arrays + // to fill in + setupParams: function(paramSize, params) { + var options = [], contexts = [], types = [], param, inverse, program; + + options.push("hash:" + this.popStack()); + + inverse = this.popStack(); + program = this.popStack(); + + // Avoid setting fn and inverse if neither are set. This allows + // helpers to do a check for `if (options.fn)` + if (program || inverse) { + if (!program) { + this.context.aliases.self = "this"; + program = "self.noop"; + } + + if (!inverse) { + this.context.aliases.self = "this"; + inverse = "self.noop"; + } + + options.push("inverse:" + inverse); + options.push("fn:" + program); + } + + for(var i=0; i 60 seconds && < 60 minutes X Minutes + * 60 minutes 1 Hour + * > 60 minutes && < 24 hours X Hours + * 24 hours 1 Day + * > 24 hours && < 7 days X Days + * 7 days 1 Week + * > 7 days && < ~ 1 Month X Weeks + * ~ 1 Month 1 Month + * > ~ 1 Month && < 1 Year X Months + * 1 Year 1 Year + * > 1 Year X Years + * + * Single units are +10%. 1 Year shows first at 1 Year + 10% + */ + + function normalize(val, single) + { + var margin = 0.1; + if(val >= single && val <= single * (1+margin)) { + return single; + } + return val; + } + + for(var i = 0, format = formats[0]; formats[i]; format = formats[++i]) { + if(seconds < format[0]) { + if(i === 0) { + // Now + return format[1]; + } + + var val = Math.ceil(normalize(seconds, format[3]) / (format[3])); + return val + + ' ' + + (val != 1 ? format[2] : format[1]) + + (i > 0 ? token : ''); + } + } +}; + +if(typeof jQuery != 'undefined') { + jQuery.fn.humaneDates = function(options) + { + var settings = jQuery.extend({ + 'lowercase': false + }, options); + + return this.each(function() + { + var $t = jQuery(this), + date = $t.attr('datetime') || $t.attr('title'); + + date = humaneDate(date); + + if(date && settings['lowercase']) { + date = date.toLowerCase(); + } + + if(date && $t.html() != date) { + // don't modify the dom if we don't have to + $t.html(date); + } + }); + }; +} \ No newline at end of file diff --git a/app/assets/javascripts/external/jquery-1.8.2.js b/app/assets/javascripts/external/jquery-1.8.2.js new file mode 100644 index 00000000000..12c7797fdc6 --- /dev/null +++ b/app/assets/javascripts/external/jquery-1.8.2.js @@ -0,0 +1,9440 @@ +/*! + * jQuery JavaScript Library v1.8.2 + * http://jquery.com/ + * + * Includes Sizzle.js + * http://sizzlejs.com/ + * + * Copyright 2012 jQuery Foundation and other contributors + * Released under the MIT license + * http://jquery.org/license + * + * Date: Thu Sep 20 2012 21:13:05 GMT-0400 (Eastern Daylight Time) + */ +(function( window, undefined ) { +var + // A central reference to the root jQuery(document) + rootjQuery, + + // The deferred used on DOM ready + readyList, + + // Use the correct document accordingly with window argument (sandbox) + document = window.document, + location = window.location, + navigator = window.navigator, + + // Map over jQuery in case of overwrite + _jQuery = window.jQuery, + + // Map over the $ in case of overwrite + _$ = window.$, + + // Save a reference to some core methods + core_push = Array.prototype.push, + core_slice = Array.prototype.slice, + core_indexOf = Array.prototype.indexOf, + core_toString = Object.prototype.toString, + core_hasOwn = Object.prototype.hasOwnProperty, + core_trim = String.prototype.trim, + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + // The jQuery object is actually just the init constructor 'enhanced' + return new jQuery.fn.init( selector, context, rootjQuery ); + }, + + // Used for matching numbers + core_pnum = /[\-+]?(?:\d*\.|)\d+(?:[eE][\-+]?\d+|)/.source, + + // Used for detecting and trimming whitespace + core_rnotwhite = /\S/, + core_rspace = /\s+/, + + // Make sure we trim BOM and NBSP (here's looking at you, Safari 5.0 and IE) + rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, + + // A simple way to check for HTML strings + // Prioritize #id over to avoid XSS via location.hash (#9521) + rquickExpr = /^(?:[^#<]*(<[\w\W]+>)[^>]*$|#([\w\-]*)$)/, + + // Match a standalone tag + rsingleTag = /^<(\w+)\s*\/?>(?:<\/\1>|)$/, + + // JSON RegExp + rvalidchars = /^[\],:{}\s]*$/, + rvalidbraces = /(?:^|:|,)(?:\s*\[)+/g, + rvalidescape = /\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g, + rvalidtokens = /"[^"\\\r\n]*"|true|false|null|-?(?:\d\d*\.|)\d+(?:[eE][\-+]?\d+|)/g, + + // Matches dashed string for camelizing + rmsPrefix = /^-ms-/, + rdashAlpha = /-([\da-z])/gi, + + // Used by jQuery.camelCase as callback to replace() + fcamelCase = function( all, letter ) { + return ( letter + "" ).toUpperCase(); + }, + + // The ready event handler and self cleanup method + DOMContentLoaded = function() { + if ( document.addEventListener ) { + document.removeEventListener( "DOMContentLoaded", DOMContentLoaded, false ); + jQuery.ready(); + } else if ( document.readyState === "complete" ) { + // we're here because readyState === "complete" in oldIE + // which is good enough for us to call the dom ready! + document.detachEvent( "onreadystatechange", DOMContentLoaded ); + jQuery.ready(); + } + }, + + // [[Class]] -> type pairs + class2type = {}; + +jQuery.fn = jQuery.prototype = { + constructor: jQuery, + init: function( selector, context, rootjQuery ) { + var match, elem, ret, doc; + + // Handle $(""), $(null), $(undefined), $(false) + if ( !selector ) { + return this; + } + + // Handle $(DOMElement) + if ( selector.nodeType ) { + this.context = this[0] = selector; + this.length = 1; + return this; + } + + // Handle HTML strings + if ( typeof selector === "string" ) { + if ( selector.charAt(0) === "<" && selector.charAt( selector.length - 1 ) === ">" && selector.length >= 3 ) { + // Assume that strings that start and end with <> are HTML and skip the regex check + match = [ null, selector, null ]; + + } else { + match = rquickExpr.exec( selector ); + } + + // Match html or make sure no context is specified for #id + if ( match && (match[1] || !context) ) { + + // HANDLE: $(html) -> $(array) + if ( match[1] ) { + context = context instanceof jQuery ? context[0] : context; + doc = ( context && context.nodeType ? context.ownerDocument || context : document ); + + // scripts is true for back-compat + selector = jQuery.parseHTML( match[1], doc, true ); + if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) { + this.attr.call( selector, context, true ); + } + + return jQuery.merge( this, selector ); + + // HANDLE: $(#id) + } else { + elem = document.getElementById( match[2] ); + + // Check parentNode to catch when Blackberry 4.6 returns + // nodes that are no longer in the document #6963 + if ( elem && elem.parentNode ) { + // Handle the case where IE and Opera return items + // by name instead of ID + if ( elem.id !== match[2] ) { + return rootjQuery.find( selector ); + } + + // Otherwise, we inject the element directly into the jQuery object + this.length = 1; + this[0] = elem; + } + + this.context = document; + this.selector = selector; + return this; + } + + // HANDLE: $(expr, $(...)) + } else if ( !context || context.jquery ) { + return ( context || rootjQuery ).find( selector ); + + // HANDLE: $(expr, context) + // (which is just equivalent to: $(context).find(expr) + } else { + return this.constructor( context ).find( selector ); + } + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( jQuery.isFunction( selector ) ) { + return rootjQuery.ready( selector ); + } + + if ( selector.selector !== undefined ) { + this.selector = selector.selector; + this.context = selector.context; + } + + return jQuery.makeArray( selector, this ); + }, + + // Start with an empty selector + selector: "", + + // The current version of jQuery being used + jquery: "1.8.2", + + // The default length of a jQuery object is 0 + length: 0, + + // The number of elements contained in the matched element set + size: function() { + return this.length; + }, + + toArray: function() { + return core_slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + return num == null ? + + // Return a 'clean' array + this.toArray() : + + // Return just the object + ( num < 0 ? this[ this.length + num ] : this[ num ] ); + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems, name, selector ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + ret.context = this.context; + + if ( name === "find" ) { + ret.selector = this.selector + ( this.selector ? " " : "" ) + selector; + } else if ( name ) { + ret.selector = this.selector + "." + name + "(" + selector + ")"; + } + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + // (You can seed the arguments with an array of args, but this is + // only used internally.) + each: function( callback, args ) { + return jQuery.each( this, callback, args ); + }, + + ready: function( fn ) { + // Add the callback + jQuery.ready.promise().done( fn ); + + return this; + }, + + eq: function( i ) { + i = +i; + return i === -1 ? + this.slice( i ) : + this.slice( i, i + 1 ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + slice: function() { + return this.pushStack( core_slice.apply( this, arguments ), + "slice", core_slice.call(arguments).join(",") ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map(this, function( elem, i ) { + return callback.call( elem, i, elem ); + })); + }, + + end: function() { + return this.prevObject || this.constructor(null); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: core_push, + sort: [].sort, + splice: [].splice +}; + +// Give the init function the jQuery prototype for later instantiation +jQuery.fn.init.prototype = jQuery.fn; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[0] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + target = arguments[1] || {}; + // skip the boolean and the target + i = 2; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !jQuery.isFunction(target) ) { + target = {}; + } + + // extend jQuery itself if only one argument is passed + if ( length === i ) { + target = this; + --i; + } + + for ( ; i < length; i++ ) { + // Only deal with non-null/undefined values + if ( (options = arguments[ i ]) != null ) { + // Extend the base object + for ( name in options ) { + src = target[ name ]; + copy = options[ name ]; + + // Prevent never-ending loop + if ( target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { + if ( copyIsArray ) { + copyIsArray = false; + clone = src && jQuery.isArray(src) ? src : []; + + } else { + clone = src && jQuery.isPlainObject(src) ? src : {}; + } + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend({ + noConflict: function( deep ) { + if ( window.$ === jQuery ) { + window.$ = _$; + } + + if ( deep && window.jQuery === jQuery ) { + window.jQuery = _jQuery; + } + + return jQuery; + }, + + // Is the DOM ready to be used? Set to true once it occurs. + isReady: false, + + // A counter to track how many items to wait for before + // the ready event fires. See #6781 + readyWait: 1, + + // Hold (or release) the ready event + holdReady: function( hold ) { + if ( hold ) { + jQuery.readyWait++; + } else { + jQuery.ready( true ); + } + }, + + // Handle when the DOM is ready + ready: function( wait ) { + + // Abort if there are pending holds or we're already ready + if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { + return; + } + + // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). + if ( !document.body ) { + return setTimeout( jQuery.ready, 1 ); + } + + // Remember that the DOM is ready + jQuery.isReady = true; + + // If a normal DOM Ready event fired, decrement, and wait if need be + if ( wait !== true && --jQuery.readyWait > 0 ) { + return; + } + + // If there are functions bound, to execute + readyList.resolveWith( document, [ jQuery ] ); + + // Trigger any bound ready events + if ( jQuery.fn.trigger ) { + jQuery( document ).trigger("ready").off("ready"); + } + }, + + // See test/unit/core.js for details concerning isFunction. + // Since version 1.3, DOM methods and functions like alert + // aren't supported. They return false on IE (#2968). + isFunction: function( obj ) { + return jQuery.type(obj) === "function"; + }, + + isArray: Array.isArray || function( obj ) { + return jQuery.type(obj) === "array"; + }, + + isWindow: function( obj ) { + return obj != null && obj == obj.window; + }, + + isNumeric: function( obj ) { + return !isNaN( parseFloat(obj) ) && isFinite( obj ); + }, + + type: function( obj ) { + return obj == null ? + String( obj ) : + class2type[ core_toString.call(obj) ] || "object"; + }, + + isPlainObject: function( obj ) { + // Must be an Object. + // Because of IE, we also have to check the presence of the constructor property. + // Make sure that DOM nodes and window objects don't pass through, as well + if ( !obj || jQuery.type(obj) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { + return false; + } + + try { + // Not own constructor property must be Object + if ( obj.constructor && + !core_hasOwn.call(obj, "constructor") && + !core_hasOwn.call(obj.constructor.prototype, "isPrototypeOf") ) { + return false; + } + } catch ( e ) { + // IE8,9 Will throw exceptions on certain host objects #9897 + return false; + } + + // Own properties are enumerated firstly, so to speed up, + // if last one is own, then all properties are own. + + var key; + for ( key in obj ) {} + + return key === undefined || core_hasOwn.call( obj, key ); + }, + + isEmptyObject: function( obj ) { + var name; + for ( name in obj ) { + return false; + } + return true; + }, + + error: function( msg ) { + throw new Error( msg ); + }, + + // data: string of html + // context (optional): If specified, the fragment will be created in this context, defaults to document + // scripts (optional): If true, will include scripts passed in the html string + parseHTML: function( data, context, scripts ) { + var parsed; + if ( !data || typeof data !== "string" ) { + return null; + } + if ( typeof context === "boolean" ) { + scripts = context; + context = 0; + } + context = context || document; + + // Single tag + if ( (parsed = rsingleTag.exec( data )) ) { + return [ context.createElement( parsed[1] ) ]; + } + + parsed = jQuery.buildFragment( [ data ], context, scripts ? null : [] ); + return jQuery.merge( [], + (parsed.cacheable ? jQuery.clone( parsed.fragment ) : parsed.fragment).childNodes ); + }, + + parseJSON: function( data ) { + if ( !data || typeof data !== "string") { + return null; + } + + // Make sure leading/trailing whitespace is removed (IE can't handle it) + data = jQuery.trim( data ); + + // Attempt to parse using the native JSON parser first + if ( window.JSON && window.JSON.parse ) { + return window.JSON.parse( data ); + } + + // Make sure the incoming data is actual JSON + // Logic borrowed from http://json.org/json2.js + if ( rvalidchars.test( data.replace( rvalidescape, "@" ) + .replace( rvalidtokens, "]" ) + .replace( rvalidbraces, "")) ) { + + return ( new Function( "return " + data ) )(); + + } + jQuery.error( "Invalid JSON: " + data ); + }, + + // Cross-browser xml parsing + parseXML: function( data ) { + var xml, tmp; + if ( !data || typeof data !== "string" ) { + return null; + } + try { + if ( window.DOMParser ) { // Standard + tmp = new DOMParser(); + xml = tmp.parseFromString( data , "text/xml" ); + } else { // IE + xml = new ActiveXObject( "Microsoft.XMLDOM" ); + xml.async = "false"; + xml.loadXML( data ); + } + } catch( e ) { + xml = undefined; + } + if ( !xml || !xml.documentElement || xml.getElementsByTagName( "parsererror" ).length ) { + jQuery.error( "Invalid XML: " + data ); + } + return xml; + }, + + noop: function() {}, + + // Evaluates a script in a global context + // Workarounds based on findings by Jim Driscoll + // http://weblogs.java.net/blog/driscoll/archive/2009/09/08/eval-javascript-global-context + globalEval: function( data ) { + if ( data && core_rnotwhite.test( data ) ) { + // We use execScript on Internet Explorer + // We use an anonymous function so that context is window + // rather than jQuery in Firefox + ( window.execScript || function( data ) { + window[ "eval" ].call( window, data ); + } )( data ); + } + }, + + // Convert dashed to camelCase; used by the css and data modules + // Microsoft forgot to hump their vendor prefix (#9572) + camelCase: function( string ) { + return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); + }, + + nodeName: function( elem, name ) { + return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); + }, + + // args is for internal usage only + each: function( obj, callback, args ) { + var name, + i = 0, + length = obj.length, + isObj = length === undefined || jQuery.isFunction( obj ); + + if ( args ) { + if ( isObj ) { + for ( name in obj ) { + if ( callback.apply( obj[ name ], args ) === false ) { + break; + } + } + } else { + for ( ; i < length; ) { + if ( callback.apply( obj[ i++ ], args ) === false ) { + break; + } + } + } + + // A special, fast, case for the most common use of each + } else { + if ( isObj ) { + for ( name in obj ) { + if ( callback.call( obj[ name ], name, obj[ name ] ) === false ) { + break; + } + } + } else { + for ( ; i < length; ) { + if ( callback.call( obj[ i ], i, obj[ i++ ] ) === false ) { + break; + } + } + } + } + + return obj; + }, + + // Use native String.trim function wherever possible + trim: core_trim && !core_trim.call("\uFEFF\xA0") ? + function( text ) { + return text == null ? + "" : + core_trim.call( text ); + } : + + // Otherwise use our own trimming functionality + function( text ) { + return text == null ? + "" : + ( text + "" ).replace( rtrim, "" ); + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var type, + ret = results || []; + + if ( arr != null ) { + // The window, strings (and functions) also have 'length' + // Tweaked logic slightly to handle Blackberry 4.7 RegExp issues #6930 + type = jQuery.type( arr ); + + if ( arr.length == null || type === "string" || type === "function" || type === "regexp" || jQuery.isWindow( arr ) ) { + core_push.call( ret, arr ); + } else { + jQuery.merge( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + var len; + + if ( arr ) { + if ( core_indexOf ) { + return core_indexOf.call( arr, elem, i ); + } + + len = arr.length; + i = i ? i < 0 ? Math.max( 0, len + i ) : i : 0; + + for ( ; i < len; i++ ) { + // Skip accessing in sparse arrays + if ( i in arr && arr[ i ] === elem ) { + return i; + } + } + } + + return -1; + }, + + merge: function( first, second ) { + var l = second.length, + i = first.length, + j = 0; + + if ( typeof l === "number" ) { + for ( ; j < l; j++ ) { + first[ i++ ] = second[ j ]; + } + + } else { + while ( second[j] !== undefined ) { + first[ i++ ] = second[ j++ ]; + } + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, inv ) { + var retVal, + ret = [], + i = 0, + length = elems.length; + inv = !!inv; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + retVal = !!callback( elems[ i ], i ); + if ( inv !== retVal ) { + ret.push( elems[ i ] ); + } + } + + return ret; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var value, key, + ret = [], + i = 0, + length = elems.length, + // jquery objects are treated as arrays + isArray = elems instanceof jQuery || length !== undefined && typeof length === "number" && ( ( length > 0 && elems[ 0 ] && elems[ length -1 ] ) || length === 0 || jQuery.isArray( elems ) ) ; + + // Go through the array, translating each of the items to their + if ( isArray ) { + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret[ ret.length ] = value; + } + } + + // Go through every key on the object, + } else { + for ( key in elems ) { + value = callback( elems[ key ], key, arg ); + + if ( value != null ) { + ret[ ret.length ] = value; + } + } + } + + // Flatten any nested arrays + return ret.concat.apply( [], ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // Bind a function to a context, optionally partially applying any + // arguments. + proxy: function( fn, context ) { + var tmp, args, proxy; + + if ( typeof context === "string" ) { + tmp = fn[ context ]; + context = fn; + fn = tmp; + } + + // Quick check to determine if target is callable, in the spec + // this throws a TypeError, but we will just return undefined. + if ( !jQuery.isFunction( fn ) ) { + return undefined; + } + + // Simulated bind + args = core_slice.call( arguments, 2 ); + proxy = function() { + return fn.apply( context, args.concat( core_slice.call( arguments ) ) ); + }; + + // Set the guid of unique handler to the same of original handler, so it can be removed + proxy.guid = fn.guid = fn.guid || jQuery.guid++; + + return proxy; + }, + + // Multifunctional method to get and set values of a collection + // The value/s can optionally be executed if it's a function + access: function( elems, fn, key, value, chainable, emptyGet, pass ) { + var exec, + bulk = key == null, + i = 0, + length = elems.length; + + // Sets many values + if ( key && typeof key === "object" ) { + for ( i in key ) { + jQuery.access( elems, fn, i, key[i], 1, emptyGet, value ); + } + chainable = 1; + + // Sets one value + } else if ( value !== undefined ) { + // Optionally, function values get executed if exec is true + exec = pass === undefined && jQuery.isFunction( value ); + + if ( bulk ) { + // Bulk operations only iterate when executing function values + if ( exec ) { + exec = fn; + fn = function( elem, key, value ) { + return exec.call( jQuery( elem ), value ); + }; + + // Otherwise they run against the entire set + } else { + fn.call( elems, value ); + fn = null; + } + } + + if ( fn ) { + for (; i < length; i++ ) { + fn( elems[i], key, exec ? value.call( elems[i], i, fn( elems[i], key ) ) : value, pass ); + } + } + + chainable = 1; + } + + return chainable ? + elems : + + // Gets + bulk ? + fn.call( elems ) : + length ? fn( elems[0], key ) : emptyGet; + }, + + now: function() { + return ( new Date() ).getTime(); + } +}); + +jQuery.ready.promise = function( obj ) { + if ( !readyList ) { + + readyList = jQuery.Deferred(); + + // Catch cases where $(document).ready() is called after the browser event has already occurred. + // we once tried to use readyState "interactive" here, but it caused issues like the one + // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15 + if ( document.readyState === "complete" ) { + // Handle it asynchronously to allow scripts the opportunity to delay ready + setTimeout( jQuery.ready, 1 ); + + // Standards-based browsers support DOMContentLoaded + } else if ( document.addEventListener ) { + // Use the handy event callback + document.addEventListener( "DOMContentLoaded", DOMContentLoaded, false ); + + // A fallback to window.onload, that will always work + window.addEventListener( "load", jQuery.ready, false ); + + // If IE event model is used + } else { + // Ensure firing before onload, maybe late but safe also for iframes + document.attachEvent( "onreadystatechange", DOMContentLoaded ); + + // A fallback to window.onload, that will always work + window.attachEvent( "onload", jQuery.ready ); + + // If IE and not a frame + // continually check to see if the document is ready + var top = false; + + try { + top = window.frameElement == null && document.documentElement; + } catch(e) {} + + if ( top && top.doScroll ) { + (function doScrollCheck() { + if ( !jQuery.isReady ) { + + try { + // Use the trick by Diego Perini + // http://javascript.nwbox.com/IEContentLoaded/ + top.doScroll("left"); + } catch(e) { + return setTimeout( doScrollCheck, 50 ); + } + + // and execute any waiting functions + jQuery.ready(); + } + })(); + } + } + } + return readyList.promise( obj ); +}; + +// Populate the class2type map +jQuery.each("Boolean Number String Function Array Date RegExp Object".split(" "), function(i, name) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); +}); + +// All jQuery objects should point back to these +rootjQuery = jQuery(document); +// String to Object options format cache +var optionsCache = {}; + +// Convert String-formatted options into Object-formatted ones and store in cache +function createOptions( options ) { + var object = optionsCache[ options ] = {}; + jQuery.each( options.split( core_rspace ), function( _, flag ) { + object[ flag ] = true; + }); + return object; +} + +/* + * Create a callback list using the following parameters: + * + * options: an optional list of space-separated options that will change how + * the callback list behaves or a more traditional option object + * + * By default a callback list will act like an event callback list and can be + * "fired" multiple times. + * + * Possible options: + * + * once: will ensure the callback list can only be fired once (like a Deferred) + * + * memory: will keep track of previous values and will call any callback added + * after the list has been fired right away with the latest "memorized" + * values (like a Deferred) + * + * unique: will ensure a callback can only be added once (no duplicate in the list) + * + * stopOnFalse: interrupt callings when a callback returns false + * + */ +jQuery.Callbacks = function( options ) { + + // Convert options from String-formatted to Object-formatted if needed + // (we check in cache first) + options = typeof options === "string" ? + ( optionsCache[ options ] || createOptions( options ) ) : + jQuery.extend( {}, options ); + + var // Last fire value (for non-forgettable lists) + memory, + // Flag to know if list was already fired + fired, + // Flag to know if list is currently firing + firing, + // First callback to fire (used internally by add and fireWith) + firingStart, + // End of the loop when firing + firingLength, + // Index of currently firing callback (modified by remove if needed) + firingIndex, + // Actual callback list + list = [], + // Stack of fire calls for repeatable lists + stack = !options.once && [], + // Fire callbacks + fire = function( data ) { + memory = options.memory && data; + fired = true; + firingIndex = firingStart || 0; + firingStart = 0; + firingLength = list.length; + firing = true; + for ( ; list && firingIndex < firingLength; firingIndex++ ) { + if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) { + memory = false; // To prevent further calls using add + break; + } + } + firing = false; + if ( list ) { + if ( stack ) { + if ( stack.length ) { + fire( stack.shift() ); + } + } else if ( memory ) { + list = []; + } else { + self.disable(); + } + } + }, + // Actual Callbacks object + self = { + // Add a callback or a collection of callbacks to the list + add: function() { + if ( list ) { + // First, we save the current length + var start = list.length; + (function add( args ) { + jQuery.each( args, function( _, arg ) { + var type = jQuery.type( arg ); + if ( type === "function" && ( !options.unique || !self.has( arg ) ) ) { + list.push( arg ); + } else if ( arg && arg.length && type !== "string" ) { + // Inspect recursively + add( arg ); + } + }); + })( arguments ); + // Do we need to add the callbacks to the + // current firing batch? + if ( firing ) { + firingLength = list.length; + // With memory, if we're not firing then + // we should call right away + } else if ( memory ) { + firingStart = start; + fire( memory ); + } + } + return this; + }, + // Remove a callback from the list + remove: function() { + if ( list ) { + jQuery.each( arguments, function( _, arg ) { + var index; + while( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { + list.splice( index, 1 ); + // Handle firing indexes + if ( firing ) { + if ( index <= firingLength ) { + firingLength--; + } + if ( index <= firingIndex ) { + firingIndex--; + } + } + } + }); + } + return this; + }, + // Control if a given callback is in the list + has: function( fn ) { + return jQuery.inArray( fn, list ) > -1; + }, + // Remove all callbacks from the list + empty: function() { + list = []; + return this; + }, + // Have the list do nothing anymore + disable: function() { + list = stack = memory = undefined; + return this; + }, + // Is it disabled? + disabled: function() { + return !list; + }, + // Lock the list in its current state + lock: function() { + stack = undefined; + if ( !memory ) { + self.disable(); + } + return this; + }, + // Is it locked? + locked: function() { + return !stack; + }, + // Call all callbacks with the given context and arguments + fireWith: function( context, args ) { + args = args || []; + args = [ context, args.slice ? args.slice() : args ]; + if ( list && ( !fired || stack ) ) { + if ( firing ) { + stack.push( args ); + } else { + fire( args ); + } + } + return this; + }, + // Call all the callbacks with the given arguments + fire: function() { + self.fireWith( this, arguments ); + return this; + }, + // To know if the callbacks have already been called at least once + fired: function() { + return !!fired; + } + }; + + return self; +}; +jQuery.extend({ + + Deferred: function( func ) { + var tuples = [ + // action, add listener, listener list, final state + [ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ], + [ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ], + [ "notify", "progress", jQuery.Callbacks("memory") ] + ], + state = "pending", + promise = { + state: function() { + return state; + }, + always: function() { + deferred.done( arguments ).fail( arguments ); + return this; + }, + then: function( /* fnDone, fnFail, fnProgress */ ) { + var fns = arguments; + return jQuery.Deferred(function( newDefer ) { + jQuery.each( tuples, function( i, tuple ) { + var action = tuple[ 0 ], + fn = fns[ i ]; + // deferred[ done | fail | progress ] for forwarding actions to newDefer + deferred[ tuple[1] ]( jQuery.isFunction( fn ) ? + function() { + var returned = fn.apply( this, arguments ); + if ( returned && jQuery.isFunction( returned.promise ) ) { + returned.promise() + .done( newDefer.resolve ) + .fail( newDefer.reject ) + .progress( newDefer.notify ); + } else { + newDefer[ action + "With" ]( this === deferred ? newDefer : this, [ returned ] ); + } + } : + newDefer[ action ] + ); + }); + fns = null; + }).promise(); + }, + // Get a promise for this deferred + // If obj is provided, the promise aspect is added to the object + promise: function( obj ) { + return obj != null ? jQuery.extend( obj, promise ) : promise; + } + }, + deferred = {}; + + // Keep pipe for back-compat + promise.pipe = promise.then; + + // Add list-specific methods + jQuery.each( tuples, function( i, tuple ) { + var list = tuple[ 2 ], + stateString = tuple[ 3 ]; + + // promise[ done | fail | progress ] = list.add + promise[ tuple[1] ] = list.add; + + // Handle state + if ( stateString ) { + list.add(function() { + // state = [ resolved | rejected ] + state = stateString; + + // [ reject_list | resolve_list ].disable; progress_list.lock + }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock ); + } + + // deferred[ resolve | reject | notify ] = list.fire + deferred[ tuple[0] ] = list.fire; + deferred[ tuple[0] + "With" ] = list.fireWith; + }); + + // Make the deferred a promise + promise.promise( deferred ); + + // Call given func if any + if ( func ) { + func.call( deferred, deferred ); + } + + // All done! + return deferred; + }, + + // Deferred helper + when: function( subordinate /* , ..., subordinateN */ ) { + var i = 0, + resolveValues = core_slice.call( arguments ), + length = resolveValues.length, + + // the count of uncompleted subordinates + remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, + + // the master Deferred. If resolveValues consist of only a single Deferred, just use that. + deferred = remaining === 1 ? subordinate : jQuery.Deferred(), + + // Update function for both resolve and progress values + updateFunc = function( i, contexts, values ) { + return function( value ) { + contexts[ i ] = this; + values[ i ] = arguments.length > 1 ? core_slice.call( arguments ) : value; + if( values === progressValues ) { + deferred.notifyWith( contexts, values ); + } else if ( !( --remaining ) ) { + deferred.resolveWith( contexts, values ); + } + }; + }, + + progressValues, progressContexts, resolveContexts; + + // add listeners to Deferred subordinates; treat others as resolved + if ( length > 1 ) { + progressValues = new Array( length ); + progressContexts = new Array( length ); + resolveContexts = new Array( length ); + for ( ; i < length; i++ ) { + if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) { + resolveValues[ i ].promise() + .done( updateFunc( i, resolveContexts, resolveValues ) ) + .fail( deferred.reject ) + .progress( updateFunc( i, progressContexts, progressValues ) ); + } else { + --remaining; + } + } + } + + // if we're not waiting on anything, resolve the master + if ( !remaining ) { + deferred.resolveWith( resolveContexts, resolveValues ); + } + + return deferred.promise(); + } +}); +jQuery.support = (function() { + + var support, + all, + a, + select, + opt, + input, + fragment, + eventName, + i, + isSupported, + clickFn, + div = document.createElement("div"); + + // Preliminary tests + div.setAttribute( "className", "t" ); + div.innerHTML = "
            a"; + + all = div.getElementsByTagName("*"); + a = div.getElementsByTagName("a")[ 0 ]; + a.style.cssText = "top:1px;float:left;opacity:.5"; + + // Can't get basic test support + if ( !all || !all.length ) { + return {}; + } + + // First batch of supports tests + select = document.createElement("select"); + opt = select.appendChild( document.createElement("option") ); + input = div.getElementsByTagName("input")[ 0 ]; + + support = { + // IE strips leading whitespace when .innerHTML is used + leadingWhitespace: ( div.firstChild.nodeType === 3 ), + + // Make sure that tbody elements aren't automatically inserted + // IE will insert them into empty tables + tbody: !div.getElementsByTagName("tbody").length, + + // Make sure that link elements get serialized correctly by innerHTML + // This requires a wrapper element in IE + htmlSerialize: !!div.getElementsByTagName("link").length, + + // Get the style information from getAttribute + // (IE uses .cssText instead) + style: /top/.test( a.getAttribute("style") ), + + // Make sure that URLs aren't manipulated + // (IE normalizes it by default) + hrefNormalized: ( a.getAttribute("href") === "/a" ), + + // Make sure that element opacity exists + // (IE uses filter instead) + // Use a regex to work around a WebKit issue. See #5145 + opacity: /^0.5/.test( a.style.opacity ), + + // Verify style float existence + // (IE uses styleFloat instead of cssFloat) + cssFloat: !!a.style.cssFloat, + + // Make sure that if no value is specified for a checkbox + // that it defaults to "on". + // (WebKit defaults to "" instead) + checkOn: ( input.value === "on" ), + + // Make sure that a selected-by-default option has a working selected property. + // (WebKit defaults to false instead of true, IE too, if it's in an optgroup) + optSelected: opt.selected, + + // Test setAttribute on camelCase class. If it works, we need attrFixes when doing get/setAttribute (ie6/7) + getSetAttribute: div.className !== "t", + + // Tests for enctype support on a form(#6743) + enctype: !!document.createElement("form").enctype, + + // Makes sure cloning an html5 element does not cause problems + // Where outerHTML is undefined, this still works + html5Clone: document.createElement("nav").cloneNode( true ).outerHTML !== "<:nav>", + + // jQuery.support.boxModel DEPRECATED in 1.8 since we don't support Quirks Mode + boxModel: ( document.compatMode === "CSS1Compat" ), + + // Will be defined later + submitBubbles: true, + changeBubbles: true, + focusinBubbles: false, + deleteExpando: true, + noCloneEvent: true, + inlineBlockNeedsLayout: false, + shrinkWrapBlocks: false, + reliableMarginRight: true, + boxSizingReliable: true, + pixelPosition: false + }; + + // Make sure checked status is properly cloned + input.checked = true; + support.noCloneChecked = input.cloneNode( true ).checked; + + // Make sure that the options inside disabled selects aren't marked as disabled + // (WebKit marks them as disabled) + select.disabled = true; + support.optDisabled = !opt.disabled; + + // Test to see if it's possible to delete an expando from an element + // Fails in Internet Explorer + try { + delete div.test; + } catch( e ) { + support.deleteExpando = false; + } + + if ( !div.addEventListener && div.attachEvent && div.fireEvent ) { + div.attachEvent( "onclick", clickFn = function() { + // Cloning a node shouldn't copy over any + // bound event handlers (IE does this) + support.noCloneEvent = false; + }); + div.cloneNode( true ).fireEvent("onclick"); + div.detachEvent( "onclick", clickFn ); + } + + // Check if a radio maintains its value + // after being appended to the DOM + input = document.createElement("input"); + input.value = "t"; + input.setAttribute( "type", "radio" ); + support.radioValue = input.value === "t"; + + input.setAttribute( "checked", "checked" ); + + // #11217 - WebKit loses check when the name is after the checked attribute + input.setAttribute( "name", "t" ); + + div.appendChild( input ); + fragment = document.createDocumentFragment(); + fragment.appendChild( div.lastChild ); + + // WebKit doesn't clone checked state correctly in fragments + support.checkClone = fragment.cloneNode( true ).cloneNode( true ).lastChild.checked; + + // Check if a disconnected checkbox will retain its checked + // value of true after appended to the DOM (IE6/7) + support.appendChecked = input.checked; + + fragment.removeChild( input ); + fragment.appendChild( div ); + + // Technique from Juriy Zaytsev + // http://perfectionkills.com/detecting-event-support-without-browser-sniffing/ + // We only care about the case where non-standard event systems + // are used, namely in IE. Short-circuiting here helps us to + // avoid an eval call (in setAttribute) which can cause CSP + // to go haywire. See: https://developer.mozilla.org/en/Security/CSP + if ( div.attachEvent ) { + for ( i in { + submit: true, + change: true, + focusin: true + }) { + eventName = "on" + i; + isSupported = ( eventName in div ); + if ( !isSupported ) { + div.setAttribute( eventName, "return;" ); + isSupported = ( typeof div[ eventName ] === "function" ); + } + support[ i + "Bubbles" ] = isSupported; + } + } + + // Run tests that need a body at doc ready + jQuery(function() { + var container, div, tds, marginDiv, + divReset = "padding:0;margin:0;border:0;display:block;overflow:hidden;", + body = document.getElementsByTagName("body")[0]; + + if ( !body ) { + // Return for frameset docs that don't have a body + return; + } + + container = document.createElement("div"); + container.style.cssText = "visibility:hidden;border:0;width:0;height:0;position:static;top:0;margin-top:1px"; + body.insertBefore( container, body.firstChild ); + + // Construct the test element + div = document.createElement("div"); + container.appendChild( div ); + + // Check if table cells still have offsetWidth/Height when they are set + // to display:none and there are still other visible table cells in a + // table row; if so, offsetWidth/Height are not reliable for use when + // determining if an element has been hidden directly using + // display:none (it is still safe to use offsets if a parent element is + // hidden; don safety goggles and see bug #4512 for more information). + // (only IE 8 fails this test) + div.innerHTML = "
            t
            "; + tds = div.getElementsByTagName("td"); + tds[ 0 ].style.cssText = "padding:0;margin:0;border:0;display:none"; + isSupported = ( tds[ 0 ].offsetHeight === 0 ); + + tds[ 0 ].style.display = ""; + tds[ 1 ].style.display = "none"; + + // Check if empty table cells still have offsetWidth/Height + // (IE <= 8 fail this test) + support.reliableHiddenOffsets = isSupported && ( tds[ 0 ].offsetHeight === 0 ); + + // Check box-sizing and margin behavior + div.innerHTML = ""; + div.style.cssText = "box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;"; + support.boxSizing = ( div.offsetWidth === 4 ); + support.doesNotIncludeMarginInBodyOffset = ( body.offsetTop !== 1 ); + + // NOTE: To any future maintainer, we've window.getComputedStyle + // because jsdom on node.js will break without it. + if ( window.getComputedStyle ) { + support.pixelPosition = ( window.getComputedStyle( div, null ) || {} ).top !== "1%"; + support.boxSizingReliable = ( window.getComputedStyle( div, null ) || { width: "4px" } ).width === "4px"; + + // Check if div with explicit width and no margin-right incorrectly + // gets computed margin-right based on width of container. For more + // info see bug #3333 + // Fails in WebKit before Feb 2011 nightlies + // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right + marginDiv = document.createElement("div"); + marginDiv.style.cssText = div.style.cssText = divReset; + marginDiv.style.marginRight = marginDiv.style.width = "0"; + div.style.width = "1px"; + div.appendChild( marginDiv ); + support.reliableMarginRight = + !parseFloat( ( window.getComputedStyle( marginDiv, null ) || {} ).marginRight ); + } + + if ( typeof div.style.zoom !== "undefined" ) { + // Check if natively block-level elements act like inline-block + // elements when setting their display to 'inline' and giving + // them layout + // (IE < 8 does this) + div.innerHTML = ""; + div.style.cssText = divReset + "width:1px;padding:1px;display:inline;zoom:1"; + support.inlineBlockNeedsLayout = ( div.offsetWidth === 3 ); + + // Check if elements with layout shrink-wrap their children + // (IE 6 does this) + div.style.display = "block"; + div.style.overflow = "visible"; + div.innerHTML = "
            "; + div.firstChild.style.width = "5px"; + support.shrinkWrapBlocks = ( div.offsetWidth !== 3 ); + + container.style.zoom = 1; + } + + // Null elements to avoid leaks in IE + body.removeChild( container ); + container = div = tds = marginDiv = null; + }); + + // Null elements to avoid leaks in IE + fragment.removeChild( div ); + all = a = select = opt = input = fragment = div = null; + + return support; +})(); +var rbrace = /(?:\{[\s\S]*\}|\[[\s\S]*\])$/, + rmultiDash = /([A-Z])/g; + +jQuery.extend({ + cache: {}, + + deletedIds: [], + + // Remove at next major release (1.9/2.0) + uuid: 0, + + // Unique for each copy of jQuery on the page + // Non-digits removed to match rinlinejQuery + expando: "jQuery" + ( jQuery.fn.jquery + Math.random() ).replace( /\D/g, "" ), + + // The following elements throw uncatchable exceptions if you + // attempt to add expando properties to them. + noData: { + "embed": true, + // Ban all objects except for Flash (which handle expandos) + "object": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000", + "applet": true + }, + + hasData: function( elem ) { + elem = elem.nodeType ? jQuery.cache[ elem[jQuery.expando] ] : elem[ jQuery.expando ]; + return !!elem && !isEmptyDataObject( elem ); + }, + + data: function( elem, name, data, pvt /* Internal Use Only */ ) { + if ( !jQuery.acceptData( elem ) ) { + return; + } + + var thisCache, ret, + internalKey = jQuery.expando, + getByName = typeof name === "string", + + // We have to handle DOM nodes and JS objects differently because IE6-7 + // can't GC object references properly across the DOM-JS boundary + isNode = elem.nodeType, + + // Only DOM nodes need the global jQuery cache; JS object data is + // attached directly to the object so GC can occur automatically + cache = isNode ? jQuery.cache : elem, + + // Only defining an ID for JS objects if its cache already exists allows + // the code to shortcut on the same path as a DOM node with no cache + id = isNode ? elem[ internalKey ] : elem[ internalKey ] && internalKey; + + // Avoid doing any more work than we need to when trying to get data on an + // object that has no data at all + if ( (!id || !cache[id] || (!pvt && !cache[id].data)) && getByName && data === undefined ) { + return; + } + + if ( !id ) { + // Only DOM nodes need a new unique ID for each element since their data + // ends up in the global cache + if ( isNode ) { + elem[ internalKey ] = id = jQuery.deletedIds.pop() || jQuery.guid++; + } else { + id = internalKey; + } + } + + if ( !cache[ id ] ) { + cache[ id ] = {}; + + // Avoids exposing jQuery metadata on plain JS objects when the object + // is serialized using JSON.stringify + if ( !isNode ) { + cache[ id ].toJSON = jQuery.noop; + } + } + + // An object can be passed to jQuery.data instead of a key/value pair; this gets + // shallow copied over onto the existing cache + if ( typeof name === "object" || typeof name === "function" ) { + if ( pvt ) { + cache[ id ] = jQuery.extend( cache[ id ], name ); + } else { + cache[ id ].data = jQuery.extend( cache[ id ].data, name ); + } + } + + thisCache = cache[ id ]; + + // jQuery data() is stored in a separate object inside the object's internal data + // cache in order to avoid key collisions between internal data and user-defined + // data. + if ( !pvt ) { + if ( !thisCache.data ) { + thisCache.data = {}; + } + + thisCache = thisCache.data; + } + + if ( data !== undefined ) { + thisCache[ jQuery.camelCase( name ) ] = data; + } + + // Check for both converted-to-camel and non-converted data property names + // If a data property was specified + if ( getByName ) { + + // First Try to find as-is property data + ret = thisCache[ name ]; + + // Test for null|undefined property data + if ( ret == null ) { + + // Try to find the camelCased property + ret = thisCache[ jQuery.camelCase( name ) ]; + } + } else { + ret = thisCache; + } + + return ret; + }, + + removeData: function( elem, name, pvt /* Internal Use Only */ ) { + if ( !jQuery.acceptData( elem ) ) { + return; + } + + var thisCache, i, l, + + isNode = elem.nodeType, + + // See jQuery.data for more information + cache = isNode ? jQuery.cache : elem, + id = isNode ? elem[ jQuery.expando ] : jQuery.expando; + + // If there is already no cache entry for this object, there is no + // purpose in continuing + if ( !cache[ id ] ) { + return; + } + + if ( name ) { + + thisCache = pvt ? cache[ id ] : cache[ id ].data; + + if ( thisCache ) { + + // Support array or space separated string names for data keys + if ( !jQuery.isArray( name ) ) { + + // try the string as a key before any manipulation + if ( name in thisCache ) { + name = [ name ]; + } else { + + // split the camel cased version by spaces unless a key with the spaces exists + name = jQuery.camelCase( name ); + if ( name in thisCache ) { + name = [ name ]; + } else { + name = name.split(" "); + } + } + } + + for ( i = 0, l = name.length; i < l; i++ ) { + delete thisCache[ name[i] ]; + } + + // If there is no data left in the cache, we want to continue + // and let the cache object itself get destroyed + if ( !( pvt ? isEmptyDataObject : jQuery.isEmptyObject )( thisCache ) ) { + return; + } + } + } + + // See jQuery.data for more information + if ( !pvt ) { + delete cache[ id ].data; + + // Don't destroy the parent cache unless the internal data object + // had been the only thing left in it + if ( !isEmptyDataObject( cache[ id ] ) ) { + return; + } + } + + // Destroy the cache + if ( isNode ) { + jQuery.cleanData( [ elem ], true ); + + // Use delete when supported for expandos or `cache` is not a window per isWindow (#10080) + } else if ( jQuery.support.deleteExpando || cache != cache.window ) { + delete cache[ id ]; + + // When all else fails, null + } else { + cache[ id ] = null; + } + }, + + // For internal use only. + _data: function( elem, name, data ) { + return jQuery.data( elem, name, data, true ); + }, + + // A method for determining if a DOM node can handle the data expando + acceptData: function( elem ) { + var noData = elem.nodeName && jQuery.noData[ elem.nodeName.toLowerCase() ]; + + // nodes accept data unless otherwise specified; rejection can be conditional + return !noData || noData !== true && elem.getAttribute("classid") === noData; + } +}); + +jQuery.fn.extend({ + data: function( key, value ) { + var parts, part, attr, name, l, + elem = this[0], + i = 0, + data = null; + + // Gets all values + if ( key === undefined ) { + if ( this.length ) { + data = jQuery.data( elem ); + + if ( elem.nodeType === 1 && !jQuery._data( elem, "parsedAttrs" ) ) { + attr = elem.attributes; + for ( l = attr.length; i < l; i++ ) { + name = attr[i].name; + + if ( !name.indexOf( "data-" ) ) { + name = jQuery.camelCase( name.substring(5) ); + + dataAttr( elem, name, data[ name ] ); + } + } + jQuery._data( elem, "parsedAttrs", true ); + } + } + + return data; + } + + // Sets multiple values + if ( typeof key === "object" ) { + return this.each(function() { + jQuery.data( this, key ); + }); + } + + parts = key.split( ".", 2 ); + parts[1] = parts[1] ? "." + parts[1] : ""; + part = parts[1] + "!"; + + return jQuery.access( this, function( value ) { + + if ( value === undefined ) { + data = this.triggerHandler( "getData" + part, [ parts[0] ] ); + + // Try to fetch any internally stored data first + if ( data === undefined && elem ) { + data = jQuery.data( elem, key ); + data = dataAttr( elem, key, data ); + } + + return data === undefined && parts[1] ? + this.data( parts[0] ) : + data; + } + + parts[1] = value; + this.each(function() { + var self = jQuery( this ); + + self.triggerHandler( "setData" + part, parts ); + jQuery.data( this, key, value ); + self.triggerHandler( "changeData" + part, parts ); + }); + }, null, value, arguments.length > 1, null, false ); + }, + + removeData: function( key ) { + return this.each(function() { + jQuery.removeData( this, key ); + }); + } +}); + +function dataAttr( elem, key, data ) { + // If nothing was found internally, try to fetch any + // data from the HTML5 data-* attribute + if ( data === undefined && elem.nodeType === 1 ) { + + var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); + + data = elem.getAttribute( name ); + + if ( typeof data === "string" ) { + try { + data = data === "true" ? true : + data === "false" ? false : + data === "null" ? null : + // Only convert to a number if it doesn't change the string + +data + "" === data ? +data : + rbrace.test( data ) ? jQuery.parseJSON( data ) : + data; + } catch( e ) {} + + // Make sure we set the data so it isn't changed later + jQuery.data( elem, key, data ); + + } else { + data = undefined; + } + } + + return data; +} + +// checks a cache object for emptiness +function isEmptyDataObject( obj ) { + var name; + for ( name in obj ) { + + // if the public data object is empty, the private is still empty + if ( name === "data" && jQuery.isEmptyObject( obj[name] ) ) { + continue; + } + if ( name !== "toJSON" ) { + return false; + } + } + + return true; +} +jQuery.extend({ + queue: function( elem, type, data ) { + var queue; + + if ( elem ) { + type = ( type || "fx" ) + "queue"; + queue = jQuery._data( elem, type ); + + // Speed up dequeue by getting out quickly if this is just a lookup + if ( data ) { + if ( !queue || jQuery.isArray(data) ) { + queue = jQuery._data( elem, type, jQuery.makeArray(data) ); + } else { + queue.push( data ); + } + } + return queue || []; + } + }, + + dequeue: function( elem, type ) { + type = type || "fx"; + + var queue = jQuery.queue( elem, type ), + startLength = queue.length, + fn = queue.shift(), + hooks = jQuery._queueHooks( elem, type ), + next = function() { + jQuery.dequeue( elem, type ); + }; + + // If the fx queue is dequeued, always remove the progress sentinel + if ( fn === "inprogress" ) { + fn = queue.shift(); + startLength--; + } + + if ( fn ) { + + // Add a progress sentinel to prevent the fx queue from being + // automatically dequeued + if ( type === "fx" ) { + queue.unshift( "inprogress" ); + } + + // clear up the last queue stop function + delete hooks.stop; + fn.call( elem, next, hooks ); + } + + if ( !startLength && hooks ) { + hooks.empty.fire(); + } + }, + + // not intended for public consumption - generates a queueHooks object, or returns the current one + _queueHooks: function( elem, type ) { + var key = type + "queueHooks"; + return jQuery._data( elem, key ) || jQuery._data( elem, key, { + empty: jQuery.Callbacks("once memory").add(function() { + jQuery.removeData( elem, type + "queue", true ); + jQuery.removeData( elem, key, true ); + }) + }); + } +}); + +jQuery.fn.extend({ + queue: function( type, data ) { + var setter = 2; + + if ( typeof type !== "string" ) { + data = type; + type = "fx"; + setter--; + } + + if ( arguments.length < setter ) { + return jQuery.queue( this[0], type ); + } + + return data === undefined ? + this : + this.each(function() { + var queue = jQuery.queue( this, type, data ); + + // ensure a hooks for this queue + jQuery._queueHooks( this, type ); + + if ( type === "fx" && queue[0] !== "inprogress" ) { + jQuery.dequeue( this, type ); + } + }); + }, + dequeue: function( type ) { + return this.each(function() { + jQuery.dequeue( this, type ); + }); + }, + // Based off of the plugin by Clint Helfers, with permission. + // http://blindsignals.com/index.php/2009/07/jquery-delay/ + delay: function( time, type ) { + time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; + type = type || "fx"; + + return this.queue( type, function( next, hooks ) { + var timeout = setTimeout( next, time ); + hooks.stop = function() { + clearTimeout( timeout ); + }; + }); + }, + clearQueue: function( type ) { + return this.queue( type || "fx", [] ); + }, + // Get a promise resolved when queues of a certain type + // are emptied (fx is the type by default) + promise: function( type, obj ) { + var tmp, + count = 1, + defer = jQuery.Deferred(), + elements = this, + i = this.length, + resolve = function() { + if ( !( --count ) ) { + defer.resolveWith( elements, [ elements ] ); + } + }; + + if ( typeof type !== "string" ) { + obj = type; + type = undefined; + } + type = type || "fx"; + + while( i-- ) { + tmp = jQuery._data( elements[ i ], type + "queueHooks" ); + if ( tmp && tmp.empty ) { + count++; + tmp.empty.add( resolve ); + } + } + resolve(); + return defer.promise( obj ); + } +}); +var nodeHook, boolHook, fixSpecified, + rclass = /[\t\r\n]/g, + rreturn = /\r/g, + rtype = /^(?:button|input)$/i, + rfocusable = /^(?:button|input|object|select|textarea)$/i, + rclickable = /^a(?:rea|)$/i, + rboolean = /^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i, + getSetAttribute = jQuery.support.getSetAttribute; + +jQuery.fn.extend({ + attr: function( name, value ) { + return jQuery.access( this, jQuery.attr, name, value, arguments.length > 1 ); + }, + + removeAttr: function( name ) { + return this.each(function() { + jQuery.removeAttr( this, name ); + }); + }, + + prop: function( name, value ) { + return jQuery.access( this, jQuery.prop, name, value, arguments.length > 1 ); + }, + + removeProp: function( name ) { + name = jQuery.propFix[ name ] || name; + return this.each(function() { + // try/catch handles cases where IE balks (such as removing a property on window) + try { + this[ name ] = undefined; + delete this[ name ]; + } catch( e ) {} + }); + }, + + addClass: function( value ) { + var classNames, i, l, elem, + setClass, c, cl; + + if ( jQuery.isFunction( value ) ) { + return this.each(function( j ) { + jQuery( this ).addClass( value.call(this, j, this.className) ); + }); + } + + if ( value && typeof value === "string" ) { + classNames = value.split( core_rspace ); + + for ( i = 0, l = this.length; i < l; i++ ) { + elem = this[ i ]; + + if ( elem.nodeType === 1 ) { + if ( !elem.className && classNames.length === 1 ) { + elem.className = value; + + } else { + setClass = " " + elem.className + " "; + + for ( c = 0, cl = classNames.length; c < cl; c++ ) { + if ( setClass.indexOf( " " + classNames[ c ] + " " ) < 0 ) { + setClass += classNames[ c ] + " "; + } + } + elem.className = jQuery.trim( setClass ); + } + } + } + } + + return this; + }, + + removeClass: function( value ) { + var removes, className, elem, c, cl, i, l; + + if ( jQuery.isFunction( value ) ) { + return this.each(function( j ) { + jQuery( this ).removeClass( value.call(this, j, this.className) ); + }); + } + if ( (value && typeof value === "string") || value === undefined ) { + removes = ( value || "" ).split( core_rspace ); + + for ( i = 0, l = this.length; i < l; i++ ) { + elem = this[ i ]; + if ( elem.nodeType === 1 && elem.className ) { + + className = (" " + elem.className + " ").replace( rclass, " " ); + + // loop over each item in the removal list + for ( c = 0, cl = removes.length; c < cl; c++ ) { + // Remove until there is nothing to remove, + while ( className.indexOf(" " + removes[ c ] + " ") >= 0 ) { + className = className.replace( " " + removes[ c ] + " " , " " ); + } + } + elem.className = value ? jQuery.trim( className ) : ""; + } + } + } + + return this; + }, + + toggleClass: function( value, stateVal ) { + var type = typeof value, + isBool = typeof stateVal === "boolean"; + + if ( jQuery.isFunction( value ) ) { + return this.each(function( i ) { + jQuery( this ).toggleClass( value.call(this, i, this.className, stateVal), stateVal ); + }); + } + + return this.each(function() { + if ( type === "string" ) { + // toggle individual class names + var className, + i = 0, + self = jQuery( this ), + state = stateVal, + classNames = value.split( core_rspace ); + + while ( (className = classNames[ i++ ]) ) { + // check each className given, space separated list + state = isBool ? state : !self.hasClass( className ); + self[ state ? "addClass" : "removeClass" ]( className ); + } + + } else if ( type === "undefined" || type === "boolean" ) { + if ( this.className ) { + // store className if set + jQuery._data( this, "__className__", this.className ); + } + + // toggle whole className + this.className = this.className || value === false ? "" : jQuery._data( this, "__className__" ) || ""; + } + }); + }, + + hasClass: function( selector ) { + var className = " " + selector + " ", + i = 0, + l = this.length; + for ( ; i < l; i++ ) { + if ( this[i].nodeType === 1 && (" " + this[i].className + " ").replace(rclass, " ").indexOf( className ) >= 0 ) { + return true; + } + } + + return false; + }, + + val: function( value ) { + var hooks, ret, isFunction, + elem = this[0]; + + if ( !arguments.length ) { + if ( elem ) { + hooks = jQuery.valHooks[ elem.type ] || jQuery.valHooks[ elem.nodeName.toLowerCase() ]; + + if ( hooks && "get" in hooks && (ret = hooks.get( elem, "value" )) !== undefined ) { + return ret; + } + + ret = elem.value; + + return typeof ret === "string" ? + // handle most common string cases + ret.replace(rreturn, "") : + // handle cases where value is null/undef or number + ret == null ? "" : ret; + } + + return; + } + + isFunction = jQuery.isFunction( value ); + + return this.each(function( i ) { + var val, + self = jQuery(this); + + if ( this.nodeType !== 1 ) { + return; + } + + if ( isFunction ) { + val = value.call( this, i, self.val() ); + } else { + val = value; + } + + // Treat null/undefined as ""; convert numbers to string + if ( val == null ) { + val = ""; + } else if ( typeof val === "number" ) { + val += ""; + } else if ( jQuery.isArray( val ) ) { + val = jQuery.map(val, function ( value ) { + return value == null ? "" : value + ""; + }); + } + + hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ]; + + // If set returns undefined, fall back to normal setting + if ( !hooks || !("set" in hooks) || hooks.set( this, val, "value" ) === undefined ) { + this.value = val; + } + }); + } +}); + +jQuery.extend({ + valHooks: { + option: { + get: function( elem ) { + // attributes.value is undefined in Blackberry 4.7 but + // uses .value. See #6932 + var val = elem.attributes.value; + return !val || val.specified ? elem.value : elem.text; + } + }, + select: { + get: function( elem ) { + var value, i, max, option, + index = elem.selectedIndex, + values = [], + options = elem.options, + one = elem.type === "select-one"; + + // Nothing was selected + if ( index < 0 ) { + return null; + } + + // Loop through all the selected options + i = one ? index : 0; + max = one ? index + 1 : options.length; + for ( ; i < max; i++ ) { + option = options[ i ]; + + // Don't return options that are disabled or in a disabled optgroup + if ( option.selected && (jQuery.support.optDisabled ? !option.disabled : option.getAttribute("disabled") === null) && + (!option.parentNode.disabled || !jQuery.nodeName( option.parentNode, "optgroup" )) ) { + + // Get the specific value for the option + value = jQuery( option ).val(); + + // We don't need an array for one selects + if ( one ) { + return value; + } + + // Multi-Selects return an array + values.push( value ); + } + } + + // Fixes Bug #2551 -- select.val() broken in IE after form.reset() + if ( one && !values.length && options.length ) { + return jQuery( options[ index ] ).val(); + } + + return values; + }, + + set: function( elem, value ) { + var values = jQuery.makeArray( value ); + + jQuery(elem).find("option").each(function() { + this.selected = jQuery.inArray( jQuery(this).val(), values ) >= 0; + }); + + if ( !values.length ) { + elem.selectedIndex = -1; + } + return values; + } + } + }, + + // Unused in 1.8, left in so attrFn-stabbers won't die; remove in 1.9 + attrFn: {}, + + attr: function( elem, name, value, pass ) { + var ret, hooks, notxml, + nType = elem.nodeType; + + // don't get/set attributes on text, comment and attribute nodes + if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + if ( pass && jQuery.isFunction( jQuery.fn[ name ] ) ) { + return jQuery( elem )[ name ]( value ); + } + + // Fallback to prop when attributes are not supported + if ( typeof elem.getAttribute === "undefined" ) { + return jQuery.prop( elem, name, value ); + } + + notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); + + // All attributes are lowercase + // Grab necessary hook if one is defined + if ( notxml ) { + name = name.toLowerCase(); + hooks = jQuery.attrHooks[ name ] || ( rboolean.test( name ) ? boolHook : nodeHook ); + } + + if ( value !== undefined ) { + + if ( value === null ) { + jQuery.removeAttr( elem, name ); + return; + + } else if ( hooks && "set" in hooks && notxml && (ret = hooks.set( elem, value, name )) !== undefined ) { + return ret; + + } else { + elem.setAttribute( name, value + "" ); + return value; + } + + } else if ( hooks && "get" in hooks && notxml && (ret = hooks.get( elem, name )) !== null ) { + return ret; + + } else { + + ret = elem.getAttribute( name ); + + // Non-existent attributes return null, we normalize to undefined + return ret === null ? + undefined : + ret; + } + }, + + removeAttr: function( elem, value ) { + var propName, attrNames, name, isBool, + i = 0; + + if ( value && elem.nodeType === 1 ) { + + attrNames = value.split( core_rspace ); + + for ( ; i < attrNames.length; i++ ) { + name = attrNames[ i ]; + + if ( name ) { + propName = jQuery.propFix[ name ] || name; + isBool = rboolean.test( name ); + + // See #9699 for explanation of this approach (setting first, then removal) + // Do not do this for boolean attributes (see #10870) + if ( !isBool ) { + jQuery.attr( elem, name, "" ); + } + elem.removeAttribute( getSetAttribute ? name : propName ); + + // Set corresponding property to false for boolean attributes + if ( isBool && propName in elem ) { + elem[ propName ] = false; + } + } + } + } + }, + + attrHooks: { + type: { + set: function( elem, value ) { + // We can't allow the type property to be changed (since it causes problems in IE) + if ( rtype.test( elem.nodeName ) && elem.parentNode ) { + jQuery.error( "type property can't be changed" ); + } else if ( !jQuery.support.radioValue && value === "radio" && jQuery.nodeName(elem, "input") ) { + // Setting the type on a radio button after the value resets the value in IE6-9 + // Reset value to it's default in case type is set after value + // This is for element creation + var val = elem.value; + elem.setAttribute( "type", value ); + if ( val ) { + elem.value = val; + } + return value; + } + } + }, + // Use the value property for back compat + // Use the nodeHook for button elements in IE6/7 (#1954) + value: { + get: function( elem, name ) { + if ( nodeHook && jQuery.nodeName( elem, "button" ) ) { + return nodeHook.get( elem, name ); + } + return name in elem ? + elem.value : + null; + }, + set: function( elem, value, name ) { + if ( nodeHook && jQuery.nodeName( elem, "button" ) ) { + return nodeHook.set( elem, value, name ); + } + // Does not return so that setAttribute is also used + elem.value = value; + } + } + }, + + propFix: { + tabindex: "tabIndex", + readonly: "readOnly", + "for": "htmlFor", + "class": "className", + maxlength: "maxLength", + cellspacing: "cellSpacing", + cellpadding: "cellPadding", + rowspan: "rowSpan", + colspan: "colSpan", + usemap: "useMap", + frameborder: "frameBorder", + contenteditable: "contentEditable" + }, + + prop: function( elem, name, value ) { + var ret, hooks, notxml, + nType = elem.nodeType; + + // don't get/set properties on text, comment and attribute nodes + if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { + return; + } + + notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); + + if ( notxml ) { + // Fix name and attach hooks + name = jQuery.propFix[ name ] || name; + hooks = jQuery.propHooks[ name ]; + } + + if ( value !== undefined ) { + if ( hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ) { + return ret; + + } else { + return ( elem[ name ] = value ); + } + + } else { + if ( hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ) { + return ret; + + } else { + return elem[ name ]; + } + } + }, + + propHooks: { + tabIndex: { + get: function( elem ) { + // elem.tabIndex doesn't always return the correct value when it hasn't been explicitly set + // http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ + var attributeNode = elem.getAttributeNode("tabindex"); + + return attributeNode && attributeNode.specified ? + parseInt( attributeNode.value, 10 ) : + rfocusable.test( elem.nodeName ) || rclickable.test( elem.nodeName ) && elem.href ? + 0 : + undefined; + } + } + } +}); + +// Hook for boolean attributes +boolHook = { + get: function( elem, name ) { + // Align boolean attributes with corresponding properties + // Fall back to attribute presence where some booleans are not supported + var attrNode, + property = jQuery.prop( elem, name ); + return property === true || typeof property !== "boolean" && ( attrNode = elem.getAttributeNode(name) ) && attrNode.nodeValue !== false ? + name.toLowerCase() : + undefined; + }, + set: function( elem, value, name ) { + var propName; + if ( value === false ) { + // Remove boolean attributes when set to false + jQuery.removeAttr( elem, name ); + } else { + // value is true since we know at this point it's type boolean and not false + // Set boolean attributes to the same name and set the DOM property + propName = jQuery.propFix[ name ] || name; + if ( propName in elem ) { + // Only set the IDL specifically if it already exists on the element + elem[ propName ] = true; + } + + elem.setAttribute( name, name.toLowerCase() ); + } + return name; + } +}; + +// IE6/7 do not support getting/setting some attributes with get/setAttribute +if ( !getSetAttribute ) { + + fixSpecified = { + name: true, + id: true, + coords: true + }; + + // Use this for any attribute in IE6/7 + // This fixes almost every IE6/7 issue + nodeHook = jQuery.valHooks.button = { + get: function( elem, name ) { + var ret; + ret = elem.getAttributeNode( name ); + return ret && ( fixSpecified[ name ] ? ret.value !== "" : ret.specified ) ? + ret.value : + undefined; + }, + set: function( elem, value, name ) { + // Set the existing or create a new attribute node + var ret = elem.getAttributeNode( name ); + if ( !ret ) { + ret = document.createAttribute( name ); + elem.setAttributeNode( ret ); + } + return ( ret.value = value + "" ); + } + }; + + // Set width and height to auto instead of 0 on empty string( Bug #8150 ) + // This is for removals + jQuery.each([ "width", "height" ], function( i, name ) { + jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], { + set: function( elem, value ) { + if ( value === "" ) { + elem.setAttribute( name, "auto" ); + return value; + } + } + }); + }); + + // Set contenteditable to false on removals(#10429) + // Setting to empty string throws an error as an invalid value + jQuery.attrHooks.contenteditable = { + get: nodeHook.get, + set: function( elem, value, name ) { + if ( value === "" ) { + value = "false"; + } + nodeHook.set( elem, value, name ); + } + }; +} + + +// Some attributes require a special call on IE +if ( !jQuery.support.hrefNormalized ) { + jQuery.each([ "href", "src", "width", "height" ], function( i, name ) { + jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], { + get: function( elem ) { + var ret = elem.getAttribute( name, 2 ); + return ret === null ? undefined : ret; + } + }); + }); +} + +if ( !jQuery.support.style ) { + jQuery.attrHooks.style = { + get: function( elem ) { + // Return undefined in the case of empty string + // Normalize to lowercase since IE uppercases css property names + return elem.style.cssText.toLowerCase() || undefined; + }, + set: function( elem, value ) { + return ( elem.style.cssText = value + "" ); + } + }; +} + +// Safari mis-reports the default selected property of an option +// Accessing the parent's selectedIndex property fixes it +if ( !jQuery.support.optSelected ) { + jQuery.propHooks.selected = jQuery.extend( jQuery.propHooks.selected, { + get: function( elem ) { + var parent = elem.parentNode; + + if ( parent ) { + parent.selectedIndex; + + // Make sure that it also works with optgroups, see #5701 + if ( parent.parentNode ) { + parent.parentNode.selectedIndex; + } + } + return null; + } + }); +} + +// IE6/7 call enctype encoding +if ( !jQuery.support.enctype ) { + jQuery.propFix.enctype = "encoding"; +} + +// Radios and checkboxes getter/setter +if ( !jQuery.support.checkOn ) { + jQuery.each([ "radio", "checkbox" ], function() { + jQuery.valHooks[ this ] = { + get: function( elem ) { + // Handle the case where in Webkit "" is returned instead of "on" if a value isn't specified + return elem.getAttribute("value") === null ? "on" : elem.value; + } + }; + }); +} +jQuery.each([ "radio", "checkbox" ], function() { + jQuery.valHooks[ this ] = jQuery.extend( jQuery.valHooks[ this ], { + set: function( elem, value ) { + if ( jQuery.isArray( value ) ) { + return ( elem.checked = jQuery.inArray( jQuery(elem).val(), value ) >= 0 ); + } + } + }); +}); +var rformElems = /^(?:textarea|input|select)$/i, + rtypenamespace = /^([^\.]*|)(?:\.(.+)|)$/, + rhoverHack = /(?:^|\s)hover(\.\S+|)\b/, + rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|contextmenu)|click/, + rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, + hoverHack = function( events ) { + return jQuery.event.special.hover ? events : events.replace( rhoverHack, "mouseenter$1 mouseleave$1" ); + }; + +/* + * Helper functions for managing events -- not part of the public interface. + * Props to Dean Edwards' addEvent library for many of the ideas. + */ +jQuery.event = { + + add: function( elem, types, handler, data, selector ) { + + var elemData, eventHandle, events, + t, tns, type, namespaces, handleObj, + handleObjIn, handlers, special; + + // Don't attach events to noData or text/comment nodes (allow plain objects tho) + if ( elem.nodeType === 3 || elem.nodeType === 8 || !types || !handler || !(elemData = jQuery._data( elem )) ) { + return; + } + + // Caller can pass in an object of custom data in lieu of the handler + if ( handler.handler ) { + handleObjIn = handler; + handler = handleObjIn.handler; + selector = handleObjIn.selector; + } + + // Make sure that the handler has a unique ID, used to find/remove it later + if ( !handler.guid ) { + handler.guid = jQuery.guid++; + } + + // Init the element's event structure and main handler, if this is the first + events = elemData.events; + if ( !events ) { + elemData.events = events = {}; + } + eventHandle = elemData.handle; + if ( !eventHandle ) { + elemData.handle = eventHandle = function( e ) { + // Discard the second event of a jQuery.event.trigger() and + // when an event is called after a page has unloaded + return typeof jQuery !== "undefined" && (!e || jQuery.event.triggered !== e.type) ? + jQuery.event.dispatch.apply( eventHandle.elem, arguments ) : + undefined; + }; + // Add elem as a property of the handle fn to prevent a memory leak with IE non-native events + eventHandle.elem = elem; + } + + // Handle multiple events separated by a space + // jQuery(...).bind("mouseover mouseout", fn); + types = jQuery.trim( hoverHack(types) ).split( " " ); + for ( t = 0; t < types.length; t++ ) { + + tns = rtypenamespace.exec( types[t] ) || []; + type = tns[1]; + namespaces = ( tns[2] || "" ).split( "." ).sort(); + + // If event changes its type, use the special event handlers for the changed type + special = jQuery.event.special[ type ] || {}; + + // If selector defined, determine special event api type, otherwise given type + type = ( selector ? special.delegateType : special.bindType ) || type; + + // Update special based on newly reset type + special = jQuery.event.special[ type ] || {}; + + // handleObj is passed to all event handlers + handleObj = jQuery.extend({ + type: type, + origType: tns[1], + data: data, + handler: handler, + guid: handler.guid, + selector: selector, + needsContext: selector && jQuery.expr.match.needsContext.test( selector ), + namespace: namespaces.join(".") + }, handleObjIn ); + + // Init the event handler queue if we're the first + handlers = events[ type ]; + if ( !handlers ) { + handlers = events[ type ] = []; + handlers.delegateCount = 0; + + // Only use addEventListener/attachEvent if the special events handler returns false + if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + // Bind the global event handler to the element + if ( elem.addEventListener ) { + elem.addEventListener( type, eventHandle, false ); + + } else if ( elem.attachEvent ) { + elem.attachEvent( "on" + type, eventHandle ); + } + } + } + + if ( special.add ) { + special.add.call( elem, handleObj ); + + if ( !handleObj.handler.guid ) { + handleObj.handler.guid = handler.guid; + } + } + + // Add to the element's handler list, delegates in front + if ( selector ) { + handlers.splice( handlers.delegateCount++, 0, handleObj ); + } else { + handlers.push( handleObj ); + } + + // Keep track of which events have ever been used, for event optimization + jQuery.event.global[ type ] = true; + } + + // Nullify elem to prevent memory leaks in IE + elem = null; + }, + + global: {}, + + // Detach an event or set of events from an element + remove: function( elem, types, handler, selector, mappedTypes ) { + + var t, tns, type, origType, namespaces, origCount, + j, events, special, eventType, handleObj, + elemData = jQuery.hasData( elem ) && jQuery._data( elem ); + + if ( !elemData || !(events = elemData.events) ) { + return; + } + + // Once for each type.namespace in types; type may be omitted + types = jQuery.trim( hoverHack( types || "" ) ).split(" "); + for ( t = 0; t < types.length; t++ ) { + tns = rtypenamespace.exec( types[t] ) || []; + type = origType = tns[1]; + namespaces = tns[2]; + + // Unbind all events (on this namespace, if provided) for the element + if ( !type ) { + for ( type in events ) { + jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); + } + continue; + } + + special = jQuery.event.special[ type ] || {}; + type = ( selector? special.delegateType : special.bindType ) || type; + eventType = events[ type ] || []; + origCount = eventType.length; + namespaces = namespaces ? new RegExp("(^|\\.)" + namespaces.split(".").sort().join("\\.(?:.*\\.|)") + "(\\.|$)") : null; + + // Remove matching events + for ( j = 0; j < eventType.length; j++ ) { + handleObj = eventType[ j ]; + + if ( ( mappedTypes || origType === handleObj.origType ) && + ( !handler || handler.guid === handleObj.guid ) && + ( !namespaces || namespaces.test( handleObj.namespace ) ) && + ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { + eventType.splice( j--, 1 ); + + if ( handleObj.selector ) { + eventType.delegateCount--; + } + if ( special.remove ) { + special.remove.call( elem, handleObj ); + } + } + } + + // Remove generic event handler if we removed something and no more handlers exist + // (avoids potential for endless recursion during removal of special event handlers) + if ( eventType.length === 0 && origCount !== eventType.length ) { + if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + jQuery.removeEvent( elem, type, elemData.handle ); + } + + delete events[ type ]; + } + } + + // Remove the expando if it's no longer used + if ( jQuery.isEmptyObject( events ) ) { + delete elemData.handle; + + // removeData also checks for emptiness and clears the expando if empty + // so use it instead of delete + jQuery.removeData( elem, "events", true ); + } + }, + + // Events that are safe to short-circuit if no handlers are attached. + // Native DOM events should not be added, they may have inline handlers. + customEvent: { + "getData": true, + "setData": true, + "changeData": true + }, + + trigger: function( event, data, elem, onlyHandlers ) { + // Don't do events on text and comment nodes + if ( elem && (elem.nodeType === 3 || elem.nodeType === 8) ) { + return; + } + + // Event object or event type + var cache, exclusive, i, cur, old, ontype, special, handle, eventPath, bubbleType, + type = event.type || event, + namespaces = []; + + // focus/blur morphs to focusin/out; ensure we're not firing them right now + if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { + return; + } + + if ( type.indexOf( "!" ) >= 0 ) { + // Exclusive events trigger only for the exact event (no namespaces) + type = type.slice(0, -1); + exclusive = true; + } + + if ( type.indexOf( "." ) >= 0 ) { + // Namespaced trigger; create a regexp to match event type in handle() + namespaces = type.split("."); + type = namespaces.shift(); + namespaces.sort(); + } + + if ( (!elem || jQuery.event.customEvent[ type ]) && !jQuery.event.global[ type ] ) { + // No jQuery handlers for this event type, and it can't have inline handlers + return; + } + + // Caller can pass in an Event, Object, or just an event type string + event = typeof event === "object" ? + // jQuery.Event object + event[ jQuery.expando ] ? event : + // Object literal + new jQuery.Event( type, event ) : + // Just the event type (string) + new jQuery.Event( type ); + + event.type = type; + event.isTrigger = true; + event.exclusive = exclusive; + event.namespace = namespaces.join( "." ); + event.namespace_re = event.namespace? new RegExp("(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)") : null; + ontype = type.indexOf( ":" ) < 0 ? "on" + type : ""; + + // Handle a global trigger + if ( !elem ) { + + // TODO: Stop taunting the data cache; remove global events and always attach to document + cache = jQuery.cache; + for ( i in cache ) { + if ( cache[ i ].events && cache[ i ].events[ type ] ) { + jQuery.event.trigger( event, data, cache[ i ].handle.elem, true ); + } + } + return; + } + + // Clean up the event in case it is being reused + event.result = undefined; + if ( !event.target ) { + event.target = elem; + } + + // Clone any incoming data and prepend the event, creating the handler arg list + data = data != null ? jQuery.makeArray( data ) : []; + data.unshift( event ); + + // Allow special events to draw outside the lines + special = jQuery.event.special[ type ] || {}; + if ( special.trigger && special.trigger.apply( elem, data ) === false ) { + return; + } + + // Determine event propagation path in advance, per W3C events spec (#9951) + // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) + eventPath = [[ elem, special.bindType || type ]]; + if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { + + bubbleType = special.delegateType || type; + cur = rfocusMorph.test( bubbleType + type ) ? elem : elem.parentNode; + for ( old = elem; cur; cur = cur.parentNode ) { + eventPath.push([ cur, bubbleType ]); + old = cur; + } + + // Only add window if we got to document (e.g., not plain obj or detached DOM) + if ( old === (elem.ownerDocument || document) ) { + eventPath.push([ old.defaultView || old.parentWindow || window, bubbleType ]); + } + } + + // Fire handlers on the event path + for ( i = 0; i < eventPath.length && !event.isPropagationStopped(); i++ ) { + + cur = eventPath[i][0]; + event.type = eventPath[i][1]; + + handle = ( jQuery._data( cur, "events" ) || {} )[ event.type ] && jQuery._data( cur, "handle" ); + if ( handle ) { + handle.apply( cur, data ); + } + // Note that this is a bare JS function and not a jQuery handler + handle = ontype && cur[ ontype ]; + if ( handle && jQuery.acceptData( cur ) && handle.apply && handle.apply( cur, data ) === false ) { + event.preventDefault(); + } + } + event.type = type; + + // If nobody prevented the default action, do it now + if ( !onlyHandlers && !event.isDefaultPrevented() ) { + + if ( (!special._default || special._default.apply( elem.ownerDocument, data ) === false) && + !(type === "click" && jQuery.nodeName( elem, "a" )) && jQuery.acceptData( elem ) ) { + + // Call a native DOM method on the target with the same name name as the event. + // Can't use an .isFunction() check here because IE6/7 fails that test. + // Don't do default actions on window, that's where global variables be (#6170) + // IE<9 dies on focus/blur to hidden element (#1486) + if ( ontype && elem[ type ] && ((type !== "focus" && type !== "blur") || event.target.offsetWidth !== 0) && !jQuery.isWindow( elem ) ) { + + // Don't re-trigger an onFOO event when we call its FOO() method + old = elem[ ontype ]; + + if ( old ) { + elem[ ontype ] = null; + } + + // Prevent re-triggering of the same event, since we already bubbled it above + jQuery.event.triggered = type; + elem[ type ](); + jQuery.event.triggered = undefined; + + if ( old ) { + elem[ ontype ] = old; + } + } + } + } + + return event.result; + }, + + dispatch: function( event ) { + + // Make a writable jQuery.Event from the native event object + event = jQuery.event.fix( event || window.event ); + + var i, j, cur, ret, selMatch, matched, matches, handleObj, sel, related, + handlers = ( (jQuery._data( this, "events" ) || {} )[ event.type ] || []), + delegateCount = handlers.delegateCount, + args = core_slice.call( arguments ), + run_all = !event.exclusive && !event.namespace, + special = jQuery.event.special[ event.type ] || {}, + handlerQueue = []; + + // Use the fix-ed jQuery.Event rather than the (read-only) native event + args[0] = event; + event.delegateTarget = this; + + // Call the preDispatch hook for the mapped type, and let it bail if desired + if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { + return; + } + + // Determine handlers that should run if there are delegated events + // Avoid non-left-click bubbling in Firefox (#3861) + if ( delegateCount && !(event.button && event.type === "click") ) { + + for ( cur = event.target; cur != this; cur = cur.parentNode || this ) { + + // Don't process clicks (ONLY) on disabled elements (#6911, #8165, #11382, #11764) + if ( cur.disabled !== true || event.type !== "click" ) { + selMatch = {}; + matches = []; + for ( i = 0; i < delegateCount; i++ ) { + handleObj = handlers[ i ]; + sel = handleObj.selector; + + if ( selMatch[ sel ] === undefined ) { + selMatch[ sel ] = handleObj.needsContext ? + jQuery( sel, this ).index( cur ) >= 0 : + jQuery.find( sel, this, null, [ cur ] ).length; + } + if ( selMatch[ sel ] ) { + matches.push( handleObj ); + } + } + if ( matches.length ) { + handlerQueue.push({ elem: cur, matches: matches }); + } + } + } + } + + // Add the remaining (directly-bound) handlers + if ( handlers.length > delegateCount ) { + handlerQueue.push({ elem: this, matches: handlers.slice( delegateCount ) }); + } + + // Run delegates first; they may want to stop propagation beneath us + for ( i = 0; i < handlerQueue.length && !event.isPropagationStopped(); i++ ) { + matched = handlerQueue[ i ]; + event.currentTarget = matched.elem; + + for ( j = 0; j < matched.matches.length && !event.isImmediatePropagationStopped(); j++ ) { + handleObj = matched.matches[ j ]; + + // Triggered event must either 1) be non-exclusive and have no namespace, or + // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace). + if ( run_all || (!event.namespace && !handleObj.namespace) || event.namespace_re && event.namespace_re.test( handleObj.namespace ) ) { + + event.data = handleObj.data; + event.handleObj = handleObj; + + ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) + .apply( matched.elem, args ); + + if ( ret !== undefined ) { + event.result = ret; + if ( ret === false ) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + } + + // Call the postDispatch hook for the mapped type + if ( special.postDispatch ) { + special.postDispatch.call( this, event ); + } + + return event.result; + }, + + // Includes some event props shared by KeyEvent and MouseEvent + // *** attrChange attrName relatedNode srcElement are not normalized, non-W3C, deprecated, will be removed in 1.8 *** + props: "attrChange attrName relatedNode srcElement altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), + + fixHooks: {}, + + keyHooks: { + props: "char charCode key keyCode".split(" "), + filter: function( event, original ) { + + // Add which for key events + if ( event.which == null ) { + event.which = original.charCode != null ? original.charCode : original.keyCode; + } + + return event; + } + }, + + mouseHooks: { + props: "button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "), + filter: function( event, original ) { + var eventDoc, doc, body, + button = original.button, + fromElement = original.fromElement; + + // Calculate pageX/Y if missing and clientX/Y available + if ( event.pageX == null && original.clientX != null ) { + eventDoc = event.target.ownerDocument || document; + doc = eventDoc.documentElement; + body = eventDoc.body; + + event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); + event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); + } + + // Add relatedTarget, if necessary + if ( !event.relatedTarget && fromElement ) { + event.relatedTarget = fromElement === event.target ? original.toElement : fromElement; + } + + // Add which for click: 1 === left; 2 === middle; 3 === right + // Note: button is not normalized, so don't use it + if ( !event.which && button !== undefined ) { + event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); + } + + return event; + } + }, + + fix: function( event ) { + if ( event[ jQuery.expando ] ) { + return event; + } + + // Create a writable copy of the event object and normalize some properties + var i, prop, + originalEvent = event, + fixHook = jQuery.event.fixHooks[ event.type ] || {}, + copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; + + event = jQuery.Event( originalEvent ); + + for ( i = copy.length; i; ) { + prop = copy[ --i ]; + event[ prop ] = originalEvent[ prop ]; + } + + // Fix target property, if necessary (#1925, IE 6/7/8 & Safari2) + if ( !event.target ) { + event.target = originalEvent.srcElement || document; + } + + // Target should not be a text node (#504, Safari) + if ( event.target.nodeType === 3 ) { + event.target = event.target.parentNode; + } + + // For mouse/key events, metaKey==false if it's undefined (#3368, #11328; IE6/7/8) + event.metaKey = !!event.metaKey; + + return fixHook.filter? fixHook.filter( event, originalEvent ) : event; + }, + + special: { + load: { + // Prevent triggered image.load events from bubbling to window.load + noBubble: true + }, + + focus: { + delegateType: "focusin" + }, + blur: { + delegateType: "focusout" + }, + + beforeunload: { + setup: function( data, namespaces, eventHandle ) { + // We only want to do this special case on windows + if ( jQuery.isWindow( this ) ) { + this.onbeforeunload = eventHandle; + } + }, + + teardown: function( namespaces, eventHandle ) { + if ( this.onbeforeunload === eventHandle ) { + this.onbeforeunload = null; + } + } + } + }, + + simulate: function( type, elem, event, bubble ) { + // Piggyback on a donor event to simulate a different one. + // Fake originalEvent to avoid donor's stopPropagation, but if the + // simulated event prevents default then we do the same on the donor. + var e = jQuery.extend( + new jQuery.Event(), + event, + { type: type, + isSimulated: true, + originalEvent: {} + } + ); + if ( bubble ) { + jQuery.event.trigger( e, null, elem ); + } else { + jQuery.event.dispatch.call( elem, e ); + } + if ( e.isDefaultPrevented() ) { + event.preventDefault(); + } + } +}; + +// Some plugins are using, but it's undocumented/deprecated and will be removed. +// The 1.7 special event interface should provide all the hooks needed now. +jQuery.event.handle = jQuery.event.dispatch; + +jQuery.removeEvent = document.removeEventListener ? + function( elem, type, handle ) { + if ( elem.removeEventListener ) { + elem.removeEventListener( type, handle, false ); + } + } : + function( elem, type, handle ) { + var name = "on" + type; + + if ( elem.detachEvent ) { + + // #8545, #7054, preventing memory leaks for custom events in IE6-8 – + // detachEvent needed property on element, by name of that event, to properly expose it to GC + if ( typeof elem[ name ] === "undefined" ) { + elem[ name ] = null; + } + + elem.detachEvent( name, handle ); + } + }; + +jQuery.Event = function( src, props ) { + // Allow instantiation without the 'new' keyword + if ( !(this instanceof jQuery.Event) ) { + return new jQuery.Event( src, props ); + } + + // Event object + if ( src && src.type ) { + this.originalEvent = src; + this.type = src.type; + + // Events bubbling up the document may have been marked as prevented + // by a handler lower down the tree; reflect the correct value. + this.isDefaultPrevented = ( src.defaultPrevented || src.returnValue === false || + src.getPreventDefault && src.getPreventDefault() ) ? returnTrue : returnFalse; + + // Event type + } else { + this.type = src; + } + + // Put explicitly provided properties onto the event object + if ( props ) { + jQuery.extend( this, props ); + } + + // Create a timestamp if incoming event doesn't have one + this.timeStamp = src && src.timeStamp || jQuery.now(); + + // Mark it as fixed + this[ jQuery.expando ] = true; +}; + +function returnFalse() { + return false; +} +function returnTrue() { + return true; +} + +// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding +// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html +jQuery.Event.prototype = { + preventDefault: function() { + this.isDefaultPrevented = returnTrue; + + var e = this.originalEvent; + if ( !e ) { + return; + } + + // if preventDefault exists run it on the original event + if ( e.preventDefault ) { + e.preventDefault(); + + // otherwise set the returnValue property of the original event to false (IE) + } else { + e.returnValue = false; + } + }, + stopPropagation: function() { + this.isPropagationStopped = returnTrue; + + var e = this.originalEvent; + if ( !e ) { + return; + } + // if stopPropagation exists run it on the original event + if ( e.stopPropagation ) { + e.stopPropagation(); + } + // otherwise set the cancelBubble property of the original event to true (IE) + e.cancelBubble = true; + }, + stopImmediatePropagation: function() { + this.isImmediatePropagationStopped = returnTrue; + this.stopPropagation(); + }, + isDefaultPrevented: returnFalse, + isPropagationStopped: returnFalse, + isImmediatePropagationStopped: returnFalse +}; + +// Create mouseenter/leave events using mouseover/out and event-time checks +jQuery.each({ + mouseenter: "mouseover", + mouseleave: "mouseout" +}, function( orig, fix ) { + jQuery.event.special[ orig ] = { + delegateType: fix, + bindType: fix, + + handle: function( event ) { + var ret, + target = this, + related = event.relatedTarget, + handleObj = event.handleObj, + selector = handleObj.selector; + + // For mousenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || (related !== target && !jQuery.contains( target, related )) ) { + event.type = handleObj.origType; + ret = handleObj.handler.apply( this, arguments ); + event.type = fix; + } + return ret; + } + }; +}); + +// IE submit delegation +if ( !jQuery.support.submitBubbles ) { + + jQuery.event.special.submit = { + setup: function() { + // Only need this for delegated form submit events + if ( jQuery.nodeName( this, "form" ) ) { + return false; + } + + // Lazy-add a submit handler when a descendant form may potentially be submitted + jQuery.event.add( this, "click._submit keypress._submit", function( e ) { + // Node name check avoids a VML-related crash in IE (#9807) + var elem = e.target, + form = jQuery.nodeName( elem, "input" ) || jQuery.nodeName( elem, "button" ) ? elem.form : undefined; + if ( form && !jQuery._data( form, "_submit_attached" ) ) { + jQuery.event.add( form, "submit._submit", function( event ) { + event._submit_bubble = true; + }); + jQuery._data( form, "_submit_attached", true ); + } + }); + // return undefined since we don't need an event listener + }, + + postDispatch: function( event ) { + // If form was submitted by the user, bubble the event up the tree + if ( event._submit_bubble ) { + delete event._submit_bubble; + if ( this.parentNode && !event.isTrigger ) { + jQuery.event.simulate( "submit", this.parentNode, event, true ); + } + } + }, + + teardown: function() { + // Only need this for delegated form submit events + if ( jQuery.nodeName( this, "form" ) ) { + return false; + } + + // Remove delegated handlers; cleanData eventually reaps submit handlers attached above + jQuery.event.remove( this, "._submit" ); + } + }; +} + +// IE change delegation and checkbox/radio fix +if ( !jQuery.support.changeBubbles ) { + + jQuery.event.special.change = { + + setup: function() { + + if ( rformElems.test( this.nodeName ) ) { + // IE doesn't fire change on a check/radio until blur; trigger it on click + // after a propertychange. Eat the blur-change in special.change.handle. + // This still fires onchange a second time for check/radio after blur. + if ( this.type === "checkbox" || this.type === "radio" ) { + jQuery.event.add( this, "propertychange._change", function( event ) { + if ( event.originalEvent.propertyName === "checked" ) { + this._just_changed = true; + } + }); + jQuery.event.add( this, "click._change", function( event ) { + if ( this._just_changed && !event.isTrigger ) { + this._just_changed = false; + } + // Allow triggered, simulated change events (#11500) + jQuery.event.simulate( "change", this, event, true ); + }); + } + return false; + } + // Delegated event; lazy-add a change handler on descendant inputs + jQuery.event.add( this, "beforeactivate._change", function( e ) { + var elem = e.target; + + if ( rformElems.test( elem.nodeName ) && !jQuery._data( elem, "_change_attached" ) ) { + jQuery.event.add( elem, "change._change", function( event ) { + if ( this.parentNode && !event.isSimulated && !event.isTrigger ) { + jQuery.event.simulate( "change", this.parentNode, event, true ); + } + }); + jQuery._data( elem, "_change_attached", true ); + } + }); + }, + + handle: function( event ) { + var elem = event.target; + + // Swallow native change events from checkbox/radio, we already triggered them above + if ( this !== elem || event.isSimulated || event.isTrigger || (elem.type !== "radio" && elem.type !== "checkbox") ) { + return event.handleObj.handler.apply( this, arguments ); + } + }, + + teardown: function() { + jQuery.event.remove( this, "._change" ); + + return !rformElems.test( this.nodeName ); + } + }; +} + +// Create "bubbling" focus and blur events +if ( !jQuery.support.focusinBubbles ) { + jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { + + // Attach a single capturing handler while someone wants focusin/focusout + var attaches = 0, + handler = function( event ) { + jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); + }; + + jQuery.event.special[ fix ] = { + setup: function() { + if ( attaches++ === 0 ) { + document.addEventListener( orig, handler, true ); + } + }, + teardown: function() { + if ( --attaches === 0 ) { + document.removeEventListener( orig, handler, true ); + } + } + }; + }); +} + +jQuery.fn.extend({ + + on: function( types, selector, data, fn, /*INTERNAL*/ one ) { + var origFn, type; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { // && selector != null + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + this.on( type, selector, data, types[ type ], one ); + } + return this; + } + + if ( data == null && fn == null ) { + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return this; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return this.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + }); + }, + one: function( types, selector, data, fn ) { + return this.on( types, selector, data, fn, 1 ); + }, + off: function( types, selector, fn ) { + var handleObj, type; + if ( types && types.preventDefault && types.handleObj ) { + // ( event ) dispatched jQuery.Event + handleObj = types.handleObj; + jQuery( types.delegateTarget ).off( + handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, + handleObj.selector, + handleObj.handler + ); + return this; + } + if ( typeof types === "object" ) { + // ( types-object [, selector] ) + for ( type in types ) { + this.off( type, selector, types[ type ] ); + } + return this; + } + if ( selector === false || typeof selector === "function" ) { + // ( types [, fn] ) + fn = selector; + selector = undefined; + } + if ( fn === false ) { + fn = returnFalse; + } + return this.each(function() { + jQuery.event.remove( this, types, fn, selector ); + }); + }, + + bind: function( types, data, fn ) { + return this.on( types, null, data, fn ); + }, + unbind: function( types, fn ) { + return this.off( types, null, fn ); + }, + + live: function( types, data, fn ) { + jQuery( this.context ).on( types, this.selector, data, fn ); + return this; + }, + die: function( types, fn ) { + jQuery( this.context ).off( types, this.selector || "**", fn ); + return this; + }, + + delegate: function( selector, types, data, fn ) { + return this.on( types, selector, data, fn ); + }, + undelegate: function( selector, types, fn ) { + // ( namespace ) or ( selector, types [, fn] ) + return arguments.length === 1 ? this.off( selector, "**" ) : this.off( types, selector || "**", fn ); + }, + + trigger: function( type, data ) { + return this.each(function() { + jQuery.event.trigger( type, data, this ); + }); + }, + triggerHandler: function( type, data ) { + if ( this[0] ) { + return jQuery.event.trigger( type, data, this[0], true ); + } + }, + + toggle: function( fn ) { + // Save reference to arguments for access in closure + var args = arguments, + guid = fn.guid || jQuery.guid++, + i = 0, + toggler = function( event ) { + // Figure out which function to execute + var lastToggle = ( jQuery._data( this, "lastToggle" + fn.guid ) || 0 ) % i; + jQuery._data( this, "lastToggle" + fn.guid, lastToggle + 1 ); + + // Make sure that clicks stop + event.preventDefault(); + + // and execute the function + return args[ lastToggle ].apply( this, arguments ) || false; + }; + + // link all the functions, so any of them can unbind this click handler + toggler.guid = guid; + while ( i < args.length ) { + args[ i++ ].guid = guid; + } + + return this.click( toggler ); + }, + + hover: function( fnOver, fnOut ) { + return this.mouseenter( fnOver ).mouseleave( fnOut || fnOver ); + } +}); + +jQuery.each( ("blur focus focusin focusout load resize scroll unload click dblclick " + + "mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave " + + "change select submit keydown keypress keyup error contextmenu").split(" "), function( i, name ) { + + // Handle event binding + jQuery.fn[ name ] = function( data, fn ) { + if ( fn == null ) { + fn = data; + data = null; + } + + return arguments.length > 0 ? + this.on( name, null, data, fn ) : + this.trigger( name ); + }; + + if ( rkeyEvent.test( name ) ) { + jQuery.event.fixHooks[ name ] = jQuery.event.keyHooks; + } + + if ( rmouseEvent.test( name ) ) { + jQuery.event.fixHooks[ name ] = jQuery.event.mouseHooks; + } +}); +/*! + * Sizzle CSS Selector Engine + * Copyright 2012 jQuery Foundation and other contributors + * Released under the MIT license + * http://sizzlejs.com/ + */ +(function( window, undefined ) { + +var cachedruns, + assertGetIdNotName, + Expr, + getText, + isXML, + contains, + compile, + sortOrder, + hasDuplicate, + outermostContext, + + baseHasDuplicate = true, + strundefined = "undefined", + + expando = ( "sizcache" + Math.random() ).replace( ".", "" ), + + Token = String, + document = window.document, + docElem = document.documentElement, + dirruns = 0, + done = 0, + pop = [].pop, + push = [].push, + slice = [].slice, + // Use a stripped-down indexOf if a native one is unavailable + indexOf = [].indexOf || function( elem ) { + var i = 0, + len = this.length; + for ( ; i < len; i++ ) { + if ( this[i] === elem ) { + return i; + } + } + return -1; + }, + + // Augment a function for special use by Sizzle + markFunction = function( fn, value ) { + fn[ expando ] = value == null || value; + return fn; + }, + + createCache = function() { + var cache = {}, + keys = []; + + return markFunction(function( key, value ) { + // Only keep the most recent entries + if ( keys.push( key ) > Expr.cacheLength ) { + delete cache[ keys.shift() ]; + } + + return (cache[ key ] = value); + }, cache ); + }, + + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + + // Regex + + // Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + // http://www.w3.org/TR/css3-syntax/#characters + characterEncoding = "(?:\\\\.|[-\\w]|[^\\x00-\\xa0])+", + + // Loosely modeled on CSS identifier characters + // An unquoted value should be a CSS identifier (http://www.w3.org/TR/css3-selectors/#attribute-selectors) + // Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier + identifier = characterEncoding.replace( "w", "w#" ), + + // Acceptable operators http://www.w3.org/TR/selectors/#attribute-selectors + operators = "([*^$|!~]?=)", + attributes = "\\[" + whitespace + "*(" + characterEncoding + ")" + whitespace + + "*(?:" + operators + whitespace + "*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|(" + identifier + ")|)|)" + whitespace + "*\\]", + + // Prefer arguments not in parens/brackets, + // then attribute selectors and non-pseudos (denoted by :), + // then anything else + // These preferences are here to reduce the number of selectors + // needing tokenize in the PSEUDO preFilter + pseudos = ":(" + characterEncoding + ")(?:\\((?:(['\"])((?:\\\\.|[^\\\\])*?)\\2|([^()[\\]]*|(?:(?:" + attributes + ")|[^:]|\\\\.)*|.*))\\)|)", + + // For matchExpr.POS and matchExpr.needsContext + pos = ":(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rcombinators = new RegExp( "^" + whitespace + "*([\\x20\\t\\r\\n\\f>+~])" + whitespace + "*" ), + rpseudo = new RegExp( pseudos ), + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w\-]+)|(\w+)|\.([\w\-]+))$/, + + rnot = /^:not/, + rsibling = /[\x20\t\r\n\f]*[+~]/, + rendsWithNot = /:not\($/, + + rheader = /h\d/i, + rinputs = /input|select|textarea|button/i, + + rbackslash = /\\(?!\\)/g, + + matchExpr = { + "ID": new RegExp( "^#(" + characterEncoding + ")" ), + "CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ), + "NAME": new RegExp( "^\\[name=['\"]?(" + characterEncoding + ")['\"]?\\]" ), + "TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "POS": new RegExp( pos, "i" ), + "CHILD": new RegExp( "^:(only|nth|first|last)-child(?:\\(" + whitespace + + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace + + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + // For use in libraries implementing .is() + "needsContext": new RegExp( "^" + whitespace + "*[>+~]|" + pos, "i" ) + }, + + // Support + + // Used for testing something on an element + assert = function( fn ) { + var div = document.createElement("div"); + + try { + return fn( div ); + } catch (e) { + return false; + } finally { + // release memory in IE + div = null; + } + }, + + // Check if getElementsByTagName("*") returns only elements + assertTagNameNoComments = assert(function( div ) { + div.appendChild( document.createComment("") ); + return !div.getElementsByTagName("*").length; + }), + + // Check if getAttribute returns normalized href attributes + assertHrefNotNormalized = assert(function( div ) { + div.innerHTML = ""; + return div.firstChild && typeof div.firstChild.getAttribute !== strundefined && + div.firstChild.getAttribute("href") === "#"; + }), + + // Check if attributes should be retrieved by attribute nodes + assertAttributes = assert(function( div ) { + div.innerHTML = ""; + var type = typeof div.lastChild.getAttribute("multiple"); + // IE8 returns a string for some attributes even when not present + return type !== "boolean" && type !== "string"; + }), + + // Check if getElementsByClassName can be trusted + assertUsableClassName = assert(function( div ) { + // Opera can't find a second classname (in 9.6) + div.innerHTML = ""; + if ( !div.getElementsByClassName || !div.getElementsByClassName("e").length ) { + return false; + } + + // Safari 3.2 caches class attributes and doesn't catch changes + div.lastChild.className = "e"; + return div.getElementsByClassName("e").length === 2; + }), + + // Check if getElementById returns elements by name + // Check if getElementsByName privileges form controls or returns elements by ID + assertUsableName = assert(function( div ) { + // Inject content + div.id = expando + 0; + div.innerHTML = "
            "; + docElem.insertBefore( div, docElem.firstChild ); + + // Test + var pass = document.getElementsByName && + // buggy browsers will return fewer than the correct 2 + document.getElementsByName( expando ).length === 2 + + // buggy browsers will return more than the correct 0 + document.getElementsByName( expando + 0 ).length; + assertGetIdNotName = !document.getElementById( expando ); + + // Cleanup + docElem.removeChild( div ); + + return pass; + }); + +// If slice is not available, provide a backup +try { + slice.call( docElem.childNodes, 0 )[0].nodeType; +} catch ( e ) { + slice = function( i ) { + var elem, + results = []; + for ( ; (elem = this[i]); i++ ) { + results.push( elem ); + } + return results; + }; +} + +function Sizzle( selector, context, results, seed ) { + results = results || []; + context = context || document; + var match, elem, xml, m, + nodeType = context.nodeType; + + if ( !selector || typeof selector !== "string" ) { + return results; + } + + if ( nodeType !== 1 && nodeType !== 9 ) { + return []; + } + + xml = isXML( context ); + + if ( !xml && !seed ) { + if ( (match = rquickExpr.exec( selector )) ) { + // Speed-up: Sizzle("#ID") + if ( (m = match[1]) ) { + if ( nodeType === 9 ) { + elem = context.getElementById( m ); + // Check parentNode to catch when Blackberry 4.6 returns + // nodes that are no longer in the document #6963 + if ( elem && elem.parentNode ) { + // Handle the case where IE, Opera, and Webkit return items + // by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + } else { + // Context is not a document + if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) && + contains( context, elem ) && elem.id === m ) { + results.push( elem ); + return results; + } + } + + // Speed-up: Sizzle("TAG") + } else if ( match[2] ) { + push.apply( results, slice.call(context.getElementsByTagName( selector ), 0) ); + return results; + + // Speed-up: Sizzle(".CLASS") + } else if ( (m = match[3]) && assertUsableClassName && context.getElementsByClassName ) { + push.apply( results, slice.call(context.getElementsByClassName( m ), 0) ); + return results; + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed, xml ); +} + +Sizzle.matches = function( expr, elements ) { + return Sizzle( expr, null, null, elements ); +}; + +Sizzle.matchesSelector = function( elem, expr ) { + return Sizzle( expr, null, null, [ elem ] ).length > 0; +}; + +// Returns a function to use in pseudos for input types +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +// Returns a function to use in pseudos for buttons +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return (name === "input" || name === "button") && elem.type === type; + }; +} + +// Returns a function to use in pseudos for positionals +function createPositionalPseudo( fn ) { + return markFunction(function( argument ) { + argument = +argument; + return markFunction(function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ (j = matchIndexes[i]) ] ) { + seed[j] = !(matches[j] = seed[j]); + } + } + }); + }); +} + +/** + * Utility function for retrieving the text value of an array of DOM nodes + * @param {Array|Element} elem + */ +getText = Sizzle.getText = function( elem ) { + var node, + ret = "", + i = 0, + nodeType = elem.nodeType; + + if ( nodeType ) { + if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { + // Use textContent for elements + // innerText usage removed for consistency of new lines (see #11153) + if ( typeof elem.textContent === "string" ) { + return elem.textContent; + } else { + // Traverse its children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + ret += getText( elem ); + } + } + } else if ( nodeType === 3 || nodeType === 4 ) { + return elem.nodeValue; + } + // Do not include comment or processing instruction nodes + } else { + + // If no nodeType, this is expected to be an array + for ( ; (node = elem[i]); i++ ) { + // Do not traverse comment nodes + ret += getText( node ); + } + } + return ret; +}; + +isXML = Sizzle.isXML = function( elem ) { + // documentElement is verified for cases where it doesn't yet exist + // (such as loading iframes in IE - #4833) + var documentElement = elem && (elem.ownerDocument || elem).documentElement; + return documentElement ? documentElement.nodeName !== "HTML" : false; +}; + +// Element contains another +contains = Sizzle.contains = docElem.contains ? + function( a, b ) { + var adown = a.nodeType === 9 ? a.documentElement : a, + bup = b && b.parentNode; + return a === bup || !!( bup && bup.nodeType === 1 && adown.contains && adown.contains(bup) ); + } : + docElem.compareDocumentPosition ? + function( a, b ) { + return b && !!( a.compareDocumentPosition( b ) & 16 ); + } : + function( a, b ) { + while ( (b = b.parentNode) ) { + if ( b === a ) { + return true; + } + } + return false; + }; + +Sizzle.attr = function( elem, name ) { + var val, + xml = isXML( elem ); + + if ( !xml ) { + name = name.toLowerCase(); + } + if ( (val = Expr.attrHandle[ name ]) ) { + return val( elem ); + } + if ( xml || assertAttributes ) { + return elem.getAttribute( name ); + } + val = elem.getAttributeNode( name ); + return val ? + typeof elem[ name ] === "boolean" ? + elem[ name ] ? name : null : + val.specified ? val.value : null : + null; +}; + +Expr = Sizzle.selectors = { + + // Can be adjusted by the user + cacheLength: 50, + + createPseudo: markFunction, + + match: matchExpr, + + // IE6/7 return a modified href + attrHandle: assertHrefNotNormalized ? + {} : + { + "href": function( elem ) { + return elem.getAttribute( "href", 2 ); + }, + "type": function( elem ) { + return elem.getAttribute("type"); + } + }, + + find: { + "ID": assertGetIdNotName ? + function( id, context, xml ) { + if ( typeof context.getElementById !== strundefined && !xml ) { + var m = context.getElementById( id ); + // Check parentNode to catch when Blackberry 4.6 returns + // nodes that are no longer in the document #6963 + return m && m.parentNode ? [m] : []; + } + } : + function( id, context, xml ) { + if ( typeof context.getElementById !== strundefined && !xml ) { + var m = context.getElementById( id ); + + return m ? + m.id === id || typeof m.getAttributeNode !== strundefined && m.getAttributeNode("id").value === id ? + [m] : + undefined : + []; + } + }, + + "TAG": assertTagNameNoComments ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== strundefined ) { + return context.getElementsByTagName( tag ); + } + } : + function( tag, context ) { + var results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + var elem, + tmp = [], + i = 0; + + for ( ; (elem = results[i]); i++ ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }, + + "NAME": assertUsableName && function( tag, context ) { + if ( typeof context.getElementsByName !== strundefined ) { + return context.getElementsByName( name ); + } + }, + + "CLASS": assertUsableClassName && function( className, context, xml ) { + if ( typeof context.getElementsByClassName !== strundefined && !xml ) { + return context.getElementsByClassName( className ); + } + } + }, + + relative: { + ">": { dir: "parentNode", first: true }, + " ": { dir: "parentNode" }, + "+": { dir: "previousSibling", first: true }, + "~": { dir: "previousSibling" } + }, + + preFilter: { + "ATTR": function( match ) { + match[1] = match[1].replace( rbackslash, "" ); + + // Move the given value to match[3] whether quoted or unquoted + match[3] = ( match[4] || match[5] || "" ).replace( rbackslash, "" ); + + if ( match[2] === "~=" ) { + match[3] = " " + match[3] + " "; + } + + return match.slice( 0, 4 ); + }, + + "CHILD": function( match ) { + /* matches from matchExpr["CHILD"] + 1 type (only|nth|...) + 2 argument (even|odd|\d*|\d*n([+-]\d+)?|...) + 3 xn-component of xn+y argument ([+-]?\d*n|) + 4 sign of xn-component + 5 x of xn-component + 6 sign of y-component + 7 y of y-component + */ + match[1] = match[1].toLowerCase(); + + if ( match[1] === "nth" ) { + // nth-child requires argument + if ( !match[2] ) { + Sizzle.error( match[0] ); + } + + // numeric x and y parameters for Expr.filter.CHILD + // remember that false/true cast respectively to 0/1 + match[3] = +( match[3] ? match[4] + (match[5] || 1) : 2 * ( match[2] === "even" || match[2] === "odd" ) ); + match[4] = +( ( match[6] + match[7] ) || match[2] === "odd" ); + + // other types prohibit arguments + } else if ( match[2] ) { + Sizzle.error( match[0] ); + } + + return match; + }, + + "PSEUDO": function( match ) { + var unquoted, excess; + if ( matchExpr["CHILD"].test( match[0] ) ) { + return null; + } + + if ( match[3] ) { + match[2] = match[3]; + } else if ( (unquoted = match[4]) ) { + // Only check arguments that contain a pseudo + if ( rpseudo.test(unquoted) && + // Get excess from tokenize (recursively) + (excess = tokenize( unquoted, true )) && + // advance to the next closing parenthesis + (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) { + + // excess is a negative index + unquoted = unquoted.slice( 0, excess ); + match[0] = match[0].slice( 0, excess ); + } + match[2] = unquoted; + } + + // Return only captures needed by the pseudo filter method (type and argument) + return match.slice( 0, 3 ); + } + }, + + filter: { + "ID": assertGetIdNotName ? + function( id ) { + id = id.replace( rbackslash, "" ); + return function( elem ) { + return elem.getAttribute("id") === id; + }; + } : + function( id ) { + id = id.replace( rbackslash, "" ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== strundefined && elem.getAttributeNode("id"); + return node && node.value === id; + }; + }, + + "TAG": function( nodeName ) { + if ( nodeName === "*" ) { + return function() { return true; }; + } + nodeName = nodeName.replace( rbackslash, "" ).toLowerCase(); + + return function( elem ) { + return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; + }; + }, + + "CLASS": function( className ) { + var pattern = classCache[ expando ][ className ]; + if ( !pattern ) { + pattern = classCache( className, new RegExp("(^|" + whitespace + ")" + className + "(" + whitespace + "|$)") ); + } + return function( elem ) { + return pattern.test( elem.className || (typeof elem.getAttribute !== strundefined && elem.getAttribute("class")) || "" ); + }; + }, + + "ATTR": function( name, operator, check ) { + return function( elem, context ) { + var result = Sizzle.attr( elem, name ); + + if ( result == null ) { + return operator === "!="; + } + if ( !operator ) { + return true; + } + + result += ""; + + return operator === "=" ? result === check : + operator === "!=" ? result !== check : + operator === "^=" ? check && result.indexOf( check ) === 0 : + operator === "*=" ? check && result.indexOf( check ) > -1 : + operator === "$=" ? check && result.substr( result.length - check.length ) === check : + operator === "~=" ? ( " " + result + " " ).indexOf( check ) > -1 : + operator === "|=" ? result === check || result.substr( 0, check.length + 1 ) === check + "-" : + false; + }; + }, + + "CHILD": function( type, argument, first, last ) { + + if ( type === "nth" ) { + return function( elem ) { + var node, diff, + parent = elem.parentNode; + + if ( first === 1 && last === 0 ) { + return true; + } + + if ( parent ) { + diff = 0; + for ( node = parent.firstChild; node; node = node.nextSibling ) { + if ( node.nodeType === 1 ) { + diff++; + if ( elem === node ) { + break; + } + } + } + } + + // Incorporate the offset (or cast to NaN), then check against cycle size + diff -= last; + return diff === first || ( diff % first === 0 && diff / first >= 0 ); + }; + } + + return function( elem ) { + var node = elem; + + switch ( type ) { + case "only": + case "first": + while ( (node = node.previousSibling) ) { + if ( node.nodeType === 1 ) { + return false; + } + } + + if ( type === "first" ) { + return true; + } + + node = elem; + + /* falls through */ + case "last": + while ( (node = node.nextSibling) ) { + if ( node.nodeType === 1 ) { + return false; + } + } + + return true; + } + }; + }, + + "PSEUDO": function( pseudo, argument ) { + // pseudo-class names are case-insensitive + // http://www.w3.org/TR/selectors/#pseudo-classes + // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters + // Remember that setFilters inherits from pseudos + var args, + fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] || + Sizzle.error( "unsupported pseudo: " + pseudo ); + + // The user may use createPseudo to indicate that + // arguments are needed to create the filter function + // just as Sizzle does + if ( fn[ expando ] ) { + return fn( argument ); + } + + // But maintain support for old signatures + if ( fn.length > 1 ) { + args = [ pseudo, pseudo, "", argument ]; + return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ? + markFunction(function( seed, matches ) { + var idx, + matched = fn( seed, argument ), + i = matched.length; + while ( i-- ) { + idx = indexOf.call( seed, matched[i] ); + seed[ idx ] = !( matches[ idx ] = matched[i] ); + } + }) : + function( elem ) { + return fn( elem, 0, args ); + }; + } + + return fn; + } + }, + + pseudos: { + "not": markFunction(function( selector ) { + // Trim the selector passed to compile + // to avoid treating leading and trailing + // spaces as combinators + var input = [], + results = [], + matcher = compile( selector.replace( rtrim, "$1" ) ); + + return matcher[ expando ] ? + markFunction(function( seed, matches, context, xml ) { + var elem, + unmatched = matcher( seed, null, xml, [] ), + i = seed.length; + + // Match elements unmatched by `matcher` + while ( i-- ) { + if ( (elem = unmatched[i]) ) { + seed[i] = !(matches[i] = elem); + } + } + }) : + function( elem, context, xml ) { + input[0] = elem; + matcher( input, null, xml, results ); + return !results.pop(); + }; + }), + + "has": markFunction(function( selector ) { + return function( elem ) { + return Sizzle( selector, elem ).length > 0; + }; + }), + + "contains": markFunction(function( text ) { + return function( elem ) { + return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1; + }; + }), + + "enabled": function( elem ) { + return elem.disabled === false; + }, + + "disabled": function( elem ) { + return elem.disabled === true; + }, + + "checked": function( elem ) { + // In CSS3, :checked should return both checked and selected elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + var nodeName = elem.nodeName.toLowerCase(); + return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected); + }, + + "selected": function( elem ) { + // Accessing this property makes selected-by-default + // options in Safari work properly + if ( elem.parentNode ) { + elem.parentNode.selectedIndex; + } + + return elem.selected === true; + }, + + "parent": function( elem ) { + return !Expr.pseudos["empty"]( elem ); + }, + + "empty": function( elem ) { + // http://www.w3.org/TR/selectors/#empty-pseudo + // :empty is only affected by element nodes and content nodes(including text(3), cdata(4)), + // not comment, processing instructions, or others + // Thanks to Diego Perini for the nodeName shortcut + // Greater than "@" means alpha characters (specifically not starting with "#" or "?") + var nodeType; + elem = elem.firstChild; + while ( elem ) { + if ( elem.nodeName > "@" || (nodeType = elem.nodeType) === 3 || nodeType === 4 ) { + return false; + } + elem = elem.nextSibling; + } + return true; + }, + + "header": function( elem ) { + return rheader.test( elem.nodeName ); + }, + + "text": function( elem ) { + var type, attr; + // IE6 and 7 will map elem.type to 'text' for new HTML5 types (search, etc) + // use getAttribute instead to test this case + return elem.nodeName.toLowerCase() === "input" && + (type = elem.type) === "text" && + ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === type ); + }, + + // Input types + "radio": createInputPseudo("radio"), + "checkbox": createInputPseudo("checkbox"), + "file": createInputPseudo("file"), + "password": createInputPseudo("password"), + "image": createInputPseudo("image"), + + "submit": createButtonPseudo("submit"), + "reset": createButtonPseudo("reset"), + + "button": function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === "button" || name === "button"; + }, + + "input": function( elem ) { + return rinputs.test( elem.nodeName ); + }, + + "focus": function( elem ) { + var doc = elem.ownerDocument; + return elem === doc.activeElement && (!doc.hasFocus || doc.hasFocus()) && !!(elem.type || elem.href); + }, + + "active": function( elem ) { + return elem === elem.ownerDocument.activeElement; + }, + + // Positional types + "first": createPositionalPseudo(function( matchIndexes, length, argument ) { + return [ 0 ]; + }), + + "last": createPositionalPseudo(function( matchIndexes, length, argument ) { + return [ length - 1 ]; + }), + + "eq": createPositionalPseudo(function( matchIndexes, length, argument ) { + return [ argument < 0 ? argument + length : argument ]; + }), + + "even": createPositionalPseudo(function( matchIndexes, length, argument ) { + for ( var i = 0; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "odd": createPositionalPseudo(function( matchIndexes, length, argument ) { + for ( var i = 1; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "lt": createPositionalPseudo(function( matchIndexes, length, argument ) { + for ( var i = argument < 0 ? argument + length : argument; --i >= 0; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "gt": createPositionalPseudo(function( matchIndexes, length, argument ) { + for ( var i = argument < 0 ? argument + length : argument; ++i < length; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }) + } +}; + +function siblingCheck( a, b, ret ) { + if ( a === b ) { + return ret; + } + + var cur = a.nextSibling; + + while ( cur ) { + if ( cur === b ) { + return -1; + } + + cur = cur.nextSibling; + } + + return 1; +} + +sortOrder = docElem.compareDocumentPosition ? + function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + return ( !a.compareDocumentPosition || !b.compareDocumentPosition ? + a.compareDocumentPosition : + a.compareDocumentPosition(b) & 4 + ) ? -1 : 1; + } : + function( a, b ) { + // The nodes are identical, we can exit early + if ( a === b ) { + hasDuplicate = true; + return 0; + + // Fallback to using sourceIndex (in IE) if it's available on both nodes + } else if ( a.sourceIndex && b.sourceIndex ) { + return a.sourceIndex - b.sourceIndex; + } + + var al, bl, + ap = [], + bp = [], + aup = a.parentNode, + bup = b.parentNode, + cur = aup; + + // If the nodes are siblings (or identical) we can do a quick check + if ( aup === bup ) { + return siblingCheck( a, b ); + + // If no parents were found then the nodes are disconnected + } else if ( !aup ) { + return -1; + + } else if ( !bup ) { + return 1; + } + + // Otherwise they're somewhere else in the tree so we need + // to build up a full list of the parentNodes for comparison + while ( cur ) { + ap.unshift( cur ); + cur = cur.parentNode; + } + + cur = bup; + + while ( cur ) { + bp.unshift( cur ); + cur = cur.parentNode; + } + + al = ap.length; + bl = bp.length; + + // Start walking down the tree looking for a discrepancy + for ( var i = 0; i < al && i < bl; i++ ) { + if ( ap[i] !== bp[i] ) { + return siblingCheck( ap[i], bp[i] ); + } + } + + // We ended someplace up the tree so do a sibling check + return i === al ? + siblingCheck( a, bp[i], -1 ) : + siblingCheck( ap[i], b, 1 ); + }; + +// Always assume the presence of duplicates if sort doesn't +// pass them to our comparison function (as in Google Chrome). +[0, 0].sort( sortOrder ); +baseHasDuplicate = !hasDuplicate; + +// Document sorting and removing duplicates +Sizzle.uniqueSort = function( results ) { + var elem, + i = 1; + + hasDuplicate = baseHasDuplicate; + results.sort( sortOrder ); + + if ( hasDuplicate ) { + for ( ; (elem = results[i]); i++ ) { + if ( elem === results[ i - 1 ] ) { + results.splice( i--, 1 ); + } + } + } + + return results; +}; + +Sizzle.error = function( msg ) { + throw new Error( "Syntax error, unrecognized expression: " + msg ); +}; + +function tokenize( selector, parseOnly ) { + var matched, match, tokens, type, soFar, groups, preFilters, + cached = tokenCache[ expando ][ selector ]; + + if ( cached ) { + return parseOnly ? 0 : cached.slice( 0 ); + } + + soFar = selector; + groups = []; + preFilters = Expr.preFilter; + + while ( soFar ) { + + // Comma and first run + if ( !matched || (match = rcomma.exec( soFar )) ) { + if ( match ) { + soFar = soFar.slice( match[0].length ); + } + groups.push( tokens = [] ); + } + + matched = false; + + // Combinators + if ( (match = rcombinators.exec( soFar )) ) { + tokens.push( matched = new Token( match.shift() ) ); + soFar = soFar.slice( matched.length ); + + // Cast descendant combinators to space + matched.type = match[0].replace( rtrim, " " ); + } + + // Filters + for ( type in Expr.filter ) { + if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || + // The last two arguments here are (context, xml) for backCompat + (match = preFilters[ type ]( match, document, true ))) ) { + + tokens.push( matched = new Token( match.shift() ) ); + soFar = soFar.slice( matched.length ); + matched.type = type; + matched.matches = match; + } + } + + if ( !matched ) { + break; + } + } + + // Return the length of the invalid excess + // if we're just parsing + // Otherwise, throw an error or return tokens + return parseOnly ? + soFar.length : + soFar ? + Sizzle.error( selector ) : + // Cache the tokens + tokenCache( selector, groups ).slice( 0 ); +} + +function addCombinator( matcher, combinator, base ) { + var dir = combinator.dir, + checkNonElements = base && combinator.dir === "parentNode", + doneName = done++; + + return combinator.first ? + // Check against closest ancestor/preceding element + function( elem, context, xml ) { + while ( (elem = elem[ dir ]) ) { + if ( checkNonElements || elem.nodeType === 1 ) { + return matcher( elem, context, xml ); + } + } + } : + + // Check against all ancestor/preceding elements + function( elem, context, xml ) { + // We can't set arbitrary data on XML nodes, so they don't benefit from dir caching + if ( !xml ) { + var cache, + dirkey = dirruns + " " + doneName + " ", + cachedkey = dirkey + cachedruns; + while ( (elem = elem[ dir ]) ) { + if ( checkNonElements || elem.nodeType === 1 ) { + if ( (cache = elem[ expando ]) === cachedkey ) { + return elem.sizset; + } else if ( typeof cache === "string" && cache.indexOf(dirkey) === 0 ) { + if ( elem.sizset ) { + return elem; + } + } else { + elem[ expando ] = cachedkey; + if ( matcher( elem, context, xml ) ) { + elem.sizset = true; + return elem; + } + elem.sizset = false; + } + } + } + } else { + while ( (elem = elem[ dir ]) ) { + if ( checkNonElements || elem.nodeType === 1 ) { + if ( matcher( elem, context, xml ) ) { + return elem; + } + } + } + } + }; +} + +function elementMatcher( matchers ) { + return matchers.length > 1 ? + function( elem, context, xml ) { + var i = matchers.length; + while ( i-- ) { + if ( !matchers[i]( elem, context, xml ) ) { + return false; + } + } + return true; + } : + matchers[0]; +} + +function condense( unmatched, map, filter, context, xml ) { + var elem, + newUnmatched = [], + i = 0, + len = unmatched.length, + mapped = map != null; + + for ( ; i < len; i++ ) { + if ( (elem = unmatched[i]) ) { + if ( !filter || filter( elem, context, xml ) ) { + newUnmatched.push( elem ); + if ( mapped ) { + map.push( i ); + } + } + } + } + + return newUnmatched; +} + +function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) { + if ( postFilter && !postFilter[ expando ] ) { + postFilter = setMatcher( postFilter ); + } + if ( postFinder && !postFinder[ expando ] ) { + postFinder = setMatcher( postFinder, postSelector ); + } + return markFunction(function( seed, results, context, xml ) { + // Positional selectors apply to seed elements, so it is invalid to follow them with relative ones + if ( seed && postFinder ) { + return; + } + + var i, elem, postFilterIn, + preMap = [], + postMap = [], + preexisting = results.length, + + // Get initial elements from seed or context + elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [], seed ), + + // Prefilter to get matcher input, preserving a map for seed-results synchronization + matcherIn = preFilter && ( seed || !selector ) ? + condense( elems, preMap, preFilter, context, xml ) : + elems, + + matcherOut = matcher ? + // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results, + postFinder || ( seed ? preFilter : preexisting || postFilter ) ? + + // ...intermediate processing is necessary + [] : + + // ...otherwise use results directly + results : + matcherIn; + + // Find primary matches + if ( matcher ) { + matcher( matcherIn, matcherOut, context, xml ); + } + + // Apply postFilter + if ( postFilter ) { + postFilterIn = condense( matcherOut, postMap ); + postFilter( postFilterIn, [], context, xml ); + + // Un-match failing elements by moving them back to matcherIn + i = postFilterIn.length; + while ( i-- ) { + if ( (elem = postFilterIn[i]) ) { + matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem); + } + } + } + + // Keep seed and results synchronized + if ( seed ) { + // Ignore postFinder because it can't coexist with seed + i = preFilter && matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) ) { + seed[ preMap[i] ] = !(results[ preMap[i] ] = elem); + } + } + } else { + matcherOut = condense( + matcherOut === results ? + matcherOut.splice( preexisting, matcherOut.length ) : + matcherOut + ); + if ( postFinder ) { + postFinder( null, results, matcherOut, xml ); + } else { + push.apply( results, matcherOut ); + } + } + }); +} + +function matcherFromTokens( tokens ) { + var checkContext, matcher, j, + len = tokens.length, + leadingRelative = Expr.relative[ tokens[0].type ], + implicitRelative = leadingRelative || Expr.relative[" "], + i = leadingRelative ? 1 : 0, + + // The foundational matcher ensures that elements are reachable from top-level context(s) + matchContext = addCombinator( function( elem ) { + return elem === checkContext; + }, implicitRelative, true ), + matchAnyContext = addCombinator( function( elem ) { + return indexOf.call( checkContext, elem ) > -1; + }, implicitRelative, true ), + matchers = [ function( elem, context, xml ) { + return ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( + (checkContext = context).nodeType ? + matchContext( elem, context, xml ) : + matchAnyContext( elem, context, xml ) ); + } ]; + + for ( ; i < len; i++ ) { + if ( (matcher = Expr.relative[ tokens[i].type ]) ) { + matchers = [ addCombinator( elementMatcher( matchers ), matcher ) ]; + } else { + // The concatenated values are (context, xml) for backCompat + matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches ); + + // Return special upon seeing a positional matcher + if ( matcher[ expando ] ) { + // Find the next relative operator (if any) for proper handling + j = ++i; + for ( ; j < len; j++ ) { + if ( Expr.relative[ tokens[j].type ] ) { + break; + } + } + return setMatcher( + i > 1 && elementMatcher( matchers ), + i > 1 && tokens.slice( 0, i - 1 ).join("").replace( rtrim, "$1" ), + matcher, + i < j && matcherFromTokens( tokens.slice( i, j ) ), + j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), + j < len && tokens.join("") + ); + } + matchers.push( matcher ); + } + } + + return elementMatcher( matchers ); +} + +function matcherFromGroupMatchers( elementMatchers, setMatchers ) { + var bySet = setMatchers.length > 0, + byElement = elementMatchers.length > 0, + superMatcher = function( seed, context, xml, results, expandContext ) { + var elem, j, matcher, + setMatched = [], + matchedCount = 0, + i = "0", + unmatched = seed && [], + outermost = expandContext != null, + contextBackup = outermostContext, + // We must always have either seed elements or context + elems = seed || byElement && Expr.find["TAG"]( "*", expandContext && context.parentNode || context ), + // Nested matchers should use non-integer dirruns + dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.E); + + if ( outermost ) { + outermostContext = context !== document && context; + cachedruns = superMatcher.el; + } + + // Add elements passing elementMatchers directly to results + for ( ; (elem = elems[i]) != null; i++ ) { + if ( byElement && elem ) { + for ( j = 0; (matcher = elementMatchers[j]); j++ ) { + if ( matcher( elem, context, xml ) ) { + results.push( elem ); + break; + } + } + if ( outermost ) { + dirruns = dirrunsUnique; + cachedruns = ++superMatcher.el; + } + } + + // Track unmatched elements for set filters + if ( bySet ) { + // They will have gone through all possible matchers + if ( (elem = !matcher && elem) ) { + matchedCount--; + } + + // Lengthen the array for every element, matched or not + if ( seed ) { + unmatched.push( elem ); + } + } + } + + // Apply set filters to unmatched elements + matchedCount += i; + if ( bySet && i !== matchedCount ) { + for ( j = 0; (matcher = setMatchers[j]); j++ ) { + matcher( unmatched, setMatched, context, xml ); + } + + if ( seed ) { + // Reintegrate element matches to eliminate the need for sorting + if ( matchedCount > 0 ) { + while ( i-- ) { + if ( !(unmatched[i] || setMatched[i]) ) { + setMatched[i] = pop.call( results ); + } + } + } + + // Discard index placeholder values to get only actual matches + setMatched = condense( setMatched ); + } + + // Add matches to results + push.apply( results, setMatched ); + + // Seedless set matches succeeding multiple successful matchers stipulate sorting + if ( outermost && !seed && setMatched.length > 0 && + ( matchedCount + setMatchers.length ) > 1 ) { + + Sizzle.uniqueSort( results ); + } + } + + // Override manipulation of globals by nested matchers + if ( outermost ) { + dirruns = dirrunsUnique; + outermostContext = contextBackup; + } + + return unmatched; + }; + + superMatcher.el = 0; + return bySet ? + markFunction( superMatcher ) : + superMatcher; +} + +compile = Sizzle.compile = function( selector, group /* Internal Use Only */ ) { + var i, + setMatchers = [], + elementMatchers = [], + cached = compilerCache[ expando ][ selector ]; + + if ( !cached ) { + // Generate a function of recursive functions that can be used to check each element + if ( !group ) { + group = tokenize( selector ); + } + i = group.length; + while ( i-- ) { + cached = matcherFromTokens( group[i] ); + if ( cached[ expando ] ) { + setMatchers.push( cached ); + } else { + elementMatchers.push( cached ); + } + } + + // Cache the compiled function + cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) ); + } + return cached; +}; + +function multipleContexts( selector, contexts, results, seed ) { + var i = 0, + len = contexts.length; + for ( ; i < len; i++ ) { + Sizzle( selector, contexts[i], results, seed ); + } + return results; +} + +function select( selector, context, results, seed, xml ) { + var i, tokens, token, type, find, + match = tokenize( selector ), + j = match.length; + + if ( !seed ) { + // Try to minimize operations if there is only one group + if ( match.length === 1 ) { + + // Take a shortcut and set the context if the root selector is an ID + tokens = match[0] = match[0].slice( 0 ); + if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && + context.nodeType === 9 && !xml && + Expr.relative[ tokens[1].type ] ) { + + context = Expr.find["ID"]( token.matches[0].replace( rbackslash, "" ), context, xml )[0]; + if ( !context ) { + return results; + } + + selector = selector.slice( tokens.shift().length ); + } + + // Fetch a seed set for right-to-left matching + for ( i = matchExpr["POS"].test( selector ) ? -1 : tokens.length - 1; i >= 0; i-- ) { + token = tokens[i]; + + // Abort if we hit a combinator + if ( Expr.relative[ (type = token.type) ] ) { + break; + } + if ( (find = Expr.find[ type ]) ) { + // Search, expanding context for leading sibling combinators + if ( (seed = find( + token.matches[0].replace( rbackslash, "" ), + rsibling.test( tokens[0].type ) && context.parentNode || context, + xml + )) ) { + + // If seed is empty or no tokens remain, we can return early + tokens.splice( i, 1 ); + selector = seed.length && tokens.join(""); + if ( !selector ) { + push.apply( results, slice.call( seed, 0 ) ); + return results; + } + + break; + } + } + } + } + } + + // Compile and execute a filtering function + // Provide `match` to avoid retokenization if we modified the selector above + compile( selector, match )( + seed, + context, + xml, + results, + rsibling.test( selector ) + ); + return results; +} + +if ( document.querySelectorAll ) { + (function() { + var disconnectedMatch, + oldSelect = select, + rescape = /'|\\/g, + rattributeQuotes = /\=[\x20\t\r\n\f]*([^'"\]]*)[\x20\t\r\n\f]*\]/g, + + // qSa(:focus) reports false when true (Chrome 21), + // A support test would require too much code (would include document ready) + rbuggyQSA = [":focus"], + + // matchesSelector(:focus) reports false when true (Chrome 21), + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + // A support test would require too much code (would include document ready) + // just skip matchesSelector for :active + rbuggyMatches = [ ":active", ":focus" ], + matches = docElem.matchesSelector || + docElem.mozMatchesSelector || + docElem.webkitMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector; + + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert(function( div ) { + // Select is set to empty string on purpose + // This is to test IE's treatment of not explictly + // setting a boolean content attribute, + // since its presence should be enough + // http://bugs.jquery.com/ticket/12359 + div.innerHTML = ""; + + // IE8 - Some boolean attributes are not treated correctly + if ( !div.querySelectorAll("[selected]").length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:checked|disabled|ismap|multiple|readonly|selected|value)" ); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here (do not put tests after this one) + if ( !div.querySelectorAll(":checked").length ) { + rbuggyQSA.push(":checked"); + } + }); + + assert(function( div ) { + + // Opera 10-12/IE9 - ^= $= *= and empty values + // Should not select anything + div.innerHTML = "

            "; + if ( div.querySelectorAll("[test^='']").length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:\"\"|'')" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here (do not put tests after this one) + div.innerHTML = ""; + if ( !div.querySelectorAll(":enabled").length ) { + rbuggyQSA.push(":enabled", ":disabled"); + } + }); + + // rbuggyQSA always contains :focus, so no need for a length check + rbuggyQSA = /* rbuggyQSA.length && */ new RegExp( rbuggyQSA.join("|") ); + + select = function( selector, context, results, seed, xml ) { + // Only use querySelectorAll when not filtering, + // when this is not xml, + // and when no QSA bugs apply + if ( !seed && !xml && (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { + var groups, i, + old = true, + nid = expando, + newContext = context, + newSelector = context.nodeType === 9 && selector; + + // qSA works strangely on Element-rooted queries + // We can work around this by specifying an extra ID on the root + // and working up from there (Thanks to Andrew Dupont for the technique) + // IE 8 doesn't work on object elements + if ( context.nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) { + groups = tokenize( selector ); + + if ( (old = context.getAttribute("id")) ) { + nid = old.replace( rescape, "\\$&" ); + } else { + context.setAttribute( "id", nid ); + } + nid = "[id='" + nid + "'] "; + + i = groups.length; + while ( i-- ) { + groups[i] = nid + groups[i].join(""); + } + newContext = rsibling.test( selector ) && context.parentNode || context; + newSelector = groups.join(","); + } + + if ( newSelector ) { + try { + push.apply( results, slice.call( newContext.querySelectorAll( + newSelector + ), 0 ) ); + return results; + } catch(qsaError) { + } finally { + if ( !old ) { + context.removeAttribute("id"); + } + } + } + } + + return oldSelect( selector, context, results, seed, xml ); + }; + + if ( matches ) { + assert(function( div ) { + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + disconnectedMatch = matches.call( div, "div" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + try { + matches.call( div, "[test!='']:sizzle" ); + rbuggyMatches.push( "!=", pseudos ); + } catch ( e ) {} + }); + + // rbuggyMatches always contains :active and :focus, so no need for a length check + rbuggyMatches = /* rbuggyMatches.length && */ new RegExp( rbuggyMatches.join("|") ); + + Sizzle.matchesSelector = function( elem, expr ) { + // Make sure that attribute selectors are quoted + expr = expr.replace( rattributeQuotes, "='$1']" ); + + // rbuggyMatches always contains :active, so no need for an existence check + if ( !isXML( elem ) && !rbuggyMatches.test( expr ) && (!rbuggyQSA || !rbuggyQSA.test( expr )) ) { + try { + var ret = matches.call( elem, expr ); + + // IE 9's matchesSelector returns false on disconnected nodes + if ( ret || disconnectedMatch || + // As well, disconnected nodes are said to be in a document + // fragment in IE 9 + elem.document && elem.document.nodeType !== 11 ) { + return ret; + } + } catch(e) {} + } + + return Sizzle( expr, null, null, [ elem ] ).length > 0; + }; + } + })(); +} + +// Deprecated +Expr.pseudos["nth"] = Expr.pseudos["eq"]; + +// Back-compat +function setFilters() {} +Expr.filters = setFilters.prototype = Expr.pseudos; +Expr.setFilters = new setFilters(); + +// Override sizzle attribute retrieval +Sizzle.attr = jQuery.attr; +jQuery.find = Sizzle; +jQuery.expr = Sizzle.selectors; +jQuery.expr[":"] = jQuery.expr.pseudos; +jQuery.unique = Sizzle.uniqueSort; +jQuery.text = Sizzle.getText; +jQuery.isXMLDoc = Sizzle.isXML; +jQuery.contains = Sizzle.contains; + + +})( window ); +var runtil = /Until$/, + rparentsprev = /^(?:parents|prev(?:Until|All))/, + isSimple = /^.[^:#\[\.,]*$/, + rneedsContext = jQuery.expr.match.needsContext, + // methods guaranteed to produce a unique set when starting from a unique set + guaranteedUnique = { + children: true, + contents: true, + next: true, + prev: true + }; + +jQuery.fn.extend({ + find: function( selector ) { + var i, l, length, n, r, ret, + self = this; + + if ( typeof selector !== "string" ) { + return jQuery( selector ).filter(function() { + for ( i = 0, l = self.length; i < l; i++ ) { + if ( jQuery.contains( self[ i ], this ) ) { + return true; + } + } + }); + } + + ret = this.pushStack( "", "find", selector ); + + for ( i = 0, l = this.length; i < l; i++ ) { + length = ret.length; + jQuery.find( selector, this[i], ret ); + + if ( i > 0 ) { + // Make sure that the results are unique + for ( n = length; n < ret.length; n++ ) { + for ( r = 0; r < length; r++ ) { + if ( ret[r] === ret[n] ) { + ret.splice(n--, 1); + break; + } + } + } + } + } + + return ret; + }, + + has: function( target ) { + var i, + targets = jQuery( target, this ), + len = targets.length; + + return this.filter(function() { + for ( i = 0; i < len; i++ ) { + if ( jQuery.contains( this, targets[i] ) ) { + return true; + } + } + }); + }, + + not: function( selector ) { + return this.pushStack( winnow(this, selector, false), "not", selector); + }, + + filter: function( selector ) { + return this.pushStack( winnow(this, selector, true), "filter", selector ); + }, + + is: function( selector ) { + return !!selector && ( + typeof selector === "string" ? + // If this is a positional/relative selector, check membership in the returned set + // so $("p:first").is("p:last") won't return true for a doc with two "p". + rneedsContext.test( selector ) ? + jQuery( selector, this.context ).index( this[0] ) >= 0 : + jQuery.filter( selector, this ).length > 0 : + this.filter( selector ).length > 0 ); + }, + + closest: function( selectors, context ) { + var cur, + i = 0, + l = this.length, + ret = [], + pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ? + jQuery( selectors, context || this.context ) : + 0; + + for ( ; i < l; i++ ) { + cur = this[i]; + + while ( cur && cur.ownerDocument && cur !== context && cur.nodeType !== 11 ) { + if ( pos ? pos.index(cur) > -1 : jQuery.find.matchesSelector(cur, selectors) ) { + ret.push( cur ); + break; + } + cur = cur.parentNode; + } + } + + ret = ret.length > 1 ? jQuery.unique( ret ) : ret; + + return this.pushStack( ret, "closest", selectors ); + }, + + // Determine the position of an element within + // the matched set of elements + index: function( elem ) { + + // No argument, return index in parent + if ( !elem ) { + return ( this[0] && this[0].parentNode ) ? this.prevAll().length : -1; + } + + // index in selector + if ( typeof elem === "string" ) { + return jQuery.inArray( this[0], jQuery( elem ) ); + } + + // Locate the position of the desired element + return jQuery.inArray( + // If it receives a jQuery object, the first element is used + elem.jquery ? elem[0] : elem, this ); + }, + + add: function( selector, context ) { + var set = typeof selector === "string" ? + jQuery( selector, context ) : + jQuery.makeArray( selector && selector.nodeType ? [ selector ] : selector ), + all = jQuery.merge( this.get(), set ); + + return this.pushStack( isDisconnected( set[0] ) || isDisconnected( all[0] ) ? + all : + jQuery.unique( all ) ); + }, + + addBack: function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter(selector) + ); + } +}); + +jQuery.fn.andSelf = jQuery.fn.addBack; + +// A painfully simple check to see if an element is disconnected +// from a document (should be improved, where feasible). +function isDisconnected( node ) { + return !node || !node.parentNode || node.parentNode.nodeType === 11; +} + +function sibling( cur, dir ) { + do { + cur = cur[ dir ]; + } while ( cur && cur.nodeType !== 1 ); + + return cur; +} + +jQuery.each({ + parent: function( elem ) { + var parent = elem.parentNode; + return parent && parent.nodeType !== 11 ? parent : null; + }, + parents: function( elem ) { + return jQuery.dir( elem, "parentNode" ); + }, + parentsUntil: function( elem, i, until ) { + return jQuery.dir( elem, "parentNode", until ); + }, + next: function( elem ) { + return sibling( elem, "nextSibling" ); + }, + prev: function( elem ) { + return sibling( elem, "previousSibling" ); + }, + nextAll: function( elem ) { + return jQuery.dir( elem, "nextSibling" ); + }, + prevAll: function( elem ) { + return jQuery.dir( elem, "previousSibling" ); + }, + nextUntil: function( elem, i, until ) { + return jQuery.dir( elem, "nextSibling", until ); + }, + prevUntil: function( elem, i, until ) { + return jQuery.dir( elem, "previousSibling", until ); + }, + siblings: function( elem ) { + return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem ); + }, + children: function( elem ) { + return jQuery.sibling( elem.firstChild ); + }, + contents: function( elem ) { + return jQuery.nodeName( elem, "iframe" ) ? + elem.contentDocument || elem.contentWindow.document : + jQuery.merge( [], elem.childNodes ); + } +}, function( name, fn ) { + jQuery.fn[ name ] = function( until, selector ) { + var ret = jQuery.map( this, fn, until ); + + if ( !runtil.test( name ) ) { + selector = until; + } + + if ( selector && typeof selector === "string" ) { + ret = jQuery.filter( selector, ret ); + } + + ret = this.length > 1 && !guaranteedUnique[ name ] ? jQuery.unique( ret ) : ret; + + if ( this.length > 1 && rparentsprev.test( name ) ) { + ret = ret.reverse(); + } + + return this.pushStack( ret, name, core_slice.call( arguments ).join(",") ); + }; +}); + +jQuery.extend({ + filter: function( expr, elems, not ) { + if ( not ) { + expr = ":not(" + expr + ")"; + } + + return elems.length === 1 ? + jQuery.find.matchesSelector(elems[0], expr) ? [ elems[0] ] : [] : + jQuery.find.matches(expr, elems); + }, + + dir: function( elem, dir, until ) { + var matched = [], + cur = elem[ dir ]; + + while ( cur && cur.nodeType !== 9 && (until === undefined || cur.nodeType !== 1 || !jQuery( cur ).is( until )) ) { + if ( cur.nodeType === 1 ) { + matched.push( cur ); + } + cur = cur[dir]; + } + return matched; + }, + + sibling: function( n, elem ) { + var r = []; + + for ( ; n; n = n.nextSibling ) { + if ( n.nodeType === 1 && n !== elem ) { + r.push( n ); + } + } + + return r; + } +}); + +// Implement the identical functionality for filter and not +function winnow( elements, qualifier, keep ) { + + // Can't pass null or undefined to indexOf in Firefox 4 + // Set to 0 to skip string check + qualifier = qualifier || 0; + + if ( jQuery.isFunction( qualifier ) ) { + return jQuery.grep(elements, function( elem, i ) { + var retVal = !!qualifier.call( elem, i, elem ); + return retVal === keep; + }); + + } else if ( qualifier.nodeType ) { + return jQuery.grep(elements, function( elem, i ) { + return ( elem === qualifier ) === keep; + }); + + } else if ( typeof qualifier === "string" ) { + var filtered = jQuery.grep(elements, function( elem ) { + return elem.nodeType === 1; + }); + + if ( isSimple.test( qualifier ) ) { + return jQuery.filter(qualifier, filtered, !keep); + } else { + qualifier = jQuery.filter( qualifier, filtered ); + } + } + + return jQuery.grep(elements, function( elem, i ) { + return ( jQuery.inArray( elem, qualifier ) >= 0 ) === keep; + }); +} +function createSafeFragment( document ) { + var list = nodeNames.split( "|" ), + safeFrag = document.createDocumentFragment(); + + if ( safeFrag.createElement ) { + while ( list.length ) { + safeFrag.createElement( + list.pop() + ); + } + } + return safeFrag; +} + +var nodeNames = "abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|" + + "header|hgroup|mark|meter|nav|output|progress|section|summary|time|video", + rinlinejQuery = / jQuery\d+="(?:null|\d+)"/g, + rleadingWhitespace = /^\s+/, + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, + rtagName = /<([\w:]+)/, + rtbody = /]", "i"), + rcheckableType = /^(?:checkbox|radio)$/, + // checked="checked" or checked + rchecked = /checked\s*(?:[^=]|=\s*.checked.)/i, + rscriptType = /\/(java|ecma)script/i, + rcleanScript = /^\s*\s*$/g, + wrapMap = { + option: [ 1, "" ], + legend: [ 1, "
            ", "
            " ], + thead: [ 1, "", "
            " ], + tr: [ 2, "", "
            " ], + td: [ 3, "", "
            " ], + col: [ 2, "", "
            " ], + area: [ 1, "", "" ], + _default: [ 0, "", "" ] + }, + safeFragment = createSafeFragment( document ), + fragmentDiv = safeFragment.appendChild( document.createElement("div") ); + +wrapMap.optgroup = wrapMap.option; +wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; +wrapMap.th = wrapMap.td; + +// IE6-8 can't serialize link, script, style, or any html5 (NoScope) tags, +// unless wrapped in a div with non-breaking characters in front of it. +if ( !jQuery.support.htmlSerialize ) { + wrapMap._default = [ 1, "X
            ", "
            " ]; +} + +jQuery.fn.extend({ + text: function( value ) { + return jQuery.access( this, function( value ) { + return value === undefined ? + jQuery.text( this ) : + this.empty().append( ( this[0] && this[0].ownerDocument || document ).createTextNode( value ) ); + }, null, value, arguments.length ); + }, + + wrapAll: function( html ) { + if ( jQuery.isFunction( html ) ) { + return this.each(function(i) { + jQuery(this).wrapAll( html.call(this, i) ); + }); + } + + if ( this[0] ) { + // The elements to wrap the target around + var wrap = jQuery( html, this[0].ownerDocument ).eq(0).clone(true); + + if ( this[0].parentNode ) { + wrap.insertBefore( this[0] ); + } + + wrap.map(function() { + var elem = this; + + while ( elem.firstChild && elem.firstChild.nodeType === 1 ) { + elem = elem.firstChild; + } + + return elem; + }).append( this ); + } + + return this; + }, + + wrapInner: function( html ) { + if ( jQuery.isFunction( html ) ) { + return this.each(function(i) { + jQuery(this).wrapInner( html.call(this, i) ); + }); + } + + return this.each(function() { + var self = jQuery( this ), + contents = self.contents(); + + if ( contents.length ) { + contents.wrapAll( html ); + + } else { + self.append( html ); + } + }); + }, + + wrap: function( html ) { + var isFunction = jQuery.isFunction( html ); + + return this.each(function(i) { + jQuery( this ).wrapAll( isFunction ? html.call(this, i) : html ); + }); + }, + + unwrap: function() { + return this.parent().each(function() { + if ( !jQuery.nodeName( this, "body" ) ) { + jQuery( this ).replaceWith( this.childNodes ); + } + }).end(); + }, + + append: function() { + return this.domManip(arguments, true, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 ) { + this.appendChild( elem ); + } + }); + }, + + prepend: function() { + return this.domManip(arguments, true, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 ) { + this.insertBefore( elem, this.firstChild ); + } + }); + }, + + before: function() { + if ( !isDisconnected( this[0] ) ) { + return this.domManip(arguments, false, function( elem ) { + this.parentNode.insertBefore( elem, this ); + }); + } + + if ( arguments.length ) { + var set = jQuery.clean( arguments ); + return this.pushStack( jQuery.merge( set, this ), "before", this.selector ); + } + }, + + after: function() { + if ( !isDisconnected( this[0] ) ) { + return this.domManip(arguments, false, function( elem ) { + this.parentNode.insertBefore( elem, this.nextSibling ); + }); + } + + if ( arguments.length ) { + var set = jQuery.clean( arguments ); + return this.pushStack( jQuery.merge( this, set ), "after", this.selector ); + } + }, + + // keepData is for internal use only--do not document + remove: function( selector, keepData ) { + var elem, + i = 0; + + for ( ; (elem = this[i]) != null; i++ ) { + if ( !selector || jQuery.filter( selector, [ elem ] ).length ) { + if ( !keepData && elem.nodeType === 1 ) { + jQuery.cleanData( elem.getElementsByTagName("*") ); + jQuery.cleanData( [ elem ] ); + } + + if ( elem.parentNode ) { + elem.parentNode.removeChild( elem ); + } + } + } + + return this; + }, + + empty: function() { + var elem, + i = 0; + + for ( ; (elem = this[i]) != null; i++ ) { + // Remove element nodes and prevent memory leaks + if ( elem.nodeType === 1 ) { + jQuery.cleanData( elem.getElementsByTagName("*") ); + } + + // Remove any remaining nodes + while ( elem.firstChild ) { + elem.removeChild( elem.firstChild ); + } + } + + return this; + }, + + clone: function( dataAndEvents, deepDataAndEvents ) { + dataAndEvents = dataAndEvents == null ? false : dataAndEvents; + deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; + + return this.map( function () { + return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); + }); + }, + + html: function( value ) { + return jQuery.access( this, function( value ) { + var elem = this[0] || {}, + i = 0, + l = this.length; + + if ( value === undefined ) { + return elem.nodeType === 1 ? + elem.innerHTML.replace( rinlinejQuery, "" ) : + undefined; + } + + // See if we can take a shortcut and just use innerHTML + if ( typeof value === "string" && !rnoInnerhtml.test( value ) && + ( jQuery.support.htmlSerialize || !rnoshimcache.test( value ) ) && + ( jQuery.support.leadingWhitespace || !rleadingWhitespace.test( value ) ) && + !wrapMap[ ( rtagName.exec( value ) || ["", ""] )[1].toLowerCase() ] ) { + + value = value.replace( rxhtmlTag, "<$1>" ); + + try { + for (; i < l; i++ ) { + // Remove element nodes and prevent memory leaks + elem = this[i] || {}; + if ( elem.nodeType === 1 ) { + jQuery.cleanData( elem.getElementsByTagName( "*" ) ); + elem.innerHTML = value; + } + } + + elem = 0; + + // If using innerHTML throws an exception, use the fallback method + } catch(e) {} + } + + if ( elem ) { + this.empty().append( value ); + } + }, null, value, arguments.length ); + }, + + replaceWith: function( value ) { + if ( !isDisconnected( this[0] ) ) { + // Make sure that the elements are removed from the DOM before they are inserted + // this can help fix replacing a parent with child elements + if ( jQuery.isFunction( value ) ) { + return this.each(function(i) { + var self = jQuery(this), old = self.html(); + self.replaceWith( value.call( this, i, old ) ); + }); + } + + if ( typeof value !== "string" ) { + value = jQuery( value ).detach(); + } + + return this.each(function() { + var next = this.nextSibling, + parent = this.parentNode; + + jQuery( this ).remove(); + + if ( next ) { + jQuery(next).before( value ); + } else { + jQuery(parent).append( value ); + } + }); + } + + return this.length ? + this.pushStack( jQuery(jQuery.isFunction(value) ? value() : value), "replaceWith", value ) : + this; + }, + + detach: function( selector ) { + return this.remove( selector, true ); + }, + + domManip: function( args, table, callback ) { + + // Flatten any nested arrays + args = [].concat.apply( [], args ); + + var results, first, fragment, iNoClone, + i = 0, + value = args[0], + scripts = [], + l = this.length; + + // We can't cloneNode fragments that contain checked, in WebKit + if ( !jQuery.support.checkClone && l > 1 && typeof value === "string" && rchecked.test( value ) ) { + return this.each(function() { + jQuery(this).domManip( args, table, callback ); + }); + } + + if ( jQuery.isFunction(value) ) { + return this.each(function(i) { + var self = jQuery(this); + args[0] = value.call( this, i, table ? self.html() : undefined ); + self.domManip( args, table, callback ); + }); + } + + if ( this[0] ) { + results = jQuery.buildFragment( args, this, scripts ); + fragment = results.fragment; + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + if ( first ) { + table = table && jQuery.nodeName( first, "tr" ); + + // Use the original fragment for the last item instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + // Fragments from the fragment cache must always be cloned and never used in place. + for ( iNoClone = results.cacheable || l - 1; i < l; i++ ) { + callback.call( + table && jQuery.nodeName( this[i], "table" ) ? + findOrAppend( this[i], "tbody" ) : + this[i], + i === iNoClone ? + fragment : + jQuery.clone( fragment, true, true ) + ); + } + } + + // Fix #11809: Avoid leaking memory + fragment = first = null; + + if ( scripts.length ) { + jQuery.each( scripts, function( i, elem ) { + if ( elem.src ) { + if ( jQuery.ajax ) { + jQuery.ajax({ + url: elem.src, + type: "GET", + dataType: "script", + async: false, + global: false, + "throws": true + }); + } else { + jQuery.error("no ajax"); + } + } else { + jQuery.globalEval( ( elem.text || elem.textContent || elem.innerHTML || "" ).replace( rcleanScript, "" ) ); + } + + if ( elem.parentNode ) { + elem.parentNode.removeChild( elem ); + } + }); + } + } + + return this; + } +}); + +function findOrAppend( elem, tag ) { + return elem.getElementsByTagName( tag )[0] || elem.appendChild( elem.ownerDocument.createElement( tag ) ); +} + +function cloneCopyEvent( src, dest ) { + + if ( dest.nodeType !== 1 || !jQuery.hasData( src ) ) { + return; + } + + var type, i, l, + oldData = jQuery._data( src ), + curData = jQuery._data( dest, oldData ), + events = oldData.events; + + if ( events ) { + delete curData.handle; + curData.events = {}; + + for ( type in events ) { + for ( i = 0, l = events[ type ].length; i < l; i++ ) { + jQuery.event.add( dest, type, events[ type ][ i ] ); + } + } + } + + // make the cloned public data object a copy from the original + if ( curData.data ) { + curData.data = jQuery.extend( {}, curData.data ); + } +} + +function cloneFixAttributes( src, dest ) { + var nodeName; + + // We do not need to do anything for non-Elements + if ( dest.nodeType !== 1 ) { + return; + } + + // clearAttributes removes the attributes, which we don't want, + // but also removes the attachEvent events, which we *do* want + if ( dest.clearAttributes ) { + dest.clearAttributes(); + } + + // mergeAttributes, in contrast, only merges back on the + // original attributes, not the events + if ( dest.mergeAttributes ) { + dest.mergeAttributes( src ); + } + + nodeName = dest.nodeName.toLowerCase(); + + if ( nodeName === "object" ) { + // IE6-10 improperly clones children of object elements using classid. + // IE10 throws NoModificationAllowedError if parent is null, #12132. + if ( dest.parentNode ) { + dest.outerHTML = src.outerHTML; + } + + // This path appears unavoidable for IE9. When cloning an object + // element in IE9, the outerHTML strategy above is not sufficient. + // If the src has innerHTML and the destination does not, + // copy the src.innerHTML into the dest.innerHTML. #10324 + if ( jQuery.support.html5Clone && (src.innerHTML && !jQuery.trim(dest.innerHTML)) ) { + dest.innerHTML = src.innerHTML; + } + + } else if ( nodeName === "input" && rcheckableType.test( src.type ) ) { + // IE6-8 fails to persist the checked state of a cloned checkbox + // or radio button. Worse, IE6-7 fail to give the cloned element + // a checked appearance if the defaultChecked value isn't also set + + dest.defaultChecked = dest.checked = src.checked; + + // IE6-7 get confused and end up setting the value of a cloned + // checkbox/radio button to an empty string instead of "on" + if ( dest.value !== src.value ) { + dest.value = src.value; + } + + // IE6-8 fails to return the selected option to the default selected + // state when cloning options + } else if ( nodeName === "option" ) { + dest.selected = src.defaultSelected; + + // IE6-8 fails to set the defaultValue to the correct value when + // cloning other types of input fields + } else if ( nodeName === "input" || nodeName === "textarea" ) { + dest.defaultValue = src.defaultValue; + + // IE blanks contents when cloning scripts + } else if ( nodeName === "script" && dest.text !== src.text ) { + dest.text = src.text; + } + + // Event data gets referenced instead of copied if the expando + // gets copied too + dest.removeAttribute( jQuery.expando ); +} + +jQuery.buildFragment = function( args, context, scripts ) { + var fragment, cacheable, cachehit, + first = args[ 0 ]; + + // Set context from what may come in as undefined or a jQuery collection or a node + // Updated to fix #12266 where accessing context[0] could throw an exception in IE9/10 & + // also doubles as fix for #8950 where plain objects caused createDocumentFragment exception + context = context || document; + context = !context.nodeType && context[0] || context; + context = context.ownerDocument || context; + + // Only cache "small" (1/2 KB) HTML strings that are associated with the main document + // Cloning options loses the selected state, so don't cache them + // IE 6 doesn't like it when you put or elements in a fragment + // Also, WebKit does not clone 'checked' attributes on cloneNode, so don't cache + // Lastly, IE6,7,8 will not correctly reuse cached fragments that were created from unknown elems #10501 + if ( args.length === 1 && typeof first === "string" && first.length < 512 && context === document && + first.charAt(0) === "<" && !rnocache.test( first ) && + (jQuery.support.checkClone || !rchecked.test( first )) && + (jQuery.support.html5Clone || !rnoshimcache.test( first )) ) { + + // Mark cacheable and look for a hit + cacheable = true; + fragment = jQuery.fragments[ first ]; + cachehit = fragment !== undefined; + } + + if ( !fragment ) { + fragment = context.createDocumentFragment(); + jQuery.clean( args, context, fragment, scripts ); + + // Update the cache, but only store false + // unless this is a second parsing of the same content + if ( cacheable ) { + jQuery.fragments[ first ] = cachehit && fragment; + } + } + + return { fragment: fragment, cacheable: cacheable }; +}; + +jQuery.fragments = {}; + +jQuery.each({ + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" +}, function( name, original ) { + jQuery.fn[ name ] = function( selector ) { + var elems, + i = 0, + ret = [], + insert = jQuery( selector ), + l = insert.length, + parent = this.length === 1 && this[0].parentNode; + + if ( (parent == null || parent && parent.nodeType === 11 && parent.childNodes.length === 1) && l === 1 ) { + insert[ original ]( this[0] ); + return this; + } else { + for ( ; i < l; i++ ) { + elems = ( i > 0 ? this.clone(true) : this ).get(); + jQuery( insert[i] )[ original ]( elems ); + ret = ret.concat( elems ); + } + + return this.pushStack( ret, name, insert.selector ); + } + }; +}); + +function getAll( elem ) { + if ( typeof elem.getElementsByTagName !== "undefined" ) { + return elem.getElementsByTagName( "*" ); + + } else if ( typeof elem.querySelectorAll !== "undefined" ) { + return elem.querySelectorAll( "*" ); + + } else { + return []; + } +} + +// Used in clean, fixes the defaultChecked property +function fixDefaultChecked( elem ) { + if ( rcheckableType.test( elem.type ) ) { + elem.defaultChecked = elem.checked; + } +} + +jQuery.extend({ + clone: function( elem, dataAndEvents, deepDataAndEvents ) { + var srcElements, + destElements, + i, + clone; + + if ( jQuery.support.html5Clone || jQuery.isXMLDoc(elem) || !rnoshimcache.test( "<" + elem.nodeName + ">" ) ) { + clone = elem.cloneNode( true ); + + // IE<=8 does not properly clone detached, unknown element nodes + } else { + fragmentDiv.innerHTML = elem.outerHTML; + fragmentDiv.removeChild( clone = fragmentDiv.firstChild ); + } + + if ( (!jQuery.support.noCloneEvent || !jQuery.support.noCloneChecked) && + (elem.nodeType === 1 || elem.nodeType === 11) && !jQuery.isXMLDoc(elem) ) { + // IE copies events bound via attachEvent when using cloneNode. + // Calling detachEvent on the clone will also remove the events + // from the original. In order to get around this, we use some + // proprietary methods to clear the events. Thanks to MooTools + // guys for this hotness. + + cloneFixAttributes( elem, clone ); + + // Using Sizzle here is crazy slow, so we use getElementsByTagName instead + srcElements = getAll( elem ); + destElements = getAll( clone ); + + // Weird iteration because IE will replace the length property + // with an element if you are cloning the body and one of the + // elements on the page has a name or id of "length" + for ( i = 0; srcElements[i]; ++i ) { + // Ensure that the destination node is not null; Fixes #9587 + if ( destElements[i] ) { + cloneFixAttributes( srcElements[i], destElements[i] ); + } + } + } + + // Copy the events from the original to the clone + if ( dataAndEvents ) { + cloneCopyEvent( elem, clone ); + + if ( deepDataAndEvents ) { + srcElements = getAll( elem ); + destElements = getAll( clone ); + + for ( i = 0; srcElements[i]; ++i ) { + cloneCopyEvent( srcElements[i], destElements[i] ); + } + } + } + + srcElements = destElements = null; + + // Return the cloned set + return clone; + }, + + clean: function( elems, context, fragment, scripts ) { + var i, j, elem, tag, wrap, depth, div, hasBody, tbody, len, handleScript, jsTags, + safe = context === document && safeFragment, + ret = []; + + // Ensure that context is a document + if ( !context || typeof context.createDocumentFragment === "undefined" ) { + context = document; + } + + // Use the already-created safe fragment if context permits + for ( i = 0; (elem = elems[i]) != null; i++ ) { + if ( typeof elem === "number" ) { + elem += ""; + } + + if ( !elem ) { + continue; + } + + // Convert html string into DOM nodes + if ( typeof elem === "string" ) { + if ( !rhtml.test( elem ) ) { + elem = context.createTextNode( elem ); + } else { + // Ensure a safe container in which to render the html + safe = safe || createSafeFragment( context ); + div = context.createElement("div"); + safe.appendChild( div ); + + // Fix "XHTML"-style tags in all browsers + elem = elem.replace(rxhtmlTag, "<$1>"); + + // Go to html and back, then peel off extra wrappers + tag = ( rtagName.exec( elem ) || ["", ""] )[1].toLowerCase(); + wrap = wrapMap[ tag ] || wrapMap._default; + depth = wrap[0]; + div.innerHTML = wrap[1] + elem + wrap[2]; + + // Move to the right depth + while ( depth-- ) { + div = div.lastChild; + } + + // Remove IE's autoinserted from table fragments + if ( !jQuery.support.tbody ) { + + // String was a , *may* have spurious + hasBody = rtbody.test(elem); + tbody = tag === "table" && !hasBody ? + div.firstChild && div.firstChild.childNodes : + + // String was a bare or + wrap[1] === "
            " && !hasBody ? + div.childNodes : + []; + + for ( j = tbody.length - 1; j >= 0 ; --j ) { + if ( jQuery.nodeName( tbody[ j ], "tbody" ) && !tbody[ j ].childNodes.length ) { + tbody[ j ].parentNode.removeChild( tbody[ j ] ); + } + } + } + + // IE completely kills leading whitespace when innerHTML is used + if ( !jQuery.support.leadingWhitespace && rleadingWhitespace.test( elem ) ) { + div.insertBefore( context.createTextNode( rleadingWhitespace.exec(elem)[0] ), div.firstChild ); + } + + elem = div.childNodes; + + // Take out of fragment container (we need a fresh div each time) + div.parentNode.removeChild( div ); + } + } + + if ( elem.nodeType ) { + ret.push( elem ); + } else { + jQuery.merge( ret, elem ); + } + } + + // Fix #11356: Clear elements from safeFragment + if ( div ) { + elem = div = safe = null; + } + + // Reset defaultChecked for any radios and checkboxes + // about to be appended to the DOM in IE 6/7 (#8060) + if ( !jQuery.support.appendChecked ) { + for ( i = 0; (elem = ret[i]) != null; i++ ) { + if ( jQuery.nodeName( elem, "input" ) ) { + fixDefaultChecked( elem ); + } else if ( typeof elem.getElementsByTagName !== "undefined" ) { + jQuery.grep( elem.getElementsByTagName("input"), fixDefaultChecked ); + } + } + } + + // Append elements to a provided document fragment + if ( fragment ) { + // Special handling of each script element + handleScript = function( elem ) { + // Check if we consider it executable + if ( !elem.type || rscriptType.test( elem.type ) ) { + // Detach the script and store it in the scripts array (if provided) or the fragment + // Return truthy to indicate that it has been handled + return scripts ? + scripts.push( elem.parentNode ? elem.parentNode.removeChild( elem ) : elem ) : + fragment.appendChild( elem ); + } + }; + + for ( i = 0; (elem = ret[i]) != null; i++ ) { + // Check if we're done after handling an executable script + if ( !( jQuery.nodeName( elem, "script" ) && handleScript( elem ) ) ) { + // Append to fragment and handle embedded scripts + fragment.appendChild( elem ); + if ( typeof elem.getElementsByTagName !== "undefined" ) { + // handleScript alters the DOM, so use jQuery.merge to ensure snapshot iteration + jsTags = jQuery.grep( jQuery.merge( [], elem.getElementsByTagName("script") ), handleScript ); + + // Splice the scripts into ret after their former ancestor and advance our index beyond them + ret.splice.apply( ret, [i + 1, 0].concat( jsTags ) ); + i += jsTags.length; + } + } + } + } + + return ret; + }, + + cleanData: function( elems, /* internal */ acceptData ) { + var data, id, elem, type, + i = 0, + internalKey = jQuery.expando, + cache = jQuery.cache, + deleteExpando = jQuery.support.deleteExpando, + special = jQuery.event.special; + + for ( ; (elem = elems[i]) != null; i++ ) { + + if ( acceptData || jQuery.acceptData( elem ) ) { + + id = elem[ internalKey ]; + data = id && cache[ id ]; + + if ( data ) { + if ( data.events ) { + for ( type in data.events ) { + if ( special[ type ] ) { + jQuery.event.remove( elem, type ); + + // This is a shortcut to avoid jQuery.event.remove's overhead + } else { + jQuery.removeEvent( elem, type, data.handle ); + } + } + } + + // Remove cache only if it was not already removed by jQuery.event.remove + if ( cache[ id ] ) { + + delete cache[ id ]; + + // IE does not allow us to delete expando properties from nodes, + // nor does it have a removeAttribute function on Document nodes; + // we must handle all of these cases + if ( deleteExpando ) { + delete elem[ internalKey ]; + + } else if ( elem.removeAttribute ) { + elem.removeAttribute( internalKey ); + + } else { + elem[ internalKey ] = null; + } + + jQuery.deletedIds.push( id ); + } + } + } + } + } +}); +// Limit scope pollution from any deprecated API +(function() { + +var matched, browser; + +// Use of jQuery.browser is frowned upon. +// More details: http://api.jquery.com/jQuery.browser +// jQuery.uaMatch maintained for back-compat +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; + +jQuery.sub = function() { + function jQuerySub( selector, context ) { + return new jQuerySub.fn.init( selector, context ); + } + jQuery.extend( true, jQuerySub, this ); + jQuerySub.superclass = this; + jQuerySub.fn = jQuerySub.prototype = this(); + jQuerySub.fn.constructor = jQuerySub; + jQuerySub.sub = this.sub; + jQuerySub.fn.init = function init( selector, context ) { + if ( context && context instanceof jQuery && !(context instanceof jQuerySub) ) { + context = jQuerySub( context ); + } + + return jQuery.fn.init.call( this, selector, context, rootjQuerySub ); + }; + jQuerySub.fn.init.prototype = jQuerySub.fn; + var rootjQuerySub = jQuerySub(document); + return jQuerySub; +}; + +})(); +var curCSS, iframe, iframeDoc, + ralpha = /alpha\([^)]*\)/i, + ropacity = /opacity=([^)]*)/, + rposition = /^(top|right|bottom|left)$/, + // swappable if display is none or starts with table except "table", "table-cell", or "table-caption" + // see here for display values: https://developer.mozilla.org/en-US/docs/CSS/display + rdisplayswap = /^(none|table(?!-c[ea]).+)/, + rmargin = /^margin/, + rnumsplit = new RegExp( "^(" + core_pnum + ")(.*)$", "i" ), + rnumnonpx = new RegExp( "^(" + core_pnum + ")(?!px)[a-z%]+$", "i" ), + rrelNum = new RegExp( "^([-+])=(" + core_pnum + ")", "i" ), + elemdisplay = {}, + + cssShow = { position: "absolute", visibility: "hidden", display: "block" }, + cssNormalTransform = { + letterSpacing: 0, + fontWeight: 400 + }, + + cssExpand = [ "Top", "Right", "Bottom", "Left" ], + cssPrefixes = [ "Webkit", "O", "Moz", "ms" ], + + eventsToggle = jQuery.fn.toggle; + +// return a css property mapped to a potentially vendor prefixed property +function vendorPropName( style, name ) { + + // shortcut for names that are not vendor prefixed + if ( name in style ) { + return name; + } + + // check for vendor prefixed names + var capName = name.charAt(0).toUpperCase() + name.slice(1), + origName = name, + i = cssPrefixes.length; + + while ( i-- ) { + name = cssPrefixes[ i ] + capName; + if ( name in style ) { + return name; + } + } + + return origName; +} + +function isHidden( elem, el ) { + elem = el || elem; + return jQuery.css( elem, "display" ) === "none" || !jQuery.contains( elem.ownerDocument, elem ); +} + +function showHide( elements, show ) { + var elem, display, + values = [], + index = 0, + length = elements.length; + + for ( ; index < length; index++ ) { + elem = elements[ index ]; + if ( !elem.style ) { + continue; + } + values[ index ] = jQuery._data( elem, "olddisplay" ); + if ( show ) { + // Reset the inline display of this element to learn if it is + // being hidden by cascaded rules or not + if ( !values[ index ] && elem.style.display === "none" ) { + elem.style.display = ""; + } + + // Set elements which have been overridden with display: none + // in a stylesheet to whatever the default browser style is + // for such an element + if ( elem.style.display === "" && isHidden( elem ) ) { + values[ index ] = jQuery._data( elem, "olddisplay", css_defaultDisplay(elem.nodeName) ); + } + } else { + display = curCSS( elem, "display" ); + + if ( !values[ index ] && display !== "none" ) { + jQuery._data( elem, "olddisplay", display ); + } + } + } + + // Set the display of most of the elements in a second loop + // to avoid the constant reflow + for ( index = 0; index < length; index++ ) { + elem = elements[ index ]; + if ( !elem.style ) { + continue; + } + if ( !show || elem.style.display === "none" || elem.style.display === "" ) { + elem.style.display = show ? values[ index ] || "" : "none"; + } + } + + return elements; +} + +jQuery.fn.extend({ + css: function( name, value ) { + return jQuery.access( this, function( elem, name, value ) { + return value !== undefined ? + jQuery.style( elem, name, value ) : + jQuery.css( elem, name ); + }, name, value, arguments.length > 1 ); + }, + show: function() { + return showHide( this, true ); + }, + hide: function() { + return showHide( this ); + }, + toggle: function( state, fn2 ) { + var bool = typeof state === "boolean"; + + if ( jQuery.isFunction( state ) && jQuery.isFunction( fn2 ) ) { + return eventsToggle.apply( this, arguments ); + } + + return this.each(function() { + if ( bool ? state : isHidden( this ) ) { + jQuery( this ).show(); + } else { + jQuery( this ).hide(); + } + }); + } +}); + +jQuery.extend({ + // Add in style property hooks for overriding the default + // behavior of getting and setting a style property + cssHooks: { + opacity: { + get: function( elem, computed ) { + if ( computed ) { + // We should always get a number back from opacity + var ret = curCSS( elem, "opacity" ); + return ret === "" ? "1" : ret; + + } + } + } + }, + + // Exclude the following css properties to add px + cssNumber: { + "fillOpacity": true, + "fontWeight": true, + "lineHeight": true, + "opacity": true, + "orphans": true, + "widows": true, + "zIndex": true, + "zoom": true + }, + + // Add in properties whose names you wish to fix before + // setting or getting the value + cssProps: { + // normalize float css property + "float": jQuery.support.cssFloat ? "cssFloat" : "styleFloat" + }, + + // Get and set the style property on a DOM Node + style: function( elem, name, value, extra ) { + // Don't set styles on text and comment nodes + if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) { + return; + } + + // Make sure that we're working with the right name + var ret, type, hooks, + origName = jQuery.camelCase( name ), + style = elem.style; + + name = jQuery.cssProps[ origName ] || ( jQuery.cssProps[ origName ] = vendorPropName( style, origName ) ); + + // gets hook for the prefixed version + // followed by the unprefixed version + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // Check if we're setting a value + if ( value !== undefined ) { + type = typeof value; + + // convert relative number strings (+= or -=) to relative numbers. #7345 + if ( type === "string" && (ret = rrelNum.exec( value )) ) { + value = ( ret[1] + 1 ) * ret[2] + parseFloat( jQuery.css( elem, name ) ); + // Fixes bug #9237 + type = "number"; + } + + // Make sure that NaN and null values aren't set. See: #7116 + if ( value == null || type === "number" && isNaN( value ) ) { + return; + } + + // If a number was passed in, add 'px' to the (except for certain CSS properties) + if ( type === "number" && !jQuery.cssNumber[ origName ] ) { + value += "px"; + } + + // If a hook was provided, use that value, otherwise just set the specified value + if ( !hooks || !("set" in hooks) || (value = hooks.set( elem, value, extra )) !== undefined ) { + // Wrapped to prevent IE from throwing errors when 'invalid' values are provided + // Fixes bug #5509 + try { + style[ name ] = value; + } catch(e) {} + } + + } else { + // If a hook was provided get the non-computed value from there + if ( hooks && "get" in hooks && (ret = hooks.get( elem, false, extra )) !== undefined ) { + return ret; + } + + // Otherwise just get the value from the style object + return style[ name ]; + } + }, + + css: function( elem, name, numeric, extra ) { + var val, num, hooks, + origName = jQuery.camelCase( name ); + + // Make sure that we're working with the right name + name = jQuery.cssProps[ origName ] || ( jQuery.cssProps[ origName ] = vendorPropName( elem.style, origName ) ); + + // gets hook for the prefixed version + // followed by the unprefixed version + hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; + + // If a hook was provided get the computed value from there + if ( hooks && "get" in hooks ) { + val = hooks.get( elem, true, extra ); + } + + // Otherwise, if a way to get the computed value exists, use that + if ( val === undefined ) { + val = curCSS( elem, name ); + } + + //convert "normal" to computed value + if ( val === "normal" && name in cssNormalTransform ) { + val = cssNormalTransform[ name ]; + } + + // Return, converting to number if forced or a qualifier was provided and val looks numeric + if ( numeric || extra !== undefined ) { + num = parseFloat( val ); + return numeric || jQuery.isNumeric( num ) ? num || 0 : val; + } + return val; + }, + + // A method for quickly swapping in/out CSS properties to get correct calculations + swap: function( elem, options, callback ) { + var ret, name, + old = {}; + + // Remember the old values, and insert the new ones + for ( name in options ) { + old[ name ] = elem.style[ name ]; + elem.style[ name ] = options[ name ]; + } + + ret = callback.call( elem ); + + // Revert the old values + for ( name in options ) { + elem.style[ name ] = old[ name ]; + } + + return ret; + } +}); + +// NOTE: To any future maintainer, we've window.getComputedStyle +// because jsdom on node.js will break without it. +if ( window.getComputedStyle ) { + curCSS = function( elem, name ) { + var ret, width, minWidth, maxWidth, + computed = window.getComputedStyle( elem, null ), + style = elem.style; + + if ( computed ) { + + ret = computed[ name ]; + if ( ret === "" && !jQuery.contains( elem.ownerDocument, elem ) ) { + ret = jQuery.style( elem, name ); + } + + // A tribute to the "awesome hack by Dean Edwards" + // Chrome < 17 and Safari 5.0 uses "computed value" instead of "used value" for margin-right + // Safari 5.1.7 (at least) returns percentage for a larger set of values, but width seems to be reliably pixels + // this is against the CSSOM draft spec: http://dev.w3.org/csswg/cssom/#resolved-values + if ( rnumnonpx.test( ret ) && rmargin.test( name ) ) { + width = style.width; + minWidth = style.minWidth; + maxWidth = style.maxWidth; + + style.minWidth = style.maxWidth = style.width = ret; + ret = computed.width; + + style.width = width; + style.minWidth = minWidth; + style.maxWidth = maxWidth; + } + } + + return ret; + }; +} else if ( document.documentElement.currentStyle ) { + curCSS = function( elem, name ) { + var left, rsLeft, + ret = elem.currentStyle && elem.currentStyle[ name ], + style = elem.style; + + // Avoid setting ret to empty string here + // so we don't default to auto + if ( ret == null && style && style[ name ] ) { + ret = style[ name ]; + } + + // From the awesome hack by Dean Edwards + // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 + + // If we're not dealing with a regular pixel number + // but a number that has a weird ending, we need to convert it to pixels + // but not position css attributes, as those are proportional to the parent element instead + // and we can't measure the parent instead because it might trigger a "stacking dolls" problem + if ( rnumnonpx.test( ret ) && !rposition.test( name ) ) { + + // Remember the original values + left = style.left; + rsLeft = elem.runtimeStyle && elem.runtimeStyle.left; + + // Put in the new values to get a computed value out + if ( rsLeft ) { + elem.runtimeStyle.left = elem.currentStyle.left; + } + style.left = name === "fontSize" ? "1em" : ret; + ret = style.pixelLeft + "px"; + + // Revert the changed values + style.left = left; + if ( rsLeft ) { + elem.runtimeStyle.left = rsLeft; + } + } + + return ret === "" ? "auto" : ret; + }; +} + +function setPositiveNumber( elem, value, subtract ) { + var matches = rnumsplit.exec( value ); + return matches ? + Math.max( 0, matches[ 1 ] - ( subtract || 0 ) ) + ( matches[ 2 ] || "px" ) : + value; +} + +function augmentWidthOrHeight( elem, name, extra, isBorderBox ) { + var i = extra === ( isBorderBox ? "border" : "content" ) ? + // If we already have the right measurement, avoid augmentation + 4 : + // Otherwise initialize for horizontal or vertical properties + name === "width" ? 1 : 0, + + val = 0; + + for ( ; i < 4; i += 2 ) { + // both box models exclude margin, so add it if we want it + if ( extra === "margin" ) { + // we use jQuery.css instead of curCSS here + // because of the reliableMarginRight CSS hook! + val += jQuery.css( elem, extra + cssExpand[ i ], true ); + } + + // From this point on we use curCSS for maximum performance (relevant in animations) + if ( isBorderBox ) { + // border-box includes padding, so remove it if we want content + if ( extra === "content" ) { + val -= parseFloat( curCSS( elem, "padding" + cssExpand[ i ] ) ) || 0; + } + + // at this point, extra isn't border nor margin, so remove border + if ( extra !== "margin" ) { + val -= parseFloat( curCSS( elem, "border" + cssExpand[ i ] + "Width" ) ) || 0; + } + } else { + // at this point, extra isn't content, so add padding + val += parseFloat( curCSS( elem, "padding" + cssExpand[ i ] ) ) || 0; + + // at this point, extra isn't content nor padding, so add border + if ( extra !== "padding" ) { + val += parseFloat( curCSS( elem, "border" + cssExpand[ i ] + "Width" ) ) || 0; + } + } + } + + return val; +} + +function getWidthOrHeight( elem, name, extra ) { + + // Start with offset property, which is equivalent to the border-box value + var val = name === "width" ? elem.offsetWidth : elem.offsetHeight, + valueIsBorderBox = true, + isBorderBox = jQuery.support.boxSizing && jQuery.css( elem, "boxSizing" ) === "border-box"; + + // some non-html elements return undefined for offsetWidth, so check for null/undefined + // svg - https://bugzilla.mozilla.org/show_bug.cgi?id=649285 + // MathML - https://bugzilla.mozilla.org/show_bug.cgi?id=491668 + if ( val <= 0 || val == null ) { + // Fall back to computed then uncomputed css if necessary + val = curCSS( elem, name ); + if ( val < 0 || val == null ) { + val = elem.style[ name ]; + } + + // Computed unit is not pixels. Stop here and return. + if ( rnumnonpx.test(val) ) { + return val; + } + + // we need the check for style in case a browser which returns unreliable values + // for getComputedStyle silently falls back to the reliable elem.style + valueIsBorderBox = isBorderBox && ( jQuery.support.boxSizingReliable || val === elem.style[ name ] ); + + // Normalize "", auto, and prepare for extra + val = parseFloat( val ) || 0; + } + + // use the active box-sizing model to add/subtract irrelevant styles + return ( val + + augmentWidthOrHeight( + elem, + name, + extra || ( isBorderBox ? "border" : "content" ), + valueIsBorderBox + ) + ) + "px"; +} + + +// Try to determine the default display value of an element +function css_defaultDisplay( nodeName ) { + if ( elemdisplay[ nodeName ] ) { + return elemdisplay[ nodeName ]; + } + + var elem = jQuery( "<" + nodeName + ">" ).appendTo( document.body ), + display = elem.css("display"); + elem.remove(); + + // If the simple way fails, + // get element's real default display by attaching it to a temp iframe + if ( display === "none" || display === "" ) { + // Use the already-created iframe if possible + iframe = document.body.appendChild( + iframe || jQuery.extend( document.createElement("iframe"), { + frameBorder: 0, + width: 0, + height: 0 + }) + ); + + // Create a cacheable copy of the iframe document on first call. + // IE and Opera will allow us to reuse the iframeDoc without re-writing the fake HTML + // document to it; WebKit & Firefox won't allow reusing the iframe document. + if ( !iframeDoc || !iframe.createElement ) { + iframeDoc = ( iframe.contentWindow || iframe.contentDocument ).document; + iframeDoc.write(""); + iframeDoc.close(); + } + + elem = iframeDoc.body.appendChild( iframeDoc.createElement(nodeName) ); + + display = curCSS( elem, "display" ); + document.body.removeChild( iframe ); + } + + // Store the correct default display + elemdisplay[ nodeName ] = display; + + return display; +} + +jQuery.each([ "height", "width" ], function( i, name ) { + jQuery.cssHooks[ name ] = { + get: function( elem, computed, extra ) { + if ( computed ) { + // certain elements can have dimension info if we invisibly show them + // however, it must have a current display style that would benefit from this + if ( elem.offsetWidth === 0 && rdisplayswap.test( curCSS( elem, "display" ) ) ) { + return jQuery.swap( elem, cssShow, function() { + return getWidthOrHeight( elem, name, extra ); + }); + } else { + return getWidthOrHeight( elem, name, extra ); + } + } + }, + + set: function( elem, value, extra ) { + return setPositiveNumber( elem, value, extra ? + augmentWidthOrHeight( + elem, + name, + extra, + jQuery.support.boxSizing && jQuery.css( elem, "boxSizing" ) === "border-box" + ) : 0 + ); + } + }; +}); + +if ( !jQuery.support.opacity ) { + jQuery.cssHooks.opacity = { + get: function( elem, computed ) { + // IE uses filters for opacity + return ropacity.test( (computed && elem.currentStyle ? elem.currentStyle.filter : elem.style.filter) || "" ) ? + ( 0.01 * parseFloat( RegExp.$1 ) ) + "" : + computed ? "1" : ""; + }, + + set: function( elem, value ) { + var style = elem.style, + currentStyle = elem.currentStyle, + opacity = jQuery.isNumeric( value ) ? "alpha(opacity=" + value * 100 + ")" : "", + filter = currentStyle && currentStyle.filter || style.filter || ""; + + // IE has trouble with opacity if it does not have layout + // Force it by setting the zoom level + style.zoom = 1; + + // if setting opacity to 1, and no other filters exist - attempt to remove filter attribute #6652 + if ( value >= 1 && jQuery.trim( filter.replace( ralpha, "" ) ) === "" && + style.removeAttribute ) { + + // Setting style.filter to null, "" & " " still leave "filter:" in the cssText + // if "filter:" is present at all, clearType is disabled, we want to avoid this + // style.removeAttribute is IE Only, but so apparently is this code path... + style.removeAttribute( "filter" ); + + // if there there is no filter style applied in a css rule, we are done + if ( currentStyle && !currentStyle.filter ) { + return; + } + } + + // otherwise, set new filter values + style.filter = ralpha.test( filter ) ? + filter.replace( ralpha, opacity ) : + filter + " " + opacity; + } + }; +} + +// These hooks cannot be added until DOM ready because the support test +// for it is not run until after DOM ready +jQuery(function() { + if ( !jQuery.support.reliableMarginRight ) { + jQuery.cssHooks.marginRight = { + get: function( elem, computed ) { + // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right + // Work around by temporarily setting element display to inline-block + return jQuery.swap( elem, { "display": "inline-block" }, function() { + if ( computed ) { + return curCSS( elem, "marginRight" ); + } + }); + } + }; + } + + // Webkit bug: https://bugs.webkit.org/show_bug.cgi?id=29084 + // getComputedStyle returns percent when specified for top/left/bottom/right + // rather than make the css module depend on the offset module, we just check for it here + if ( !jQuery.support.pixelPosition && jQuery.fn.position ) { + jQuery.each( [ "top", "left" ], function( i, prop ) { + jQuery.cssHooks[ prop ] = { + get: function( elem, computed ) { + if ( computed ) { + var ret = curCSS( elem, prop ); + // if curCSS returns percentage, fallback to offset + return rnumnonpx.test( ret ) ? jQuery( elem ).position()[ prop ] + "px" : ret; + } + } + }; + }); + } + +}); + +if ( jQuery.expr && jQuery.expr.filters ) { + jQuery.expr.filters.hidden = function( elem ) { + return ( elem.offsetWidth === 0 && elem.offsetHeight === 0 ) || (!jQuery.support.reliableHiddenOffsets && ((elem.style && elem.style.display) || curCSS( elem, "display" )) === "none"); + }; + + jQuery.expr.filters.visible = function( elem ) { + return !jQuery.expr.filters.hidden( elem ); + }; +} + +// These hooks are used by animate to expand properties +jQuery.each({ + margin: "", + padding: "", + border: "Width" +}, function( prefix, suffix ) { + jQuery.cssHooks[ prefix + suffix ] = { + expand: function( value ) { + var i, + + // assumes a single number if not a string + parts = typeof value === "string" ? value.split(" ") : [ value ], + expanded = {}; + + for ( i = 0; i < 4; i++ ) { + expanded[ prefix + cssExpand[ i ] + suffix ] = + parts[ i ] || parts[ i - 2 ] || parts[ 0 ]; + } + + return expanded; + } + }; + + if ( !rmargin.test( prefix ) ) { + jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber; + } +}); +var r20 = /%20/g, + rbracket = /\[\]$/, + rCRLF = /\r?\n/g, + rinput = /^(?:color|date|datetime|datetime-local|email|hidden|month|number|password|range|search|tel|text|time|url|week)$/i, + rselectTextarea = /^(?:select|textarea)/i; + +jQuery.fn.extend({ + serialize: function() { + return jQuery.param( this.serializeArray() ); + }, + serializeArray: function() { + return this.map(function(){ + return this.elements ? jQuery.makeArray( this.elements ) : this; + }) + .filter(function(){ + return this.name && !this.disabled && + ( this.checked || rselectTextarea.test( this.nodeName ) || + rinput.test( this.type ) ); + }) + .map(function( i, elem ){ + var val = jQuery( this ).val(); + + return val == null ? + null : + jQuery.isArray( val ) ? + jQuery.map( val, function( val, i ){ + return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + }) : + { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; + }).get(); + } +}); + +//Serialize an array of form elements or a set of +//key/values into a query string +jQuery.param = function( a, traditional ) { + var prefix, + s = [], + add = function( key, value ) { + // If value is a function, invoke it and return its value + value = jQuery.isFunction( value ) ? value() : ( value == null ? "" : value ); + s[ s.length ] = encodeURIComponent( key ) + "=" + encodeURIComponent( value ); + }; + + // Set traditional to true for jQuery <= 1.3.2 behavior. + if ( traditional === undefined ) { + traditional = jQuery.ajaxSettings && jQuery.ajaxSettings.traditional; + } + + // If an array was passed in, assume that it is an array of form elements. + if ( jQuery.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) { + // Serialize the form elements + jQuery.each( a, function() { + add( this.name, this.value ); + }); + + } else { + // If traditional, encode the "old" way (the way 1.3.2 or older + // did it), otherwise encode params recursively. + for ( prefix in a ) { + buildParams( prefix, a[ prefix ], traditional, add ); + } + } + + // Return the resulting serialization + return s.join( "&" ).replace( r20, "+" ); +}; + +function buildParams( prefix, obj, traditional, add ) { + var name; + + if ( jQuery.isArray( obj ) ) { + // Serialize array item. + jQuery.each( obj, function( i, v ) { + if ( traditional || rbracket.test( prefix ) ) { + // Treat each array item as a scalar. + add( prefix, v ); + + } else { + // If array item is non-scalar (array or object), encode its + // numeric index to resolve deserialization ambiguity issues. + // Note that rack (as of 1.0.0) can't currently deserialize + // nested arrays properly, and attempting to do so may cause + // a server error. Possible fixes are to modify rack's + // deserialization algorithm or to provide an option or flag + // to force array serialization to be shallow. + buildParams( prefix + "[" + ( typeof v === "object" ? i : "" ) + "]", v, traditional, add ); + } + }); + + } else if ( !traditional && jQuery.type( obj ) === "object" ) { + // Serialize object item. + for ( name in obj ) { + buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add ); + } + + } else { + // Serialize scalar item. + add( prefix, obj ); + } +} +var + // Document location + ajaxLocParts, + ajaxLocation, + + rhash = /#.*$/, + rheaders = /^(.*?):[ \t]*([^\r\n]*)\r?$/mg, // IE leaves an \r character at EOL + // #7653, #8125, #8152: local protocol detection + rlocalProtocol = /^(?:about|app|app\-storage|.+\-extension|file|res|widget):$/, + rnoContent = /^(?:GET|HEAD)$/, + rprotocol = /^\/\//, + rquery = /\?/, + rscript = /)<[^<]*)*<\/script>/gi, + rts = /([?&])_=[^&]*/, + rurl = /^([\w\+\.\-]+:)(?:\/\/([^\/?#:]*)(?::(\d+)|)|)/, + + // Keep a copy of the old load method + _load = jQuery.fn.load, + + /* Prefilters + * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example) + * 2) These are called: + * - BEFORE asking for a transport + * - AFTER param serialization (s.data is a string if s.processData is true) + * 3) key is the dataType + * 4) the catchall symbol "*" can be used + * 5) execution will start with transport dataType and THEN continue down to "*" if needed + */ + prefilters = {}, + + /* Transports bindings + * 1) key is the dataType + * 2) the catchall symbol "*" can be used + * 3) selection will start with transport dataType and THEN go to "*" if needed + */ + transports = {}, + + // Avoid comment-prolog char sequence (#10098); must appease lint and evade compression + allTypes = ["*/"] + ["*"]; + +// #8138, IE may throw an exception when accessing +// a field from window.location if document.domain has been set +try { + ajaxLocation = location.href; +} catch( e ) { + // Use the href attribute of an A element + // since IE will modify it given document.location + ajaxLocation = document.createElement( "a" ); + ajaxLocation.href = ""; + ajaxLocation = ajaxLocation.href; +} + +// Segment location into parts +ajaxLocParts = rurl.exec( ajaxLocation.toLowerCase() ) || []; + +// Base "constructor" for jQuery.ajaxPrefilter and jQuery.ajaxTransport +function addToPrefiltersOrTransports( structure ) { + + // dataTypeExpression is optional and defaults to "*" + return function( dataTypeExpression, func ) { + + if ( typeof dataTypeExpression !== "string" ) { + func = dataTypeExpression; + dataTypeExpression = "*"; + } + + var dataType, list, placeBefore, + dataTypes = dataTypeExpression.toLowerCase().split( core_rspace ), + i = 0, + length = dataTypes.length; + + if ( jQuery.isFunction( func ) ) { + // For each dataType in the dataTypeExpression + for ( ; i < length; i++ ) { + dataType = dataTypes[ i ]; + // We control if we're asked to add before + // any existing element + placeBefore = /^\+/.test( dataType ); + if ( placeBefore ) { + dataType = dataType.substr( 1 ) || "*"; + } + list = structure[ dataType ] = structure[ dataType ] || []; + // then we add to the structure accordingly + list[ placeBefore ? "unshift" : "push" ]( func ); + } + } + }; +} + +// Base inspection function for prefilters and transports +function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR, + dataType /* internal */, inspected /* internal */ ) { + + dataType = dataType || options.dataTypes[ 0 ]; + inspected = inspected || {}; + + inspected[ dataType ] = true; + + var selection, + list = structure[ dataType ], + i = 0, + length = list ? list.length : 0, + executeOnly = ( structure === prefilters ); + + for ( ; i < length && ( executeOnly || !selection ); i++ ) { + selection = list[ i ]( options, originalOptions, jqXHR ); + // If we got redirected to another dataType + // we try there if executing only and not done already + if ( typeof selection === "string" ) { + if ( !executeOnly || inspected[ selection ] ) { + selection = undefined; + } else { + options.dataTypes.unshift( selection ); + selection = inspectPrefiltersOrTransports( + structure, options, originalOptions, jqXHR, selection, inspected ); + } + } + } + // If we're only executing or nothing was selected + // we try the catchall dataType if not done already + if ( ( executeOnly || !selection ) && !inspected[ "*" ] ) { + selection = inspectPrefiltersOrTransports( + structure, options, originalOptions, jqXHR, "*", inspected ); + } + // unnecessary when only executing (prefilters) + // but it'll be ignored by the caller in that case + return selection; +} + +// A special extend for ajax options +// that takes "flat" options (not to be deep extended) +// Fixes #9887 +function ajaxExtend( target, src ) { + var key, deep, + flatOptions = jQuery.ajaxSettings.flatOptions || {}; + for ( key in src ) { + if ( src[ key ] !== undefined ) { + ( flatOptions[ key ] ? target : ( deep || ( deep = {} ) ) )[ key ] = src[ key ]; + } + } + if ( deep ) { + jQuery.extend( true, target, deep ); + } +} + +jQuery.fn.load = function( url, params, callback ) { + if ( typeof url !== "string" && _load ) { + return _load.apply( this, arguments ); + } + + // Don't do a request if no elements are being requested + if ( !this.length ) { + return this; + } + + var selector, type, response, + self = this, + off = url.indexOf(" "); + + if ( off >= 0 ) { + selector = url.slice( off, url.length ); + url = url.slice( 0, off ); + } + + // If it's a function + if ( jQuery.isFunction( params ) ) { + + // We assume that it's the callback + callback = params; + params = undefined; + + // Otherwise, build a param string + } else if ( params && typeof params === "object" ) { + type = "POST"; + } + + // Request the remote document + jQuery.ajax({ + url: url, + + // if "type" variable is undefined, then "GET" method will be used + type: type, + dataType: "html", + data: params, + complete: function( jqXHR, status ) { + if ( callback ) { + self.each( callback, response || [ jqXHR.responseText, status, jqXHR ] ); + } + } + }).done(function( responseText ) { + + // Save response for use in complete callback + response = arguments; + + // See if a selector was specified + self.html( selector ? + + // Create a dummy div to hold the results + jQuery("
            ") + + // inject the contents of the document in, removing the scripts + // to avoid any 'Permission Denied' errors in IE + .append( responseText.replace( rscript, "" ) ) + + // Locate the specified elements + .find( selector ) : + + // If not, just inject the full result + responseText ); + + }); + + return this; +}; + +// Attach a bunch of functions for handling common AJAX events +jQuery.each( "ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split( " " ), function( i, o ){ + jQuery.fn[ o ] = function( f ){ + return this.on( o, f ); + }; +}); + +jQuery.each( [ "get", "post" ], function( i, method ) { + jQuery[ method ] = function( url, data, callback, type ) { + // shift arguments if data argument was omitted + if ( jQuery.isFunction( data ) ) { + type = type || callback; + callback = data; + data = undefined; + } + + return jQuery.ajax({ + type: method, + url: url, + data: data, + success: callback, + dataType: type + }); + }; +}); + +jQuery.extend({ + + getScript: function( url, callback ) { + return jQuery.get( url, undefined, callback, "script" ); + }, + + getJSON: function( url, data, callback ) { + return jQuery.get( url, data, callback, "json" ); + }, + + // Creates a full fledged settings object into target + // with both ajaxSettings and settings fields. + // If target is omitted, writes into ajaxSettings. + ajaxSetup: function( target, settings ) { + if ( settings ) { + // Building a settings object + ajaxExtend( target, jQuery.ajaxSettings ); + } else { + // Extending ajaxSettings + settings = target; + target = jQuery.ajaxSettings; + } + ajaxExtend( target, settings ); + return target; + }, + + ajaxSettings: { + url: ajaxLocation, + isLocal: rlocalProtocol.test( ajaxLocParts[ 1 ] ), + global: true, + type: "GET", + contentType: "application/x-www-form-urlencoded; charset=UTF-8", + processData: true, + async: true, + /* + timeout: 0, + data: null, + dataType: null, + username: null, + password: null, + cache: null, + throws: false, + traditional: false, + headers: {}, + */ + + accepts: { + xml: "application/xml, text/xml", + html: "text/html", + text: "text/plain", + json: "application/json, text/javascript", + "*": allTypes + }, + + contents: { + xml: /xml/, + html: /html/, + json: /json/ + }, + + responseFields: { + xml: "responseXML", + text: "responseText" + }, + + // List of data converters + // 1) key format is "source_type destination_type" (a single space in-between) + // 2) the catchall symbol "*" can be used for source_type + converters: { + + // Convert anything to text + "* text": window.String, + + // Text to html (true = no transformation) + "text html": true, + + // Evaluate text as a json expression + "text json": jQuery.parseJSON, + + // Parse text as xml + "text xml": jQuery.parseXML + }, + + // For options that shouldn't be deep extended: + // you can add your own custom options here if + // and when you create one that shouldn't be + // deep extended (see ajaxExtend) + flatOptions: { + context: true, + url: true + } + }, + + ajaxPrefilter: addToPrefiltersOrTransports( prefilters ), + ajaxTransport: addToPrefiltersOrTransports( transports ), + + // Main method + ajax: function( url, options ) { + + // If url is an object, simulate pre-1.5 signature + if ( typeof url === "object" ) { + options = url; + url = undefined; + } + + // Force options to be an object + options = options || {}; + + var // ifModified key + ifModifiedKey, + // Response headers + responseHeadersString, + responseHeaders, + // transport + transport, + // timeout handle + timeoutTimer, + // Cross-domain detection vars + parts, + // To know if global events are to be dispatched + fireGlobals, + // Loop variable + i, + // Create the final options object + s = jQuery.ajaxSetup( {}, options ), + // Callbacks context + callbackContext = s.context || s, + // Context for global events + // It's the callbackContext if one was provided in the options + // and if it's a DOM node or a jQuery collection + globalEventContext = callbackContext !== s && + ( callbackContext.nodeType || callbackContext instanceof jQuery ) ? + jQuery( callbackContext ) : jQuery.event, + // Deferreds + deferred = jQuery.Deferred(), + completeDeferred = jQuery.Callbacks( "once memory" ), + // Status-dependent callbacks + statusCode = s.statusCode || {}, + // Headers (they are sent all at once) + requestHeaders = {}, + requestHeadersNames = {}, + // The jqXHR state + state = 0, + // Default abort message + strAbort = "canceled", + // Fake xhr + jqXHR = { + + readyState: 0, + + // Caches the header + setRequestHeader: function( name, value ) { + if ( !state ) { + var lname = name.toLowerCase(); + name = requestHeadersNames[ lname ] = requestHeadersNames[ lname ] || name; + requestHeaders[ name ] = value; + } + return this; + }, + + // Raw string + getAllResponseHeaders: function() { + return state === 2 ? responseHeadersString : null; + }, + + // Builds headers hashtable if needed + getResponseHeader: function( key ) { + var match; + if ( state === 2 ) { + if ( !responseHeaders ) { + responseHeaders = {}; + while( ( match = rheaders.exec( responseHeadersString ) ) ) { + responseHeaders[ match[1].toLowerCase() ] = match[ 2 ]; + } + } + match = responseHeaders[ key.toLowerCase() ]; + } + return match === undefined ? null : match; + }, + + // Overrides response content-type header + overrideMimeType: function( type ) { + if ( !state ) { + s.mimeType = type; + } + return this; + }, + + // Cancel the request + abort: function( statusText ) { + statusText = statusText || strAbort; + if ( transport ) { + transport.abort( statusText ); + } + done( 0, statusText ); + return this; + } + }; + + // Callback for when everything is done + // It is defined here because jslint complains if it is declared + // at the end of the function (which would be more logical and readable) + function done( status, nativeStatusText, responses, headers ) { + var isSuccess, success, error, response, modified, + statusText = nativeStatusText; + + // Called once + if ( state === 2 ) { + return; + } + + // State is "done" now + state = 2; + + // Clear timeout if it exists + if ( timeoutTimer ) { + clearTimeout( timeoutTimer ); + } + + // Dereference transport for early garbage collection + // (no matter how long the jqXHR object will be used) + transport = undefined; + + // Cache response headers + responseHeadersString = headers || ""; + + // Set readyState + jqXHR.readyState = status > 0 ? 4 : 0; + + // Get response data + if ( responses ) { + response = ajaxHandleResponses( s, jqXHR, responses ); + } + + // If successful, handle type chaining + if ( status >= 200 && status < 300 || status === 304 ) { + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + + modified = jqXHR.getResponseHeader("Last-Modified"); + if ( modified ) { + jQuery.lastModified[ ifModifiedKey ] = modified; + } + modified = jqXHR.getResponseHeader("Etag"); + if ( modified ) { + jQuery.etag[ ifModifiedKey ] = modified; + } + } + + // If not modified + if ( status === 304 ) { + + statusText = "notmodified"; + isSuccess = true; + + // If we have data + } else { + + isSuccess = ajaxConvert( s, response ); + statusText = isSuccess.state; + success = isSuccess.data; + error = isSuccess.error; + isSuccess = !error; + } + } else { + // We extract error from statusText + // then normalize statusText and status for non-aborts + error = statusText; + if ( !statusText || status ) { + statusText = "error"; + if ( status < 0 ) { + status = 0; + } + } + } + + // Set data for the fake xhr object + jqXHR.status = status; + jqXHR.statusText = ( nativeStatusText || statusText ) + ""; + + // Success/Error + if ( isSuccess ) { + deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); + } else { + deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); + } + + // Status-dependent callbacks + jqXHR.statusCode( statusCode ); + statusCode = undefined; + + if ( fireGlobals ) { + globalEventContext.trigger( "ajax" + ( isSuccess ? "Success" : "Error" ), + [ jqXHR, s, isSuccess ? success : error ] ); + } + + // Complete + completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] ); + + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxComplete", [ jqXHR, s ] ); + // Handle the global AJAX counter + if ( !( --jQuery.active ) ) { + jQuery.event.trigger( "ajaxStop" ); + } + } + } + + // Attach deferreds + deferred.promise( jqXHR ); + jqXHR.success = jqXHR.done; + jqXHR.error = jqXHR.fail; + jqXHR.complete = completeDeferred.add; + + // Status-dependent callbacks + jqXHR.statusCode = function( map ) { + if ( map ) { + var tmp; + if ( state < 2 ) { + for ( tmp in map ) { + statusCode[ tmp ] = [ statusCode[tmp], map[tmp] ]; + } + } else { + tmp = map[ jqXHR.status ]; + jqXHR.always( tmp ); + } + } + return this; + }; + + // Remove hash character (#7531: and string promotion) + // Add protocol if not provided (#5866: IE7 issue with protocol-less urls) + // We also use the url parameter if available + s.url = ( ( url || s.url ) + "" ).replace( rhash, "" ).replace( rprotocol, ajaxLocParts[ 1 ] + "//" ); + + // Extract dataTypes list + s.dataTypes = jQuery.trim( s.dataType || "*" ).toLowerCase().split( core_rspace ); + + // A cross-domain request is in order when we have a protocol:host:port mismatch + if ( s.crossDomain == null ) { + parts = rurl.exec( s.url.toLowerCase() ) || false; + s.crossDomain = parts && ( parts.join(":") + ( parts[ 3 ] ? "" : parts[ 1 ] === "http:" ? 80 : 443 ) ) !== + ( ajaxLocParts.join(":") + ( ajaxLocParts[ 3 ] ? "" : ajaxLocParts[ 1 ] === "http:" ? 80 : 443 ) ); + } + + // Convert data if not already a string + if ( s.data && s.processData && typeof s.data !== "string" ) { + s.data = jQuery.param( s.data, s.traditional ); + } + + // Apply prefilters + inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); + + // If request was aborted inside a prefilter, stop there + if ( state === 2 ) { + return jqXHR; + } + + // We can fire global events as of now if asked to + fireGlobals = s.global; + + // Uppercase the type + s.type = s.type.toUpperCase(); + + // Determine if request has content + s.hasContent = !rnoContent.test( s.type ); + + // Watch for a new set of requests + if ( fireGlobals && jQuery.active++ === 0 ) { + jQuery.event.trigger( "ajaxStart" ); + } + + // More options handling for requests with no content + if ( !s.hasContent ) { + + // If data is available, append data to url + if ( s.data ) { + s.url += ( rquery.test( s.url ) ? "&" : "?" ) + s.data; + // #9682: remove data so that it's not used in an eventual retry + delete s.data; + } + + // Get ifModifiedKey before adding the anti-cache parameter + ifModifiedKey = s.url; + + // Add anti-cache in url if needed + if ( s.cache === false ) { + + var ts = jQuery.now(), + // try replacing _= if it is there + ret = s.url.replace( rts, "$1_=" + ts ); + + // if nothing was replaced, add timestamp to the end + s.url = ret + ( ( ret === s.url ) ? ( rquery.test( s.url ) ? "&" : "?" ) + "_=" + ts : "" ); + } + } + + // Set the correct header, if data is being sent + if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) { + jqXHR.setRequestHeader( "Content-Type", s.contentType ); + } + + // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. + if ( s.ifModified ) { + ifModifiedKey = ifModifiedKey || s.url; + if ( jQuery.lastModified[ ifModifiedKey ] ) { + jqXHR.setRequestHeader( "If-Modified-Since", jQuery.lastModified[ ifModifiedKey ] ); + } + if ( jQuery.etag[ ifModifiedKey ] ) { + jqXHR.setRequestHeader( "If-None-Match", jQuery.etag[ ifModifiedKey ] ); + } + } + + // Set the Accepts header for the server, depending on the dataType + jqXHR.setRequestHeader( + "Accept", + s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[0] ] ? + s.accepts[ s.dataTypes[0] ] + ( s.dataTypes[ 0 ] !== "*" ? ", " + allTypes + "; q=0.01" : "" ) : + s.accepts[ "*" ] + ); + + // Check for headers option + for ( i in s.headers ) { + jqXHR.setRequestHeader( i, s.headers[ i ] ); + } + + // Allow custom headers/mimetypes and early abort + if ( s.beforeSend && ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || state === 2 ) ) { + // Abort if not done already and return + return jqXHR.abort(); + + } + + // aborting is no longer a cancellation + strAbort = "abort"; + + // Install callbacks on deferreds + for ( i in { success: 1, error: 1, complete: 1 } ) { + jqXHR[ i ]( s[ i ] ); + } + + // Get transport + transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR ); + + // If no transport, we auto-abort + if ( !transport ) { + done( -1, "No Transport" ); + } else { + jqXHR.readyState = 1; + // Send global event + if ( fireGlobals ) { + globalEventContext.trigger( "ajaxSend", [ jqXHR, s ] ); + } + // Timeout + if ( s.async && s.timeout > 0 ) { + timeoutTimer = setTimeout( function(){ + jqXHR.abort( "timeout" ); + }, s.timeout ); + } + + try { + state = 1; + transport.send( requestHeaders, done ); + } catch (e) { + // Propagate exception as error if not done + if ( state < 2 ) { + done( -1, e ); + // Simply rethrow otherwise + } else { + throw e; + } + } + } + + return jqXHR; + }, + + // Counter for holding the number of active queries + active: 0, + + // Last-Modified header cache for next request + lastModified: {}, + etag: {} + +}); + +/* Handles responses to an ajax request: + * - sets all responseXXX fields accordingly + * - finds the right dataType (mediates between content-type and expected dataType) + * - returns the corresponding response + */ +function ajaxHandleResponses( s, jqXHR, responses ) { + + var ct, type, finalDataType, firstDataType, + contents = s.contents, + dataTypes = s.dataTypes, + responseFields = s.responseFields; + + // Fill responseXXX fields + for ( type in responseFields ) { + if ( type in responses ) { + jqXHR[ responseFields[type] ] = responses[ type ]; + } + } + + // Remove auto dataType and get content-type in the process + while( dataTypes[ 0 ] === "*" ) { + dataTypes.shift(); + if ( ct === undefined ) { + ct = s.mimeType || jqXHR.getResponseHeader( "content-type" ); + } + } + + // Check if we're dealing with a known content-type + if ( ct ) { + for ( type in contents ) { + if ( contents[ type ] && contents[ type ].test( ct ) ) { + dataTypes.unshift( type ); + break; + } + } + } + + // Check to see if we have a response for the expected dataType + if ( dataTypes[ 0 ] in responses ) { + finalDataType = dataTypes[ 0 ]; + } else { + // Try convertible dataTypes + for ( type in responses ) { + if ( !dataTypes[ 0 ] || s.converters[ type + " " + dataTypes[0] ] ) { + finalDataType = type; + break; + } + if ( !firstDataType ) { + firstDataType = type; + } + } + // Or just use first one + finalDataType = finalDataType || firstDataType; + } + + // If we found a dataType + // We add the dataType to the list if needed + // and return the corresponding response + if ( finalDataType ) { + if ( finalDataType !== dataTypes[ 0 ] ) { + dataTypes.unshift( finalDataType ); + } + return responses[ finalDataType ]; + } +} + +// Chain conversions given the request and the original response +function ajaxConvert( s, response ) { + + var conv, conv2, current, tmp, + // Work with a copy of dataTypes in case we need to modify it for conversion + dataTypes = s.dataTypes.slice(), + prev = dataTypes[ 0 ], + converters = {}, + i = 0; + + // Apply the dataFilter if provided + if ( s.dataFilter ) { + response = s.dataFilter( response, s.dataType ); + } + + // Create converters map with lowercased keys + if ( dataTypes[ 1 ] ) { + for ( conv in s.converters ) { + converters[ conv.toLowerCase() ] = s.converters[ conv ]; + } + } + + // Convert to each sequential dataType, tolerating list modification + for ( ; (current = dataTypes[++i]); ) { + + // There's only work to do if current dataType is non-auto + if ( current !== "*" ) { + + // Convert response if prev dataType is non-auto and differs from current + if ( prev !== "*" && prev !== current ) { + + // Seek a direct converter + conv = converters[ prev + " " + current ] || converters[ "* " + current ]; + + // If none found, seek a pair + if ( !conv ) { + for ( conv2 in converters ) { + + // If conv2 outputs current + tmp = conv2.split(" "); + if ( tmp[ 1 ] === current ) { + + // If prev can be converted to accepted input + conv = converters[ prev + " " + tmp[ 0 ] ] || + converters[ "* " + tmp[ 0 ] ]; + if ( conv ) { + // Condense equivalence converters + if ( conv === true ) { + conv = converters[ conv2 ]; + + // Otherwise, insert the intermediate dataType + } else if ( converters[ conv2 ] !== true ) { + current = tmp[ 0 ]; + dataTypes.splice( i--, 0, current ); + } + + break; + } + } + } + } + + // Apply converter (if not an equivalence) + if ( conv !== true ) { + + // Unless errors are allowed to bubble, catch and return them + if ( conv && s["throws"] ) { + response = conv( response ); + } else { + try { + response = conv( response ); + } catch ( e ) { + return { state: "parsererror", error: conv ? e : "No conversion from " + prev + " to " + current }; + } + } + } + } + + // Update prev for next iteration + prev = current; + } + } + + return { state: "success", data: response }; +} +var oldCallbacks = [], + rquestion = /\?/, + rjsonp = /(=)\?(?=&|$)|\?\?/, + nonce = jQuery.now(); + +// Default jsonp settings +jQuery.ajaxSetup({ + jsonp: "callback", + jsonpCallback: function() { + var callback = oldCallbacks.pop() || ( jQuery.expando + "_" + ( nonce++ ) ); + this[ callback ] = true; + return callback; + } +}); + +// Detect, normalize options and install callbacks for jsonp requests +jQuery.ajaxPrefilter( "json jsonp", function( s, originalSettings, jqXHR ) { + + var callbackName, overwritten, responseContainer, + data = s.data, + url = s.url, + hasCallback = s.jsonp !== false, + replaceInUrl = hasCallback && rjsonp.test( url ), + replaceInData = hasCallback && !replaceInUrl && typeof data === "string" && + !( s.contentType || "" ).indexOf("application/x-www-form-urlencoded") && + rjsonp.test( data ); + + // Handle iff the expected data type is "jsonp" or we have a parameter to set + if ( s.dataTypes[ 0 ] === "jsonp" || replaceInUrl || replaceInData ) { + + // Get callback name, remembering preexisting value associated with it + callbackName = s.jsonpCallback = jQuery.isFunction( s.jsonpCallback ) ? + s.jsonpCallback() : + s.jsonpCallback; + overwritten = window[ callbackName ]; + + // Insert callback into url or form data + if ( replaceInUrl ) { + s.url = url.replace( rjsonp, "$1" + callbackName ); + } else if ( replaceInData ) { + s.data = data.replace( rjsonp, "$1" + callbackName ); + } else if ( hasCallback ) { + s.url += ( rquestion.test( url ) ? "&" : "?" ) + s.jsonp + "=" + callbackName; + } + + // Use data converter to retrieve json after script execution + s.converters["script json"] = function() { + if ( !responseContainer ) { + jQuery.error( callbackName + " was not called" ); + } + return responseContainer[ 0 ]; + }; + + // force json dataType + s.dataTypes[ 0 ] = "json"; + + // Install callback + window[ callbackName ] = function() { + responseContainer = arguments; + }; + + // Clean-up function (fires after converters) + jqXHR.always(function() { + // Restore preexisting value + window[ callbackName ] = overwritten; + + // Save back as free + if ( s[ callbackName ] ) { + // make sure that re-using the options doesn't screw things around + s.jsonpCallback = originalSettings.jsonpCallback; + + // save the callback name for future use + oldCallbacks.push( callbackName ); + } + + // Call if it was a function and we have a response + if ( responseContainer && jQuery.isFunction( overwritten ) ) { + overwritten( responseContainer[ 0 ] ); + } + + responseContainer = overwritten = undefined; + }); + + // Delegate to script + return "script"; + } +}); +// Install script dataType +jQuery.ajaxSetup({ + accepts: { + script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript" + }, + contents: { + script: /javascript|ecmascript/ + }, + converters: { + "text script": function( text ) { + jQuery.globalEval( text ); + return text; + } + } +}); + +// Handle cache's special case and global +jQuery.ajaxPrefilter( "script", function( s ) { + if ( s.cache === undefined ) { + s.cache = false; + } + if ( s.crossDomain ) { + s.type = "GET"; + s.global = false; + } +}); + +// Bind script tag hack transport +jQuery.ajaxTransport( "script", function(s) { + + // This transport only deals with cross domain requests + if ( s.crossDomain ) { + + var script, + head = document.head || document.getElementsByTagName( "head" )[0] || document.documentElement; + + return { + + send: function( _, callback ) { + + script = document.createElement( "script" ); + + script.async = "async"; + + if ( s.scriptCharset ) { + script.charset = s.scriptCharset; + } + + script.src = s.url; + + // Attach handlers for all browsers + script.onload = script.onreadystatechange = function( _, isAbort ) { + + if ( isAbort || !script.readyState || /loaded|complete/.test( script.readyState ) ) { + + // Handle memory leak in IE + script.onload = script.onreadystatechange = null; + + // Remove the script + if ( head && script.parentNode ) { + head.removeChild( script ); + } + + // Dereference the script + script = undefined; + + // Callback if not abort + if ( !isAbort ) { + callback( 200, "success" ); + } + } + }; + // Use insertBefore instead of appendChild to circumvent an IE6 bug. + // This arises when a base node is used (#2709 and #4378). + head.insertBefore( script, head.firstChild ); + }, + + abort: function() { + if ( script ) { + script.onload( 0, 1 ); + } + } + }; + } +}); +var xhrCallbacks, + // #5280: Internet Explorer will keep connections alive if we don't abort on unload + xhrOnUnloadAbort = window.ActiveXObject ? function() { + // Abort all pending requests + for ( var key in xhrCallbacks ) { + xhrCallbacks[ key ]( 0, 1 ); + } + } : false, + xhrId = 0; + +// Functions to create xhrs +function createStandardXHR() { + try { + return new window.XMLHttpRequest(); + } catch( e ) {} +} + +function createActiveXHR() { + try { + return new window.ActiveXObject( "Microsoft.XMLHTTP" ); + } catch( e ) {} +} + +// Create the request object +// (This is still attached to ajaxSettings for backward compatibility) +jQuery.ajaxSettings.xhr = window.ActiveXObject ? + /* Microsoft failed to properly + * implement the XMLHttpRequest in IE7 (can't request local files), + * so we use the ActiveXObject when it is available + * Additionally XMLHttpRequest can be disabled in IE7/IE8 so + * we need a fallback. + */ + function() { + return !this.isLocal && createStandardXHR() || createActiveXHR(); + } : + // For all other browsers, use the standard XMLHttpRequest object + createStandardXHR; + +// Determine support properties +(function( xhr ) { + jQuery.extend( jQuery.support, { + ajax: !!xhr, + cors: !!xhr && ( "withCredentials" in xhr ) + }); +})( jQuery.ajaxSettings.xhr() ); + +// Create transport if the browser can provide an xhr +if ( jQuery.support.ajax ) { + + jQuery.ajaxTransport(function( s ) { + // Cross domain only allowed if supported through XMLHttpRequest + if ( !s.crossDomain || jQuery.support.cors ) { + + var callback; + + return { + send: function( headers, complete ) { + + // Get a new xhr + var handle, i, + xhr = s.xhr(); + + // Open the socket + // Passing null username, generates a login popup on Opera (#2865) + if ( s.username ) { + xhr.open( s.type, s.url, s.async, s.username, s.password ); + } else { + xhr.open( s.type, s.url, s.async ); + } + + // Apply custom fields if provided + if ( s.xhrFields ) { + for ( i in s.xhrFields ) { + xhr[ i ] = s.xhrFields[ i ]; + } + } + + // Override mime type if needed + if ( s.mimeType && xhr.overrideMimeType ) { + xhr.overrideMimeType( s.mimeType ); + } + + // X-Requested-With header + // For cross-domain requests, seeing as conditions for a preflight are + // akin to a jigsaw puzzle, we simply never set it to be sure. + // (it can always be set on a per-request basis or even using ajaxSetup) + // For same-domain requests, won't change header if already provided. + if ( !s.crossDomain && !headers["X-Requested-With"] ) { + headers[ "X-Requested-With" ] = "XMLHttpRequest"; + } + + // Need an extra try/catch for cross domain requests in Firefox 3 + try { + for ( i in headers ) { + xhr.setRequestHeader( i, headers[ i ] ); + } + } catch( _ ) {} + + // Do send the request + // This may raise an exception which is actually + // handled in jQuery.ajax (so no try/catch here) + xhr.send( ( s.hasContent && s.data ) || null ); + + // Listener + callback = function( _, isAbort ) { + + var status, + statusText, + responseHeaders, + responses, + xml; + + // Firefox throws exceptions when accessing properties + // of an xhr when a network error occurred + // http://helpful.knobs-dials.com/index.php/Component_returned_failure_code:_0x80040111_(NS_ERROR_NOT_AVAILABLE) + try { + + // Was never called and is aborted or complete + if ( callback && ( isAbort || xhr.readyState === 4 ) ) { + + // Only called once + callback = undefined; + + // Do not keep as active anymore + if ( handle ) { + xhr.onreadystatechange = jQuery.noop; + if ( xhrOnUnloadAbort ) { + delete xhrCallbacks[ handle ]; + } + } + + // If it's an abort + if ( isAbort ) { + // Abort it manually if needed + if ( xhr.readyState !== 4 ) { + xhr.abort(); + } + } else { + status = xhr.status; + responseHeaders = xhr.getAllResponseHeaders(); + responses = {}; + xml = xhr.responseXML; + + // Construct response list + if ( xml && xml.documentElement /* #4958 */ ) { + responses.xml = xml; + } + + // When requesting binary data, IE6-9 will throw an exception + // on any attempt to access responseText (#11426) + try { + responses.text = xhr.responseText; + } catch( _ ) { + } + + // Firefox throws an exception when accessing + // statusText for faulty cross-domain requests + try { + statusText = xhr.statusText; + } catch( e ) { + // We normalize with Webkit giving an empty statusText + statusText = ""; + } + + // Filter status for non standard behaviors + + // If the request is local and we have data: assume a success + // (success with no data won't get notified, that's the best we + // can do given current implementations) + if ( !status && s.isLocal && !s.crossDomain ) { + status = responses.text ? 200 : 404; + // IE - #1450: sometimes returns 1223 when it should be 204 + } else if ( status === 1223 ) { + status = 204; + } + } + } + } catch( firefoxAccessException ) { + if ( !isAbort ) { + complete( -1, firefoxAccessException ); + } + } + + // Call complete if needed + if ( responses ) { + complete( status, statusText, responses, responseHeaders ); + } + }; + + if ( !s.async ) { + // if we're in sync mode we fire the callback + callback(); + } else if ( xhr.readyState === 4 ) { + // (IE6 & IE7) if it's in cache and has been + // retrieved directly we need to fire the callback + setTimeout( callback, 0 ); + } else { + handle = ++xhrId; + if ( xhrOnUnloadAbort ) { + // Create the active xhrs callbacks list if needed + // and attach the unload handler + if ( !xhrCallbacks ) { + xhrCallbacks = {}; + jQuery( window ).unload( xhrOnUnloadAbort ); + } + // Add to list of active xhrs callbacks + xhrCallbacks[ handle ] = callback; + } + xhr.onreadystatechange = callback; + } + }, + + abort: function() { + if ( callback ) { + callback(0,1); + } + } + }; + } + }); +} +var fxNow, timerId, + rfxtypes = /^(?:toggle|show|hide)$/, + rfxnum = new RegExp( "^(?:([-+])=|)(" + core_pnum + ")([a-z%]*)$", "i" ), + rrun = /queueHooks$/, + animationPrefilters = [ defaultPrefilter ], + tweeners = { + "*": [function( prop, value ) { + var end, unit, + tween = this.createTween( prop, value ), + parts = rfxnum.exec( value ), + target = tween.cur(), + start = +target || 0, + scale = 1, + maxIterations = 20; + + if ( parts ) { + end = +parts[2]; + unit = parts[3] || ( jQuery.cssNumber[ prop ] ? "" : "px" ); + + // We need to compute starting value + if ( unit !== "px" && start ) { + // Iteratively approximate from a nonzero starting point + // Prefer the current property, because this process will be trivial if it uses the same units + // Fallback to end or a simple constant + start = jQuery.css( tween.elem, prop, true ) || end || 1; + + do { + // If previous iteration zeroed out, double until we get *something* + // Use a string for doubling factor so we don't accidentally see scale as unchanged below + scale = scale || ".5"; + + // Adjust and apply + start = start / scale; + jQuery.style( tween.elem, prop, start + unit ); + + // Update scale, tolerating zero or NaN from tween.cur() + // And breaking the loop if scale is unchanged or perfect, or if we've just had enough + } while ( scale !== (scale = tween.cur() / target) && scale !== 1 && --maxIterations ); + } + + tween.unit = unit; + tween.start = start; + // If a +=/-= token was provided, we're doing a relative animation + tween.end = parts[1] ? start + ( parts[1] + 1 ) * end : end; + } + return tween; + }] + }; + +// Animations created synchronously will run synchronously +function createFxNow() { + setTimeout(function() { + fxNow = undefined; + }, 0 ); + return ( fxNow = jQuery.now() ); +} + +function createTweens( animation, props ) { + jQuery.each( props, function( prop, value ) { + var collection = ( tweeners[ prop ] || [] ).concat( tweeners[ "*" ] ), + index = 0, + length = collection.length; + for ( ; index < length; index++ ) { + if ( collection[ index ].call( animation, prop, value ) ) { + + // we're done with this property + return; + } + } + }); +} + +function Animation( elem, properties, options ) { + var result, + index = 0, + tweenerIndex = 0, + length = animationPrefilters.length, + deferred = jQuery.Deferred().always( function() { + // don't match elem in the :animated selector + delete tick.elem; + }), + tick = function() { + var currentTime = fxNow || createFxNow(), + remaining = Math.max( 0, animation.startTime + animation.duration - currentTime ), + percent = 1 - ( remaining / animation.duration || 0 ), + index = 0, + length = animation.tweens.length; + + for ( ; index < length ; index++ ) { + animation.tweens[ index ].run( percent ); + } + + deferred.notifyWith( elem, [ animation, percent, remaining ]); + + if ( percent < 1 && length ) { + return remaining; + } else { + deferred.resolveWith( elem, [ animation ] ); + return false; + } + }, + animation = deferred.promise({ + elem: elem, + props: jQuery.extend( {}, properties ), + opts: jQuery.extend( true, { specialEasing: {} }, options ), + originalProperties: properties, + originalOptions: options, + startTime: fxNow || createFxNow(), + duration: options.duration, + tweens: [], + createTween: function( prop, end, easing ) { + var tween = jQuery.Tween( elem, animation.opts, prop, end, + animation.opts.specialEasing[ prop ] || animation.opts.easing ); + animation.tweens.push( tween ); + return tween; + }, + stop: function( gotoEnd ) { + var index = 0, + // if we are going to the end, we want to run all the tweens + // otherwise we skip this part + length = gotoEnd ? animation.tweens.length : 0; + + for ( ; index < length ; index++ ) { + animation.tweens[ index ].run( 1 ); + } + + // resolve when we played the last frame + // otherwise, reject + if ( gotoEnd ) { + deferred.resolveWith( elem, [ animation, gotoEnd ] ); + } else { + deferred.rejectWith( elem, [ animation, gotoEnd ] ); + } + return this; + } + }), + props = animation.props; + + propFilter( props, animation.opts.specialEasing ); + + for ( ; index < length ; index++ ) { + result = animationPrefilters[ index ].call( animation, elem, props, animation.opts ); + if ( result ) { + return result; + } + } + + createTweens( animation, props ); + + if ( jQuery.isFunction( animation.opts.start ) ) { + animation.opts.start.call( elem, animation ); + } + + jQuery.fx.timer( + jQuery.extend( tick, { + anim: animation, + queue: animation.opts.queue, + elem: elem + }) + ); + + // attach callbacks from options + return animation.progress( animation.opts.progress ) + .done( animation.opts.done, animation.opts.complete ) + .fail( animation.opts.fail ) + .always( animation.opts.always ); +} + +function propFilter( props, specialEasing ) { + var index, name, easing, value, hooks; + + // camelCase, specialEasing and expand cssHook pass + for ( index in props ) { + name = jQuery.camelCase( index ); + easing = specialEasing[ name ]; + value = props[ index ]; + if ( jQuery.isArray( value ) ) { + easing = value[ 1 ]; + value = props[ index ] = value[ 0 ]; + } + + if ( index !== name ) { + props[ name ] = value; + delete props[ index ]; + } + + hooks = jQuery.cssHooks[ name ]; + if ( hooks && "expand" in hooks ) { + value = hooks.expand( value ); + delete props[ name ]; + + // not quite $.extend, this wont overwrite keys already present. + // also - reusing 'index' from above because we have the correct "name" + for ( index in value ) { + if ( !( index in props ) ) { + props[ index ] = value[ index ]; + specialEasing[ index ] = easing; + } + } + } else { + specialEasing[ name ] = easing; + } + } +} + +jQuery.Animation = jQuery.extend( Animation, { + + tweener: function( props, callback ) { + if ( jQuery.isFunction( props ) ) { + callback = props; + props = [ "*" ]; + } else { + props = props.split(" "); + } + + var prop, + index = 0, + length = props.length; + + for ( ; index < length ; index++ ) { + prop = props[ index ]; + tweeners[ prop ] = tweeners[ prop ] || []; + tweeners[ prop ].unshift( callback ); + } + }, + + prefilter: function( callback, prepend ) { + if ( prepend ) { + animationPrefilters.unshift( callback ); + } else { + animationPrefilters.push( callback ); + } + } +}); + +function defaultPrefilter( elem, props, opts ) { + var index, prop, value, length, dataShow, tween, hooks, oldfire, + anim = this, + style = elem.style, + orig = {}, + handled = [], + hidden = elem.nodeType && isHidden( elem ); + + // handle queue: false promises + if ( !opts.queue ) { + hooks = jQuery._queueHooks( elem, "fx" ); + if ( hooks.unqueued == null ) { + hooks.unqueued = 0; + oldfire = hooks.empty.fire; + hooks.empty.fire = function() { + if ( !hooks.unqueued ) { + oldfire(); + } + }; + } + hooks.unqueued++; + + anim.always(function() { + // doing this makes sure that the complete handler will be called + // before this completes + anim.always(function() { + hooks.unqueued--; + if ( !jQuery.queue( elem, "fx" ).length ) { + hooks.empty.fire(); + } + }); + }); + } + + // height/width overflow pass + if ( elem.nodeType === 1 && ( "height" in props || "width" in props ) ) { + // Make sure that nothing sneaks out + // Record all 3 overflow attributes because IE does not + // change the overflow attribute when overflowX and + // overflowY are set to the same value + opts.overflow = [ style.overflow, style.overflowX, style.overflowY ]; + + // Set display property to inline-block for height/width + // animations on inline elements that are having width/height animated + if ( jQuery.css( elem, "display" ) === "inline" && + jQuery.css( elem, "float" ) === "none" ) { + + // inline-level elements accept inline-block; + // block-level elements need to be inline with layout + if ( !jQuery.support.inlineBlockNeedsLayout || css_defaultDisplay( elem.nodeName ) === "inline" ) { + style.display = "inline-block"; + + } else { + style.zoom = 1; + } + } + } + + if ( opts.overflow ) { + style.overflow = "hidden"; + if ( !jQuery.support.shrinkWrapBlocks ) { + anim.done(function() { + style.overflow = opts.overflow[ 0 ]; + style.overflowX = opts.overflow[ 1 ]; + style.overflowY = opts.overflow[ 2 ]; + }); + } + } + + + // show/hide pass + for ( index in props ) { + value = props[ index ]; + if ( rfxtypes.exec( value ) ) { + delete props[ index ]; + if ( value === ( hidden ? "hide" : "show" ) ) { + continue; + } + handled.push( index ); + } + } + + length = handled.length; + if ( length ) { + dataShow = jQuery._data( elem, "fxshow" ) || jQuery._data( elem, "fxshow", {} ); + if ( hidden ) { + jQuery( elem ).show(); + } else { + anim.done(function() { + jQuery( elem ).hide(); + }); + } + anim.done(function() { + var prop; + jQuery.removeData( elem, "fxshow", true ); + for ( prop in orig ) { + jQuery.style( elem, prop, orig[ prop ] ); + } + }); + for ( index = 0 ; index < length ; index++ ) { + prop = handled[ index ]; + tween = anim.createTween( prop, hidden ? dataShow[ prop ] : 0 ); + orig[ prop ] = dataShow[ prop ] || jQuery.style( elem, prop ); + + if ( !( prop in dataShow ) ) { + dataShow[ prop ] = tween.start; + if ( hidden ) { + tween.end = tween.start; + tween.start = prop === "width" || prop === "height" ? 1 : 0; + } + } + } + } +} + +function Tween( elem, options, prop, end, easing ) { + return new Tween.prototype.init( elem, options, prop, end, easing ); +} +jQuery.Tween = Tween; + +Tween.prototype = { + constructor: Tween, + init: function( elem, options, prop, end, easing, unit ) { + this.elem = elem; + this.prop = prop; + this.easing = easing || "swing"; + this.options = options; + this.start = this.now = this.cur(); + this.end = end; + this.unit = unit || ( jQuery.cssNumber[ prop ] ? "" : "px" ); + }, + cur: function() { + var hooks = Tween.propHooks[ this.prop ]; + + return hooks && hooks.get ? + hooks.get( this ) : + Tween.propHooks._default.get( this ); + }, + run: function( percent ) { + var eased, + hooks = Tween.propHooks[ this.prop ]; + + if ( this.options.duration ) { + this.pos = eased = jQuery.easing[ this.easing ]( + percent, this.options.duration * percent, 0, 1, this.options.duration + ); + } else { + this.pos = eased = percent; + } + this.now = ( this.end - this.start ) * eased + this.start; + + if ( this.options.step ) { + this.options.step.call( this.elem, this.now, this ); + } + + if ( hooks && hooks.set ) { + hooks.set( this ); + } else { + Tween.propHooks._default.set( this ); + } + return this; + } +}; + +Tween.prototype.init.prototype = Tween.prototype; + +Tween.propHooks = { + _default: { + get: function( tween ) { + var result; + + if ( tween.elem[ tween.prop ] != null && + (!tween.elem.style || tween.elem.style[ tween.prop ] == null) ) { + return tween.elem[ tween.prop ]; + } + + // passing any value as a 4th parameter to .css will automatically + // attempt a parseFloat and fallback to a string if the parse fails + // so, simple values such as "10px" are parsed to Float. + // complex values such as "rotate(1rad)" are returned as is. + result = jQuery.css( tween.elem, tween.prop, false, "" ); + // Empty strings, null, undefined and "auto" are converted to 0. + return !result || result === "auto" ? 0 : result; + }, + set: function( tween ) { + // use step hook for back compat - use cssHook if its there - use .style if its + // available and use plain properties where available + if ( jQuery.fx.step[ tween.prop ] ) { + jQuery.fx.step[ tween.prop ]( tween ); + } else if ( tween.elem.style && ( tween.elem.style[ jQuery.cssProps[ tween.prop ] ] != null || jQuery.cssHooks[ tween.prop ] ) ) { + jQuery.style( tween.elem, tween.prop, tween.now + tween.unit ); + } else { + tween.elem[ tween.prop ] = tween.now; + } + } + } +}; + +// Remove in 2.0 - this supports IE8's panic based approach +// to setting things on disconnected nodes + +Tween.propHooks.scrollTop = Tween.propHooks.scrollLeft = { + set: function( tween ) { + if ( tween.elem.nodeType && tween.elem.parentNode ) { + tween.elem[ tween.prop ] = tween.now; + } + } +}; + +jQuery.each([ "toggle", "show", "hide" ], function( i, name ) { + var cssFn = jQuery.fn[ name ]; + jQuery.fn[ name ] = function( speed, easing, callback ) { + return speed == null || typeof speed === "boolean" || + // special check for .toggle( handler, handler, ... ) + ( !i && jQuery.isFunction( speed ) && jQuery.isFunction( easing ) ) ? + cssFn.apply( this, arguments ) : + this.animate( genFx( name, true ), speed, easing, callback ); + }; +}); + +jQuery.fn.extend({ + fadeTo: function( speed, to, easing, callback ) { + + // show any hidden elements after setting opacity to 0 + return this.filter( isHidden ).css( "opacity", 0 ).show() + + // animate to the value specified + .end().animate({ opacity: to }, speed, easing, callback ); + }, + animate: function( prop, speed, easing, callback ) { + var empty = jQuery.isEmptyObject( prop ), + optall = jQuery.speed( speed, easing, callback ), + doAnimation = function() { + // Operate on a copy of prop so per-property easing won't be lost + var anim = Animation( this, jQuery.extend( {}, prop ), optall ); + + // Empty animations resolve immediately + if ( empty ) { + anim.stop( true ); + } + }; + + return empty || optall.queue === false ? + this.each( doAnimation ) : + this.queue( optall.queue, doAnimation ); + }, + stop: function( type, clearQueue, gotoEnd ) { + var stopQueue = function( hooks ) { + var stop = hooks.stop; + delete hooks.stop; + stop( gotoEnd ); + }; + + if ( typeof type !== "string" ) { + gotoEnd = clearQueue; + clearQueue = type; + type = undefined; + } + if ( clearQueue && type !== false ) { + this.queue( type || "fx", [] ); + } + + return this.each(function() { + var dequeue = true, + index = type != null && type + "queueHooks", + timers = jQuery.timers, + data = jQuery._data( this ); + + if ( index ) { + if ( data[ index ] && data[ index ].stop ) { + stopQueue( data[ index ] ); + } + } else { + for ( index in data ) { + if ( data[ index ] && data[ index ].stop && rrun.test( index ) ) { + stopQueue( data[ index ] ); + } + } + } + + for ( index = timers.length; index--; ) { + if ( timers[ index ].elem === this && (type == null || timers[ index ].queue === type) ) { + timers[ index ].anim.stop( gotoEnd ); + dequeue = false; + timers.splice( index, 1 ); + } + } + + // start the next in the queue if the last step wasn't forced + // timers currently will call their complete callbacks, which will dequeue + // but only if they were gotoEnd + if ( dequeue || !gotoEnd ) { + jQuery.dequeue( this, type ); + } + }); + } +}); + +// Generate parameters to create a standard animation +function genFx( type, includeWidth ) { + var which, + attrs = { height: type }, + i = 0; + + // if we include width, step value is 1 to do all cssExpand values, + // if we don't include width, step value is 2 to skip over Left and Right + includeWidth = includeWidth? 1 : 0; + for( ; i < 4 ; i += 2 - includeWidth ) { + which = cssExpand[ i ]; + attrs[ "margin" + which ] = attrs[ "padding" + which ] = type; + } + + if ( includeWidth ) { + attrs.opacity = attrs.width = type; + } + + return attrs; +} + +// Generate shortcuts for custom animations +jQuery.each({ + slideDown: genFx("show"), + slideUp: genFx("hide"), + slideToggle: genFx("toggle"), + fadeIn: { opacity: "show" }, + fadeOut: { opacity: "hide" }, + fadeToggle: { opacity: "toggle" } +}, function( name, props ) { + jQuery.fn[ name ] = function( speed, easing, callback ) { + return this.animate( props, speed, easing, callback ); + }; +}); + +jQuery.speed = function( speed, easing, fn ) { + var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : { + complete: fn || !fn && easing || + jQuery.isFunction( speed ) && speed, + duration: speed, + easing: fn && easing || easing && !jQuery.isFunction( easing ) && easing + }; + + opt.duration = jQuery.fx.off ? 0 : typeof opt.duration === "number" ? opt.duration : + opt.duration in jQuery.fx.speeds ? jQuery.fx.speeds[ opt.duration ] : jQuery.fx.speeds._default; + + // normalize opt.queue - true/undefined/null -> "fx" + if ( opt.queue == null || opt.queue === true ) { + opt.queue = "fx"; + } + + // Queueing + opt.old = opt.complete; + + opt.complete = function() { + if ( jQuery.isFunction( opt.old ) ) { + opt.old.call( this ); + } + + if ( opt.queue ) { + jQuery.dequeue( this, opt.queue ); + } + }; + + return opt; +}; + +jQuery.easing = { + linear: function( p ) { + return p; + }, + swing: function( p ) { + return 0.5 - Math.cos( p*Math.PI ) / 2; + } +}; + +jQuery.timers = []; +jQuery.fx = Tween.prototype.init; +jQuery.fx.tick = function() { + var timer, + timers = jQuery.timers, + i = 0; + + for ( ; i < timers.length; i++ ) { + timer = timers[ i ]; + // Checks the timer has not already been removed + if ( !timer() && timers[ i ] === timer ) { + timers.splice( i--, 1 ); + } + } + + if ( !timers.length ) { + jQuery.fx.stop(); + } +}; + +jQuery.fx.timer = function( timer ) { + if ( timer() && jQuery.timers.push( timer ) && !timerId ) { + timerId = setInterval( jQuery.fx.tick, jQuery.fx.interval ); + } +}; + +jQuery.fx.interval = 13; + +jQuery.fx.stop = function() { + clearInterval( timerId ); + timerId = null; +}; + +jQuery.fx.speeds = { + slow: 600, + fast: 200, + // Default speed + _default: 400 +}; + +// Back Compat <1.8 extension point +jQuery.fx.step = {}; + +if ( jQuery.expr && jQuery.expr.filters ) { + jQuery.expr.filters.animated = function( elem ) { + return jQuery.grep(jQuery.timers, function( fn ) { + return elem === fn.elem; + }).length; + }; +} +var rroot = /^(?:body|html)$/i; + +jQuery.fn.offset = function( options ) { + if ( arguments.length ) { + return options === undefined ? + this : + this.each(function( i ) { + jQuery.offset.setOffset( this, options, i ); + }); + } + + var docElem, body, win, clientTop, clientLeft, scrollTop, scrollLeft, + box = { top: 0, left: 0 }, + elem = this[ 0 ], + doc = elem && elem.ownerDocument; + + if ( !doc ) { + return; + } + + if ( (body = doc.body) === elem ) { + return jQuery.offset.bodyOffset( elem ); + } + + docElem = doc.documentElement; + + // Make sure it's not a disconnected DOM node + if ( !jQuery.contains( docElem, elem ) ) { + return box; + } + + // If we don't have gBCR, just use 0,0 rather than error + // BlackBerry 5, iOS 3 (original iPhone) + if ( typeof elem.getBoundingClientRect !== "undefined" ) { + box = elem.getBoundingClientRect(); + } + win = getWindow( doc ); + clientTop = docElem.clientTop || body.clientTop || 0; + clientLeft = docElem.clientLeft || body.clientLeft || 0; + scrollTop = win.pageYOffset || docElem.scrollTop; + scrollLeft = win.pageXOffset || docElem.scrollLeft; + return { + top: box.top + scrollTop - clientTop, + left: box.left + scrollLeft - clientLeft + }; +}; + +jQuery.offset = { + + bodyOffset: function( body ) { + var top = body.offsetTop, + left = body.offsetLeft; + + if ( jQuery.support.doesNotIncludeMarginInBodyOffset ) { + top += parseFloat( jQuery.css(body, "marginTop") ) || 0; + left += parseFloat( jQuery.css(body, "marginLeft") ) || 0; + } + + return { top: top, left: left }; + }, + + setOffset: function( elem, options, i ) { + var position = jQuery.css( elem, "position" ); + + // set position first, in-case top/left are set even on static elem + if ( position === "static" ) { + elem.style.position = "relative"; + } + + var curElem = jQuery( elem ), + curOffset = curElem.offset(), + curCSSTop = jQuery.css( elem, "top" ), + curCSSLeft = jQuery.css( elem, "left" ), + calculatePosition = ( position === "absolute" || position === "fixed" ) && jQuery.inArray("auto", [curCSSTop, curCSSLeft]) > -1, + props = {}, curPosition = {}, curTop, curLeft; + + // need to be able to calculate position if either top or left is auto and position is either absolute or fixed + if ( calculatePosition ) { + curPosition = curElem.position(); + curTop = curPosition.top; + curLeft = curPosition.left; + } else { + curTop = parseFloat( curCSSTop ) || 0; + curLeft = parseFloat( curCSSLeft ) || 0; + } + + if ( jQuery.isFunction( options ) ) { + options = options.call( elem, i, curOffset ); + } + + if ( options.top != null ) { + props.top = ( options.top - curOffset.top ) + curTop; + } + if ( options.left != null ) { + props.left = ( options.left - curOffset.left ) + curLeft; + } + + if ( "using" in options ) { + options.using.call( elem, props ); + } else { + curElem.css( props ); + } + } +}; + + +jQuery.fn.extend({ + + position: function() { + if ( !this[0] ) { + return; + } + + var elem = this[0], + + // Get *real* offsetParent + offsetParent = this.offsetParent(), + + // Get correct offsets + offset = this.offset(), + parentOffset = rroot.test(offsetParent[0].nodeName) ? { top: 0, left: 0 } : offsetParent.offset(); + + // Subtract element margins + // note: when an element has margin: auto the offsetLeft and marginLeft + // are the same in Safari causing offset.left to incorrectly be 0 + offset.top -= parseFloat( jQuery.css(elem, "marginTop") ) || 0; + offset.left -= parseFloat( jQuery.css(elem, "marginLeft") ) || 0; + + // Add offsetParent borders + parentOffset.top += parseFloat( jQuery.css(offsetParent[0], "borderTopWidth") ) || 0; + parentOffset.left += parseFloat( jQuery.css(offsetParent[0], "borderLeftWidth") ) || 0; + + // Subtract the two offsets + return { + top: offset.top - parentOffset.top, + left: offset.left - parentOffset.left + }; + }, + + offsetParent: function() { + return this.map(function() { + var offsetParent = this.offsetParent || document.body; + while ( offsetParent && (!rroot.test(offsetParent.nodeName) && jQuery.css(offsetParent, "position") === "static") ) { + offsetParent = offsetParent.offsetParent; + } + return offsetParent || document.body; + }); + } +}); + + +// Create scrollLeft and scrollTop methods +jQuery.each( {scrollLeft: "pageXOffset", scrollTop: "pageYOffset"}, function( method, prop ) { + var top = /Y/.test( prop ); + + jQuery.fn[ method ] = function( val ) { + return jQuery.access( this, function( elem, method, val ) { + var win = getWindow( elem ); + + if ( val === undefined ) { + return win ? (prop in win) ? win[ prop ] : + win.document.documentElement[ method ] : + elem[ method ]; + } + + if ( win ) { + win.scrollTo( + !top ? val : jQuery( win ).scrollLeft(), + top ? val : jQuery( win ).scrollTop() + ); + + } else { + elem[ method ] = val; + } + }, method, val, arguments.length, null ); + }; +}); + +function getWindow( elem ) { + return jQuery.isWindow( elem ) ? + elem : + elem.nodeType === 9 ? + elem.defaultView || elem.parentWindow : + false; +} +// Create innerHeight, innerWidth, height, width, outerHeight and outerWidth methods +jQuery.each( { Height: "height", Width: "width" }, function( name, type ) { + jQuery.each( { padding: "inner" + name, content: type, "": "outer" + name }, function( defaultExtra, funcName ) { + // margin is only for outerHeight, outerWidth + jQuery.fn[ funcName ] = function( margin, value ) { + var chainable = arguments.length && ( defaultExtra || typeof margin !== "boolean" ), + extra = defaultExtra || ( margin === true || value === true ? "margin" : "border" ); + + return jQuery.access( this, function( elem, type, value ) { + var doc; + + if ( jQuery.isWindow( elem ) ) { + // As of 5/8/2012 this will yield incorrect results for Mobile Safari, but there + // isn't a whole lot we can do. See pull request at this URL for discussion: + // https://github.com/jquery/jquery/pull/764 + return elem.document.documentElement[ "client" + name ]; + } + + // Get document width or height + if ( elem.nodeType === 9 ) { + doc = elem.documentElement; + + // Either scroll[Width/Height] or offset[Width/Height] or client[Width/Height], whichever is greatest + // unfortunately, this causes bug #3838 in IE6/8 only, but there is currently no good, small way to fix it. + return Math.max( + elem.body[ "scroll" + name ], doc[ "scroll" + name ], + elem.body[ "offset" + name ], doc[ "offset" + name ], + doc[ "client" + name ] + ); + } + + return value === undefined ? + // Get width or height on the element, requesting but not forcing parseFloat + jQuery.css( elem, type, value, extra ) : + + // Set width or height on the element + jQuery.style( elem, type, value, extra ); + }, type, chainable ? margin : undefined, chainable, null ); + }; + }); +}); +// Expose jQuery to the global object +window.jQuery = window.$ = jQuery; + +// Expose jQuery as an AMD module, but only for AMD loaders that +// understand the issues with loading multiple versions of jQuery +// in a page that all might call define(). The loader will indicate +// they have special allowances for multiple jQuery versions by +// specifying define.amd.jQuery = true. Register as a named module, +// since jQuery can be concatenated with other files that may use define, +// but not use a proper concatenation script that understands anonymous +// AMD modules. A named AMD is safest and most robust way to register. +// Lowercase jquery is used because AMD module names are derived from +// file names, and jQuery is normally delivered in a lowercase file name. +// Do this after creating the global so that if an AMD module wants to call +// noConflict to hide this version of jQuery, it will work. +if ( typeof define === "function" && define.amd && define.amd.jQuery ) { + define( "jquery", [], function () { return jQuery; } ); +} + +})( window ); diff --git a/app/assets/javascripts/external/jquery.ba-replacetext.js b/app/assets/javascripts/external/jquery.ba-replacetext.js new file mode 100644 index 00000000000..54955c8c69a --- /dev/null +++ b/app/assets/javascripts/external/jquery.ba-replacetext.js @@ -0,0 +1,129 @@ +/*! + * jQuery replaceText - v1.1 - 11/21/2009 + * http://benalman.com/projects/jquery-replacetext-plugin/ + * + * Copyright (c) 2009 "Cowboy" Ben Alman + * Dual licensed under the MIT and GPL licenses. + * http://benalman.com/about/license/ + */ + +// Script: jQuery replaceText: String replace for your jQueries! +// +// *Version: 1.1, Last updated: 11/21/2009* +// +// Project Home - http://benalman.com/projects/jquery-replacetext-plugin/ +// GitHub - http://github.com/cowboy/jquery-replacetext/ +// Source - http://github.com/cowboy/jquery-replacetext/raw/master/jquery.ba-replacetext.js +// (Minified) - http://github.com/cowboy/jquery-replacetext/raw/master/jquery.ba-replacetext.min.js (0.5kb) +// +// About: License +// +// Copyright (c) 2009 "Cowboy" Ben Alman, +// Dual licensed under the MIT and GPL licenses. +// http://benalman.com/about/license/ +// +// About: Examples +// +// This working example, complete with fully commented code, illustrates one way +// in which this plugin can be used. +// +// replaceText - http://benalman.com/code/projects/jquery-replacetext/examples/replacetext/ +// +// About: Support and Testing +// +// Information about what version or versions of jQuery this plugin has been +// tested with, and what browsers it has been tested in. +// +// jQuery Versions - 1.3.2, 1.4.1 +// Browsers Tested - Internet Explorer 6-8, Firefox 2-3.6, Safari 3-4, Chrome, Opera 9.6-10.1. +// +// About: Release History +// +// 1.1 - (11/21/2009) Simplified the code and API substantially. +// 1.0 - (11/21/2009) Initial release + +(function($){ + '$:nomunge'; // Used by YUI compressor. + + // Method: jQuery.fn.replaceText + // + // Replace text in specified elements. Note that only text content will be + // modified, leaving all tags and attributes untouched. The new text can be + // either text or HTML. + // + // Uses the String prototype replace method, full documentation on that method + // can be found here: + // + // https://developer.mozilla.org/En/Core_JavaScript_1.5_Reference/Objects/String/Replace + // + // Usage: + // + // > jQuery('selector').replaceText( search, replace [, text_only ] ); + // + // Arguments: + // + // search - (RegExp|String) A RegExp object or substring to be replaced. + // Because the String prototype replace method is used internally, this + // argument should be specified accordingly. + // replace - (String|Function) The String that replaces the substring received + // from the search argument, or a function to be invoked to create the new + // substring. Because the String prototype replace method is used internally, + // this argument should be specified accordingly. + // text_only - (Boolean) If true, any HTML will be rendered as text. Defaults + // to false. + // + // Returns: + // + // (jQuery) The initial jQuery collection of elements. + + $.fn.replaceText = function( search, replace, text_only ) { + return this.each(function(){ + var node = this.firstChild, + val, + new_val, + + // Elements to be removed at the end. + remove = []; + + // Only continue if firstChild exists. + if ( node ) { + + // Loop over all childNodes. + do { + + // Only process text nodes. + if ( node.nodeType === 3 ) { + + // The original node value. + val = node.nodeValue; + + // The new value. + new_val = val.replace( search, replace ); + + // Only replace text if the new value is actually different! + if ( new_val !== val ) { + + if ( !text_only && /" )[ 0 ], + + // colors = jQuery.Color.names + colors, + + // local aliases of functions called often + each = jQuery.each; + +// determine rgba support immediately +supportElem.style.cssText = "background-color:rgba(1,1,1,.5)"; +support.rgba = supportElem.style.backgroundColor.indexOf( "rgba" ) > -1; + +// define cache name and alpha properties +// for rgba and hsla spaces +each( spaces, function( spaceName, space ) { + space.cache = "_" + spaceName; + space.props.alpha = { + idx: 3, + type: "percent", + def: 1 + }; +}); + +function clamp( value, prop, allowEmpty ) { + var type = propTypes[ prop.type ] || {}; + + if ( value == null ) { + return (allowEmpty || !prop.def) ? null : prop.def; + } + + // ~~ is an short way of doing floor for positive numbers + value = type.floor ? ~~value : parseFloat( value ); + + // IE will pass in empty strings as value for alpha, + // which will hit this case + if ( isNaN( value ) ) { + return prop.def; + } + + if ( type.mod ) { + // we add mod before modding to make sure that negatives values + // get converted properly: -10 -> 350 + return (value + type.mod) % type.mod; + } + + // for now all property types without mod have min and max + return 0 > value ? 0 : type.max < value ? type.max : value; +} + +function stringParse( string ) { + var inst = color(), + rgba = inst._rgba = []; + + string = string.toLowerCase(); + + each( stringParsers, function( i, parser ) { + var parsed, + match = parser.re.exec( string ), + values = match && parser.parse( match ), + spaceName = parser.space || "rgba"; + + if ( values ) { + parsed = inst[ spaceName ]( values ); + + // if this was an rgba parse the assignment might happen twice + // oh well.... + inst[ spaces[ spaceName ].cache ] = parsed[ spaces[ spaceName ].cache ]; + rgba = inst._rgba = parsed._rgba; + + // exit each( stringParsers ) here because we matched + return false; + } + }); + + // Found a stringParser that handled it + if ( rgba.length ) { + + // if this came from a parsed string, force "transparent" when alpha is 0 + // chrome, (and maybe others) return "transparent" as rgba(0,0,0,0) + if ( rgba.join() === "0,0,0,0" ) { + jQuery.extend( rgba, colors.transparent ); + } + return inst; + } + + // named colors + return colors[ string ]; +} + +color.fn = jQuery.extend( color.prototype, { + parse: function( red, green, blue, alpha ) { + if ( red === undefined ) { + this._rgba = [ null, null, null, null ]; + return this; + } + if ( red.jquery || red.nodeType ) { + red = jQuery( red ).css( green ); + green = undefined; + } + + var inst = this, + type = jQuery.type( red ), + rgba = this._rgba = [], + source; + + // more than 1 argument specified - assume ( red, green, blue, alpha ) + if ( green !== undefined ) { + red = [ red, green, blue, alpha ]; + type = "array"; + } + + if ( type === "string" ) { + return this.parse( stringParse( red ) || colors._default ); + } + + if ( type === "array" ) { + each( spaces.rgba.props, function( key, prop ) { + rgba[ prop.idx ] = clamp( red[ prop.idx ], prop ); + }); + return this; + } + + if ( type === "object" ) { + if ( red instanceof color ) { + each( spaces, function( spaceName, space ) { + if ( red[ space.cache ] ) { + inst[ space.cache ] = red[ space.cache ].slice(); + } + }); + } else { + each( spaces, function( spaceName, space ) { + var cache = space.cache; + each( space.props, function( key, prop ) { + + // if the cache doesn't exist, and we know how to convert + if ( !inst[ cache ] && space.to ) { + + // if the value was null, we don't need to copy it + // if the key was alpha, we don't need to copy it either + if ( key === "alpha" || red[ key ] == null ) { + return; + } + inst[ cache ] = space.to( inst._rgba ); + } + + // this is the only case where we allow nulls for ALL properties. + // call clamp with alwaysAllowEmpty + inst[ cache ][ prop.idx ] = clamp( red[ key ], prop, true ); + }); + + // everything defined but alpha? + if ( inst[ cache ] && jQuery.inArray( null, inst[ cache ].slice( 0, 3 ) ) < 0 ) { + // use the default of 1 + inst[ cache ][ 3 ] = 1; + if ( space.from ) { + inst._rgba = space.from( inst[ cache ] ); + } + } + }); + } + return this; + } + }, + is: function( compare ) { + var is = color( compare ), + same = true, + inst = this; + + each( spaces, function( _, space ) { + var localCache, + isCache = is[ space.cache ]; + if (isCache) { + localCache = inst[ space.cache ] || space.to && space.to( inst._rgba ) || []; + each( space.props, function( _, prop ) { + if ( isCache[ prop.idx ] != null ) { + same = ( isCache[ prop.idx ] === localCache[ prop.idx ] ); + return same; + } + }); + } + return same; + }); + return same; + }, + _space: function() { + var used = [], + inst = this; + each( spaces, function( spaceName, space ) { + if ( inst[ space.cache ] ) { + used.push( spaceName ); + } + }); + return used.pop(); + }, + transition: function( other, distance ) { + var end = color( other ), + spaceName = end._space(), + space = spaces[ spaceName ], + startColor = this.alpha() === 0 ? color( "transparent" ) : this, + start = startColor[ space.cache ] || space.to( startColor._rgba ), + result = start.slice(); + + end = end[ space.cache ]; + each( space.props, function( key, prop ) { + var index = prop.idx, + startValue = start[ index ], + endValue = end[ index ], + type = propTypes[ prop.type ] || {}; + + // if null, don't override start value + if ( endValue === null ) { + return; + } + // if null - use end + if ( startValue === null ) { + result[ index ] = endValue; + } else { + if ( type.mod ) { + if ( endValue - startValue > type.mod / 2 ) { + startValue += type.mod; + } else if ( startValue - endValue > type.mod / 2 ) { + startValue -= type.mod; + } + } + result[ index ] = clamp( ( endValue - startValue ) * distance + startValue, prop ); + } + }); + return this[ spaceName ]( result ); + }, + blend: function( opaque ) { + // if we are already opaque - return ourself + if ( this._rgba[ 3 ] === 1 ) { + return this; + } + + var rgb = this._rgba.slice(), + a = rgb.pop(), + blend = color( opaque )._rgba; + + return color( jQuery.map( rgb, function( v, i ) { + return ( 1 - a ) * blend[ i ] + a * v; + })); + }, + toRgbaString: function() { + var prefix = "rgba(", + rgba = jQuery.map( this._rgba, function( v, i ) { + return v == null ? ( i > 2 ? 1 : 0 ) : v; + }); + + if ( rgba[ 3 ] === 1 ) { + rgba.pop(); + prefix = "rgb("; + } + + return prefix + rgba.join() + ")"; + }, + toHslaString: function() { + var prefix = "hsla(", + hsla = jQuery.map( this.hsla(), function( v, i ) { + if ( v == null ) { + v = i > 2 ? 1 : 0; + } + + // catch 1 and 2 + if ( i && i < 3 ) { + v = Math.round( v * 100 ) + "%"; + } + return v; + }); + + if ( hsla[ 3 ] === 1 ) { + hsla.pop(); + prefix = "hsl("; + } + return prefix + hsla.join() + ")"; + }, + toHexString: function( includeAlpha ) { + var rgba = this._rgba.slice(), + alpha = rgba.pop(); + + if ( includeAlpha ) { + rgba.push( ~~( alpha * 255 ) ); + } + + return "#" + jQuery.map( rgba, function( v, i ) { + + // default to 0 when nulls exist + v = ( v || 0 ).toString( 16 ); + return v.length === 1 ? "0" + v : v; + }).join(""); + }, + toString: function() { + return this._rgba[ 3 ] === 0 ? "transparent" : this.toRgbaString(); + } +}); +color.fn.parse.prototype = color.fn; + +// hsla conversions adapted from: +// https://code.google.com/p/maashaack/source/browse/packages/graphics/trunk/src/graphics/colors/HUE2RGB.as?r=5021 + +function hue2rgb( p, q, h ) { + h = ( h + 1 ) % 1; + if ( h * 6 < 1 ) { + return p + (q - p) * h * 6; + } + if ( h * 2 < 1) { + return q; + } + if ( h * 3 < 2 ) { + return p + (q - p) * ((2/3) - h) * 6; + } + return p; +} + +spaces.hsla.to = function ( rgba ) { + if ( rgba[ 0 ] == null || rgba[ 1 ] == null || rgba[ 2 ] == null ) { + return [ null, null, null, rgba[ 3 ] ]; + } + var r = rgba[ 0 ] / 255, + g = rgba[ 1 ] / 255, + b = rgba[ 2 ] / 255, + a = rgba[ 3 ], + max = Math.max( r, g, b ), + min = Math.min( r, g, b ), + diff = max - min, + add = max + min, + l = add * 0.5, + h, s; + + if ( min === max ) { + h = 0; + } else if ( r === max ) { + h = ( 60 * ( g - b ) / diff ) + 360; + } else if ( g === max ) { + h = ( 60 * ( b - r ) / diff ) + 120; + } else { + h = ( 60 * ( r - g ) / diff ) + 240; + } + + if ( l === 0 || l === 1 ) { + s = l; + } else if ( l <= 0.5 ) { + s = diff / add; + } else { + s = diff / ( 2 - add ); + } + return [ Math.round(h) % 360, s, l, a == null ? 1 : a ]; +}; + +spaces.hsla.from = function ( hsla ) { + if ( hsla[ 0 ] == null || hsla[ 1 ] == null || hsla[ 2 ] == null ) { + return [ null, null, null, hsla[ 3 ] ]; + } + var h = hsla[ 0 ] / 360, + s = hsla[ 1 ], + l = hsla[ 2 ], + a = hsla[ 3 ], + q = l <= 0.5 ? l * ( 1 + s ) : l + s - l * s, + p = 2 * l - q, + r, g, b; + + return [ + Math.round( hue2rgb( p, q, h + ( 1 / 3 ) ) * 255 ), + Math.round( hue2rgb( p, q, h ) * 255 ), + Math.round( hue2rgb( p, q, h - ( 1 / 3 ) ) * 255 ), + a + ]; +}; + + +each( spaces, function( spaceName, space ) { + var props = space.props, + cache = space.cache, + to = space.to, + from = space.from; + + // makes rgba() and hsla() + color.fn[ spaceName ] = function( value ) { + + // generate a cache for this space if it doesn't exist + if ( to && !this[ cache ] ) { + this[ cache ] = to( this._rgba ); + } + if ( value === undefined ) { + return this[ cache ].slice(); + } + + var ret, + type = jQuery.type( value ), + arr = ( type === "array" || type === "object" ) ? value : arguments, + local = this[ cache ].slice(); + + each( props, function( key, prop ) { + var val = arr[ type === "object" ? key : prop.idx ]; + if ( val == null ) { + val = local[ prop.idx ]; + } + local[ prop.idx ] = clamp( val, prop ); + }); + + if ( from ) { + ret = color( from( local ) ); + ret[ cache ] = local; + return ret; + } else { + return color( local ); + } + }; + + // makes red() green() blue() alpha() hue() saturation() lightness() + each( props, function( key, prop ) { + // alpha is included in more than one space + if ( color.fn[ key ] ) { + return; + } + color.fn[ key ] = function( value ) { + var vtype = jQuery.type( value ), + fn = ( key === "alpha" ? ( this._hsla ? "hsla" : "rgba" ) : spaceName ), + local = this[ fn ](), + cur = local[ prop.idx ], + match; + + if ( vtype === "undefined" ) { + return cur; + } + + if ( vtype === "function" ) { + value = value.call( this, cur ); + vtype = jQuery.type( value ); + } + if ( value == null && prop.empty ) { + return this; + } + if ( vtype === "string" ) { + match = rplusequals.exec( value ); + if ( match ) { + value = cur + parseFloat( match[ 2 ] ) * ( match[ 1 ] === "+" ? 1 : -1 ); + } + } + local[ prop.idx ] = value; + return this[ fn ]( local ); + }; + }); +}); + +// add cssHook and .fx.step function for each named hook. +// accept a space separated string of properties +color.hook = function( hook ) { + var hooks = hook.split( " " ); + each( hooks, function( i, hook ) { + jQuery.cssHooks[ hook ] = { + set: function( elem, value ) { + var parsed, curElem, + backgroundColor = ""; + + if ( jQuery.type( value ) !== "string" || ( parsed = stringParse( value ) ) ) { + value = color( parsed || value ); + if ( !support.rgba && value._rgba[ 3 ] !== 1 ) { + curElem = hook === "backgroundColor" ? elem.parentNode : elem; + while ( + (backgroundColor === "" || backgroundColor === "transparent") && + curElem && curElem.style + ) { + try { + backgroundColor = jQuery.css( curElem, "backgroundColor" ); + curElem = curElem.parentNode; + } catch ( e ) { + } + } + + value = value.blend( backgroundColor && backgroundColor !== "transparent" ? + backgroundColor : + "_default" ); + } + + value = value.toRgbaString(); + } + try { + elem.style[ hook ] = value; + } catch( value ) { + // wrapped to prevent IE from throwing errors on "invalid" values like 'auto' or 'inherit' + } + } + }; + jQuery.fx.step[ hook ] = function( fx ) { + if ( !fx.colorInit ) { + fx.start = color( fx.elem, hook ); + fx.end = color( fx.end ); + fx.colorInit = true; + } + jQuery.cssHooks[ hook ].set( fx.elem, fx.start.transition( fx.end, fx.pos ) ); + }; + }); + +}; + +color.hook( stepHooks ); + +jQuery.cssHooks.borderColor = { + expand: function( value ) { + var expanded = {}; + + each( [ "Top", "Right", "Bottom", "Left" ], function( i, part ) { + expanded[ "border" + part + "Color" ] = value; + }); + return expanded; + } +}; + +// Basic color names only. +// Usage of any of the other color names requires adding yourself or including +// jquery.color.svg-names.js. +colors = jQuery.Color.names = { + // 4.1. Basic color keywords + aqua: "#00ffff", + black: "#000000", + blue: "#0000ff", + fuchsia: "#ff00ff", + gray: "#808080", + green: "#008000", + lime: "#00ff00", + maroon: "#800000", + navy: "#000080", + olive: "#808000", + purple: "#800080", + red: "#ff0000", + silver: "#c0c0c0", + teal: "#008080", + white: "#ffffff", + yellow: "#ffff00", + + // 4.2.3. ‘transparent’ color keyword + transparent: [ null, null, null, 0 ], + + _default: "#ffffff" +}; + +})( jQuery ); + diff --git a/app/assets/javascripts/external/jquery.cookie.js b/app/assets/javascripts/external/jquery.cookie.js new file mode 100644 index 00000000000..6d5974a2c57 --- /dev/null +++ b/app/assets/javascripts/external/jquery.cookie.js @@ -0,0 +1,47 @@ +/*! + * jQuery Cookie Plugin + * https://github.com/carhartl/jquery-cookie + * + * Copyright 2011, Klaus Hartl + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://www.opensource.org/licenses/mit-license.php + * http://www.opensource.org/licenses/GPL-2.0 + */ +(function($) { + $.cookie = function(key, value, options) { + + // key and at least value given, set cookie... + if (arguments.length > 1 && (!/Object/.test(Object.prototype.toString.call(value)) || value === null || value === undefined)) { + options = $.extend({}, options); + + if (value === null || value === undefined) { + options.expires = -1; + } + + if (typeof options.expires === 'number') { + var days = options.expires, t = options.expires = new Date(); + t.setDate(t.getDate() + days); + } + + value = String(value); + + return (document.cookie = [ + encodeURIComponent(key), '=', options.raw ? value : encodeURIComponent(value), + options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE + options.path ? '; path=' + options.path : '', + options.domain ? '; domain=' + options.domain : '', + options.secure ? '; secure' : '' + ].join('')); + } + + // key and possibly options given, get cookie... + options = value || {}; + var decode = options.raw ? function(s) { return s; } : decodeURIComponent; + + var pairs = document.cookie.split('; '); + for (var i = 0, pair; pair = pairs[i] && pairs[i].split('='); i++) { + if (decode(pair[0]) === key) return decode(pair[1] || ''); // IE saves cookies with empty string as "c; ", e.g. without "=" as opposed to EOMB, thus pair[1] may be undefined + } + return null; + }; +})(jQuery); diff --git a/app/assets/javascripts/external/jquery.fileupload.js b/app/assets/javascripts/external/jquery.fileupload.js new file mode 100644 index 00000000000..d104c069239 --- /dev/null +++ b/app/assets/javascripts/external/jquery.fileupload.js @@ -0,0 +1,1128 @@ +/* + * jQuery File Upload Plugin 5.17.7 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2010, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true, regexp: true */ +/*global define, window, document, Blob, FormData, location */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'jquery.ui.widget' + ], factory); + } else { + // Browser globals: + factory(window.jQuery); + } +}(function ($) { + 'use strict'; + + // The FileReader API is not actually used, but works as feature detection, + // as e.g. Safari supports XHR file uploads via the FormData API, + // but not non-multipart XHR file uploads: + $.support.xhrFileUpload = !!(window.XMLHttpRequestUpload && window.FileReader); + $.support.xhrFormDataFileUpload = !!window.FormData; + + // The fileupload widget listens for change events on file input fields defined + // via fileInput setting and paste or drop events of the given dropZone. + // In addition to the default jQuery Widget methods, the fileupload widget + // exposes the "add" and "send" methods, to add or directly send files using + // the fileupload API. + // By default, files added via file input selection, paste, drag & drop or + // "add" method are uploaded immediately, but it is possible to override + // the "add" callback option to queue file uploads. + $.widget('blueimp.fileupload', { + + options: { + // The namespace used for event handler binding on the fileInput, + // dropZone and pasteZone document nodes. + // If not set, the name of the widget ("fileupload") is used. + namespace: undefined, + // The drop target element(s), by the default the complete document. + // Set to null to disable drag & drop support: + dropZone: $(document), + // The paste target element(s), by the default the complete document. + // Set to null to disable paste support: + pasteZone: $(document), + // The file input field(s), that are listened to for change events. + // If undefined, it is set to the file input fields inside + // of the widget element on plugin initialization. + // Set to null to disable the change listener. + fileInput: undefined, + // By default, the file input field is replaced with a clone after + // each input field change event. This is required for iframe transport + // queues and allows change events to be fired for the same file + // selection, but can be disabled by setting the following option to false: + replaceFileInput: true, + // The parameter name for the file form data (the request argument name). + // If undefined or empty, the name property of the file input field is + // used, or "files[]" if the file input name property is also empty, + // can be a string or an array of strings: + paramName: undefined, + // By default, each file of a selection is uploaded using an individual + // request for XHR type uploads. Set to false to upload file + // selections in one request each: + singleFileUploads: true, + // To limit the number of files uploaded with one XHR request, + // set the following option to an integer greater than 0: + limitMultiFileUploads: undefined, + // Set the following option to true to issue all file upload requests + // in a sequential order: + sequentialUploads: false, + // To limit the number of concurrent uploads, + // set the following option to an integer greater than 0: + limitConcurrentUploads: undefined, + // Set the following option to true to force iframe transport uploads: + forceIframeTransport: false, + // Set the following option to the location of a redirect url on the + // origin server, for cross-domain iframe transport uploads: + redirect: undefined, + // The parameter name for the redirect url, sent as part of the form + // data and set to 'redirect' if this option is empty: + redirectParamName: undefined, + // Set the following option to the location of a postMessage window, + // to enable postMessage transport uploads: + postMessage: undefined, + // By default, XHR file uploads are sent as multipart/form-data. + // The iframe transport is always using multipart/form-data. + // Set to false to enable non-multipart XHR uploads: + multipart: true, + // To upload large files in smaller chunks, set the following option + // to a preferred maximum chunk size. If set to 0, null or undefined, + // or the browser does not support the required Blob API, files will + // be uploaded as a whole. + maxChunkSize: undefined, + // When a non-multipart upload or a chunked multipart upload has been + // aborted, this option can be used to resume the upload by setting + // it to the size of the already uploaded bytes. This option is most + // useful when modifying the options object inside of the "add" or + // "send" callbacks, as the options are cloned for each file upload. + uploadedBytes: undefined, + // By default, failed (abort or error) file uploads are removed from the + // global progress calculation. Set the following option to false to + // prevent recalculating the global progress data: + recalculateProgress: true, + // Interval in milliseconds to calculate and trigger progress events: + progressInterval: 100, + // Interval in milliseconds to calculate progress bitrate: + bitrateInterval: 500, + + // Additional form data to be sent along with the file uploads can be set + // using this option, which accepts an array of objects with name and + // value properties, a function returning such an array, a FormData + // object (for XHR file uploads), or a simple object. + // The form of the first fileInput is given as parameter to the function: + formData: function (form) { + return form.serializeArray(); + }, + + // The add callback is invoked as soon as files are added to the fileupload + // widget (via file input selection, drag & drop, paste or add API call). + // If the singleFileUploads option is enabled, this callback will be + // called once for each file in the selection for XHR file uplaods, else + // once for each file selection. + // The upload starts when the submit method is invoked on the data parameter. + // The data object contains a files property holding the added files + // and allows to override plugin options as well as define ajax settings. + // Listeners for this callback can also be bound the following way: + // .bind('fileuploadadd', func); + // data.submit() returns a Promise object and allows to attach additional + // handlers using jQuery's Deferred callbacks: + // data.submit().done(func).fail(func).always(func); + add: function (e, data) { + data.submit(); + }, + + // Other callbacks: + // Callback for the submit event of each file upload: + // submit: function (e, data) {}, // .bind('fileuploadsubmit', func); + // Callback for the start of each file upload request: + // send: function (e, data) {}, // .bind('fileuploadsend', func); + // Callback for successful uploads: + // done: function (e, data) {}, // .bind('fileuploaddone', func); + // Callback for failed (abort or error) uploads: + // fail: function (e, data) {}, // .bind('fileuploadfail', func); + // Callback for completed (success, abort or error) requests: + // always: function (e, data) {}, // .bind('fileuploadalways', func); + // Callback for upload progress events: + // progress: function (e, data) {}, // .bind('fileuploadprogress', func); + // Callback for global upload progress events: + // progressall: function (e, data) {}, // .bind('fileuploadprogressall', func); + // Callback for uploads start, equivalent to the global ajaxStart event: + // start: function (e) {}, // .bind('fileuploadstart', func); + // Callback for uploads stop, equivalent to the global ajaxStop event: + // stop: function (e) {}, // .bind('fileuploadstop', func); + // Callback for change events of the fileInput(s): + // change: function (e, data) {}, // .bind('fileuploadchange', func); + // Callback for paste events to the pasteZone(s): + // paste: function (e, data) {}, // .bind('fileuploadpaste', func); + // Callback for drop events of the dropZone(s): + // drop: function (e, data) {}, // .bind('fileuploaddrop', func); + // Callback for dragover events of the dropZone(s): + // dragover: function (e) {}, // .bind('fileuploaddragover', func); + + // The plugin options are used as settings object for the ajax calls. + // The following are jQuery ajax settings required for the file uploads: + processData: false, + contentType: false, + cache: false + }, + + // A list of options that require a refresh after assigning a new value: + _refreshOptionsList: [ + 'namespace', + 'fileInput', + 'dropZone', + 'pasteZone', + 'multipart', + 'forceIframeTransport' + ], + + _BitrateTimer: function () { + this.timestamp = +(new Date()); + this.loaded = 0; + this.bitrate = 0; + this.getBitrate = function (now, loaded, interval) { + var timeDiff = now - this.timestamp; + if (!this.bitrate || !interval || timeDiff > interval) { + this.bitrate = (loaded - this.loaded) * (1000 / timeDiff) * 8; + this.loaded = loaded; + this.timestamp = now; + } + return this.bitrate; + }; + }, + + _isXHRUpload: function (options) { + return !options.forceIframeTransport && + ((!options.multipart && $.support.xhrFileUpload) || + $.support.xhrFormDataFileUpload); + }, + + _getFormData: function (options) { + var formData; + if (typeof options.formData === 'function') { + return options.formData(options.form); + } + if ($.isArray(options.formData)) { + return options.formData; + } + if (options.formData) { + formData = []; + $.each(options.formData, function (name, value) { + formData.push({name: name, value: value}); + }); + return formData; + } + return []; + }, + + _getTotal: function (files) { + var total = 0; + $.each(files, function (index, file) { + total += file.size || 1; + }); + return total; + }, + + _onProgress: function (e, data) { + if (e.lengthComputable) { + var now = +(new Date()), + total, + loaded; + if (data._time && data.progressInterval && + (now - data._time < data.progressInterval) && + e.loaded !== e.total) { + return; + } + data._time = now; + total = data.total || this._getTotal(data.files); + loaded = parseInt( + e.loaded / e.total * (data.chunkSize || total), + 10 + ) + (data.uploadedBytes || 0); + this._loaded += loaded - (data.loaded || data.uploadedBytes || 0); + data.lengthComputable = true; + data.loaded = loaded; + data.total = total; + data.bitrate = data._bitrateTimer.getBitrate( + now, + loaded, + data.bitrateInterval + ); + // Trigger a custom progress event with a total data property set + // to the file size(s) of the current upload and a loaded data + // property calculated accordingly: + this._trigger('progress', e, data); + // Trigger a global progress event for all current file uploads, + // including ajax calls queued for sequential file uploads: + this._trigger('progressall', e, { + lengthComputable: true, + loaded: this._loaded, + total: this._total, + bitrate: this._bitrateTimer.getBitrate( + now, + this._loaded, + data.bitrateInterval + ) + }); + } + }, + + _initProgressListener: function (options) { + var that = this, + xhr = options.xhr ? options.xhr() : $.ajaxSettings.xhr(); + // Accesss to the native XHR object is required to add event listeners + // for the upload progress event: + if (xhr.upload) { + $(xhr.upload).bind('progress', function (e) { + var oe = e.originalEvent; + // Make sure the progress event properties get copied over: + e.lengthComputable = oe.lengthComputable; + e.loaded = oe.loaded; + e.total = oe.total; + that._onProgress(e, options); + }); + options.xhr = function () { + return xhr; + }; + } + }, + + _initXHRData: function (options) { + var formData, + file = options.files[0], + // Ignore non-multipart setting if not supported: + multipart = options.multipart || !$.support.xhrFileUpload, + paramName = options.paramName[0]; + if (!multipart || options.blob) { + // For non-multipart uploads and chunked uploads, + // file meta data is not part of the request body, + // so we transmit this data as part of the HTTP headers. + // For cross domain requests, these headers must be allowed + // via Access-Control-Allow-Headers or removed using + // the beforeSend callback: + options.headers = $.extend(options.headers, { + 'X-File-Name': file.name, + 'X-File-Type': file.type, + 'X-File-Size': file.size + }); + if (!options.blob) { + // Non-chunked non-multipart upload: + options.contentType = file.type; + options.data = file; + } else if (!multipart) { + // Chunked non-multipart upload: + options.contentType = 'application/octet-stream'; + options.data = options.blob; + } + } + if (multipart && $.support.xhrFormDataFileUpload) { + if (options.postMessage) { + // window.postMessage does not allow sending FormData + // objects, so we just add the File/Blob objects to + // the formData array and let the postMessage window + // create the FormData object out of this array: + formData = this._getFormData(options); + if (options.blob) { + formData.push({ + name: paramName, + value: options.blob + }); + } else { + $.each(options.files, function (index, file) { + formData.push({ + name: options.paramName[index] || paramName, + value: file + }); + }); + } + } else { + if (options.formData instanceof FormData) { + formData = options.formData; + } else { + formData = new FormData(); + $.each(this._getFormData(options), function (index, field) { + formData.append(field.name, field.value); + }); + } + if (options.blob) { + formData.append(paramName, options.blob, file.name); + } else { + $.each(options.files, function (index, file) { + // File objects are also Blob instances. + // This check allows the tests to run with + // dummy objects: + if (file instanceof Blob) { + formData.append( + options.paramName[index] || paramName, + file, + file.name + ); + } + }); + } + } + options.data = formData; + } + // Blob reference is not needed anymore, free memory: + options.blob = null; + }, + + _initIframeSettings: function (options) { + // Setting the dataType to iframe enables the iframe transport: + options.dataType = 'iframe ' + (options.dataType || ''); + // The iframe transport accepts a serialized array as form data: + options.formData = this._getFormData(options); + // Add redirect url to form data on cross-domain uploads: + if (options.redirect && $('').prop('href', options.url) + .prop('host') !== location.host) { + options.formData.push({ + name: options.redirectParamName || 'redirect', + value: options.redirect + }); + } + }, + + _initDataSettings: function (options) { + if (this._isXHRUpload(options)) { + if (!this._chunkedUpload(options, true)) { + if (!options.data) { + this._initXHRData(options); + } + this._initProgressListener(options); + } + if (options.postMessage) { + // Setting the dataType to postmessage enables the + // postMessage transport: + options.dataType = 'postmessage ' + (options.dataType || ''); + } + } else { + this._initIframeSettings(options, 'iframe'); + } + }, + + _getParamName: function (options) { + var fileInput = $(options.fileInput), + paramName = options.paramName; + if (!paramName) { + paramName = []; + fileInput.each(function () { + var input = $(this), + name = input.prop('name') || 'files[]', + i = (input.prop('files') || [1]).length; + while (i) { + paramName.push(name); + i -= 1; + } + }); + if (!paramName.length) { + paramName = [fileInput.prop('name') || 'files[]']; + } + } else if (!$.isArray(paramName)) { + paramName = [paramName]; + } + return paramName; + }, + + _initFormSettings: function (options) { + // Retrieve missing options from the input field and the + // associated form, if available: + if (!options.form || !options.form.length) { + options.form = $(options.fileInput.prop('form')); + // If the given file input doesn't have an associated form, + // use the default widget file input's form: + if (!options.form.length) { + options.form = $(this.options.fileInput.prop('form')); + } + } + options.paramName = this._getParamName(options); + if (!options.url) { + options.url = options.form.prop('action') || location.href; + } + // The HTTP request method must be "POST" or "PUT": + options.type = (options.type || options.form.prop('method') || '') + .toUpperCase(); + if (options.type !== 'POST' && options.type !== 'PUT') { + options.type = 'POST'; + } + if (!options.formAcceptCharset) { + options.formAcceptCharset = options.form.attr('accept-charset'); + } + }, + + _getAJAXSettings: function (data) { + var options = $.extend({}, this.options, data); + this._initFormSettings(options); + this._initDataSettings(options); + return options; + }, + + // Maps jqXHR callbacks to the equivalent + // methods of the given Promise object: + _enhancePromise: function (promise) { + promise.success = promise.done; + promise.error = promise.fail; + promise.complete = promise.always; + return promise; + }, + + // Creates and returns a Promise object enhanced with + // the jqXHR methods abort, success, error and complete: + _getXHRPromise: function (resolveOrReject, context, args) { + var dfd = $.Deferred(), + promise = dfd.promise(); + context = context || this.options.context || promise; + if (resolveOrReject === true) { + dfd.resolveWith(context, args); + } else if (resolveOrReject === false) { + dfd.rejectWith(context, args); + } + promise.abort = dfd.promise; + return this._enhancePromise(promise); + }, + + // Uploads a file in multiple, sequential requests + // by splitting the file up in multiple blob chunks. + // If the second parameter is true, only tests if the file + // should be uploaded in chunks, but does not invoke any + // upload requests: + _chunkedUpload: function (options, testOnly) { + var that = this, + file = options.files[0], + fs = file.size, + ub = options.uploadedBytes = options.uploadedBytes || 0, + mcs = options.maxChunkSize || fs, + slice = file.slice || file.webkitSlice || file.mozSlice, + upload, + n, + jqXHR, + pipe; + if (!(this._isXHRUpload(options) && slice && (ub || mcs < fs)) || + options.data) { + return false; + } + if (testOnly) { + return true; + } + if (ub >= fs) { + file.error = 'Uploaded bytes exceed file size'; + return this._getXHRPromise( + false, + options.context, + [null, 'error', file.error] + ); + } + // n is the number of blobs to upload, + // calculated via filesize, uploaded bytes and max chunk size: + n = Math.ceil((fs - ub) / mcs); + // The chunk upload method accepting the chunk number as parameter: + upload = function (i) { + if (!i) { + return that._getXHRPromise(true, options.context); + } + // Upload the blobs in sequential order: + return upload(i -= 1).pipe(function () { + // Clone the options object for each chunk upload: + var o = $.extend({}, options); + o.blob = slice.call( + file, + ub + i * mcs, + ub + (i + 1) * mcs + ); + // Expose the chunk index: + o.chunkIndex = i; + // Expose the number of chunks: + o.chunksNumber = n; + // Store the current chunk size, as the blob itself + // will be dereferenced after data processing: + o.chunkSize = o.blob.size; + // Process the upload data (the blob and potential form data): + that._initXHRData(o); + // Add progress listeners for this chunk upload: + that._initProgressListener(o); + jqXHR = ($.ajax(o) || that._getXHRPromise(false, o.context)) + .done(function () { + // Create a progress event if upload is done and + // no progress event has been invoked for this chunk: + if (!o.loaded) { + that._onProgress($.Event('progress', { + lengthComputable: true, + loaded: o.chunkSize, + total: o.chunkSize + }), o); + } + options.uploadedBytes = o.uploadedBytes += + o.chunkSize; + }); + return jqXHR; + }); + }; + // Return the piped Promise object, enhanced with an abort method, + // which is delegated to the jqXHR object of the current upload, + // and jqXHR callbacks mapped to the equivalent Promise methods: + pipe = upload(n); + pipe.abort = function () { + return jqXHR.abort(); + }; + return this._enhancePromise(pipe); + }, + + _beforeSend: function (e, data) { + if (this._active === 0) { + // the start callback is triggered when an upload starts + // and no other uploads are currently running, + // equivalent to the global ajaxStart event: + this._trigger('start'); + // Set timer for global bitrate progress calculation: + this._bitrateTimer = new this._BitrateTimer(); + } + this._active += 1; + // Initialize the global progress values: + this._loaded += data.uploadedBytes || 0; + this._total += this._getTotal(data.files); + }, + + _onDone: function (result, textStatus, jqXHR, options) { + if (!this._isXHRUpload(options)) { + // Create a progress event for each iframe load: + this._onProgress($.Event('progress', { + lengthComputable: true, + loaded: 1, + total: 1 + }), options); + } + options.result = result; + options.textStatus = textStatus; + options.jqXHR = jqXHR; + this._trigger('done', null, options); + }, + + _onFail: function (jqXHR, textStatus, errorThrown, options) { + options.jqXHR = jqXHR; + options.textStatus = textStatus; + options.errorThrown = errorThrown; + this._trigger('fail', null, options); + if (options.recalculateProgress) { + // Remove the failed (error or abort) file upload from + // the global progress calculation: + this._loaded -= options.loaded || options.uploadedBytes || 0; + this._total -= options.total || this._getTotal(options.files); + } + }, + + _onAlways: function (jqXHRorResult, textStatus, jqXHRorError, options) { + this._active -= 1; + options.textStatus = textStatus; + if (jqXHRorError && jqXHRorError.always) { + options.jqXHR = jqXHRorError; + options.result = jqXHRorResult; + } else { + options.jqXHR = jqXHRorResult; + options.errorThrown = jqXHRorError; + } + this._trigger('always', null, options); + if (this._active === 0) { + // The stop callback is triggered when all uploads have + // been completed, equivalent to the global ajaxStop event: + this._trigger('stop'); + // Reset the global progress values: + this._loaded = this._total = 0; + this._bitrateTimer = null; + } + }, + + _onSend: function (e, data) { + var that = this, + jqXHR, + slot, + pipe, + options = that._getAJAXSettings(data), + send = function (resolve, args) { + that._sending += 1; + // Set timer for bitrate progress calculation: + options._bitrateTimer = new that._BitrateTimer(); + jqXHR = jqXHR || ( + (resolve !== false && + that._trigger('send', e, options) !== false && + (that._chunkedUpload(options) || $.ajax(options))) || + that._getXHRPromise(false, options.context, args) + ).done(function (result, textStatus, jqXHR) { + that._onDone(result, textStatus, jqXHR, options); + }).fail(function (jqXHR, textStatus, errorThrown) { + that._onFail(jqXHR, textStatus, errorThrown, options); + }).always(function (jqXHRorResult, textStatus, jqXHRorError) { + that._sending -= 1; + that._onAlways( + jqXHRorResult, + textStatus, + jqXHRorError, + options + ); + if (options.limitConcurrentUploads && + options.limitConcurrentUploads > that._sending) { + // Start the next queued upload, + // that has not been aborted: + var nextSlot = that._slots.shift(), + isPending; + while (nextSlot) { + // jQuery 1.6 doesn't provide .state(), + // while jQuery 1.8+ removed .isRejected(): + isPending = nextSlot.state ? + nextSlot.state() === 'pending' : + !nextSlot.isRejected(); + if (isPending) { + nextSlot.resolve(); + break; + } + nextSlot = that._slots.shift(); + } + } + }); + return jqXHR; + }; + this._beforeSend(e, options); + if (this.options.sequentialUploads || + (this.options.limitConcurrentUploads && + this.options.limitConcurrentUploads <= this._sending)) { + if (this.options.limitConcurrentUploads > 1) { + slot = $.Deferred(); + this._slots.push(slot); + pipe = slot.pipe(send); + } else { + pipe = (this._sequence = this._sequence.pipe(send, send)); + } + // Return the piped Promise object, enhanced with an abort method, + // which is delegated to the jqXHR object of the current upload, + // and jqXHR callbacks mapped to the equivalent Promise methods: + pipe.abort = function () { + var args = [undefined, 'abort', 'abort']; + if (!jqXHR) { + if (slot) { + slot.rejectWith(pipe, args); + } + return send(false, args); + } + return jqXHR.abort(); + }; + return this._enhancePromise(pipe); + } + return send(); + }, + + _onAdd: function (e, data) { + var that = this, + result = true, + options = $.extend({}, this.options, data), + limit = options.limitMultiFileUploads, + paramName = this._getParamName(options), + paramNameSet, + paramNameSlice, + fileSet, + i; + if (!(options.singleFileUploads || limit) || + !this._isXHRUpload(options)) { + fileSet = [data.files]; + paramNameSet = [paramName]; + } else if (!options.singleFileUploads && limit) { + fileSet = []; + paramNameSet = []; + for (i = 0; i < data.files.length; i += limit) { + fileSet.push(data.files.slice(i, i + limit)); + paramNameSlice = paramName.slice(i, i + limit); + if (!paramNameSlice.length) { + paramNameSlice = paramName; + } + paramNameSet.push(paramNameSlice); + } + } else { + paramNameSet = paramName; + } + data.originalFiles = data.files; + $.each(fileSet || data.files, function (index, element) { + var newData = $.extend({}, data); + newData.files = fileSet ? element : [element]; + newData.paramName = paramNameSet[index]; + newData.submit = function () { + newData.jqXHR = this.jqXHR = + (that._trigger('submit', e, this) !== false) && + that._onSend(e, this); + return this.jqXHR; + }; + return (result = that._trigger('add', e, newData)); + }); + return result; + }, + + _replaceFileInput: function (input) { + var inputClone = input.clone(true); + $('
            ').append(inputClone)[0].reset(); + // Detaching allows to insert the fileInput on another form + // without loosing the file input value: + input.after(inputClone).detach(); + // Avoid memory leaks with the detached file input: + $.cleanData(input.unbind('remove')); + // Replace the original file input element in the fileInput + // elements set with the clone, which has been copied including + // event handlers: + this.options.fileInput = this.options.fileInput.map(function (i, el) { + if (el === input[0]) { + return inputClone[0]; + } + return el; + }); + // If the widget has been initialized on the file input itself, + // override this.element with the file input clone: + if (input[0] === this.element[0]) { + this.element = inputClone; + } + }, + + _handleFileTreeEntry: function (entry, path) { + var that = this, + dfd = $.Deferred(), + errorHandler = function (e) { + if (e && !e.entry) { + e.entry = entry; + } + // Since $.when returns immediately if one + // Deferred is rejected, we use resolve instead. + // This allows valid files and invalid items + // to be returned together in one set: + dfd.resolve([e]); + }, + dirReader; + path = path || ''; + if (entry.isFile) { + if (entry._file) { + // Workaround for Chrome bug #149735 + entry._file.relativePath = path; + dfd.resolve(entry._file); + } else { + entry.file(function (file) { + file.relativePath = path; + dfd.resolve(file); + }, errorHandler); + } + } else if (entry.isDirectory) { + dirReader = entry.createReader(); + dirReader.readEntries(function (entries) { + that._handleFileTreeEntries( + entries, + path + entry.name + '/' + ).done(function (files) { + dfd.resolve(files); + }).fail(errorHandler); + }, errorHandler); + } else { + // Return an empy list for file system items + // other than files or directories: + dfd.resolve([]); + } + return dfd.promise(); + }, + + _handleFileTreeEntries: function (entries, path) { + var that = this; + return $.when.apply( + $, + $.map(entries, function (entry) { + return that._handleFileTreeEntry(entry, path); + }) + ).pipe(function () { + return Array.prototype.concat.apply( + [], + arguments + ); + }); + }, + + _getDroppedFiles: function (dataTransfer) { + dataTransfer = dataTransfer || {}; + var items = dataTransfer.items; + if (items && items.length && (items[0].webkitGetAsEntry || + items[0].getAsEntry)) { + return this._handleFileTreeEntries( + $.map(items, function (item) { + var entry; + if (item.webkitGetAsEntry) { + entry = item.webkitGetAsEntry(); + if (entry) { + // Workaround for Chrome bug #149735: + entry._file = item.getAsFile(); + } + return entry; + } + return item.getAsEntry(); + }) + ); + } + return $.Deferred().resolve( + $.makeArray(dataTransfer.files) + ).promise(); + }, + + _getSingleFileInputFiles: function (fileInput) { + fileInput = $(fileInput); + var entries = fileInput.prop('webkitEntries') || + fileInput.prop('entries'), + files, + value; + if (entries && entries.length) { + return this._handleFileTreeEntries(entries); + } + files = $.makeArray(fileInput.prop('files')); + if (!files.length) { + value = fileInput.prop('value'); + if (!value) { + return $.Deferred().resolve([]).promise(); + } + // If the files property is not available, the browser does not + // support the File API and we add a pseudo File object with + // the input value as name with path information removed: + files = [{name: value.replace(/^.*\\/, '')}]; + } + return $.Deferred().resolve(files).promise(); + }, + + _getFileInputFiles: function (fileInput) { + if (!(fileInput instanceof $) || fileInput.length === 1) { + return this._getSingleFileInputFiles(fileInput); + } + return $.when.apply( + $, + $.map(fileInput, this._getSingleFileInputFiles) + ).pipe(function () { + return Array.prototype.concat.apply( + [], + arguments + ); + }); + }, + + _onChange: function (e) { + var that = e.data.fileupload, + data = { + fileInput: $(e.target), + form: $(e.target.form) + }; + that._getFileInputFiles(data.fileInput).always(function (files) { + data.files = files; + if (that.options.replaceFileInput) { + that._replaceFileInput(data.fileInput); + } + if (that._trigger('change', e, data) !== false) { + that._onAdd(e, data); + } + }); + }, + + _onPaste: function (e) { + var that = e.data.fileupload, + cbd = e.originalEvent.clipboardData, + items = (cbd && cbd.items) || [], + data = {files: []}; + $.each(items, function (index, item) { + var file = item.getAsFile && item.getAsFile(); + if (file) { + data.files.push(file); + } + }); + if (that._trigger('paste', e, data) === false || + that._onAdd(e, data) === false) { + return false; + } + }, + + _onDrop: function (e) { + e.preventDefault(); + var that = e.data.fileupload, + dataTransfer = e.dataTransfer = e.originalEvent.dataTransfer, + data = {}; + that._getDroppedFiles(dataTransfer).always(function (files) { + data.files = files; + if (that._trigger('drop', e, data) !== false) { + that._onAdd(e, data); + } + }); + }, + + _onDragOver: function (e) { + var that = e.data.fileupload, + dataTransfer = e.dataTransfer = e.originalEvent.dataTransfer; + if (that._trigger('dragover', e) === false) { + return false; + } + if (dataTransfer) { + dataTransfer.dropEffect = 'copy'; + } + e.preventDefault(); + }, + + _initEventHandlers: function () { + var ns = this.options.namespace; + if (this._isXHRUpload(this.options)) { + this.options.dropZone + .bind('dragover.' + ns, {fileupload: this}, this._onDragOver) + .bind('drop.' + ns, {fileupload: this}, this._onDrop); + this.options.pasteZone + .bind('paste.' + ns, {fileupload: this}, this._onPaste); + } + this.options.fileInput + .bind('change.' + ns, {fileupload: this}, this._onChange); + }, + + _destroyEventHandlers: function () { + var ns = this.options.namespace; + this.options.dropZone + .unbind('dragover.' + ns, this._onDragOver) + .unbind('drop.' + ns, this._onDrop); + this.options.pasteZone + .unbind('paste.' + ns, this._onPaste); + this.options.fileInput + .unbind('change.' + ns, this._onChange); + }, + + _setOption: function (key, value) { + var refresh = $.inArray(key, this._refreshOptionsList) !== -1; + if (refresh) { + this._destroyEventHandlers(); + } + $.Widget.prototype._setOption.call(this, key, value); + if (refresh) { + this._initSpecialOptions(); + this._initEventHandlers(); + } + }, + + _initSpecialOptions: function () { + var options = this.options; + if (options.fileInput === undefined) { + options.fileInput = this.element.is('input[type="file"]') ? + this.element : this.element.find('input[type="file"]'); + } else if (!(options.fileInput instanceof $)) { + options.fileInput = $(options.fileInput); + } + if (!(options.dropZone instanceof $)) { + options.dropZone = $(options.dropZone); + } + if (!(options.pasteZone instanceof $)) { + options.pasteZone = $(options.pasteZone); + } + }, + + _create: function () { + var options = this.options; + // Initialize options set via HTML5 data-attributes: + $.extend(options, $(this.element[0].cloneNode(false)).data()); + options.namespace = options.namespace || this.widgetName; + this._initSpecialOptions(); + this._slots = []; + this._sequence = this._getXHRPromise(true); + this._sending = this._active = this._loaded = this._total = 0; + this._initEventHandlers(); + }, + + destroy: function () { + this._destroyEventHandlers(); + $.Widget.prototype.destroy.call(this); + }, + + enable: function () { + var wasDisabled = false; + if (this.options.disabled) { + wasDisabled = true; + } + $.Widget.prototype.enable.call(this); + if (wasDisabled) { + this._initEventHandlers(); + } + }, + + disable: function () { + if (!this.options.disabled) { + this._destroyEventHandlers(); + } + $.Widget.prototype.disable.call(this); + }, + + // This method is exposed to the widget API and allows adding files + // using the fileupload API. The data parameter accepts an object which + // must have a files property and can contain additional options: + // .fileupload('add', {files: filesList}); + add: function (data) { + var that = this; + if (!data || this.options.disabled) { + return; + } + if (data.fileInput && !data.files) { + this._getFileInputFiles(data.fileInput).always(function (files) { + data.files = files; + that._onAdd(null, data); + }); + } else { + data.files = $.makeArray(data.files); + this._onAdd(null, data); + } + }, + + // This method is exposed to the widget API and allows sending files + // using the fileupload API. The data parameter accepts an object which + // must have a files or fileInput property and can contain additional options: + // .fileupload('send', {files: filesList}); + // The method returns a Promise object for the file upload call. + send: function (data) { + if (data && !this.options.disabled) { + if (data.fileInput && !data.files) { + var that = this, + dfd = $.Deferred(), + promise = dfd.promise(), + jqXHR, + aborted; + promise.abort = function () { + aborted = true; + if (jqXHR) { + return jqXHR.abort(); + } + dfd.reject(null, 'abort', 'abort'); + return promise; + }; + this._getFileInputFiles(data.fileInput).always( + function (files) { + if (aborted) { + return; + } + data.files = files; + jqXHR = that._onSend(null, data).then( + function (result, textStatus, jqXHR) { + dfd.resolve(result, textStatus, jqXHR); + }, + function (jqXHR, textStatus, errorThrown) { + dfd.reject(jqXHR, textStatus, errorThrown); + } + ); + } + ); + return this._enhancePromise(promise); + } + data.files = $.makeArray(data.files); + if (data.files.length) { + return this._onSend(null, data); + } + } + return this._getXHRPromise(false, data && data.context); + } + + }); + +})); diff --git a/app/assets/javascripts/external/jquery.iframe-transport.js b/app/assets/javascripts/external/jquery.iframe-transport.js new file mode 100644 index 00000000000..4749f469936 --- /dev/null +++ b/app/assets/javascripts/external/jquery.iframe-transport.js @@ -0,0 +1,172 @@ +/* + * jQuery Iframe Transport Plugin 1.5 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint unparam: true, nomen: true */ +/*global define, window, document */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery'], factory); + } else { + // Browser globals: + factory(window.jQuery); + } +}(function ($) { + 'use strict'; + + // Helper variable to create unique names for the transport iframes: + var counter = 0; + + // The iframe transport accepts three additional options: + // options.fileInput: a jQuery collection of file input fields + // options.paramName: the parameter name for the file form data, + // overrides the name property of the file input field(s), + // can be a string or an array of strings. + // options.formData: an array of objects with name and value properties, + // equivalent to the return data of .serializeArray(), e.g.: + // [{name: 'a', value: 1}, {name: 'b', value: 2}] + $.ajaxTransport('iframe', function (options) { + if (options.async && (options.type === 'POST' || options.type === 'GET')) { + var form, + iframe; + return { + send: function (_, completeCallback) { + form = $('
            '); + form.attr('accept-charset', options.formAcceptCharset); + // javascript:false as initial iframe src + // prevents warning popups on HTTPS in IE6. + // IE versions below IE8 cannot set the name property of + // elements that have already been added to the DOM, + // so we set the name along with the iframe HTML markup: + iframe = $( + '' + ).bind('load', function () { + var fileInputClones, + paramNames = $.isArray(options.paramName) ? + options.paramName : [options.paramName]; + iframe + .unbind('load') + .bind('load', function () { + var response; + // Wrap in a try/catch block to catch exceptions thrown + // when trying to access cross-domain iframe contents: + try { + response = iframe.contents(); + // Google Chrome and Firefox do not throw an + // exception when calling iframe.contents() on + // cross-domain requests, so we unify the response: + if (!response.length || !response[0].firstChild) { + throw new Error(); + } + } catch (e) { + response = undefined; + } + // The complete callback returns the + // iframe content document as response object: + completeCallback( + 200, + 'success', + {'iframe': response} + ); + // Fix for IE endless progress bar activity bug + // (happens on form submits to iframe targets): + $('') + .appendTo(form); + form.remove(); + }); + form + .prop('target', iframe.prop('name')) + .prop('action', options.url) + .prop('method', options.type); + if (options.formData) { + $.each(options.formData, function (index, field) { + $('') + .prop('name', field.name) + .val(field.value) + .appendTo(form); + }); + } + if (options.fileInput && options.fileInput.length && + options.type === 'POST') { + fileInputClones = options.fileInput.clone(); + // Insert a clone for each file input field: + options.fileInput.after(function (index) { + return fileInputClones[index]; + }); + if (options.paramName) { + options.fileInput.each(function (index) { + $(this).prop( + 'name', + paramNames[index] || options.paramName + ); + }); + } + // Appending the file input fields to the hidden form + // removes them from their original location: + form + .append(options.fileInput) + .prop('enctype', 'multipart/form-data') + // enctype must be set as encoding for IE: + .prop('encoding', 'multipart/form-data'); + } + form.submit(); + // Insert the file input fields at their original location + // by replacing the clones with the originals: + if (fileInputClones && fileInputClones.length) { + options.fileInput.each(function (index, input) { + var clone = $(fileInputClones[index]); + $(input).prop('name', clone.prop('name')); + clone.replaceWith(input); + }); + } + }); + form.append(iframe).appendTo(document.body); + }, + abort: function () { + if (iframe) { + // javascript:false as iframe src aborts the request + // and prevents warning popups on HTTPS in IE6. + // concat is used to avoid the "Script URL" JSLint error: + iframe + .unbind('load') + .prop('src', 'javascript'.concat(':false;')); + } + if (form) { + form.remove(); + } + } + }; + } + }); + + // The iframe transport returns the iframe content document as response. + // The following adds converters from iframe to text, json, html, and script: + $.ajaxSetup({ + converters: { + 'iframe text': function (iframe) { + return $(iframe[0].body).text(); + }, + 'iframe json': function (iframe) { + return $.parseJSON($(iframe[0].body).text()); + }, + 'iframe html': function (iframe) { + return $(iframe[0].body).html(); + }, + 'iframe script': function (iframe) { + return $.globalEval($(iframe[0].body).text()); + } + } + }); + +})); diff --git a/app/assets/javascripts/external/jquery.putcursoratend.js b/app/assets/javascripts/external/jquery.putcursoratend.js new file mode 100644 index 00000000000..d957d03f748 --- /dev/null +++ b/app/assets/javascripts/external/jquery.putcursoratend.js @@ -0,0 +1,38 @@ +// jQuery plugin: PutCursorAtEnd 1.0 +// http://plugins.jquery.com/project/PutCursorAtEnd +// by teedyay +// +// Puts the cursor at the end of a textbox/ textarea + +// codesnippet: 691e18b1-f4f9-41b4-8fe8-bc8ee51b48d4 +(function($) +{ + jQuery.fn.putCursorAtEnd = function() + { + return this.each(function() + { + $(this).focus() + + // If this function exists... + if (this.setSelectionRange) + { + // ... then use it + // (Doesn't work in IE) + + // Double the length because Opera is inconsistent about whether a carriage return is one character or two. Sigh. + var len = $(this).val().length * 2; + this.setSelectionRange(len, len); + } + else + { + // ... otherwise replace the contents with itself + // (Doesn't work in Google Chrome) + $(this).val($(this).val()); + } + + // Scroll to the bottom, in case we're in a tall textarea + // (Necessary for Firefox and Google Chrome) + this.scrollTop = 999999; + }); + }; +})(jQuery); \ No newline at end of file diff --git a/app/assets/javascripts/external/jquery.tagsinput.js b/app/assets/javascripts/external/jquery.tagsinput.js new file mode 100644 index 00000000000..d25c1164ee9 --- /dev/null +++ b/app/assets/javascripts/external/jquery.tagsinput.js @@ -0,0 +1,345 @@ +/* + + jQuery Tags Input Plugin 1.3.3 + + Copyright (c) 2011 XOXCO, Inc + + Documentation for this plugin lives here: + http://xoxco.com/clickable/jquery-tags-input + + Licensed under the MIT license: + http://www.opensource.org/licenses/mit-license.php + + ben@xoxco.com + +*/ + +(function($) { + + var delimiter = new Array(); + var tags_callbacks = new Array(); + $.fn.doAutosize = function(o){ + var minWidth = $(this).data('minwidth'), + maxWidth = $(this).data('maxwidth'), + val = '', + input = $(this), + testSubject = $('#'+$(this).data('tester_id')); + + if (val === (val = input.val())) {return;} + + // Enter new content into testSubject + var escaped = val.replace(/&/g, '&').replace(/\s/g,' ').replace(//g, '>'); + testSubject.html(escaped); + // Calculate new width + whether to change + var testerWidth = testSubject.width(), + newWidth = (testerWidth + o.comfortZone) >= minWidth ? testerWidth + o.comfortZone : minWidth, + currentWidth = input.width(), + isValidWidthChange = (newWidth < currentWidth && newWidth >= minWidth) + || (newWidth > minWidth && newWidth < maxWidth); + + // Animate width + if (isValidWidthChange) { + input.width(newWidth); + } + + + }; + $.fn.resetAutosize = function(options){ + // alert(JSON.stringify(options)); + var minWidth = $(this).data('minwidth') || options.minInputWidth || $(this).width(), + maxWidth = $(this).data('maxwidth') || options.maxInputWidth || ($(this).closest('.tagsinput').width() - options.inputPadding), + val = '', + input = $(this), + testSubject = $('').css({ + position: 'absolute', + top: -9999, + left: -9999, + width: 'auto', + fontSize: input.css('fontSize'), + fontFamily: input.css('fontFamily'), + fontWeight: input.css('fontWeight'), + letterSpacing: input.css('letterSpacing'), + whiteSpace: 'nowrap' + }), + testerId = $(this).attr('id')+'_autosize_tester'; + if(! $('#'+testerId).length > 0){ + testSubject.attr('id', testerId); + testSubject.appendTo('body'); + } + + input.data('minwidth', minWidth); + input.data('maxwidth', maxWidth); + input.data('tester_id', testerId); + input.css('width', minWidth); + }; + + $.fn.addTag = function(value,options) { + options = jQuery.extend({focus:false,callback:true},options); + this.each(function() { + var id = $(this).attr('id'); + + var tagslist = $(this).val().split(delimiter[id]); + if (tagslist[0] == '') { + tagslist = new Array(); + } + + value = jQuery.trim(value); + + if (options.unique) { + var skipTag = $(this).tagExist(value); + if(skipTag == true) { + //Marks fake input as not_valid to let styling it + $('#'+id+'_tag').addClass('not_valid'); + } + } else { + var skipTag = false; + } + + if (value !='' && skipTag != true) { + $('').addClass('tag').append( + $('').text(value).append('  '), + $('', { + href : '#', + title : 'Removing tag', + text : 'x' + }).click(function () { + return $('#' + id).removeTag(escape(value)); + }) + ).insertBefore('#' + id + '_addTag'); + + tagslist.push(value); + + $('#'+id+'_tag').val(''); + if (options.focus) { + $('#'+id+'_tag').focus(); + } else { + $('#'+id+'_tag').blur(); + } + + $.fn.tagsInput.updateTagsField(this,tagslist); + + if (options.callback && tags_callbacks[id] && tags_callbacks[id]['onAddTag']) { + var f = tags_callbacks[id]['onAddTag']; + f.call(this, value); + } + if(tags_callbacks[id] && tags_callbacks[id]['onChange']) + { + var i = tagslist.length; + var f = tags_callbacks[id]['onChange']; + f.call(this, $(this), tagslist[i-1]); + } + } + + }); + + return false; + }; + + $.fn.removeTag = function(value) { + value = unescape(value); + this.each(function() { + var id = $(this).attr('id'); + + var old = $(this).val().split(delimiter[id]); + + $('#'+id+'_tagsinput .tag').remove(); + str = ''; + for (i=0; i< old.length; i++) { + if (old[i]!=value) { + str = str + delimiter[id] +old[i]; + } + } + + $.fn.tagsInput.importTags(this,str); + + if (tags_callbacks[id] && tags_callbacks[id]['onRemoveTag']) { + var f = tags_callbacks[id]['onRemoveTag']; + f.call(this, value); + } + }); + + return false; + }; + + $.fn.tagExist = function(val) { + var id = $(this).attr('id'); + var tagslist = $(this).val().split(delimiter[id]); + return (jQuery.inArray(val, tagslist) >= 0); //true when tag exists, false when not + }; + + // clear all existing tags and import new ones from a string + $.fn.importTags = function(str) { + id = $(this).attr('id'); + $('#'+id+'_tagsinput .tag').remove(); + $.fn.tagsInput.importTags(this,str); + } + + $.fn.tagsInput = function(options) { + var settings = jQuery.extend({ + interactive:true, + defaultText:'add a tag', + minChars:0, + width:'300px', + height:'100px', + autocomplete: {selectFirst: false }, + 'hide':true, + 'delimiter':',', + 'unique':true, + removeWithBackspace:true, + autosize: true, + comfortZone: 20, + inputPadding: 6*2 + },options); + + this.each(function() { + if (settings.hide) { + $(this).hide(); + } + var id = $(this).attr('id'); + if (!id || delimiter[$(this).attr('id')]) { + id = $(this).attr('id', 'tags' + new Date().getTime()).attr('id'); + } + + var data = jQuery.extend({ + pid:id, + real_input: '#'+id, + holder: '#'+id+'_tagsinput', + input_wrapper: '#'+id+'_addTag', + fake_input: '#'+id+'_tag' + },settings); + + delimiter[id] = data.delimiter; + + if (settings.onAddTag || settings.onRemoveTag || settings.onChange) { + tags_callbacks[id] = new Array(); + tags_callbacks[id]['onAddTag'] = settings.onAddTag; + tags_callbacks[id]['onRemoveTag'] = settings.onRemoveTag; + tags_callbacks[id]['onChange'] = settings.onChange; + } + + var markup = '
            '; + + if (settings.interactive) { + markup = markup + ''; + } + + markup = markup + '
            '; + + $(markup).insertAfter(this); + + $(data.holder).css('width',settings.width); + $(data.holder).css('min-height',settings.height); + $(data.holder).css('height','100%'); + + if ($(data.real_input).val()!='') { + $.fn.tagsInput.importTags($(data.real_input),$(data.real_input).val()); + } + if (settings.interactive) { + $(data.fake_input).resetAutosize(settings); + + $(data.holder).bind('click',data,function(event) { + $(event.data.fake_input).focus(); + }); + + + if (settings.autocomplete_url != undefined) { + autocomplete_options = {source: settings.autocomplete_url}; + for (attrname in settings.autocomplete) { + autocomplete_options[attrname] = settings.autocomplete[attrname]; + } + + if (jQuery.Autocompleter !== undefined) { + $(data.fake_input).autocomplete(settings.autocomplete_url, settings.autocomplete); + $(data.fake_input).bind('result',data,function(event,data,formatted) { + if (data) { + $('#'+id).addTag(data[0] + "",{focus:true,unique:(settings.unique)}); + } + }); + } else if (jQuery.ui.autocomplete !== undefined) { + $(data.fake_input).autocomplete(autocomplete_options); + $(data.fake_input).bind('autocompleteselect',data,function(event,ui) { + $(event.data.real_input).addTag(ui.item.value,{focus:true,unique:(settings.unique)}); + return false; + }); + } + + + } else { + // if a user tabs out of the field, create a new tag + // this is only available if autocomplete is not used. + $(data.fake_input).bind('blur',data,function(event) { + var d = $(this).attr('data-default'); + if ($(event.data.fake_input).val()!='' && $(event.data.fake_input).val()!=d) { + if( (event.data.minChars <= $(event.data.fake_input).val().length) && (!event.data.maxChars || (event.data.maxChars >= $(event.data.fake_input).val().length)) ) + $(event.data.real_input).addTag($(event.data.fake_input).val(),{focus:true,unique:(settings.unique)}); + } else { + $(event.data.fake_input).val($(event.data.fake_input).attr('data-default')); + $(event.data.fake_input).css('color',settings.placeholderColor); + } + return false; + }); + + } + // if user types a comma, create a new tag + $(data.fake_input).bind('keypress',data,function(event) { + if (event.which==event.data.delimiter.charCodeAt(0) || event.which==13 ) { + event.preventDefault(); + if( (event.data.minChars <= $(event.data.fake_input).val().length) && (!event.data.maxChars || (event.data.maxChars >= $(event.data.fake_input).val().length)) ) + $(event.data.real_input).addTag($(event.data.fake_input).val(),{focus:true,unique:(settings.unique)}); + $(event.data.fake_input).resetAutosize(settings); + return false; + } else if (event.data.autosize) { + $(event.data.fake_input).doAutosize(settings); + + } + }); + //Delete last tag on backspace + data.removeWithBackspace && $(data.fake_input).bind('keydown', function(event) + { + if(event.keyCode == 8 && $(this).val() == '') + { + event.preventDefault(); + var last_tag = $(this).closest('.tagsinput').find('.tag:last').text(); + var id = $(this).attr('id').replace(/_tag$/, ''); + last_tag = last_tag.replace(/[\s]+x$/, ''); + $('#' + id).removeTag(escape(last_tag)); + $(this).trigger('focus'); + } + }); + $(data.fake_input).blur(); + + //Removes the not_valid class when user changes the value of the fake input + if(data.unique) { + $(data.fake_input).keydown(function(event){ + if(event.keyCode == 8 || String.fromCharCode(event.which).match(/\w+|[áéíóúÁÉÍÓÚñÑ,/]+/)) { + $(this).removeClass('not_valid'); + } + }); + } + } // if settings.interactive + }); + + return this; + + }; + + $.fn.tagsInput.updateTagsField = function(obj,tagslist) { + var id = $(obj).attr('id'); + $(obj).val(tagslist.join(delimiter[id])); + }; + + $.fn.tagsInput.importTags = function(obj,val) { + $(obj).val(''); + var id = $(obj).attr('id'); + var tags = val.split(delimiter[id]); + for (i=0; i',a,""].join(""),l.id=h,(m?l:n).innerHTML+=f,n.appendChild(l),m||(n.style.background="",n.style.overflow="hidden",k=g.style.overflow,g.style.overflow="hidden",g.appendChild(n)),i=c(l,a),m?l.parentNode.removeChild(l):(n.parentNode.removeChild(n),g.style.overflow=k),!!i},x={}.hasOwnProperty,y;!B(x,"undefined")&&!B(x.call,"undefined")?y=function(a,b){return x.call(a,b)}:y=function(a,b){return b in a&&B(a.constructor.prototype[b],"undefined")},Function.prototype.bind||(Function.prototype.bind=function(b){var c=this;if(typeof c!="function")throw new TypeError;var d=u.call(arguments,1),e=function(){if(this instanceof e){var a=function(){};a.prototype=c.prototype;var f=new a,g=c.apply(f,d.concat(u.call(arguments)));return Object(g)===g?g:f}return c.apply(b,d.concat(u.call(arguments)))};return e}),q.touch=function(){var c;return"ontouchstart"in a||a.DocumentTouch&&b instanceof DocumentTouch?c=!0:w(["@media (",m.join("touch-enabled),("),h,")","{#modernizr{top:9px;position:absolute}}"].join(""),function(a){c=a.offsetTop===9}),c};for(var G in q)y(q,G)&&(v=G.toLowerCase(),e[v]=q[G](),t.push((e[v]?"":"no-")+v));return e.addTest=function(a,b){if(typeof a=="object")for(var d in a)y(a,d)&&e.addTest(d,a[d]);else{a=a.toLowerCase();if(e[a]!==c)return e;b=typeof b=="function"?b():b,typeof f!="undefined"&&f&&(g.className+=" "+(b?"":"no-")+a),e[a]=b}return e},z(""),i=k=null,function(a,b){function k(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function l(){var a=r.elements;return typeof a=="string"?a.split(" "):a}function m(a){var b=i[a[g]];return b||(b={},h++,a[g]=h,i[h]=b),b}function n(a,c,f){c||(c=b);if(j)return c.createElement(a);f||(f=m(c));var g;return f.cache[a]?g=f.cache[a].cloneNode():e.test(a)?g=(f.cache[a]=f.createElem(a)).cloneNode():g=f.createElem(a),g.canHaveChildren&&!d.test(a)?f.frag.appendChild(g):g}function o(a,c){a||(a=b);if(j)return a.createDocumentFragment();c=c||m(a);var d=c.frag.cloneNode(),e=0,f=l(),g=f.length;for(;e",f="hidden"in a,j=a.childNodes.length==1||function(){b.createElement("a");var a=b.createDocumentFragment();return typeof a.cloneNode=="undefined"||typeof a.createDocumentFragment=="undefined"||typeof a.createElement=="undefined"}()}catch(c){f=!0,j=!0}})();var r={elements:c.elements||"abbr article aside audio bdi canvas data datalist details figcaption figure footer header hgroup mark meter nav output progress section summary time video",shivCSS:c.shivCSS!==!1,supportsUnknownElements:j,shivMethods:c.shivMethods!==!1,type:"default",shivDocument:q,createElement:n,createDocumentFragment:o};a.html5=r,q(b)}(this,b),e._version=d,e._prefixes=m,e._domPrefixes=p,e._cssomPrefixes=o,e.testProp=function(a){return D([a])},e.testAllProps=F,e.testStyles=w,e.prefixed=function(a,b,c){return b?F(a,b,c):F(a,"pfx")},g.className=g.className.replace(/(^|\s)no-js(\s|$)/,"$1$2")+(f?" js "+t.join(" "):""),e}(this,this.document),function(a,b,c){function d(a){return"[object Function]"==o.call(a)}function e(a){return"string"==typeof a}function f(){}function g(a){return!a||"loaded"==a||"complete"==a||"uninitialized"==a}function h(){var a=p.shift();q=1,a?a.t?m(function(){("c"==a.t?B.injectCss:B.injectJs)(a.s,0,a.a,a.x,a.e,1)},0):(a(),h()):q=0}function i(a,c,d,e,f,i,j){function k(b){if(!o&&g(l.readyState)&&(u.r=o=1,!q&&h(),l.onload=l.onreadystatechange=null,b)){"img"!=a&&m(function(){t.removeChild(l)},50);for(var d in y[c])y[c].hasOwnProperty(d)&&y[c][d].onload()}}var j=j||B.errorTimeout,l=b.createElement(a),o=0,r=0,u={t:d,s:c,e:f,a:i,x:j};1===y[c]&&(r=1,y[c]=[]),"object"==a?l.data=c:(l.src=c,l.type=a),l.width=l.height="0",l.onerror=l.onload=l.onreadystatechange=function(){k.call(this,r)},p.splice(e,0,u),"img"!=a&&(r||2===y[c]?(t.insertBefore(l,s?null:n),m(k,j)):y[c].push(l))}function j(a,b,c,d,f){return q=0,b=b||"j",e(a)?i("c"==b?v:u,a,b,this.i++,c,d,f):(p.splice(this.i++,0,a),1==p.length&&h()),this}function k(){var a=B;return a.loader={load:j,i:0},a}var l=b.documentElement,m=a.setTimeout,n=b.getElementsByTagName("script")[0],o={}.toString,p=[],q=0,r="MozAppearance"in l.style,s=r&&!!b.createRange().compareNode,t=s?l:n.parentNode,l=a.opera&&"[object Opera]"==o.call(a.opera),l=!!b.attachEvent&&!l,u=r?"object":l?"script":"img",v=l?"script":u,w=Array.isArray||function(a){return"[object Array]"==o.call(a)},x=[],y={},z={timeout:function(a,b){return b.length&&(a.timeout=b[0]),a}},A,B;B=function(a){function b(a){var a=a.split("!"),b=x.length,c=a.pop(),d=a.length,c={url:c,origUrl:c,prefixes:a},e,f,g;for(f=0;f': '.', + '?': '/', + '|': '\\' + }, + + /** + * this is a list of special strings you can use to map + * to modifier keys when you specify your keyboard shortcuts + * + * @type {Object} + */ + _SPECIAL_ALIASES = { + 'option': 'alt', + 'command': 'meta', + 'return': 'enter', + 'escape': 'esc' + }, + + /** + * variable to store the flipped version of _MAP from above + * needed to check if we should use keypress or not when no action + * is specified + * + * @type {Object|undefined} + */ + _REVERSE_MAP, + + /** + * a list of all the callbacks setup via Mousetrap.bind() + * + * @type {Object} + */ + _callbacks = {}, + + /** + * direct map of string combinations to callbacks used for trigger() + * + * @type {Object} + */ + _direct_map = {}, + + /** + * keeps track of what level each sequence is at since multiple + * sequences can start out with the same sequence + * + * @type {Object} + */ + _sequence_levels = {}, + + /** + * variable to store the setTimeout call + * + * @type {null|number} + */ + _reset_timer, + + /** + * temporary state where we will ignore the next keyup + * + * @type {boolean|string} + */ + _ignore_next_keyup = false, + + /** + * are we currently inside of a sequence? + * type of action ("keyup" or "keydown" or "keypress") or false + * + * @type {boolean|string} + */ + _sequence_type = false; + + /** + * loop through the f keys, f1 to f19 and add them to the map + * programatically + */ + for (var i = 1; i < 20; ++i) { + _MAP[111 + i] = 'f' + i; + } + + /** + * loop through to map numbers on the numeric keypad + */ + for (i = 0; i <= 9; ++i) { + _MAP[i + 96] = i; + } + + /** + * cross browser add event method + * + * @param {Element|HTMLDocument} object + * @param {string} type + * @param {Function} callback + * @returns void + */ + function _addEvent(object, type, callback) { + if (object.addEventListener) { + object.addEventListener(type, callback, false); + return; + } + + object.attachEvent('on' + type, callback); + } + + /** + * takes the event and returns the key character + * + * @param {Event} e + * @return {string} + */ + function _characterFromEvent(e) { + + // for keypress events we should return the character as is + if (e.type == 'keypress') { + return String.fromCharCode(e.which); + } + + // for non keypress events the special maps are needed + if (_MAP[e.which]) { + return _MAP[e.which]; + } + + if (_KEYCODE_MAP[e.which]) { + return _KEYCODE_MAP[e.which]; + } + + // if it is not in the special map + return String.fromCharCode(e.which).toLowerCase(); + } + + /** + * checks if two arrays are equal + * + * @param {Array} modifiers1 + * @param {Array} modifiers2 + * @returns {boolean} + */ + function _modifiersMatch(modifiers1, modifiers2) { + return modifiers1.sort().join(',') === modifiers2.sort().join(','); + } + + /** + * resets all sequence counters except for the ones passed in + * + * @param {Object} do_not_reset + * @returns void + */ + function _resetSequences(do_not_reset, max_level) { + do_not_reset = do_not_reset || {}; + + var active_sequences = false, + key; + + for (key in _sequence_levels) { + if (do_not_reset[key] && _sequence_levels[key] > max_level) { + active_sequences = true; + continue; + } + _sequence_levels[key] = 0; + } + + if (!active_sequences) { + _sequence_type = false; + } + } + + /** + * finds all callbacks that match based on the keycode, modifiers, + * and action + * + * @param {string} character + * @param {Array} modifiers + * @param {Event|Object} e + * @param {boolean=} remove - should we remove any matches + * @param {string=} combination + * @returns {Array} + */ + function _getMatches(character, modifiers, e, remove, combination) { + var i, + callback, + matches = [], + action = e.type; + + // if there are no events related to this keycode + if (!_callbacks[character]) { + return []; + } + + // if a modifier key is coming up on its own we should allow it + if (action == 'keyup' && _isModifier(character)) { + modifiers = [character]; + } + + // loop through all callbacks for the key that was pressed + // and see if any of them match + for (i = 0; i < _callbacks[character].length; ++i) { + callback = _callbacks[character][i]; + + // if this is a sequence but it is not at the right level + // then move onto the next match + if (callback.seq && _sequence_levels[callback.seq] != callback.level) { + continue; + } + + // if the action we are looking for doesn't match the action we got + // then we should keep going + if (action != callback.action) { + continue; + } + + // if this is a keypress event and the meta key and control key + // are not pressed that means that we need to only look at the + // character, otherwise check the modifiers as well + // + // chrome will not fire a keypress if meta or control is down + // safari will fire a keypress if meta or meta+shift is down + // firefox will fire a keypress if meta or control is down + if ((action == 'keypress' && !e.metaKey && !e.ctrlKey) || _modifiersMatch(modifiers, callback.modifiers)) { + + // remove is used so if you change your mind and call bind a + // second time with a new function the first one is overwritten + if (remove && callback.combo == combination) { + _callbacks[character].splice(i, 1); + } + + matches.push(callback); + } + } + + return matches; + } + + /** + * takes a key event and figures out what the modifiers are + * + * @param {Event} e + * @returns {Array} + */ + function _eventModifiers(e) { + var modifiers = []; + + if (e.shiftKey) { + modifiers.push('shift'); + } + + if (e.altKey) { + modifiers.push('alt'); + } + + if (e.ctrlKey) { + modifiers.push('ctrl'); + } + + if (e.metaKey) { + modifiers.push('meta'); + } + + return modifiers; + } + + /** + * actually calls the callback function + * + * if your callback function returns false this will use the jquery + * convention - prevent default and stop propogation on the event + * + * @param {Function} callback + * @param {Event} e + * @returns void + */ + function _fireCallback(callback, e, combo) { + + // if this event should not happen stop here + if (Mousetrap.stopCallback(e, e.target || e.srcElement, combo)) { + return; + } + + if (callback(e, combo) === false) { + if (e.preventDefault) { + e.preventDefault(); + } + + if (e.stopPropagation) { + e.stopPropagation(); + } + + e.returnValue = false; + e.cancelBubble = true; + } + } + + /** + * handles a character key event + * + * @param {string} character + * @param {Event} e + * @returns void + */ + function _handleCharacter(character, e) { + var callbacks = _getMatches(character, _eventModifiers(e), e), + i, + do_not_reset = {}, + max_level = 0, + processed_sequence_callback = false; + + // loop through matching callbacks for this key event + for (i = 0; i < callbacks.length; ++i) { + + // fire for all sequence callbacks + // this is because if for example you have multiple sequences + // bound such as "g i" and "g t" they both need to fire the + // callback for matching g cause otherwise you can only ever + // match the first one + if (callbacks[i].seq) { + processed_sequence_callback = true; + + // as we loop through keep track of the max + // any sequence at a lower level will be discarded + max_level = Math.max(max_level, callbacks[i].level); + + // keep a list of which sequences were matches for later + do_not_reset[callbacks[i].seq] = 1; + _fireCallback(callbacks[i].callback, e, callbacks[i].combo); + continue; + } + + // if there were no sequence matches but we are still here + // that means this is a regular match so we should fire that + if (!processed_sequence_callback && !_sequence_type) { + _fireCallback(callbacks[i].callback, e, callbacks[i].combo); + } + } + + // if you are inside of a sequence and the key you are pressing + // is not a modifier key then we should reset all sequences + // that were not matched by this key event + if (e.type == _sequence_type && !_isModifier(character)) { + _resetSequences(do_not_reset, max_level); + } + } + + /** + * handles a keydown event + * + * @param {Event} e + * @returns void + */ + function _handleKey(e) { + + // normalize e.which for key events + // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion + if (typeof e.which !== 'number') { + e.which = e.keyCode; + } + + var character = _characterFromEvent(e); + + // no character found then stop + if (!character) { + return; + } + + if (e.type == 'keyup' && _ignore_next_keyup == character) { + _ignore_next_keyup = false; + return; + } + + _handleCharacter(character, e); + } + + /** + * determines if the keycode specified is a modifier key or not + * + * @param {string} key + * @returns {boolean} + */ + function _isModifier(key) { + return key == 'shift' || key == 'ctrl' || key == 'alt' || key == 'meta'; + } + + /** + * called to set a 1 second timeout on the specified sequence + * + * this is so after each key press in the sequence you have 1 second + * to press the next key before you have to start over + * + * @returns void + */ + function _resetSequenceTimer() { + clearTimeout(_reset_timer); + _reset_timer = setTimeout(_resetSequences, 1000); + } + + /** + * reverses the map lookup so that we can look for specific keys + * to see what can and can't use keypress + * + * @return {Object} + */ + function _getReverseMap() { + if (!_REVERSE_MAP) { + _REVERSE_MAP = {}; + for (var key in _MAP) { + + // pull out the numeric keypad from here cause keypress should + // be able to detect the keys from the character + if (key > 95 && key < 112) { + continue; + } + + if (_MAP.hasOwnProperty(key)) { + _REVERSE_MAP[_MAP[key]] = key; + } + } + } + return _REVERSE_MAP; + } + + /** + * picks the best action based on the key combination + * + * @param {string} key - character for key + * @param {Array} modifiers + * @param {string=} action passed in + */ + function _pickBestAction(key, modifiers, action) { + + // if no action was picked in we should try to pick the one + // that we think would work best for this key + if (!action) { + action = _getReverseMap()[key] ? 'keydown' : 'keypress'; + } + + // modifier keys don't work as expected with keypress, + // switch to keydown + if (action == 'keypress' && modifiers.length) { + action = 'keydown'; + } + + return action; + } + + /** + * binds a key sequence to an event + * + * @param {string} combo - combo specified in bind call + * @param {Array} keys + * @param {Function} callback + * @param {string=} action + * @returns void + */ + function _bindSequence(combo, keys, callback, action) { + + // start off by adding a sequence level record for this combination + // and setting the level to 0 + _sequence_levels[combo] = 0; + + // if there is no action pick the best one for the first key + // in the sequence + if (!action) { + action = _pickBestAction(keys[0], []); + } + + /** + * callback to increase the sequence level for this sequence and reset + * all other sequences that were active + * + * @param {Event} e + * @returns void + */ + var _increaseSequence = function(e) { + _sequence_type = action; + ++_sequence_levels[combo]; + _resetSequenceTimer(); + }, + + /** + * wraps the specified callback inside of another function in order + * to reset all sequence counters as soon as this sequence is done + * + * @param {Event} e + * @returns void + */ + _callbackAndReset = function(e) { + _fireCallback(callback, e, combo); + + // we should ignore the next key up if the action is key down + // or keypress. this is so if you finish a sequence and + // release the key the final key will not trigger a keyup + if (action !== 'keyup') { + _ignore_next_keyup = _characterFromEvent(e); + } + + // weird race condition if a sequence ends with the key + // another sequence begins with + setTimeout(_resetSequences, 10); + }, + i; + + // loop through keys one at a time and bind the appropriate callback + // function. for any key leading up to the final one it should + // increase the sequence. after the final, it should reset all sequences + for (i = 0; i < keys.length; ++i) { + _bindSingle(keys[i], i < keys.length - 1 ? _increaseSequence : _callbackAndReset, action, combo, i); + } + } + + /** + * binds a single keyboard combination + * + * @param {string} combination + * @param {Function} callback + * @param {string=} action + * @param {string=} sequence_name - name of sequence if part of sequence + * @param {number=} level - what part of the sequence the command is + * @returns void + */ + function _bindSingle(combination, callback, action, sequence_name, level) { + + // make sure multiple spaces in a row become a single space + combination = combination.replace(/\s+/g, ' '); + + var sequence = combination.split(' '), + i, + key, + keys, + modifiers = []; + + // if this pattern is a sequence of keys then run through this method + // to reprocess each pattern one key at a time + if (sequence.length > 1) { + _bindSequence(combination, sequence, callback, action); + return; + } + + // take the keys from this pattern and figure out what the actual + // pattern is all about + keys = combination === '+' ? ['+'] : combination.split('+'); + + for (i = 0; i < keys.length; ++i) { + key = keys[i]; + + // normalize key names + if (_SPECIAL_ALIASES[key]) { + key = _SPECIAL_ALIASES[key]; + } + + // if this is not a keypress event then we should + // be smart about using shift keys + // this will only work for US keyboards however + if (action && action != 'keypress' && _SHIFT_MAP[key]) { + key = _SHIFT_MAP[key]; + modifiers.push('shift'); + } + + // if this key is a modifier then add it to the list of modifiers + if (_isModifier(key)) { + modifiers.push(key); + } + } + + // depending on what the key combination is + // we will try to pick the best event for it + action = _pickBestAction(key, modifiers, action); + + // make sure to initialize array if this is the first time + // a callback is added for this key + if (!_callbacks[key]) { + _callbacks[key] = []; + } + + // remove an existing match if there is one + _getMatches(key, modifiers, {type: action}, !sequence_name, combination); + + // add this call back to the array + // if it is a sequence put it at the beginning + // if not put it at the end + // + // this is important because the way these are processed expects + // the sequence ones to come first + _callbacks[key][sequence_name ? 'unshift' : 'push']({ + callback: callback, + modifiers: modifiers, + action: action, + seq: sequence_name, + level: level, + combo: combination + }); + } + + /** + * binds multiple combinations to the same callback + * + * @param {Array} combinations + * @param {Function} callback + * @param {string|undefined} action + * @returns void + */ + function _bindMultiple(combinations, callback, action) { + for (var i = 0; i < combinations.length; ++i) { + _bindSingle(combinations[i], callback, action); + } + } + + // start! + _addEvent(document, 'keypress', _handleKey); + _addEvent(document, 'keydown', _handleKey); + _addEvent(document, 'keyup', _handleKey); + + var Mousetrap = { + + /** + * binds an event to mousetrap + * + * can be a single key, a combination of keys separated with +, + * an array of keys, or a sequence of keys separated by spaces + * + * be sure to list the modifier keys first to make sure that the + * correct key ends up getting bound (the last key in the pattern) + * + * @param {string|Array} keys + * @param {Function} callback + * @param {string=} action - 'keypress', 'keydown', or 'keyup' + * @returns void + */ + bind: function(keys, callback, action) { + _bindMultiple(keys instanceof Array ? keys : [keys], callback, action); + _direct_map[keys + ':' + action] = callback; + return this; + }, + + /** + * unbinds an event to mousetrap + * + * the unbinding sets the callback function of the specified key combo + * to an empty function and deletes the corresponding key in the + * _direct_map dict. + * + * the keycombo+action has to be exactly the same as + * it was defined in the bind method + * + * TODO: actually remove this from the _callbacks dictionary instead + * of binding an empty function + * + * @param {string|Array} keys + * @param {string} action + * @returns void + */ + unbind: function(keys, action) { + if (_direct_map[keys + ':' + action]) { + delete _direct_map[keys + ':' + action]; + this.bind(keys, function() {}, action); + } + return this; + }, + + /** + * triggers an event that has already been bound + * + * @param {string} keys + * @param {string=} action + * @returns void + */ + trigger: function(keys, action) { + _direct_map[keys + ':' + action](); + return this; + }, + + /** + * resets the library back to its initial state. this is useful + * if you want to clear out the current keyboard shortcuts and bind + * new ones - for example if you switch to another page + * + * @returns void + */ + reset: function() { + _callbacks = {}; + _direct_map = {}; + return this; + }, + + /** + * should we stop this event before firing off callbacks + * + * @param {Event} e + * @param {Element} element + * @return {boolean} + */ + stopCallback: function(e, element, combo) { + + // if the element has the class "mousetrap" then no need to stop + if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) { + return false; + } + + // stop for input, select, and textarea + return element.tagName == 'INPUT' || element.tagName == 'SELECT' || element.tagName == 'TEXTAREA' || (element.contentEditable && element.contentEditable == 'true'); + } + }; + + // expose mousetrap to the global object + window.Mousetrap = Mousetrap; + + // expose mousetrap as an AMD module + if (typeof define === 'function' && define.amd) { + define(Mousetrap); + } +}) (); + + +/** + * adds a bindGlobal method to Mousetrap that allows you to + * bind specific keyboard shortcuts that will still work + * inside a text input field + * + * usage: + * Mousetrap.bindGlobal('ctrl+s', _saveChanges); + */ +Mousetrap = (function(Mousetrap) { + var _global_callbacks = {}, + _original_stop_callback = Mousetrap.stopCallback; + + Mousetrap.stopCallback = function(e, element, combo) { + if (_global_callbacks[combo]) { + return false; + } + + return _original_stop_callback(e, element, combo); + }; + + Mousetrap.bindGlobal = function(keys, callback, action) { + Mousetrap.bind(keys, callback, action); + + if (keys instanceof Array) { + for (var i = 0; i < keys.length; i++) { + _global_callbacks[keys[i]] = true; + } + return; + } + + _global_callbacks[keys] = true; + }; + + Mousetrap.unbindGlobal = function(keys) { + if (keys instanceof Array) { + for (var i = 0; i < keys.length; i++) { + _global_callbacks[keys[i]] = false; + } + return; + } + }; + + return Mousetrap; +}) (Mousetrap); diff --git a/app/assets/javascripts/external/respond.min.js b/app/assets/javascripts/external/respond.min.js new file mode 100644 index 00000000000..21437ba0b0b --- /dev/null +++ b/app/assets/javascripts/external/respond.min.js @@ -0,0 +1,6 @@ +/*! matchMedia() polyfill - Test a CSS media type/query in JS. Authors & copyright (c) 2012: Scott Jehl, Paul Irish, Nicholas Zakas. Dual MIT/BSD license */ +/*! NOTE: If you're already including a window.matchMedia polyfill via Modernizr or otherwise, you don't need this part */ +window.matchMedia=window.matchMedia||(function(e,f){var c,a=e.documentElement,b=a.firstElementChild||a.firstChild,d=e.createElement("body"),g=e.createElement("div");g.id="mq-test-1";g.style.cssText="position:absolute;top:-100em";d.style.background="none";d.appendChild(g);return function(h){g.innerHTML='­';a.insertBefore(d,b);c=g.offsetWidth==42;a.removeChild(d);return{matches:c,media:h}}})(document); + +/*! Respond.js v1.1.0: min/max-width media query polyfill. (c) Scott Jehl. MIT/GPLv2 Lic. j.mp/respondjs */ +(function(e){e.respond={};respond.update=function(){};respond.mediaQueriesSupported=e.matchMedia&&e.matchMedia("only all").matches;if(respond.mediaQueriesSupported){return}var w=e.document,s=w.documentElement,i=[],k=[],q=[],o={},h=30,f=w.getElementsByTagName("head")[0]||s,g=w.getElementsByTagName("base")[0],b=f.getElementsByTagName("link"),d=[],a=function(){var D=b,y=D.length,B=0,A,z,C,x;for(;B-1,minw:F.match(/\(min\-width:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/)&&parseFloat(RegExp.$1)+(RegExp.$2||""),maxw:F.match(/\(max\-width:[\s]*([\s]*[0-9\.]+)(px|em)[\s]*\)/)&&parseFloat(RegExp.$1)+(RegExp.$2||"")})}}j()},l,r,v=function(){var z,A=w.createElement("div"),x=w.body,y=false;A.style.cssText="position:absolute;font-size:1em;width:1em";if(!x){x=y=w.createElement("body");x.style.background="none"}x.appendChild(A);s.insertBefore(x,s.firstChild);z=A.offsetWidth;if(y){s.removeChild(x)}else{x.removeChild(A)}z=p=parseFloat(z);return z},p,j=function(I){var x="clientWidth",B=s[x],H=w.compatMode==="CSS1Compat"&&B||w.body[x]||B,D={},G=b[b.length-1],z=(new Date()).getTime();if(I&&l&&z-l-1?(p||v()):1)}if(!!J){J=parseFloat(J)*(J.indexOf(y)>-1?(p||v()):1)}if(!K.hasquery||(!A||!L)&&(A||H>=C)&&(L||H<=J)){if(!D[K.media]){D[K.media]=[]}D[K.media].push(k[K.rules])}}for(var E in q){if(q[E]&&q[E].parentNode===f){f.removeChild(q[E])}}for(var E in D){var M=w.createElement("style"),F=D[E].join("\n");M.type="text/css";M.media=E;f.insertBefore(M,G.nextSibling);if(M.styleSheet){M.styleSheet.cssText=F}else{M.appendChild(w.createTextNode(F))}q.push(M)}},n=function(x,z){var y=c();if(!y){return}y.open("GET",x,true);y.onreadystatechange=function(){if(y.readyState!=4||y.status!=200&&y.status!=304){return}z(y.responseText)};if(y.readyState==4){return}y.send(null)},c=(function(){var x=false;try{x=new XMLHttpRequest()}catch(y){x=new ActiveXObject("Microsoft.XMLHTTP")}return function(){return x}})();a();respond.update=a;function t(){j(true)}if(e.addEventListener){e.addEventListener("resize",t,false)}else{if(e.attachEvent){e.attachEvent("onresize",t)}}})(this); \ No newline at end of file diff --git a/app/assets/javascripts/external/rsvp.js b/app/assets/javascripts/external/rsvp.js new file mode 100644 index 00000000000..ed3b2391c81 --- /dev/null +++ b/app/assets/javascripts/external/rsvp.js @@ -0,0 +1,289 @@ +(function(exports) { + "use strict"; + var config = {}; + + var browserGlobal = (typeof window !== 'undefined') ? window : {}; + + var MutationObserver = browserGlobal.MutationObserver || browserGlobal.WebKitMutationObserver; + var RSVP; + + if (typeof process !== 'undefined' && + {}.toString.call(process) === '[object process]') { + config.async = function(callback, binding) { + process.nextTick(function() { + callback.call(binding); + }); + }; + } else if (MutationObserver) { + var queue = []; + + var observer = new MutationObserver(function() { + var toProcess = queue.slice(); + queue = []; + + toProcess.forEach(function(tuple) { + var callback = tuple[0], binding = tuple[1]; + callback.call(binding); + }); + }); + + var element = document.createElement('div'); + observer.observe(element, { attributes: true }); + + // Chrome Memory Leak: https://bugs.webkit.org/show_bug.cgi?id=93661 + window.addEventListener('unload', function(){ + observer.disconnect(); + observer = null; + }); + + config.async = function(callback, binding) { + queue.push([callback, binding]); + element.setAttribute('drainQueue', 'drainQueue'); + }; + } else { + config.async = function(callback, binding) { + setTimeout(function() { + callback.call(binding); + }, 1); + }; + } + + var Event = function(type, options) { + this.type = type; + + for (var option in options) { + if (!options.hasOwnProperty(option)) { continue; } + + this[option] = options[option]; + } + }; + + var indexOf = function(callbacks, callback) { + for (var i=0, l=callbacks.length; i -1) { + defineProperty(m.instance ? klass.prototype : klass, name, m.method); + } + }); + }, + 'extend': function(methods, override, instance) { + extend(klass, instance !== false, override, methods); + } + }); + } + + // Class extending methods + + function extend(klass, instance, override, methods) { + var extendee = instance ? klass.prototype : klass, original; + initializeClass(klass); + iterateOverObject(methods, function(name, method) { + original = extendee[name]; + if(typeof override === 'function') { + method = wrapNative(extendee[name], method, override); + } + if(override !== false || !extendee[name]) { + defineProperty(extendee, name, method); + } + // If the method is internal to Sugar, then store a reference so it can be restored later. + klass['SugarMethods'][name] = { instance: instance, method: method, original: original }; + }); + } + + function extendSimilar(klass, instance, override, set, fn) { + var methods = {}; + set = isString(set) ? set.split(',') : set; + set.forEach(function(name, i) { + fn(methods, name, i); + }); + extend(klass, instance, override, methods); + } + + function wrapNative(nativeFn, extendedFn, condition) { + return function() { + if(nativeFn && (condition === true || !condition.apply(this, arguments))) { + return nativeFn.apply(this, arguments); + } else { + return extendedFn.apply(this, arguments); + } + } + } + + function defineProperty(target, name, method) { + if(definePropertySupport) { + object.defineProperty(target, name, { 'value': method, 'configurable': true, 'enumerable': false, 'writable': true }); + } else { + target[name] = method; + } + } + + + // Argument helpers + + function multiArgs(args, fn) { + var result = [], i; + for(i = 0; i < args.length; i++) { + result.push(args[i]); + if(fn) fn.call(args, args[i], i); + } + return result; + } + + + // General helpers + + function isDefined(o) { + return o !== Undefined; + } + + function isUndefined(o) { + return o === Undefined; + } + + + // Object helpers + + function isObjectPrimitive(obj) { + // Check for null + return obj && typeof obj === 'object'; + } + + function isObject(obj) { + // === on the constructor is not safe across iframes + // 'hasOwnProperty' ensures that the object also inherits + // from Object, which is false for DOMElements in IE. + return !!obj && className(obj) === '[object Object]' && 'hasOwnProperty' in obj; + } + + function hasOwnProperty(obj, key) { + return object['hasOwnProperty'].call(obj, key); + } + + function iterateOverObject(obj, fn) { + var key; + for(key in obj) { + if(!hasOwnProperty(obj, key)) continue; + if(fn.call(obj, key, obj[key]) === false) break; + } + } + + function simpleMerge(target, source) { + iterateOverObject(source, function(key) { + target[key] = source[key]; + }); + return target; + } + + // Hash definition + + function Hash(obj) { + simpleMerge(this, obj); + }; + + Hash.prototype.constructor = object; + + // Number helpers + + function getRange(start, stop, fn, step) { + var arr = [], i = parseInt(start), down = step < 0; + while((!down && i <= stop) || (down && i >= stop)) { + arr.push(i); + if(fn) fn.call(this, i); + i += step || 1; + } + return arr; + } + + function round(val, precision, method) { + var fn = math[method || 'round']; + var multiplier = math.pow(10, math.abs(precision || 0)); + if(precision < 0) multiplier = 1 / multiplier; + return fn(val * multiplier) / multiplier; + } + + function ceil(val, precision) { + return round(val, precision, 'ceil'); + } + + function floor(val, precision) { + return round(val, precision, 'floor'); + } + + function padNumber(num, place, sign, base) { + var str = math.abs(num).toString(base || 10); + str = repeatString(place - str.replace(/\.\d+/, '').length, '0') + str; + if(sign || num < 0) { + str = (num < 0 ? '-' : '+') + str; + } + return str; + } + + function getOrdinalizedSuffix(num) { + if(num >= 11 && num <= 13) { + return 'th'; + } else { + switch(num % 10) { + case 1: return 'st'; + case 2: return 'nd'; + case 3: return 'rd'; + default: return 'th'; + } + } + } + + + // String helpers + + // WhiteSpace/LineTerminator as defined in ES5.1 plus Unicode characters in the Space, Separator category. + function getTrimmableCharacters() { + return '\u0009\u000A\u000B\u000C\u000D\u0020\u00A0\u1680\u180E\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u2028\u2029\u3000\uFEFF'; + } + + function repeatString(times, str) { + return array(math.max(0, isDefined(times) ? times : 1) + 1).join(str || ''); + } + + + // RegExp helpers + + function getRegExpFlags(reg, add) { + var flags = reg.toString().match(/[^/]*$/)[0]; + if(add) { + flags = (flags + add).split('').sort().join('').replace(/([gimy])\1+/g, '$1'); + } + return flags; + } + + function escapeRegExp(str) { + if(!isString(str)) str = string(str); + return str.replace(/([\\/'*+?|()\[\]{}.^$])/g,'\\$1'); + } + + + // Specialized helpers + + + // Used by Array#unique and Object.equal + + function stringify(thing, stack) { + var type = typeof thing, + thingIsObject, + thingIsArray, + klass, value, + arr, key, i; + + // Return quickly if string to save cycles + if(type === 'string') return thing; + + klass = object.prototype.toString.call(thing) + thingIsObject = isObject(thing); + thingIsArray = klass === '[object Array]'; + + if(thing != null && thingIsObject || thingIsArray) { + // This method for checking for cyclic structures was egregiously stolen from + // the ingenious method by @kitcambridge from the Underscore script: + // https://github.com/documentcloud/underscore/issues/240 + if(!stack) stack = []; + // Allowing a step into the structure before triggering this + // script to save cycles on standard JSON structures and also to + // try as hard as possible to catch basic properties that may have + // been modified. + if(stack.length > 1) { + i = stack.length; + while (i--) { + if (stack[i] === thing) { + return 'CYC'; + } + } + } + stack.push(thing); + value = string(thing.constructor); + arr = thingIsArray ? thing : object.keys(thing).sort(); + for(i = 0; i < arr.length; i++) { + key = thingIsArray ? i : arr[i]; + value += key + stringify(thing[key], stack); + } + stack.pop(); + } else if(1 / thing === -Infinity) { + value = '-0'; + } else { + value = string(thing && thing.valueOf ? thing.valueOf() : thing); + } + return type + klass + value; + } + + function isEqual(a, b) { + if(objectIsMatchedByValue(a) && objectIsMatchedByValue(b)) { + return stringify(a) === stringify(b); + } else { + return a === b; + } + } + + function objectIsMatchedByValue(obj) { + var klass = className(obj); + return klass === '[object Date]' || + klass === '[object Array]' || + klass === '[object String]' || + klass === '[object Number]' || + klass === '[object RegExp]' || + klass === '[object Boolean]' || + klass === '[object Arguments]' || + isObject(obj); + } + + + // Used by Array#at and String#at + + function entryAtIndex(arr, args, str) { + var result = [], length = arr.length, loop = args[args.length - 1] !== false, r; + multiArgs(args, function(index) { + if(isBoolean(index)) return false; + if(loop) { + index = index % length; + if(index < 0) index = length + index; + } + r = str ? arr.charAt(index) || '' : arr[index]; + result.push(r); + }); + return result.length < 2 ? result[0] : result; + } + + + // Object class methods implemented as instance methods + + function buildObjectInstanceMethods(set, target) { + extendSimilar(target, true, false, set, function(methods, name) { + methods[name + (name === 'equal' ? 's' : '')] = function() { + return object[name].apply(null, [this].concat(multiArgs(arguments))); + } + }); + } + + initializeClasses(); + + + + /*** + * @package ES5 + * @description Shim methods that provide ES5 compatible functionality. This package can be excluded if you do not require legacy browser support (IE8 and below). + * + ***/ + + + /*** + * Object module + * + ***/ + + extend(object, false, false, { + + 'keys': function(obj) { + var keys = []; + if(!isObjectPrimitive(obj) && !isRegExp(obj) && !isFunction(obj)) { + throw new TypeError('Object required'); + } + iterateOverObject(obj, function(key, value) { + keys.push(key); + }); + return keys; + } + + }); + + + /*** + * Array module + * + ***/ + + // ECMA5 methods + + function arrayIndexOf(arr, search, fromIndex, increment) { + var length = arr.length, + fromRight = increment == -1, + start = fromRight ? length - 1 : 0, + index = toIntegerWithDefault(fromIndex, start); + if(index < 0) { + index = length + index; + } + if((!fromRight && index < 0) || (fromRight && index >= length)) { + index = start; + } + while((fromRight && index >= 0) || (!fromRight && index < length)) { + if(arr[index] === search) { + return index; + } + index += increment; + } + return -1; + } + + function arrayReduce(arr, fn, initialValue, fromRight) { + var length = arr.length, count = 0, defined = isDefined(initialValue), result, index; + checkCallback(fn); + if(length == 0 && !defined) { + throw new TypeError('Reduce called on empty array with no initial value'); + } else if(defined) { + result = initialValue; + } else { + result = arr[fromRight ? length - 1 : count]; + count++; + } + while(count < length) { + index = fromRight ? length - count - 1 : count; + if(index in arr) { + result = fn(result, arr[index], index, arr); + } + count++; + } + return result; + } + + function toIntegerWithDefault(i, d) { + if(isNaN(i)) { + return d; + } else { + return parseInt(i >> 0); + } + } + + function checkCallback(fn) { + if(!fn || !fn.call) { + throw new TypeError('Callback is not callable'); + } + } + + function checkFirstArgumentExists(args) { + if(args.length === 0) { + throw new TypeError('First argument must be defined'); + } + } + + + + + + extend(array, false, false, { + + /*** + * + * @method Array.isArray() + * @returns Boolean + * @short Returns true if is an Array. + * @extra This method is provided for browsers that don't support it internally. + * @example + * + * Array.isArray(3) -> false + * Array.isArray(true) -> false + * Array.isArray('wasabi') -> false + * Array.isArray([1,2,3]) -> true + * + ***/ + 'isArray': function(obj) { + return isArray(obj); + } + + }); + + + extend(array, true, false, { + + /*** + * @method every(, [scope]) + * @returns Boolean + * @short Returns true if all elements in the array match . + * @extra [scope] is the %this% object. %all% is provided an alias. In addition to providing this method for browsers that don't support it natively, this method also implements @array_matching. + * @example + * + + ['a','a','a'].every(function(n) { + * return n == 'a'; + * }); + * ['a','a','a'].every('a') -> true + * [{a:2},{a:2}].every({a:2}) -> true + ***/ + 'every': function(fn, scope) { + var length = this.length, index = 0; + checkFirstArgumentExists(arguments); + while(index < length) { + if(index in this && !fn.call(scope, this[index], index, this)) { + return false; + } + index++; + } + return true; + }, + + /*** + * @method some(, [scope]) + * @returns Boolean + * @short Returns true if any element in the array matches . + * @extra [scope] is the %this% object. %any% is provided as an alias. In addition to providing this method for browsers that don't support it natively, this method also implements @array_matching. + * @example + * + + ['a','b','c'].some(function(n) { + * return n == 'a'; + * }); + + ['a','b','c'].some(function(n) { + * return n == 'd'; + * }); + * ['a','b','c'].some('a') -> true + * [{a:2},{b:5}].some({a:2}) -> true + ***/ + 'some': function(fn, scope) { + var length = this.length, index = 0; + checkFirstArgumentExists(arguments); + while(index < length) { + if(index in this && fn.call(scope, this[index], index, this)) { + return true; + } + index++; + } + return false; + }, + + /*** + * @method map(, [scope]) + * @returns Array + * @short Maps the array to another array containing the values that are the result of calling on each element. + * @extra [scope] is the %this% object. In addition to providing this method for browsers that don't support it natively, this enhanced method also directly accepts a string, which is a shortcut for a function that gets that property (or invokes a function) on each element. + * @example + * + + [1,2,3].map(function(n) { + * return n * 3; + * }); -> [3,6,9] + * ['one','two','three'].map(function(n) { + * return n.length; + * }); -> [3,3,5] + * ['one','two','three'].map('length') -> [3,3,5] + ***/ + 'map': function(fn, scope) { + var length = this.length, index = 0, result = new Array(length); + checkFirstArgumentExists(arguments); + while(index < length) { + if(index in this) { + result[index] = fn.call(scope, this[index], index, this); + } + index++; + } + return result; + }, + + /*** + * @method filter(, [scope]) + * @returns Array + * @short Returns any elements in the array that match . + * @extra [scope] is the %this% object. In addition to providing this method for browsers that don't support it natively, this method also implements @array_matching. + * @example + * + + [1,2,3].filter(function(n) { + * return n > 1; + * }); + * [1,2,2,4].filter(2) -> 2 + * + ***/ + 'filter': function(fn, scope) { + var length = this.length, index = 0, result = []; + checkFirstArgumentExists(arguments); + while(index < length) { + if(index in this && fn.call(scope, this[index], index, this)) { + result.push(this[index]); + } + index++; + } + return result; + }, + + /*** + * @method indexOf(, [fromIndex]) + * @returns Number + * @short Searches the array and returns the first index where occurs, or -1 if the element is not found. + * @extra [fromIndex] is the index from which to begin the search. This method performs a simple strict equality comparison on . It does not support enhanced functionality such as searching the contents against a regex, callback, or deep comparison of objects. For such functionality, use the %findIndex% method instead. + * @example + * + * [1,2,3].indexOf(3) -> 1 + * [1,2,3].indexOf(7) -> -1 + * + ***/ + 'indexOf': function(search, fromIndex) { + if(isString(this)) return this.indexOf(search, fromIndex); + return arrayIndexOf(this, search, fromIndex, 1); + }, + + /*** + * @method lastIndexOf(, [fromIndex]) + * @returns Number + * @short Searches the array and returns the last index where occurs, or -1 if the element is not found. + * @extra [fromIndex] is the index from which to begin the search. This method performs a simple strict equality comparison on . + * @example + * + * [1,2,1].lastIndexOf(1) -> 2 + * [1,2,1].lastIndexOf(7) -> -1 + * + ***/ + 'lastIndexOf': function(search, fromIndex) { + if(isString(this)) return this.lastIndexOf(search, fromIndex); + return arrayIndexOf(this, search, fromIndex, -1); + }, + + /*** + * @method forEach([fn], [scope]) + * @returns Nothing + * @short Iterates over the array, calling [fn] on each loop. + * @extra This method is only provided for those browsers that do not support it natively. [scope] becomes the %this% object. + * @example + * + * ['a','b','c'].forEach(function(a) { + * // Called 3 times: 'a','b','c' + * }); + * + ***/ + 'forEach': function(fn, scope) { + var length = this.length, index = 0; + checkCallback(fn); + while(index < length) { + if(index in this) { + fn.call(scope, this[index], index, this); + } + index++; + } + }, + + /*** + * @method reduce(, [init]) + * @returns Mixed + * @short Reduces the array to a single result. + * @extra If [init] is passed as a starting value, that value will be passed as the first argument to the callback. The second argument will be the first element in the array. From that point, the result of the callback will then be used as the first argument of the next iteration. This is often refered to as "accumulation", and [init] is often called an "accumulator". If [init] is not passed, then will be called n - 1 times, where n is the length of the array. In this case, on the first iteration only, the first argument will be the first element of the array, and the second argument will be the second. After that callbacks work as normal, using the result of the previous callback as the first argument of the next. This method is only provided for those browsers that do not support it natively. + * + * @example + * + + [1,2,3,4].reduce(function(a, b) { + * return a - b; + * }); + + [1,2,3,4].reduce(function(a, b) { + * return a - b; + * }, 100); + * + ***/ + 'reduce': function(fn, init) { + return arrayReduce(this, fn, init); + }, + + /*** + * @method reduceRight([fn], [init]) + * @returns Mixed + * @short Identical to %Array#reduce%, but operates on the elements in reverse order. + * @extra This method is only provided for those browsers that do not support it natively. + * + * + * + * + * @example + * + + [1,2,3,4].reduceRight(function(a, b) { + * return a - b; + * }); + * + ***/ + 'reduceRight': function(fn, init) { + return arrayReduce(this, fn, init, true); + } + + + }); + + + + + /*** + * String module + * + ***/ + + + function buildTrim() { + var support = getTrimmableCharacters().match(/^\s+$/); + try { string.prototype.trim.call([1]); } catch(e) { support = false; } + extend(string, true, !support, { + + /*** + * @method trim[Side]() + * @returns String + * @short Removes leading and/or trailing whitespace from the string. + * @extra Whitespace is defined as line breaks, tabs, and any character in the "Space, Separator" Unicode category, conforming to the the ES5 spec. The standard %trim% method is only added when not fully supported natively. + * + * @set + * trim + * trimLeft + * trimRight + * + * @example + * + * ' wasabi '.trim() -> 'wasabi' + * ' wasabi '.trimLeft() -> 'wasabi ' + * ' wasabi '.trimRight() -> ' wasabi' + * + ***/ + 'trim': function() { + return this.toString().trimLeft().trimRight(); + }, + + 'trimLeft': function() { + return this.replace(regexp('^['+getTrimmableCharacters()+']+'), ''); + }, + + 'trimRight': function() { + return this.replace(regexp('['+getTrimmableCharacters()+']+$'), ''); + } + }); + } + + + + /*** + * Function module + * + ***/ + + + extend(Function, true, false, { + + /*** + * @method bind(, [arg1], ...) + * @returns Function + * @short Binds as the %this% object for the function when it is called. Also allows currying an unlimited number of parameters. + * @extra "currying" means setting parameters ([arg1], [arg2], etc.) ahead of time so that they are passed when the function is called later. If you pass additional parameters when the function is actually called, they will be added will be added to the end of the curried parameters. This method is provided for browsers that don't support it internally. + * @example + * + + (function() { + * return this; + * }).bind('woof')(); -> returns 'woof'; function is bound with 'woof' as the this object. + * (function(a) { + * return a; + * }).bind(1, 2)(); -> returns 2; function is bound with 1 as the this object and 2 curried as the first parameter + * (function(a, b) { + * return a + b; + * }).bind(1, 2)(3); -> returns 5; function is bound with 1 as the this object, 2 curied as the first parameter and 3 passed as the second when calling the function + * + ***/ + 'bind': function(scope) { + var fn = this, args = multiArgs(arguments).slice(1), nop, bound; + if(!isFunction(this)) { + throw new TypeError('Function.prototype.bind called on a non-function'); + } + bound = function() { + return fn.apply(fn.prototype && this instanceof fn ? this : scope, args.concat(multiArgs(arguments))); + } + bound.prototype = this.prototype; + return bound; + } + + }); + + /*** + * Date module + * + ***/ + + /*** + * @method toISOString() + * @returns String + * @short Formats the string to ISO8601 format. + * @extra This will always format as UTC time. Provided for browsers that do not support this method. + * @example + * + * Date.create().toISOString() -> ex. 2011-07-05 12:24:55.528Z + * + *** + * @method toJSON() + * @returns String + * @short Returns a JSON representation of the date. + * @extra This is effectively an alias for %toISOString%. Will always return the date in UTC time. Provided for browsers that do not support this method. + * @example + * + * Date.create().toJSON() -> ex. 2011-07-05 12:24:55.528Z + * + ***/ + + extend(date, false, false, { + + /*** + * @method Date.now() + * @returns String + * @short Returns the number of milliseconds since January 1st, 1970 00:00:00 (UTC time). + * @extra Provided for browsers that do not support this method. + * @example + * + * Date.now() -> ex. 1311938296231 + * + ***/ + 'now': function() { + return new date().getTime(); + } + + }); + + function buildISOString() { + var d = new date(date.UTC(1999, 11, 31)), target = '1999-12-31T00:00:00.000Z'; + var support = d.toISOString && d.toISOString() === target; + extendSimilar(date, true, !support, 'toISOString,toJSON', function(methods, name) { + methods[name] = function() { + return padNumber(this.getUTCFullYear(), 4) + '-' + + padNumber(this.getUTCMonth() + 1, 2) + '-' + + padNumber(this.getUTCDate(), 2) + 'T' + + padNumber(this.getUTCHours(), 2) + ':' + + padNumber(this.getUTCMinutes(), 2) + ':' + + padNumber(this.getUTCSeconds(), 2) + '.' + + padNumber(this.getUTCMilliseconds(), 3) + 'Z'; + } + }); + } + + // Initialize + buildTrim(); + buildISOString(); + + + + /*** + * @package Array + * @dependency core + * @description Array manipulation and traversal, "fuzzy matching" against elements, alphanumeric sorting and collation, enumerable methods on Object. + * + ***/ + + + function multiMatch(el, match, scope, params) { + var result = true; + if(el === match) { + // Match strictly equal values up front. + return true; + } else if(isRegExp(match) && isString(el)) { + // Match against a regexp + return regexp(match).test(el); + } else if(isFunction(match) && !isFunction(el)) { + // Match against a filtering function + return match.apply(scope, params); + } else if(isObject(match) && isObjectPrimitive(el)) { + // Match against a hash or array. + iterateOverObject(match, function(key, value) { + if(!multiMatch(el[key], match[key], scope, [el[key], el])) { + result = false; + } + }); + return result; + } else { + return isEqual(el, match); + } + } + + function transformArgument(el, map, context, mapArgs) { + if(isUndefined(map)) { + return el; + } else if(isFunction(map)) { + return map.apply(context, mapArgs || []); + } else if(isFunction(el[map])) { + return el[map].call(el); + } else { + return el[map]; + } + } + + // Basic array internal methods + + function arrayEach(arr, fn, startIndex, loop) { + var length, index, i; + if(startIndex < 0) startIndex = arr.length + startIndex; + i = isNaN(startIndex) ? 0 : startIndex; + length = loop === true ? arr.length + i : arr.length; + while(i < length) { + index = i % arr.length; + if(!(index in arr)) { + return iterateOverSparseArray(arr, fn, i, loop); + } else if(fn.call(arr, arr[index], index, arr) === false) { + break; + } + i++; + } + } + + function iterateOverSparseArray(arr, fn, fromIndex, loop) { + var indexes = [], i; + for(i in arr) { + if(isArrayIndex(arr, i) && i >= fromIndex) { + indexes.push(parseInt(i)); + } + } + indexes.sort().each(function(index) { + return fn.call(arr, arr[index], index, arr); + }); + return arr; + } + + function isArrayIndex(arr, i) { + return i in arr && toUInt32(i) == i && i != 0xffffffff; + } + + function toUInt32(i) { + return i >>> 0; + } + + function arrayFind(arr, f, startIndex, loop, returnIndex) { + var result, index; + arrayEach(arr, function(el, i, arr) { + if(multiMatch(el, f, arr, [el, i, arr])) { + result = el; + index = i; + return false; + } + }, startIndex, loop); + return returnIndex ? index : result; + } + + function arrayUnique(arr, map) { + var result = [], o = {}, transformed; + arrayEach(arr, function(el, i) { + transformed = map ? transformArgument(el, map, arr, [el, i, arr]) : el; + if(!checkForElementInHashAndSet(o, transformed)) { + result.push(el); + } + }) + return result; + } + + function arrayIntersect(arr1, arr2, subtract) { + var result = [], o = {}; + arr2.each(function(el) { + checkForElementInHashAndSet(o, el); + }); + arr1.each(function(el) { + var stringified = stringify(el), + isReference = !objectIsMatchedByValue(el); + // Add the result to the array if: + // 1. We're subtracting intersections or it doesn't already exist in the result and + // 2. It exists in the compared array and we're adding, or it doesn't exist and we're removing. + if(elementExistsInHash(o, stringified, el, isReference) != subtract) { + discardElementFromHash(o, stringified, el, isReference); + result.push(el); + } + }); + return result; + } + + function arrayFlatten(arr, level, current) { + level = level || Infinity; + current = current || 0; + var result = []; + arrayEach(arr, function(el) { + if(isArray(el) && current < level) { + result = result.concat(arrayFlatten(el, level, current + 1)); + } else { + result.push(el); + } + }); + return result; + } + + function flatArguments(args) { + var result = []; + multiArgs(args, function(arg) { + result = result.concat(arg); + }); + return result; + } + + function elementExistsInHash(hash, key, element, isReference) { + var exists = key in hash; + if(isReference) { + if(!hash[key]) { + hash[key] = []; + } + exists = hash[key].indexOf(element) !== -1; + } + return exists; + } + + function checkForElementInHashAndSet(hash, element) { + var stringified = stringify(element), + isReference = !objectIsMatchedByValue(element), + exists = elementExistsInHash(hash, stringified, element, isReference); + if(isReference) { + hash[stringified].push(element); + } else { + hash[stringified] = element; + } + return exists; + } + + function discardElementFromHash(hash, key, element, isReference) { + var arr, i = 0; + if(isReference) { + arr = hash[key]; + while(i < arr.length) { + if(arr[i] === element) { + arr.splice(i, 1); + } else { + i += 1; + } + } + } else { + delete hash[key]; + } + } + + // Support methods + + function getMinOrMax(obj, map, which, all) { + var edge, + result = [], + max = which === 'max', + min = which === 'min', + isArray = Array.isArray(obj); + iterateOverObject(obj, function(key) { + var el = obj[key]; + var test = transformArgument(el, map, obj, isArray ? [el, parseInt(key), obj] : []); + if(test === edge) { + result.push(el); + } else if(isUndefined(edge) || (max && test > edge) || (min && test < edge)) { + result = [el]; + edge = test; + } + }); + if(!isArray) result = arrayFlatten(result, 1); + return all ? result : result[0]; + } + + + // Alphanumeric collation helpers + + function collateStrings(a, b) { + var aValue, bValue, aChar, bChar, aEquiv, bEquiv, index = 0, tiebreaker = 0; + a = getCollationReadyString(a); + b = getCollationReadyString(b); + do { + aChar = getCollationCharacter(a, index); + bChar = getCollationCharacter(b, index); + aValue = getCollationValue(aChar); + bValue = getCollationValue(bChar); + if(aValue === -1 || bValue === -1) { + aValue = a.charCodeAt(index) || null; + bValue = b.charCodeAt(index) || null; + } + aEquiv = aChar !== a.charAt(index); + bEquiv = bChar !== b.charAt(index); + if(aEquiv !== bEquiv && tiebreaker === 0) { + tiebreaker = aEquiv - bEquiv; + } + index += 1; + } while(aValue != null && bValue != null && aValue === bValue); + if(aValue === bValue) return tiebreaker; + return aValue < bValue ? -1 : 1; + } + + function getCollationReadyString(str) { + if(array[AlphanumericSortIgnoreCase]) { + str = str.toLowerCase(); + } + return str.replace(array[AlphanumericSortIgnore], ''); + } + + function getCollationCharacter(str, index) { + var chr = str.charAt(index), eq = array[AlphanumericSortEquivalents] || {}; + return eq[chr] || chr; + } + + function getCollationValue(chr) { + var order = array[AlphanumericSortOrder]; + if(!chr) { + return null; + } else { + return order.indexOf(chr); + } + } + + var AlphanumericSortOrder = 'AlphanumericSortOrder'; + var AlphanumericSortIgnore = 'AlphanumericSortIgnore'; + var AlphanumericSortIgnoreCase = 'AlphanumericSortIgnoreCase'; + var AlphanumericSortEquivalents = 'AlphanumericSortEquivalents'; + + + + function buildEnhancements() { + var callbackCheck = function() { var a = arguments; return a.length > 0 && !isFunction(a[0]); }; + extendSimilar(array, true, callbackCheck, 'map,every,all,some,any,none,filter', function(methods, name) { + methods[name] = function(f) { + return this[name](function(el, index) { + if(name === 'map') { + return transformArgument(el, f, this, [el, index, this]); + } else { + return multiMatch(el, f, this, [el, index, this]); + } + }); + } + }); + } + + function buildAlphanumericSort() { + var order = 'AÁÀÂÃĄBCĆČÇDĎÐEÉÈĚÊËĘFGĞHıIÍÌİÎÏJKLŁMNŃŇÑOÓÒÔPQRŘSŚŠŞTŤUÚÙŮÛÜVWXYÝZŹŻŽÞÆŒØÕÅÄÖ'; + var equiv = 'AÁÀÂÃÄ,CÇ,EÉÈÊË,IÍÌİÎÏ,OÓÒÔÕÖ,Sß,UÚÙÛÜ'; + array[AlphanumericSortOrder] = order.split('').map(function(str) { + return str + str.toLowerCase(); + }).join(''); + var equivalents = {}; + arrayEach(equiv.split(','), function(set) { + var equivalent = set.charAt(0); + arrayEach(set.slice(1).split(''), function(chr) { + equivalents[chr] = equivalent; + equivalents[chr.toLowerCase()] = equivalent.toLowerCase(); + }); + }); + array[AlphanumericSortIgnoreCase] = true; + array[AlphanumericSortEquivalents] = equivalents; + } + + extend(array, false, false, { + + /*** + * + * @method Array.create(, , ...) + * @returns Array + * @short Alternate array constructor. + * @extra This method will create a single array by calling %concat% on all arguments passed. In addition to ensuring that an unknown variable is in a single, flat array (the standard constructor will create nested arrays, this one will not), it is also a useful shorthand to convert a function's arguments object into a standard array. + * @example + * + * Array.create('one', true, 3) -> ['one', true, 3] + * Array.create(['one', true, 3]) -> ['one', true, 3] + + Array.create(function(n) { + * return arguments; + * }('howdy', 'doody')); + * + ***/ + 'create': function() { + var result = [], tmp; + multiArgs(arguments, function(a) { + if(isObjectPrimitive(a)) { + tmp = array.prototype.slice.call(a); + if(tmp.length > 0) { + a = tmp; + } + } + result = result.concat(a); + }); + return result; + } + + }); + + extend(array, true, false, { + + /*** + * @method find(, [index] = 0, [loop] = false) + * @returns Mixed + * @short Returns the first element that matches . + * @extra will match a string, number, array, object, or alternately test against a function or regex. Starts at [index], and will continue once from index = 0 if [loop] is true. This method implements @array_matching. + * @example + * + + [{a:1,b:2},{a:1,b:3},{a:1,b:4}].find(function(n) { + * return n['a'] == 1; + * }); -> {a:1,b:3} + * ['cuba','japan','canada'].find(/^c/, 2) -> 'canada' + * + ***/ + 'find': function(f, index, loop) { + return arrayFind(this, f, index, loop); + }, + + /*** + * @method findAll(, [index] = 0, [loop] = false) + * @returns Array + * @short Returns all elements that match . + * @extra will match a string, number, array, object, or alternately test against a function or regex. Starts at [index], and will continue once from index = 0 if [loop] is true. This method implements @array_matching. + * @example + * + + [{a:1,b:2},{a:1,b:3},{a:2,b:4}].findAll(function(n) { + * return n['a'] == 1; + * }); -> [{a:1,b:3},{a:1,b:4}] + * ['cuba','japan','canada'].findAll(/^c/) -> 'cuba','canada' + * ['cuba','japan','canada'].findAll(/^c/, 2) -> 'canada' + * + ***/ + 'findAll': function(f, index, loop) { + var result = []; + arrayEach(this, function(el, i, arr) { + if(multiMatch(el, f, arr, [el, i, arr])) { + result.push(el); + } + }, index, loop); + return result; + }, + + /*** + * @method findIndex(, [startIndex] = 0, [loop] = false) + * @returns Number + * @short Returns the index of the first element that matches or -1 if not found. + * @extra This method has a few notable differences to native %indexOf%. Although will similarly match a primitive such as a string or number, it will also match deep objects and arrays that are not equal by reference (%===%). Additionally, if a function is passed it will be run as a matching function (similar to the behavior of %Array#filter%) rather than attempting to find that function itself by reference in the array. Starts at [index], and will continue once from index = 0 if [loop] is true. This method implements @array_matching. + * @example + * + + [1,2,3,4].findIndex(3); -> 2 + + [1,2,3,4].findIndex(function(n) { + * return n % 2 == 0; + * }); -> 1 + + ['one','two','three'].findIndex(/th/); -> 2 + * + ***/ + 'findIndex': function(f, startIndex, loop) { + var index = arrayFind(this, f, startIndex, loop, true); + return isUndefined(index) ? -1 : index; + }, + + /*** + * @method count() + * @returns Number + * @short Counts all elements in the array that match . + * @extra will match a string, number, array, object, or alternately test against a function or regex. This method implements @array_matching. + * @example + * + * [1,2,3,1].count(1) -> 2 + * ['a','b','c'].count(/b/) -> 1 + + [{a:1},{b:2}].count(function(n) { + * return n['a'] > 1; + * }); -> 0 + * + ***/ + 'count': function(f) { + if(isUndefined(f)) return this.length; + return this.findAll(f).length; + }, + + /*** + * @method removeAt(, [end]) + * @returns Array + * @short Removes element at . If [end] is specified, removes the range between and [end]. This method will change the array! If you don't intend the array to be changed use %clone% first. + * @example + * + * ['a','b','c'].removeAt(0) -> ['b','c'] + * [1,2,3,4].removeAt(1, 3) -> [1] + * + ***/ + 'removeAt': function(start, end) { + if(isUndefined(start)) return this; + if(isUndefined(end)) end = start; + for(var i = 0; i <= (end - start); i++) { + this.splice(start, 1); + } + return this; + }, + + /*** + * @method include(, [index]) + * @returns Array + * @short Adds to the array. + * @extra This is a non-destructive alias for %add%. It will not change the original array. + * @example + * + * [1,2,3,4].include(5) -> [1,2,3,4,5] + * [1,2,3,4].include(8, 1) -> [1,8,2,3,4] + * [1,2,3,4].include([5,6,7]) -> [1,2,3,4,5,6,7] + * + ***/ + 'include': function(el, index) { + return this.clone().add(el, index); + }, + + /*** + * @method exclude([f1], [f2], ...) + * @returns Array + * @short Removes any element in the array that matches [f1], [f2], etc. + * @extra This is a non-destructive alias for %remove%. It will not change the original array. This method implements @array_matching. + * @example + * + * [1,2,3].exclude(3) -> [1,2] + * ['a','b','c'].exclude(/b/) -> ['a','c'] + + [{a:1},{b:2}].exclude(function(n) { + * return n['a'] == 1; + * }); -> [{b:2}] + * + ***/ + 'exclude': function() { + return array.prototype.remove.apply(this.clone(), arguments); + }, + + /*** + * @method clone() + * @returns Array + * @short Clones the array. + * @example + * + * [1,2,3].clone() -> [1,2,3] + * + ***/ + 'clone': function() { + return simpleMerge([], this); + }, + + /*** + * @method unique([map] = null) + * @returns Array + * @short Removes all duplicate elements in the array. + * @extra [map] may be a function mapping the value to be uniqued on or a string acting as a shortcut. This is most commonly used when you have a key that ensures the object's uniqueness, and don't need to check all fields. This method will also correctly operate on arrays of objects. + * @example + * + * [1,2,2,3].unique() -> [1,2,3] + * [{foo:'bar'},{foo:'bar'}].unique() -> [{foo:'bar'}] + + [{foo:'bar'},{foo:'bar'}].unique(function(obj){ + * return obj.foo; + * }); -> [{foo:'bar'}] + * [{foo:'bar'},{foo:'bar'}].unique('foo') -> [{foo:'bar'}] + * + ***/ + 'unique': function(map) { + return arrayUnique(this, map); + }, + + /*** + * @method flatten([limit] = Infinity) + * @returns Array + * @short Returns a flattened, one-dimensional copy of the array. + * @extra You can optionally specify a [limit], which will only flatten that depth. + * @example + * + * [[1], 2, [3]].flatten() -> [1,2,3] + * [['a'],[],'b','c'].flatten() -> ['a','b','c'] + * + ***/ + 'flatten': function(limit) { + return arrayFlatten(this, limit); + }, + + /*** + * @method union([a1], [a2], ...) + * @returns Array + * @short Returns an array containing all elements in all arrays with duplicates removed. + * @extra This method will also correctly operate on arrays of objects. + * @example + * + * [1,3,5].union([5,7,9]) -> [1,3,5,7,9] + * ['a','b'].union(['b','c']) -> ['a','b','c'] + * + ***/ + 'union': function() { + return arrayUnique(this.concat(flatArguments(arguments))); + }, + + /*** + * @method intersect([a1], [a2], ...) + * @returns Array + * @short Returns an array containing the elements all arrays have in common. + * @extra This method will also correctly operate on arrays of objects. + * @example + * + * [1,3,5].intersect([5,7,9]) -> [5] + * ['a','b'].intersect('b','c') -> ['b'] + * + ***/ + 'intersect': function() { + return arrayIntersect(this, flatArguments(arguments), false); + }, + + /*** + * @method subtract([a1], [a2], ...) + * @returns Array + * @short Subtracts from the array all elements in [a1], [a2], etc. + * @extra This method will also correctly operate on arrays of objects. + * @example + * + * [1,3,5].subtract([5,7,9]) -> [1,3] + * [1,3,5].subtract([3],[5]) -> [1] + * ['a','b'].subtract('b','c') -> ['a'] + * + ***/ + 'subtract': function(a) { + return arrayIntersect(this, flatArguments(arguments), true); + }, + + /*** + * @method at(, [loop] = true) + * @returns Mixed + * @short Gets the element(s) at a given index. + * @extra When [loop] is true, overshooting the end of the array (or the beginning) will begin counting from the other end. As an alternate syntax, passing multiple indexes will get the elements at those indexes. + * @example + * + * [1,2,3].at(0) -> 1 + * [1,2,3].at(2) -> 3 + * [1,2,3].at(4) -> 2 + * [1,2,3].at(4, false) -> null + * [1,2,3].at(-1) -> 3 + * [1,2,3].at(0,1) -> [1,2] + * + ***/ + 'at': function() { + return entryAtIndex(this, arguments); + }, + + /*** + * @method first([num] = 1) + * @returns Mixed + * @short Returns the first element(s) in the array. + * @extra When is passed, returns the first elements in the array. + * @example + * + * [1,2,3].first() -> 1 + * [1,2,3].first(2) -> [1,2] + * + ***/ + 'first': function(num) { + if(isUndefined(num)) return this[0]; + if(num < 0) num = 0; + return this.slice(0, num); + }, + + /*** + * @method last([num] = 1) + * @returns Mixed + * @short Returns the last element(s) in the array. + * @extra When is passed, returns the last elements in the array. + * @example + * + * [1,2,3].last() -> 3 + * [1,2,3].last(2) -> [2,3] + * + ***/ + 'last': function(num) { + if(isUndefined(num)) return this[this.length - 1]; + var start = this.length - num < 0 ? 0 : this.length - num; + return this.slice(start); + }, + + /*** + * @method from() + * @returns Array + * @short Returns a slice of the array from . + * @example + * + * [1,2,3].from(1) -> [2,3] + * [1,2,3].from(2) -> [3] + * + ***/ + 'from': function(num) { + return this.slice(num); + }, + + /*** + * @method to() + * @returns Array + * @short Returns a slice of the array up to . + * @example + * + * [1,2,3].to(1) -> [1] + * [1,2,3].to(2) -> [1,2] + * + ***/ + 'to': function(num) { + if(isUndefined(num)) num = this.length; + return this.slice(0, num); + }, + + /*** + * @method min([map], [all] = false) + * @returns Mixed + * @short Returns the element in the array with the lowest value. + * @extra [map] may be a function mapping the value to be checked or a string acting as a shortcut. If [all] is true, will return all min values in an array. + * @example + * + * [1,2,3].min() -> 1 + * ['fee','fo','fum'].min('length') -> 'fo' + * ['fee','fo','fum'].min('length', true) -> ['fo'] + + ['fee','fo','fum'].min(function(n) { + * return n.length; + * }); -> ['fo'] + + [{a:3,a:2}].min(function(n) { + * return n['a']; + * }); -> [{a:2}] + * + ***/ + 'min': function(map, all) { + return getMinOrMax(this, map, 'min', all); + }, + + /*** + * @method max([map], [all] = false) + * @returns Mixed + * @short Returns the element in the array with the greatest value. + * @extra [map] may be a function mapping the value to be checked or a string acting as a shortcut. If [all] is true, will return all max values in an array. + * @example + * + * [1,2,3].max() -> 3 + * ['fee','fo','fum'].max('length') -> 'fee' + * ['fee','fo','fum'].max('length', true) -> ['fee'] + + [{a:3,a:2}].max(function(n) { + * return n['a']; + * }); -> {a:3} + * + ***/ + 'max': function(map, all) { + return getMinOrMax(this, map, 'max', all); + }, + + /*** + * @method least([map]) + * @returns Array + * @short Returns the elements in the array with the least commonly occuring value. + * @extra [map] may be a function mapping the value to be checked or a string acting as a shortcut. + * @example + * + * [3,2,2].least() -> [3] + * ['fe','fo','fum'].least('length') -> ['fum'] + + [{age:35,name:'ken'},{age:12,name:'bob'},{age:12,name:'ted'}].least(function(n) { + * return n.age; + * }); -> [{age:35,name:'ken'}] + * + ***/ + 'least': function(map, all) { + return getMinOrMax(this.groupBy.apply(this, [map]), 'length', 'min', all); + }, + + /*** + * @method most([map]) + * @returns Array + * @short Returns the elements in the array with the most commonly occuring value. + * @extra [map] may be a function mapping the value to be checked or a string acting as a shortcut. + * @example + * + * [3,2,2].most() -> [2] + * ['fe','fo','fum'].most('length') -> ['fe','fo'] + + [{age:35,name:'ken'},{age:12,name:'bob'},{age:12,name:'ted'}].most(function(n) { + * return n.age; + * }); -> [{age:12,name:'bob'},{age:12,name:'ted'}] + * + ***/ + 'most': function(map, all) { + return getMinOrMax(this.groupBy.apply(this, [map]), 'length', 'max', all); + }, + + /*** + * @method sum([map]) + * @returns Number + * @short Sums all values in the array. + * @extra [map] may be a function mapping the value to be summed or a string acting as a shortcut. + * @example + * + * [1,2,2].sum() -> 5 + + [{age:35},{age:12},{age:12}].sum(function(n) { + * return n.age; + * }); -> 59 + * [{age:35},{age:12},{age:12}].sum('age') -> 59 + * + ***/ + 'sum': function(map) { + var arr = map ? this.map(map) : this; + return arr.length > 0 ? arr.reduce(function(a,b) { return a + b; }) : 0; + }, + + /*** + * @method average([map]) + * @returns Number + * @short Averages all values in the array. + * @extra [map] may be a function mapping the value to be averaged or a string acting as a shortcut. + * @example + * + * [1,2,3].average() -> 2 + + [{age:35},{age:11},{age:11}].average(function(n) { + * return n.age; + * }); -> 19 + * [{age:35},{age:11},{age:11}].average('age') -> 19 + * + ***/ + 'average': function(map) { + var arr = map ? this.map(map) : this; + return arr.length > 0 ? arr.sum() / arr.length : 0; + }, + + /*** + * @method inGroups(, [padding]) + * @returns Array + * @short Groups the array into arrays. + * @extra [padding] specifies a value with which to pad the last array so that they are all equal length. + * @example + * + * [1,2,3,4,5,6,7].inGroups(3) -> [ [1,2,3], [4,5,6], [7] ] + * [1,2,3,4,5,6,7].inGroups(3, 'none') -> [ [1,2,3], [4,5,6], [7,'none','none'] ] + * + ***/ + 'inGroups': function(num, padding) { + var pad = arguments.length > 1; + var arr = this; + var result = []; + var divisor = ceil(this.length / num); + getRange(0, num - 1, function(i) { + var index = i * divisor; + var group = arr.slice(index, index + divisor); + if(pad && group.length < divisor) { + getRange(1, divisor - group.length, function() { + group = group.add(padding); + }); + } + result.push(group); + }); + return result; + }, + + /*** + * @method inGroupsOf(, [padding] = null) + * @returns Array + * @short Groups the array into arrays of elements each. + * @extra [padding] specifies a value with which to pad the last array so that they are all equal length. + * @example + * + * [1,2,3,4,5,6,7].inGroupsOf(4) -> [ [1,2,3,4], [5,6,7] ] + * [1,2,3,4,5,6,7].inGroupsOf(4, 'none') -> [ [1,2,3,4], [5,6,7,'none'] ] + * + ***/ + 'inGroupsOf': function(num, padding) { + var result = [], len = this.length, arr = this, group; + if(len === 0 || num === 0) return arr; + if(isUndefined(num)) num = 1; + if(isUndefined(padding)) padding = null; + getRange(0, ceil(len / num) - 1, function(i) { + group = arr.slice(num * i, num * i + num); + while(group.length < num) { + group.push(padding); + } + result.push(group); + }); + return result; + }, + + /*** + * @method isEmpty() + * @returns Boolean + * @short Returns true if the array is empty. + * @extra This is true if the array has a length of zero, or contains only %undefined%, %null%, or %NaN%. + * @example + * + * [].isEmpty() -> true + * [null,undefined].isEmpty() -> true + * + ***/ + 'isEmpty': function() { + return this.compact().length == 0; + }, + + /*** + * @method sortBy(, [desc] = false) + * @returns Array + * @short Sorts the array by . + * @extra may be a function, a string acting as a shortcut, or blank (direct comparison of array values). [desc] will sort the array in descending order. When the field being sorted on is a string, the resulting order will be determined by an internal collation algorithm that is optimized for major Western languages, but can be customized. For more information see @array_sorting. + * @example + * + * ['world','a','new'].sortBy('length') -> ['a','new','world'] + * ['world','a','new'].sortBy('length', true) -> ['world','new','a'] + + [{age:72},{age:13},{age:18}].sortBy(function(n) { + * return n.age; + * }); -> [{age:13},{age:18},{age:72}] + * + ***/ + 'sortBy': function(map, desc) { + var arr = this.clone(); + arr.sort(function(a, b) { + var aProperty, bProperty, comp; + aProperty = transformArgument(a, map, arr, [a]); + bProperty = transformArgument(b, map, arr, [b]); + if(isString(aProperty) && isString(bProperty)) { + comp = collateStrings(aProperty, bProperty); + } else if(aProperty < bProperty) { + comp = -1; + } else if(aProperty > bProperty) { + comp = 1; + } else { + comp = 0; + } + return comp * (desc ? -1 : 1); + }); + return arr; + }, + + /*** + * @method randomize() + * @returns Array + * @short Returns a copy of the array with the elements randomized. + * @extra Uses Fisher-Yates algorithm. + * @example + * + * [1,2,3,4].randomize() -> [?,?,?,?] + * + ***/ + 'randomize': function() { + var a = this.concat(); + for(var j, x, i = a.length; i; j = parseInt(math.random() * i), x = a[--i], a[i] = a[j], a[j] = x) {}; + return a; + }, + + /*** + * @method zip([arr1], [arr2], ...) + * @returns Array + * @short Merges multiple arrays together. + * @extra This method "zips up" smaller arrays into one large whose elements are "all elements at index 0", "all elements at index 1", etc. Useful when you have associated data that is split over separated arrays. If the arrays passed have more elements than the original array, they will be discarded. If they have fewer elements, the missing elements will filled with %null%. + * @example + * + * [1,2,3].zip([4,5,6]) -> [[1,2], [3,4], [5,6]] + * ['Martin','John'].zip(['Luther','F.'], ['King','Kennedy']) -> [['Martin','Luther','King'], ['John','F.','Kennedy']] + * + ***/ + 'zip': function() { + var args = multiArgs(arguments); + return this.map(function(el, i) { + return [el].concat(args.map(function(k) { + return (i in k) ? k[i] : null; + })); + }); + }, + + /*** + * @method sample([num]) + * @returns Mixed + * @short Returns a random element from the array. + * @extra If [num] is passed, will return [num] samples from the array. + * @example + * + * [1,2,3,4,5].sample() -> // Random element + * [1,2,3,4,5].sample(3) -> // Array of 3 random elements + * + ***/ + 'sample': function(num) { + var result = [], arr = this.clone(), index; + if(isUndefined(num)) num = 1; + while(result.length < num) { + index = floor(math.random() * (arr.length - 1)); + result.push(arr[index]); + arr.removeAt(index); + if(arr.length == 0) break; + } + return arguments.length > 0 ? result : result[0]; + }, + + /*** + * @method each(, [index] = 0, [loop] = false) + * @returns Array + * @short Runs against each element in the array. Enhanced version of %Array#forEach%. + * @extra Parameters passed to are identical to %forEach%, ie. the first parameter is the current element, second parameter is the current index, and third parameter is the array itself. If returns %false% at any time it will break out of the loop. Once %each% finishes, it will return the array. If [index] is passed, will begin at that index and work its way to the end. If [loop] is true, it will then start over from the beginning of the array and continue until it reaches [index] - 1. + * @example + * + * [1,2,3,4].each(function(n) { + * // Called 4 times: 1, 2, 3, 4 + * }); + * [1,2,3,4].each(function(n) { + * // Called 4 times: 3, 4, 1, 2 + * }, 2, true); + * + ***/ + 'each': function(fn, index, loop) { + arrayEach(this, fn, index, loop); + return this; + }, + + /*** + * @method add(, [index]) + * @returns Array + * @short Adds to the array. + * @extra If [index] is specified, it will add at [index], otherwise adds to the end of the array. %add% behaves like %concat% in that if is an array it will be joined, not inserted. This method will change the array! Use %include% for a non-destructive alias. Also, %insert% is provided as an alias that reads better when using an index. + * @example + * + * [1,2,3,4].add(5) -> [1,2,3,4,5] + * [1,2,3,4].add([5,6,7]) -> [1,2,3,4,5,6,7] + * [1,2,3,4].insert(8, 1) -> [1,8,2,3,4] + * + ***/ + 'add': function(el, index) { + if(!isNumber(number(index)) || isNaN(index)) index = this.length; + array.prototype.splice.apply(this, [index, 0].concat(el)); + return this; + }, + + /*** + * @method remove([f1], [f2], ...) + * @returns Array + * @short Removes any element in the array that matches [f1], [f2], etc. + * @extra Will match a string, number, array, object, or alternately test against a function or regex. This method will change the array! Use %exclude% for a non-destructive alias. This method implements @array_matching. + * @example + * + * [1,2,3].remove(3) -> [1,2] + * ['a','b','c'].remove(/b/) -> ['a','c'] + + [{a:1},{b:2}].remove(function(n) { + * return n['a'] == 1; + * }); -> [{b:2}] + * + ***/ + 'remove': function() { + var i, arr = this; + multiArgs(arguments, function(f) { + i = 0; + while(i < arr.length) { + if(multiMatch(arr[i], f, arr, [arr[i], i, arr])) { + arr.splice(i, 1); + } else { + i++; + } + } + }); + return arr; + }, + + /*** + * @method compact([all] = false) + * @returns Array + * @short Removes all instances of %undefined%, %null%, and %NaN% from the array. + * @extra If [all] is %true%, all "falsy" elements will be removed. This includes empty strings, 0, and false. + * @example + * + * [1,null,2,undefined,3].compact() -> [1,2,3] + * [1,'',2,false,3].compact() -> [1,'',2,false,3] + * [1,'',2,false,3].compact(true) -> [1,2,3] + * + ***/ + 'compact': function(all) { + var result = []; + arrayEach(this, function(el, i) { + if(isArray(el)) { + result.push(el.compact()); + } else if(all && el) { + result.push(el); + } else if(!all && el != null && el.valueOf() === el.valueOf()) { + result.push(el); + } + }); + return result; + }, + + /*** + * @method groupBy(, [fn]) + * @returns Object + * @short Groups the array by . + * @extra Will return an object with keys equal to the grouped values. may be a mapping function, or a string acting as a shortcut. Optionally calls [fn] for each group. + * @example + * + * ['fee','fi','fum'].groupBy('length') -> { 2: ['fi'], 3: ['fee','fum'] } + + [{age:35,name:'ken'},{age:15,name:'bob'}].groupBy(function(n) { + * return n.age; + * }); -> { 35: [{age:35,name:'ken'}], 15: [{age:15,name:'bob'}] } + * + ***/ + 'groupBy': function(map, fn) { + var arr = this, result = {}, key; + arrayEach(arr, function(el, index) { + key = transformArgument(el, map, arr, [el, index, arr]); + if(!result[key]) result[key] = []; + result[key].push(el); + }); + if(fn) { + iterateOverObject(result, fn); + } + return result; + }, + + /*** + * @method none() + * @returns Boolean + * @short Returns true if none of the elements in the array match . + * @extra will match a string, number, array, object, or alternately test against a function or regex. This method implements @array_matching. + * @example + * + * [1,2,3].none(5) -> true + * ['a','b','c'].none(/b/) -> false + + [{a:1},{b:2}].none(function(n) { + * return n['a'] > 1; + * }); -> true + * + ***/ + 'none': function() { + return !this.any.apply(this, arguments); + } + + + }); + + // Aliases + extend(array, true, false, { + + /*** + * @method all() + * @alias every + * + ***/ + 'all': array.prototype.every, + + /*** @method any() + * @alias some + * + ***/ + 'any': array.prototype.some, + + /*** + * @method insert() + * @alias add + * + ***/ + 'insert': array.prototype.add + + }); + + + /*** + * Object module + * Enumerable methods on objects + * + ***/ + + function keysWithCoercion(obj) { + if(obj && obj.valueOf) { + obj = obj.valueOf(); + } + return object.keys(obj); + } + + /*** + * @method [enumerable]() + * @returns Boolean + * @short Enumerable methods in the Array package are also available to the Object class. They will perform their normal operations for every property in . + * @extra In cases where a callback is used, instead of %element, index%, the callback will instead be passed %key, value%. Enumerable methods are also available to extended objects as instance methods. + * + * @set + * map + * any + * all + * none + * count + * find + * findAll + * reduce + * isEmpty + * sum + * average + * min + * max + * least + * most + * + * @example + * + * Object.any({foo:'bar'}, 'bar') -> true + * Object.extended({foo:'bar'}).any('bar') -> true + * Object.isEmpty({}) -> true + + Object.map({ fred: { age: 52 } }, 'age'); -> { fred: 52 } + * + ***/ + + function buildEnumerableMethods(names, mapping) { + extendSimilar(object, false, false, names, function(methods, name) { + methods[name] = function(obj, arg1, arg2) { + var result; + result = array.prototype[name].call(keysWithCoercion(obj), function(key) { + if(mapping) { + return transformArgument(obj[key], arg1, obj, [key, obj[key], obj]); + } else { + return multiMatch(obj[key], arg1, obj, [key, obj[key], obj]); + } + }, arg2); + if(isArray(result)) { + // The method has returned an array of keys so use this array + // to build up the resulting object in the form we want it in. + result = result.reduce(function(o, key, i) { + o[key] = obj[key]; + return o; + }, {}); + } + return result; + }; + }); + buildObjectInstanceMethods(names, Hash); + } + + extend(object, false, false, { + + 'map': function(obj, map) { + return keysWithCoercion(obj).reduce(function(result, key) { + result[key] = transformArgument(obj[key], map, obj, [key, obj[key], obj]); + return result; + }, {}); + }, + + 'reduce': function(obj) { + var values = keysWithCoercion(obj).map(function(key) { + return obj[key]; + }); + return values.reduce.apply(values, multiArgs(arguments).slice(1)); + }, + + /*** + * @method size() + * @returns Number + * @short Returns the number of properties in . + * @extra %size% is available as an instance method on extended objects. + * @example + * + * Object.size({ foo: 'bar' }) -> 1 + * + ***/ + 'size': function (obj) { + return keysWithCoercion(obj).length; + } + + }); + + buildEnhancements(); + buildAlphanumericSort(); + buildEnumerableMethods('each,any,all,none,count,find,findAll,isEmpty'); + buildEnumerableMethods('sum,average,min,max,least,most', true); + buildObjectInstanceMethods('map,reduce,size', Hash); + + + /*** + * @package Date + * @dependency core + * @description Date parsing and formatting, relative formats like "1 minute ago", Number methods like "daysAgo", localization support with default English locale definition. + * + ***/ + + var English; + var CurrentLocalization; + + var TimeFormat = ['ampm','hour','minute','second','ampm','utc','offset_sign','offset_hours','offset_minutes','ampm'] + var FloatReg = '\\d{1,2}(?:[,.]\\d+)?'; + var RequiredTime = '({t})?\\s*('+FloatReg+')(?:{h}('+FloatReg+')?{m}(?::?('+FloatReg+'){s})?\\s*(?:({t})|(Z)|(?:([+-])(\\d{2,2})(?::?(\\d{2,2}))?)?)?|\\s*({t}))'; + + var KanjiDigits = '〇一二三四五六七八九十百千万'; + var FullWidthDigits = '0123456789'; + var AsianDigitMap = {}; + var AsianDigitReg; + + var DateArgumentUnits; + var DateUnitsReversed; + var CoreDateFormats = []; + + var DateOutputFormats = [ + { + token: 'f{1,4}|ms|milliseconds', + format: function(d) { + return callDateGet(d, 'Milliseconds'); + } + }, + { + token: 'ss?|seconds', + format: function(d, len) { + return callDateGet(d, 'Seconds'); + } + }, + { + token: 'mm?|minutes', + format: function(d, len) { + return callDateGet(d, 'Minutes'); + } + }, + { + token: 'hh?|hours|12hr', + format: function(d) { + return getShortHour(d); + } + }, + { + token: 'HH?|24hr', + format: function(d) { + return callDateGet(d, 'Hours'); + } + }, + { + token: 'dd?|date|day', + format: function(d) { + return callDateGet(d, 'Date'); + } + }, + { + token: 'dow|weekday', + word: true, + format: function(d, loc, n, t) { + var dow = callDateGet(d, 'Day'); + return loc['weekdays'][dow + (n - 1) * 7]; + } + }, + { + token: 'MM?', + format: function(d) { + return callDateGet(d, 'Month') + 1; + } + }, + { + token: 'mon|month', + word: true, + format: function(d, loc, n, len) { + var month = callDateGet(d, 'Month'); + return loc['months'][month + (n - 1) * 12]; + } + }, + { + token: 'y{2,4}|year', + format: function(d) { + return callDateGet(d, 'FullYear'); + } + }, + { + token: '[Tt]{1,2}', + format: function(d, loc, n, format) { + if(loc['ampm'].length == 0) return ''; + var hours = callDateGet(d, 'Hours'); + var str = loc['ampm'][floor(hours / 12)]; + if(format.length === 1) str = str.slice(0,1); + if(format.slice(0,1) === 'T') str = str.toUpperCase(); + return str; + } + }, + { + token: 'z{1,4}|tz|timezone', + text: true, + format: function(d, loc, n, format) { + var tz = d.getUTCOffset(); + if(format == 'z' || format == 'zz') { + tz = tz.replace(/(\d{2})(\d{2})/, function(f,h,m) { + return padNumber(h, format.length); + }); + } + return tz; + } + }, + { + token: 'iso(tz|timezone)', + format: function(d) { + return d.getUTCOffset(true); + } + }, + { + token: 'ord', + format: function(d) { + var date = callDateGet(d, 'Date'); + return date + getOrdinalizedSuffix(date); + } + } + ]; + + var DateUnits = [ + { + unit: 'year', + method: 'FullYear', + ambiguous: true, + multiplier: function(d) { + var adjust = d ? (d.isLeapYear() ? 1 : 0) : 0.25; + return (365 + adjust) * 24 * 60 * 60 * 1000; + } + }, + { + unit: 'month', + method: 'Month', + ambiguous: true, + multiplier: function(d, ms) { + var days = 30.4375, inMonth; + if(d) { + inMonth = d.daysInMonth(); + if(ms <= inMonth.days()) { + days = inMonth; + } + } + return days * 24 * 60 * 60 * 1000; + } + }, + { + unit: 'week', + method: 'Week', + multiplier: function() { + return 7 * 24 * 60 * 60 * 1000; + } + }, + { + unit: 'day', + method: 'Date', + ambiguous: true, + multiplier: function() { + return 24 * 60 * 60 * 1000; + } + }, + { + unit: 'hour', + method: 'Hours', + multiplier: function() { + return 60 * 60 * 1000; + } + }, + { + unit: 'minute', + method: 'Minutes', + multiplier: function() { + return 60 * 1000; + } + }, + { + unit: 'second', + method: 'Seconds', + multiplier: function() { + return 1000; + } + }, + { + unit: 'millisecond', + method: 'Milliseconds', + multiplier: function() { + return 1; + } + } + ]; + + + + + // Date Localization + + var Localizations = {}; + + // Localization object + + function Localization(l) { + simpleMerge(this, l); + this.compiledFormats = CoreDateFormats.concat(); + } + + Localization.prototype = { + + getMonth: function(n) { + if(isNumber(n)) { + return n - 1; + } else { + return this['months'].indexOf(n) % 12; + } + }, + + getWeekday: function(n) { + return this['weekdays'].indexOf(n) % 7; + }, + + getNumber: function(n) { + var i; + if(isNumber(n)) { + return n; + } else if(n && (i = this['numbers'].indexOf(n)) !== -1) { + return (i + 1) % 10; + } else { + return 1; + } + }, + + getNumericDate: function(n) { + var self = this; + return n.replace(regexp(this['num'], 'g'), function(d) { + var num = self.getNumber(d); + return num || ''; + }); + }, + + getEnglishUnit: function(n) { + return English['units'][this['units'].indexOf(n) % 8]; + }, + + getRelativeFormat: function(adu) { + return this.convertAdjustedToFormat(adu, adu[2] > 0 ? 'future' : 'past'); + }, + + getDuration: function(ms) { + return this.convertAdjustedToFormat(getAdjustedUnit(ms), 'duration'); + }, + + hasVariant: function(code) { + code = code || this.code; + return code === 'en' || code === 'en-US' ? true : this['variant']; + }, + + matchAM: function(str) { + return str === this['ampm'][0]; + }, + + matchPM: function(str) { + return str && str === this['ampm'][1]; + }, + + convertAdjustedToFormat: function(adu, mode) { + var sign, unit, mult, + num = adu[0], + u = adu[1], + ms = adu[2], + format = this[mode] || this['relative']; + if(isFunction(format)) { + return format.call(this, num, u, ms, mode); + } + mult = this['plural'] && num > 1 ? 1 : 0; + unit = this['units'][mult * 8 + u] || this['units'][u]; + if(this['capitalizeUnit']) unit = simpleCapitalize(unit); + sign = this['modifiers'].filter(function(m) { return m.name == 'sign' && m.value == (ms > 0 ? 1 : -1); })[0]; + return format.replace(/\{(.*?)\}/g, function(full, match) { + switch(match) { + case 'num': return num; + case 'unit': return unit; + case 'sign': return sign.src; + } + }); + }, + + getFormats: function() { + return this.cachedFormat ? [this.cachedFormat].concat(this.compiledFormats) : this.compiledFormats; + }, + + addFormat: function(src, allowsTime, match, variant, iso) { + var to = match || [], loc = this, time, timeMarkers, lastIsNumeral; + + src = src.replace(/\s+/g, '[-,. ]*'); + src = src.replace(/\{([^,]+?)\}/g, function(all, k) { + var value, arr, result, + opt = k.match(/\?$/), + nc = k.match(/^(\d+)\??$/), + slice = k.match(/(\d)(?:-(\d))?/), + key = k.replace(/[^a-z]+$/, ''); + if(nc) { + value = loc['tokens'][nc[1]]; + } else if(loc[key]) { + value = loc[key]; + } else if(loc[key + 's']) { + value = loc[key + 's']; + if(slice) { + // Can't use filter here as Prototype hijacks the method and doesn't + // pass an index, so use a simple loop instead! + arr = []; + value.forEach(function(m, i) { + var mod = i % (loc['units'] ? 8 : value.length); + if(mod >= slice[1] && mod <= (slice[2] || slice[1])) { + arr.push(m); + } + }); + value = arr; + } + value = arrayToAlternates(value); + } + if(nc) { + result = '(?:' + value + ')'; + } else { + if(!match) { + to.push(key); + } + result = '(' + value + ')'; + } + if(opt) { + result += '?'; + } + return result; + }); + if(allowsTime) { + time = prepareTime(RequiredTime, loc, iso); + timeMarkers = ['t','[\\s\\u3000]'].concat(loc['timeMarker']); + lastIsNumeral = src.match(/\\d\{\d,\d\}\)+\??$/); + addDateInputFormat(loc, '(?:' + time + ')[,\\s\\u3000]+?' + src, TimeFormat.concat(to), variant); + addDateInputFormat(loc, src + '(?:[,\\s]*(?:' + timeMarkers.join('|') + (lastIsNumeral ? '+' : '*') +')' + time + ')?', to.concat(TimeFormat), variant); + } else { + addDateInputFormat(loc, src, to, variant); + } + } + + }; + + + // Localization helpers + + function getLocalization(localeCode, fallback) { + var loc; + if(!isString(localeCode)) localeCode = ''; + loc = Localizations[localeCode] || Localizations[localeCode.slice(0,2)]; + if(fallback === false && !loc) { + throw new Error('Invalid locale.'); + } + return loc || CurrentLocalization; + } + + function setLocalization(localeCode, set) { + var loc, canAbbreviate; + + function initializeField(name) { + var val = loc[name]; + if(isString(val)) { + loc[name] = val.split(','); + } else if(!val) { + loc[name] = []; + } + } + + function eachAlternate(str, fn) { + str = str.split('+').map(function(split) { + return split.replace(/(.+):(.+)$/, function(full, base, suffixes) { + return suffixes.split('|').map(function(suffix) { + return base + suffix; + }).join('|'); + }); + }).join('|'); + return str.split('|').forEach(fn); + } + + function setArray(name, abbreviate, multiple) { + var arr = []; + loc[name].forEach(function(full, i) { + if(abbreviate) { + full += '+' + full.slice(0,3); + } + eachAlternate(full, function(day, j) { + arr[j * multiple + i] = day.toLowerCase(); + }); + }); + loc[name] = arr; + } + + function getDigit(start, stop, allowNumbers) { + var str = '\\d{' + start + ',' + stop + '}'; + if(allowNumbers) str += '|(?:' + arrayToAlternates(loc['numbers']) + ')+'; + return str; + } + + function getNum() { + var arr = ['\\d+'].concat(loc['articles']); + if(loc['numbers']) arr = arr.concat(loc['numbers']); + return arrayToAlternates(arr); + } + + function setModifiers() { + var arr = []; + loc.modifiersByName = {}; + loc['modifiers'].forEach(function(modifier) { + var name = modifier.name; + eachAlternate(modifier.src, function(t) { + var locEntry = loc[name]; + loc.modifiersByName[t] = modifier; + arr.push({ name: name, src: t, value: modifier.value }); + loc[name] = locEntry ? locEntry + '|' + t : t; + }); + }); + loc['day'] += '|' + arrayToAlternates(loc['weekdays']); + loc['modifiers'] = arr; + } + + // Initialize the locale + loc = new Localization(set); + initializeField('modifiers'); + 'months,weekdays,units,numbers,articles,tokens,timeMarker,ampm,timeSuffixes,dateParse,timeParse'.split(',').forEach(initializeField); + + canAbbreviate = !loc['monthSuffix']; + + setArray('months', canAbbreviate, 12); + setArray('weekdays', canAbbreviate, 7); + setArray('units', false, 8); + setArray('numbers', false, 10); + + loc['code'] = localeCode; + loc['date'] = getDigit(1,2, loc['digitDate']); + loc['year'] = getDigit(4,4); + loc['num'] = getNum(); + + setModifiers(); + + if(loc['monthSuffix']) { + loc['month'] = getDigit(1,2); + loc['months'] = getRange(1, 12).map(function(n) { return n + loc['monthSuffix']; }); + } + loc['full_month'] = getDigit(1,2) + '|' + arrayToAlternates(loc['months']); + + // The order of these formats is very important. Order is reversed so formats that come + // later will take precedence over formats that come before. This generally means that + // more specific formats should come later, however, the {year} format should come before + // {day}, as 2011 needs to be parsed as a year (2011) and not date (20) + hours (11) + + // If the locale has time suffixes then add a time only format for that locale + // that is separate from the core English-based one. + if(loc['timeSuffixes'].length > 0) { + loc.addFormat(prepareTime(RequiredTime, loc), false, TimeFormat) + } + + loc.addFormat('{day}', true); + loc.addFormat('{month}' + (loc['monthSuffix'] || '')); + loc.addFormat('{year}' + (loc['yearSuffix'] || '')); + + loc['timeParse'].forEach(function(src) { + loc.addFormat(src, true); + }); + + loc['dateParse'].forEach(function(src) { + loc.addFormat(src); + }); + + return Localizations[localeCode] = loc; + } + + + // General helpers + + function addDateInputFormat(locale, format, match, variant) { + locale.compiledFormats.unshift({ + variant: variant, + locale: locale, + reg: regexp('^' + format + '$', 'i'), + to: match + }); + } + + function simpleCapitalize(str) { + return str.slice(0,1).toUpperCase() + str.slice(1); + } + + function arrayToAlternates(arr) { + return arr.filter(function(el) { + return !!el; + }).join('|'); + } + + // Date argument helpers + + function collectDateArguments(args, allowDuration) { + var obj, arr; + if(isObject(args[0])) { + return args; + } else if (isNumber(args[0]) && !isNumber(args[1])) { + return [args[0]]; + } else if (isString(args[0]) && allowDuration) { + return [getDateParamsFromString(args[0]), args[1]]; + } + obj = {}; + DateArgumentUnits.forEach(function(u,i) { + obj[u.unit] = args[i]; + }); + return [obj]; + } + + function getDateParamsFromString(str, num) { + var params = {}; + match = str.match(/^(\d+)?\s?(\w+?)s?$/i); + if(match) { + if(isUndefined(num)) { + num = parseInt(match[1]) || 1; + } + params[match[2].toLowerCase()] = num; + } + return params; + } + + // Date parsing helpers + + function getFormatMatch(match, arr) { + var obj = {}, value, num; + arr.forEach(function(key, i) { + value = match[i + 1]; + if(isUndefined(value) || value === '') return; + if(key === 'year') obj.yearAsString = value; + num = parseFloat(value.replace(/,/, '.')); + obj[key] = !isNaN(num) ? num : value.toLowerCase(); + }); + return obj; + } + + function cleanDateInput(str) { + str = str.trim().replace(/^(just )?now|\.+$/i, ''); + return convertAsianDigits(str); + } + + function convertAsianDigits(str) { + return str.replace(AsianDigitReg, function(full, disallowed, match) { + var sum = 0, place = 1, lastWasHolder, lastHolder; + if(disallowed) return full; + match.split('').reverse().forEach(function(letter) { + var value = AsianDigitMap[letter], holder = value > 9; + if(holder) { + if(lastWasHolder) sum += place; + place *= value / (lastHolder || 1); + lastHolder = value; + } else { + if(lastWasHolder === false) { + place *= 10; + } + sum += place * value; + } + lastWasHolder = holder; + }); + if(lastWasHolder) sum += place; + return sum; + }); + } + + function getExtendedDate(f, localeCode, prefer, forceUTC) { + var d = new date(), relative = false, baseLocalization, loc, format, set, unit, weekday, num, tmp, after; + + d.utc(forceUTC); + + if(isDate(f)) { + d = new date(f.getTime()); + } else if(isNumber(f)) { + d = new date(f); + } else if(isObject(f)) { + d.set(f, true); + set = f; + } else if(isString(f)) { + + // The act of getting the localization will pre-initialize + // if it is missing and add the required formats. + baseLocalization = getLocalization(localeCode); + + // Clean the input and convert Kanji based numerals if they exist. + f = cleanDateInput(f); + + if(baseLocalization) { + iterateOverObject(baseLocalization.getFormats(), function(i, dif) { + var match = f.match(dif.reg); + if(match) { + format = dif; + loc = format.locale; + set = getFormatMatch(match, format.to, loc); + + if(set['utc']) { + d.utc(); + } + + loc.cachedFormat = format; + + if(set.timestamp) { + set = set.timestamp; + return false; + } + + // If there's a variant (crazy Endian American format), swap the month and day. + if(format.variant && !isString(set['month']) && (isString(set['date']) || baseLocalization.hasVariant(localeCode))) { + tmp = set['month']; + set['month'] = set['date']; + set['date'] = tmp; + } + + // If the year is 2 digits then get the implied century. + if(set['year'] && set.yearAsString.length === 2) { + set['year'] = getYearFromAbbreviation(set['year']); + } + + // Set the month which may be localized. + if(set['month']) { + set['month'] = loc.getMonth(set['month']); + if(set['shift'] && !set['unit']) set['unit'] = loc['units'][7]; + } + + // If there is both a weekday and a date, the date takes precedence. + if(set['weekday'] && set['date']) { + delete set['weekday']; + // Otherwise set a localized weekday. + } else if(set['weekday']) { + set['weekday'] = loc.getWeekday(set['weekday']); + if(set['shift'] && !set['unit']) set['unit'] = loc['units'][5]; + } + + // Relative day localizations such as "today" and "tomorrow". + if(set['day'] && (tmp = loc.modifiersByName[set['day']])) { + set['day'] = tmp.value; + d.reset(); + relative = true; + // If the day is a weekday, then set that instead. + } else if(set['day'] && (weekday = loc.getWeekday(set['day'])) > -1) { + delete set['day']; + if(set['num'] && set['month']) { + // If we have "the 2nd tuesday of June", set the day to the beginning of the month, then + // look ahead to set the weekday after all other properties have been set. The weekday needs + // to be set after the actual set because it requires overriding the "prefer" argument which + // could unintentionally send the year into the future, past, etc. + after = function() { + var w = d.getWeekday(); + d.setWeekday((7 * (set['num'] - 1)) + (w > weekday ? weekday + 7 : weekday)); + } + set['day'] = 1; + } else { + set['weekday'] = weekday; + } + } + + if(set['date'] && !isNumber(set['date'])) { + set['date'] = loc.getNumericDate(set['date']); + } + + // If the time is 1pm-11pm advance the time by 12 hours. + if(loc.matchPM(set['ampm']) && set['hour'] < 12) { + set['hour'] += 12; + } else if(loc.matchAM(set['ampm']) && set['hour'] === 12) { + set['hour'] = 0; + } + + // Adjust for timezone offset + if('offset_hours' in set || 'offset_minutes' in set) { + d.utc(); + set['offset_minutes'] = set['offset_minutes'] || 0; + set['offset_minutes'] += set['offset_hours'] * 60; + if(set['offset_sign'] === '-') { + set['offset_minutes'] *= -1; + } + set['minute'] -= set['offset_minutes']; + } + + // Date has a unit like "days", "months", etc. are all relative to the current date. + if(set['unit']) { + relative = true; + num = loc.getNumber(set['num']); + unit = loc.getEnglishUnit(set['unit']); + + // Shift and unit, ie "next month", "last week", etc. + if(set['shift'] || set['edge']) { + num *= (tmp = loc.modifiersByName[set['shift']]) ? tmp.value : 0; + + // Relative month and static date: "the 15th of last month" + if(unit === 'month' && isDefined(set['date'])) { + d.set({ 'day': set['date'] }, true); + delete set['date']; + } + + // Relative year and static month/date: "June 15th of last year" + if(unit === 'year' && isDefined(set['month'])) { + d.set({ 'month': set['month'], 'day': set['date'] }, true); + delete set['month']; + delete set['date']; + } + } + // Unit and sign, ie "months ago", "weeks from now", etc. + if(set['sign'] && (tmp = loc.modifiersByName[set['sign']])) { + num *= tmp.value; + } + + // Units can be with non-relative dates, set here. ie "the day after monday" + if(isDefined(set['weekday'])) { + d.set({'weekday': set['weekday'] }, true); + delete set['weekday']; + } + + // Finally shift the unit. + set[unit] = (set[unit] || 0) + num; + } + + if(set['year_sign'] === '-') { + set['year'] *= -1; + } + + DateUnitsReversed.slice(1,4).forEach(function(u, i) { + var value = set[u.unit], fraction = value % 1; + if(fraction) { + set[DateUnitsReversed[i].unit] = round(fraction * (u.unit === 'second' ? 1000 : 60)); + set[u.unit] = floor(value); + } + }); + return false; + } + }); + } + if(!format) { + // The Date constructor does something tricky like checking the number + // of arguments so simply passing in undefined won't work. + d = f ? new date(f) : new date(); + } else if(relative) { + d.advance(set); + } else { + if(d._utc) { + // UTC times can traverse into other days or even months, + // so preemtively reset the time here to prevent this. + d.reset(); + } + updateDate(d, set, true, false, prefer); + } + + // If there is an "edge" it needs to be set after the + // other fields are set. ie "the end of February" + if(set && set['edge']) { + tmp = loc.modifiersByName[set['edge']]; + iterateOverObject(DateUnitsReversed.slice(4), function(i, u) { + if(isDefined(set[u.unit])) { + unit = u.unit; + return false; + } + }); + if(unit === 'year') set.specificity = 'month'; + else if(unit === 'month' || unit === 'week') set.specificity = 'day'; + d[(tmp.value < 0 ? 'endOf' : 'beginningOf') + simpleCapitalize(unit)](); + // This value of -2 is arbitrary but it's a nice clean way to hook into this system. + if(tmp.value === -2) d.reset(); + } + if(after) { + after(); + } + + } + d.utc(false); + return { + date: d, + set: set + } + } + + // If the year is two digits, add the most appropriate century prefix. + function getYearFromAbbreviation(year) { + return round(callDateGet(new date(), 'FullYear') / 100) * 100 - round(year / 100) * 100 + year; + } + + function getShortHour(d) { + var hours = callDateGet(d, 'Hours'); + return hours === 0 ? 12 : hours - (floor(hours / 13) * 12); + } + + // weeksSince won't work here as the result needs to be floored, not rounded. + function getWeekNumber(date) { + date = date.clone(); + var dow = callDateGet(date, 'Day') || 7; + date.addDays(4 - dow).reset(); + return 1 + floor(date.daysSince(date.clone().beginningOfYear()) / 7); + } + + function getAdjustedUnit(ms) { + var next, ams = math.abs(ms), value = ams, unit = 0; + DateUnitsReversed.slice(1).forEach(function(u, i) { + next = floor(round(ams / u.multiplier() * 10) / 10); + if(next >= 1) { + value = next; + unit = i + 1; + } + }); + return [value, unit, ms]; + } + + + // Date formatting helpers + + function formatDate(date, format, relative, localeCode) { + var adu, loc = getLocalization(localeCode), caps = regexp(/^[A-Z]/), value, shortcut; + if(!date.isValid()) { + return 'Invalid Date'; + } else if(Date[format]) { + format = Date[format]; + } else if(isFunction(format)) { + adu = getAdjustedUnit(date.millisecondsFromNow()); + format = format.apply(date, adu.concat(loc)); + } + if(!format && relative) { + adu = adu || getAdjustedUnit(date.millisecondsFromNow()); + // Adjust up if time is in ms, as this doesn't + // look very good for a standard relative date. + if(adu[1] === 0) { + adu[1] = 1; + adu[0] = 1; + } + return loc.getRelativeFormat(adu); + } + + format = format || 'long'; + format = loc[format] || format; + + DateOutputFormats.forEach(function(dof) { + format = format.replace(regexp('\\{('+dof.token+')(\\d)?\\}', dof.word ? 'i' : ''), function(m,t,d) { + var val = dof.format(date, loc, d || 1, t), l = t.length, one = t.match(/^(.)\1+$/); + if(dof.word) { + if(l === 3) val = val.slice(0,3); + if(one || t.match(caps)) val = simpleCapitalize(val); + } else if(one && !dof.text) { + val = (isNumber(val) ? padNumber(val, l) : val.toString()).slice(-l); + } + return val; + }); + }); + return format; + } + + // Date comparison helpers + + function compareDate(d, find, buffer, forceUTC) { + var p = getExtendedDate(find, null, null, forceUTC), accuracy = 0, loBuffer = 0, hiBuffer = 0, override, capitalized; + if(buffer > 0) { + loBuffer = hiBuffer = buffer; + override = true; + } + if(!p.date.isValid()) return false; + if(p.set && p.set.specificity) { + DateUnits.forEach(function(u, i) { + if(u.unit === p.set.specificity) { + accuracy = u.multiplier(p.date, d - p.date) - 1; + } + }); + capitalized = simpleCapitalize(p.set.specificity); + if(p.set['edge'] || p.set['shift']) { + p.date['beginningOf' + capitalized](); + } + if(p.set.specificity === 'month') { + max = p.date.clone()['endOf' + capitalized]().getTime(); + } + if(!override && p.set['sign'] && p.set.specificity != 'millisecond') { + // If the time is relative, there can occasionally be an disparity between the relative date + // and "now", which it is being compared to, so set an extra buffer to account for this. + loBuffer = 50; + hiBuffer = -50; + } + } + var t = d.getTime(); + var min = p.date.getTime(); + var max = max || (min + accuracy); + return t >= (min - loBuffer) && t <= (max + hiBuffer); + } + + function updateDate(d, params, reset, advance, prefer) { + var weekday, specificityIndex; + + function getParam(key) { + return isDefined(params[key]) ? params[key] : params[key + 's']; + } + + function paramExists(key) { + return isDefined(getParam(key)); + } + + function uniqueParamExists(key, isDay) { + return paramExists(key) || (isDay && paramExists('weekday')); + } + + function canDisambiguate() { + var now = new date; + return (prefer === -1 && d > now) || (prefer === 1 && d < now); + } + + if(isNumber(params) && advance) { + // If param is a number and we're advancing, the number is presumed to be milliseconds. + params = { 'milliseconds': params }; + } else if(isNumber(params)) { + // Otherwise just set the timestamp and return. + d.setTime(params); + return d; + } + + // "date" can also be passed for the day + if(params['date']) params['day'] = params['date']; + + // Reset any unit lower than the least specific unit set. Do not do this for weeks + // or for years. This needs to be performed before the acutal setting of the date + // because the order needs to be reversed in order to get the lowest specificity, + // also because higher order units can be overwritten by lower order units, such + // as setting hour: 3, minute: 345, etc. + iterateOverObject(DateUnitsReversed, function(i,u) { + var isDay = u.unit === 'day'; + if(uniqueParamExists(u.unit, isDay)) { + params.specificity = u.unit; + specificityIndex = +i; + return false; + } else if(reset && u.unit !== 'week' && (!isDay || !paramExists('week'))) { + // Days are relative to months, not weeks, so don't reset if a week exists. + callDateSet(d, u.method, (isDay ? 1 : 0)); + } + }); + + + // Now actually set or advance the date in order, higher units first. + DateUnits.forEach(function(u,i) { + var unit = u.unit, method = u.method, higherUnit = DateUnits[i - 1], value; + value = getParam(unit) + if(isUndefined(value)) return; + if(advance) { + if(unit === 'week') { + value = (params['day'] || 0) + (value * 7); + method = 'Date'; + } + value = (value * advance) + callDateGet(d, method); + } else if(unit === 'month' && paramExists('day')) { + // When setting the month, there is a chance that we will traverse into a new month. + // This happens in DST shifts, for example June 1st DST jumping to January 1st + // (non-DST) will have a shift of -1:00 which will traverse into the previous year. + // Prevent this by proactively setting the day when we know it will be set again anyway. + // It can also happen when there are not enough days in the target month. This second + // situation is identical to checkMonthTraversal below, however when we are advancing + // we want to reset the date to "the last date in the target month". In the case of + // DST shifts, however, we want to avoid the "edges" of months as that is where this + // unintended traversal can happen. This is the reason for the different handling of + // two similar but slightly different situations. + // + // TL;DR This method avoids the edges of a month IF not advancing and the date is going + // to be set anyway, while checkMonthTraversal resets the date to the last day if advancing. + // + callDateSet(d, 'Date', 15); + } + callDateSet(d, method, value); + if(advance && unit === 'month') { + checkMonthTraversal(d, value); + } + }); + + + // If a weekday is included in the params, set it ahead of time and set the params + // to reflect the updated date so that resetting works properly. + if(!advance && !paramExists('day') && paramExists('weekday')) { + var weekday = getParam('weekday'), isAhead, futurePreferred; + d.setWeekday(weekday); + } + + if(canDisambiguate()) { + iterateOverObject(DateUnitsReversed.slice(specificityIndex + 1), function(i,u) { + var ambiguous = u.ambiguous || (u.unit === 'week' && paramExists('weekday')); + if(ambiguous && !uniqueParamExists(u.unit, u.unit === 'day')) { + d[u.addMethod](prefer); + return false; + } + }); + } + return d; + } + + function callDateGet(d, method) { + return d['get' + (d._utc ? 'UTC' : '') + method](); + } + + function callDateSet(d, method, value) { + return d['set' + (d._utc ? 'UTC' : '') + method](value); + } + + // The ISO format allows times strung together without a demarcating ":", so make sure + // that these markers are now optional. + function prepareTime(format, loc, iso) { + var timeSuffixMapping = {'h':0,'m':1,'s':2}, add; + loc = loc || English; + return format.replace(/{([a-z])}/g, function(full, token) { + var separators = [], + isHours = token === 'h', + tokenIsRequired = isHours && !iso; + if(token === 't') { + return loc['ampm'].join('|'); + } else { + if(isHours) { + separators.push(':'); + } + if(add = loc['timeSuffixes'][timeSuffixMapping[token]]) { + separators.push(add + '\\s*'); + } + return separators.length === 0 ? '' : '(?:' + separators.join('|') + ')' + (tokenIsRequired ? '' : '?'); + } + }); + } + + + // If the month is being set, then we don't want to accidentally + // traverse into a new month just because the target month doesn't have enough + // days. In other words, "5 months ago" from July 30th is still February, even + // though there is no February 30th, so it will of necessity be February 28th + // (or 29th in the case of a leap year). + + function checkMonthTraversal(date, targetMonth) { + if(targetMonth < 0) targetMonth += 12; + if(targetMonth % 12 != callDateGet(date, 'Month')) { + callDateSet(date, 'Date', 0); + } + } + + function createDate(args, prefer, forceUTC) { + var f, localeCode; + if(isNumber(args[1])) { + // If the second argument is a number, then we have an enumerated constructor type as in "new Date(2003, 2, 12);" + f = collectDateArguments(args)[0]; + } else { + f = args[0]; + localeCode = args[1]; + } + return getExtendedDate(f, localeCode, prefer, forceUTC).date; + } + + function buildDateUnits() { + DateUnitsReversed = DateUnits.concat().reverse(); + DateArgumentUnits = DateUnits.concat(); + DateArgumentUnits.splice(2,1); + } + + + /*** + * @method [units]Since([d], [locale] = currentLocale) + * @returns Number + * @short Returns the time since [d] in the appropriate unit. + * @extra [d] will accept a date object, timestamp, or text format. If not specified, [d] is assumed to be now. [locale] can be passed to specify the locale that the date is in. %[unit]Ago% is provided as an alias to make this more readable when [d] is assumed to be the current date. For more see @date_format. + * + * @set + * millisecondsSince + * secondsSince + * minutesSince + * hoursSince + * daysSince + * weeksSince + * monthsSince + * yearsSince + * + * @example + * + * Date.create().millisecondsSince('1 hour ago') -> 3,600,000 + * Date.create().daysSince('1 week ago') -> 7 + * Date.create().yearsSince('15 years ago') -> 15 + * Date.create('15 years ago').yearsAgo() -> 15 + * + *** + * @method [units]Ago() + * @returns Number + * @short Returns the time ago in the appropriate unit. + * + * @set + * millisecondsAgo + * secondsAgo + * minutesAgo + * hoursAgo + * daysAgo + * weeksAgo + * monthsAgo + * yearsAgo + * + * @example + * + * Date.create('last year').millisecondsAgo() -> 3,600,000 + * Date.create('last year').daysAgo() -> 7 + * Date.create('last year').yearsAgo() -> 15 + * + *** + * @method [units]Until([d], [locale] = currentLocale) + * @returns Number + * @short Returns the time until [d] in the appropriate unit. + * @extra [d] will accept a date object, timestamp, or text format. If not specified, [d] is assumed to be now. [locale] can be passed to specify the locale that the date is in. %[unit]FromNow% is provided as an alias to make this more readable when [d] is assumed to be the current date. For more see @date_format. + * + * @set + * millisecondsUntil + * secondsUntil + * minutesUntil + * hoursUntil + * daysUntil + * weeksUntil + * monthsUntil + * yearsUntil + * + * @example + * + * Date.create().millisecondsUntil('1 hour from now') -> 3,600,000 + * Date.create().daysUntil('1 week from now') -> 7 + * Date.create().yearsUntil('15 years from now') -> 15 + * Date.create('15 years from now').yearsFromNow() -> 15 + * + *** + * @method [units]FromNow() + * @returns Number + * @short Returns the time from now in the appropriate unit. + * + * @set + * millisecondsFromNow + * secondsFromNow + * minutesFromNow + * hoursFromNow + * daysFromNow + * weeksFromNow + * monthsFromNow + * yearsFromNow + * + * @example + * + * Date.create('next year').millisecondsFromNow() -> 3,600,000 + * Date.create('next year').daysFromNow() -> 7 + * Date.create('next year').yearsFromNow() -> 15 + * + *** + * @method add[Units](, [reset] = false) + * @returns Date + * @short Adds of the unit to the date. If [reset] is true, all lower units will be reset. + * @extra Note that "months" is ambiguous as a unit of time. If the target date falls on a day that does not exist (ie. August 31 -> February 31), the date will be shifted to the last day of the month. Don't use %addMonths% if you need precision. + * + * @set + * addMilliseconds + * addSeconds + * addMinutes + * addHours + * addDays + * addWeeks + * addMonths + * addYears + * + * @example + * + * Date.create().addMilliseconds(5) -> current time + 5 milliseconds + * Date.create().addDays(5) -> current time + 5 days + * Date.create().addYears(5) -> current time + 5 years + * + *** + * @method isLast[Unit]() + * @returns Boolean + * @short Returns true if the date is last week/month/year. + * + * @set + * isLastWeek + * isLastMonth + * isLastYear + * + * @example + * + * Date.create('yesterday').isLastWeek() -> true or false? + * Date.create('yesterday').isLastMonth() -> probably not... + * Date.create('yesterday').isLastYear() -> even less likely... + * + *** + * @method isThis[Unit]() + * @returns Boolean + * @short Returns true if the date is this week/month/year. + * + * @set + * isThisWeek + * isThisMonth + * isThisYear + * + * @example + * + * Date.create('tomorrow').isThisWeek() -> true or false? + * Date.create('tomorrow').isThisMonth() -> probably... + * Date.create('tomorrow').isThisYear() -> signs point to yes... + * + *** + * @method isNext[Unit]() + * @returns Boolean + * @short Returns true if the date is next week/month/year. + * + * @set + * isNextWeek + * isNextMonth + * isNextYear + * + * @example + * + * Date.create('tomorrow').isNextWeek() -> true or false? + * Date.create('tomorrow').isNextMonth() -> probably not... + * Date.create('tomorrow').isNextYear() -> even less likely... + * + *** + * @method beginningOf[Unit]() + * @returns Date + * @short Sets the date to the beginning of the appropriate unit. + * + * @set + * beginningOfDay + * beginningOfWeek + * beginningOfMonth + * beginningOfYear + * + * @example + * + * Date.create().beginningOfDay() -> the beginning of today (resets the time) + * Date.create().beginningOfWeek() -> the beginning of the week + * Date.create().beginningOfMonth() -> the beginning of the month + * Date.create().beginningOfYear() -> the beginning of the year + * + *** + * @method endOf[Unit]() + * @returns Date + * @short Sets the date to the end of the appropriate unit. + * + * @set + * endOfDay + * endOfWeek + * endOfMonth + * endOfYear + * + * @example + * + * Date.create().endOfDay() -> the end of today (sets the time to 23:59:59.999) + * Date.create().endOfWeek() -> the end of the week + * Date.create().endOfMonth() -> the end of the month + * Date.create().endOfYear() -> the end of the year + * + ***/ + + function buildDateMethods() { + extendSimilar(date, true, false, DateUnits, function(methods, u, i) { + var unit = u.unit, caps = simpleCapitalize(unit), multiplier = u.multiplier(), since, until; + u.addMethod = 'add' + caps + 's'; + since = function(f, localeCode) { + return round((this.getTime() - date.create(f, localeCode).getTime()) / multiplier); + }; + until = function(f, localeCode) { + return round((date.create(f, localeCode).getTime() - this.getTime()) / multiplier); + }; + methods[unit+'sAgo'] = until; + methods[unit+'sUntil'] = until; + methods[unit+'sSince'] = since; + methods[unit+'sFromNow'] = since; + methods[u.addMethod] = function(num, reset) { + var set = {}; + set[unit] = num; + return this.advance(set, reset); + }; + buildNumberToDateAlias(u, multiplier); + if(i < 3) { + ['Last','This','Next'].forEach(function(shift) { + methods['is' + shift + caps] = function() { + return this.is(shift + ' ' + unit); + }; + }); + } + if(i < 4) { + methods['beginningOf' + caps] = function() { + var set = {}; + switch(unit) { + case 'year': set['year'] = callDateGet(this, 'FullYear'); break; + case 'month': set['month'] = callDateGet(this, 'Month'); break; + case 'day': set['day'] = callDateGet(this, 'Date'); break; + case 'week': set['weekday'] = 0; break; + } + return this.set(set, true); + }; + methods['endOf' + caps] = function() { + var set = { 'hours': 23, 'minutes': 59, 'seconds': 59, 'milliseconds': 999 }; + switch(unit) { + case 'year': set['month'] = 11; set['day'] = 31; break; + case 'month': set['day'] = this.daysInMonth(); break; + case 'week': set['weekday'] = 6; break; + } + return this.set(set, true); + }; + } + }); + } + + function buildCoreInputFormats() { + English.addFormat('([+-])?(\\d{4,4})[-.]?{full_month}[-.]?(\\d{1,2})?', true, ['year_sign','year','month','date'], false, true); + English.addFormat('(\\d{1,2})[-.\\/]{full_month}(?:[-.\\/](\\d{2,4}))?', true, ['date','month','year'], true); + English.addFormat('{full_month}[-.](\\d{4,4})', false, ['month','year']); + English.addFormat('\\/Date\\((\\d+(?:\\+\\d{4,4})?)\\)\\/', false, ['timestamp']) + English.addFormat(prepareTime(RequiredTime, English), false, TimeFormat) + + // When a new locale is initialized it will have the CoreDateFormats initialized by default. + // From there, adding new formats will push them in front of the previous ones, so the core + // formats will be the last to be reached. However, the core formats themselves have English + // months in them, which means that English needs to first be initialized and creates a race + // condition. I'm getting around this here by adding these generalized formats in the order + // specific -> general, which will mean they will be added to the English localization in + // general -> specific order, then chopping them off the front and reversing to get the correct + // order. Note that there are 7 formats as 2 have times which adds a front and a back format. + CoreDateFormats = English.compiledFormats.slice(0,7).reverse(); + English.compiledFormats = English.compiledFormats.slice(7).concat(CoreDateFormats); + } + + function buildDateOutputShortcuts() { + extendSimilar(date, true, false, 'short,long,full', function(methods, name) { + methods[name] = function(localeCode) { + return formatDate(this, name, false, localeCode); + } + }); + } + + function buildAsianDigits() { + KanjiDigits.split('').forEach(function(digit, value) { + var holder; + if(value > 9) { + value = math.pow(10, value - 9); + } + AsianDigitMap[digit] = value; + }); + FullWidthDigits.split('').forEach(function(digit, value) { + AsianDigitMap[digit] = value; + }); + // Kanji numerals may also be included in phrases which are text-based rather + // than actual numbers such as Chinese weekdays (上周三), and "the day before + // yesterday" (一昨日) in Japanese, so don't match these. + AsianDigitReg = regexp('([期週周])?([' + KanjiDigits + FullWidthDigits + ']+)(?!昨)', 'g'); + } + + /*** + * @method is[Day]() + * @returns Boolean + * @short Returns true if the date falls on that day. + * @extra Also available: %isYesterday%, %isToday%, %isTomorrow%, %isWeekday%, and %isWeekend%. + * + * @set + * isToday + * isYesterday + * isTomorrow + * isWeekday + * isWeekend + * isSunday + * isMonday + * isTuesday + * isWednesday + * isThursday + * isFriday + * isSaturday + * + * @example + * + * Date.create('tomorrow').isToday() -> false + * Date.create('thursday').isTomorrow() -> ? + * Date.create('yesterday').isWednesday() -> ? + * Date.create('today').isWeekend() -> ? + * + *** + * @method isFuture() + * @returns Boolean + * @short Returns true if the date is in the future. + * @example + * + * Date.create('next week').isFuture() -> true + * Date.create('last week').isFuture() -> false + * + *** + * @method isPast() + * @returns Boolean + * @short Returns true if the date is in the past. + * @example + * + * Date.create('last week').isPast() -> true + * Date.create('next week').isPast() -> false + * + ***/ + function buildRelativeAliases() { + var special = 'today,yesterday,tomorrow,weekday,weekend,future,past'.split(','); + var weekdays = English['weekdays'].slice(0,7); + var months = English['months'].slice(0,12); + extendSimilar(date, true, false, special.concat(weekdays).concat(months), function(methods, name) { + methods['is'+ simpleCapitalize(name)] = function(utc) { + return this.is(name, 0, utc); + }; + }); + } + + function buildUTCAliases() { + date.extend({ + 'utc': { + + 'create': function() { + return createDate(arguments, 0, true); + }, + + 'past': function() { + return createDate(arguments, -1, true); + }, + + 'future': function() { + return createDate(arguments, 1, true); + } + + } + }, false, false); + } + + function setDateProperties() { + date.extend({ + 'RFC1123': '{Dow}, {dd} {Mon} {yyyy} {HH}:{mm}:{ss} {tz}', + 'RFC1036': '{Weekday}, {dd}-{Mon}-{yy} {HH}:{mm}:{ss} {tz}', + 'ISO8601_DATE': '{yyyy}-{MM}-{dd}', + 'ISO8601_DATETIME': '{yyyy}-{MM}-{dd}T{HH}:{mm}:{ss}.{fff}{isotz}' + }, false, false); + } + + + date.extend({ + + /*** + * @method Date.create(, [locale] = currentLocale) + * @returns Date + * @short Alternate Date constructor which understands many different text formats, a timestamp, or another date. + * @extra If no argument is given, date is assumed to be now. %Date.create% additionally can accept enumerated parameters as with the standard date constructor. [locale] can be passed to specify the locale that the date is in. When unspecified, the current locale (default is English) is assumed. UTC-based dates can be created through the %utc% object. For more see @date_format. + * @set + * Date.utc.create + * + * @example + * + * Date.create('July') -> July of this year + * Date.create('1776') -> 1776 + * Date.create('today') -> today + * Date.create('wednesday') -> This wednesday + * Date.create('next friday') -> Next friday + * Date.create('July 4, 1776') -> July 4, 1776 + * Date.create(-446806800000) -> November 5, 1955 + * Date.create(1776, 6, 4) -> July 4, 1776 + * Date.create('1776年07月04日', 'ja') -> July 4, 1776 + * Date.utc.create('July 4, 1776', 'en') -> July 4, 1776 + * + ***/ + 'create': function() { + return createDate(arguments); + }, + + /*** + * @method Date.past(, [locale] = currentLocale) + * @returns Date + * @short Alternate form of %Date.create% with any ambiguity assumed to be the past. + * @extra For example %"Sunday"% can be either "the Sunday coming up" or "the Sunday last" depending on context. Note that dates explicitly in the future ("next Sunday") will remain in the future. This method simply provides a hint when ambiguity exists. UTC-based dates can be created through the %utc% object. For more, see @date_format. + * @set + * Date.utc.past + * @example + * + * Date.past('July') -> July of this year or last depending on the current month + * Date.past('Wednesday') -> This wednesday or last depending on the current weekday + * + ***/ + 'past': function() { + return createDate(arguments, -1); + }, + + /*** + * @method Date.future(, [locale] = currentLocale) + * @returns Date + * @short Alternate form of %Date.create% with any ambiguity assumed to be the future. + * @extra For example %"Sunday"% can be either "the Sunday coming up" or "the Sunday last" depending on context. Note that dates explicitly in the past ("last Sunday") will remain in the past. This method simply provides a hint when ambiguity exists. UTC-based dates can be created through the %utc% object. For more, see @date_format. + * @set + * Date.utc.future + * + * @example + * + * Date.future('July') -> July of this year or next depending on the current month + * Date.future('Wednesday') -> This wednesday or next depending on the current weekday + * + ***/ + 'future': function() { + return createDate(arguments, 1); + }, + + /*** + * @method Date.addLocale(, ) + * @returns Locale + * @short Adds a locale to the locales understood by Sugar. + * @extra For more see @date_format. + * + ***/ + 'addLocale': function(localeCode, set) { + return setLocalization(localeCode, set); + }, + + /*** + * @method Date.setLocale() + * @returns Locale + * @short Sets the current locale to be used with dates. + * @extra Sugar has support for 13 locales that are available through the "Date Locales" package. In addition you can define a new locale with %Date.addLocale%. For more see @date_format. + * + ***/ + 'setLocale': function(localeCode, set) { + var loc = getLocalization(localeCode, false); + CurrentLocalization = loc; + // The code is allowed to be more specific than the codes which are required: + // i.e. zh-CN or en-US. Currently this only affects US date variants such as 8/10/2000. + if(localeCode && localeCode != loc['code']) { + loc['code'] = localeCode; + } + return loc; + }, + + /*** + * @method Date.getLocale([code] = current) + * @returns Locale + * @short Gets the locale for the given code, or the current locale. + * @extra The resulting locale object can be manipulated to provide more control over date localizations. For more about locales, see @date_format. + * + ***/ + 'getLocale': function(localeCode) { + return !localeCode ? CurrentLocalization : getLocalization(localeCode, false); + }, + + /** + * @method Date.addFormat(, , [code] = null) + * @returns Nothing + * @short Manually adds a new date input format. + * @extra This method allows fine grained control for alternate formats. is a string that can have regex tokens inside. is an array of the tokens that each regex capturing group will map to, for example %year%, %date%, etc. For more, see @date_format. + * + **/ + 'addFormat': function(format, match, localeCode) { + addDateInputFormat(getLocalization(localeCode), format, match); + } + + }, false, false); + + date.extend({ + + /*** + * @method set(, [reset] = false) + * @returns Date + * @short Sets the date object. + * @extra This method can accept multiple formats including a single number as a timestamp, an object, or enumerated parameters (as with the Date constructor). If [reset] is %true%, any units more specific than those passed will be reset. + * + * @example + * + * new Date().set({ year: 2011, month: 11, day: 31 }) -> December 31, 2011 + * new Date().set(2011, 11, 31) -> December 31, 2011 + * new Date().set(86400000) -> 1 day after Jan 1, 1970 + * new Date().set({ year: 2004, month: 6 }, true) -> June 1, 2004, 00:00:00.000 + * + ***/ + 'set': function() { + var args = collectDateArguments(arguments); + return updateDate(this, args[0], args[1]) + }, + + /*** + * @method setWeekday() + * @returns Nothing + * @short Sets the weekday of the date. + * + * @example + * + * d = new Date(); d.setWeekday(1); d; -> Monday of this week + * d = new Date(); d.setWeekday(6); d; -> Saturday of this week + * + ***/ + 'setWeekday': function(dow) { + if(isUndefined(dow)) return; + return callDateSet(this, 'Date', callDateGet(this, 'Date') + dow - callDateGet(this, 'Day')); + }, + + /*** + * @method setWeek() + * @returns Nothing + * @short Sets the week (of the year). + * + * @example + * + * d = new Date(); d.setWeek(15); d; -> 15th week of the year + * + ***/ + 'setWeek': function(week) { + if(isUndefined(week)) return; + var date = callDateGet(this, 'Date'); + callDateSet(this, 'Month', 0); + callDateSet(this, 'Date', (week * 7) + 1); + return this.getTime(); + }, + + /*** + * @method getWeek() + * @returns Number + * @short Gets the date's week (of the year). + * @extra If %utc% is set on the date, the week will be according to UTC time. + * + * @example + * + * new Date().getWeek() -> today's week of the year + * + ***/ + 'getWeek': function() { + return getWeekNumber(this); + }, + + /*** + * @method getUTCOffset([iso]) + * @returns String + * @short Returns a string representation of the offset from UTC time. If [iso] is true the offset will be in ISO8601 format. + * @example + * + * new Date().getUTCOffset() -> "+0900" + * new Date().getUTCOffset(true) -> "+09:00" + * + ***/ + 'getUTCOffset': function(iso) { + var offset = this._utc ? 0 : this.getTimezoneOffset(); + var colon = iso === true ? ':' : ''; + if(!offset && iso) return 'Z'; + return padNumber(round(-offset / 60), 2, true) + colon + padNumber(offset % 60, 2); + }, + + /*** + * @method utc([on] = true) + * @returns Date + * @short Sets the internal utc flag for the date. When on, UTC-based methods will be called internally. + * @extra For more see @date_format. + * @example + * + * new Date().utc(true) + * new Date().utc(false) + * + ***/ + 'utc': function(set) { + this._utc = set === true || arguments.length === 0; + return this; + }, + + /*** + * @method isUTC() + * @returns Boolean + * @short Returns true if the date has no timezone offset. + * @extra This will also return true for a date that has had %toUTC% called on it. This is intended to help approximate shifting timezones which is not possible in client-side Javascript. Note that the native method %getTimezoneOffset% will always report the same thing, even if %isUTC% becomes true. + * @example + * + * new Date().isUTC() -> true or false? + * new Date().toUTC().isUTC() -> true + * + ***/ + 'isUTC': function() { + return !!this._utc || this.getTimezoneOffset() === 0; + }, + + /*** + * @method advance(, [reset] = false) + * @returns Date + * @short Sets the date forward. + * @extra This method can accept multiple formats including an object, a string in the format %3 days%, a single number as milliseconds, or enumerated parameters (as with the Date constructor). If [reset] is %true%, any units more specific than those passed will be reset. For more see @date_format. + * @example + * + * new Date().advance({ year: 2 }) -> 2 years in the future + * new Date().advance('2 days') -> 2 days in the future + * new Date().advance(0, 2, 3) -> 2 months 3 days in the future + * new Date().advance(86400000) -> 1 day in the future + * + ***/ + 'advance': function() { + var args = collectDateArguments(arguments, true); + return updateDate(this, args[0], args[1], 1); + }, + + /*** + * @method rewind(, [reset] = false) + * @returns Date + * @short Sets the date back. + * @extra This method can accept multiple formats including a single number as a timestamp, an object, or enumerated parameters (as with the Date constructor). If [reset] is %true%, any units more specific than those passed will be reset. For more see @date_format. + * @example + * + * new Date().rewind({ year: 2 }) -> 2 years in the past + * new Date().rewind(0, 2, 3) -> 2 months 3 days in the past + * new Date().rewind(86400000) -> 1 day in the past + * + ***/ + 'rewind': function() { + var args = collectDateArguments(arguments, true); + return updateDate(this, args[0], args[1], -1); + }, + + /*** + * @method isValid() + * @returns Boolean + * @short Returns true if the date is valid. + * @example + * + * new Date().isValid() -> true + * new Date('flexor').isValid() -> false + * + ***/ + 'isValid': function() { + return !isNaN(this.getTime()); + }, + + /*** + * @method isAfter(, [margin] = 0) + * @returns Boolean + * @short Returns true if the date is after the . + * @extra [margin] is to allow extra margin of error (in ms). will accept a date object, timestamp, or text format. If not specified, is assumed to be now. See @date_format for more. + * @example + * + * new Date().isAfter('tomorrow') -> false + * new Date().isAfter('yesterday') -> true + * + ***/ + 'isAfter': function(d, margin, utc) { + return this.getTime() > date.create(d).getTime() - (margin || 0); + }, + + /*** + * @method isBefore(, [margin] = 0) + * @returns Boolean + * @short Returns true if the date is before . + * @extra [margin] is to allow extra margin of error (in ms). will accept a date object, timestamp, or text format. If not specified, is assumed to be now. See @date_format for more. + * @example + * + * new Date().isBefore('tomorrow') -> true + * new Date().isBefore('yesterday') -> false + * + ***/ + 'isBefore': function(d, margin) { + return this.getTime() < date.create(d).getTime() + (margin || 0); + }, + + /*** + * @method isBetween(, , [margin] = 0) + * @returns Boolean + * @short Returns true if the date falls between and . + * @extra [margin] is to allow extra margin of error (in ms). and will accept a date object, timestamp, or text format. If not specified, they are assumed to be now. See @date_format for more. + * @example + * + * new Date().isBetween('yesterday', 'tomorrow') -> true + * new Date().isBetween('last year', '2 years ago') -> false + * + ***/ + 'isBetween': function(d1, d2, margin) { + var t = this.getTime(); + var t1 = date.create(d1).getTime(); + var t2 = date.create(d2).getTime(); + var lo = math.min(t1, t2); + var hi = math.max(t1, t2); + margin = margin || 0; + return (lo - margin < t) && (hi + margin > t); + }, + + /*** + * @method isLeapYear() + * @returns Boolean + * @short Returns true if the date is a leap year. + * @example + * + * Date.create('2000').isLeapYear() -> true + * + ***/ + 'isLeapYear': function() { + var year = callDateGet(this, 'FullYear'); + return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); + }, + + /*** + * @method daysInMonth() + * @returns Number + * @short Returns the number of days in the date's month. + * @example + * + * Date.create('May').daysInMonth() -> 31 + * Date.create('February, 2000').daysInMonth() -> 29 + * + ***/ + 'daysInMonth': function() { + return 32 - callDateGet(new date(callDateGet(this, 'FullYear'), callDateGet(this, 'Month'), 32), 'Date'); + }, + + /*** + * @method format(, [locale] = currentLocale) + * @returns String + * @short Formats and outputs the date. + * @extra can be a number of pre-determined formats or a string of tokens. Locale-specific formats are %short%, %long%, and %full% which have their own aliases and can be called with %date.short()%, etc. If is not specified the %long% format is assumed. [locale] specifies a locale code to use (if not specified the current locale is used). See @date_format for more details. + * + * @set + * short + * long + * full + * + * @example + * + * Date.create().format() -> ex. July 4, 2003 + * Date.create().format('{Weekday} {d} {Month}, {yyyy}') -> ex. Monday July 4, 2003 + * Date.create().format('{hh}:{mm}') -> ex. 15:57 + * Date.create().format('{12hr}:{mm}{tt}') -> ex. 3:57pm + * Date.create().format(Date.ISO8601_DATETIME) -> ex. 2011-07-05 12:24:55.528Z + * Date.create('last week').format('short', 'ja') -> ex. 先週 + * Date.create('yesterday').format(function(value,unit,ms,loc) { + * // value = 1, unit = 3, ms = -86400000, loc = [current locale object] + * }); -> ex. 1 day ago + * + ***/ + 'format': function(f, localeCode) { + return formatDate(this, f, false, localeCode); + }, + + /*** + * @method relative([fn], [locale] = currentLocale) + * @returns String + * @short Returns a relative date string offset to the current time. + * @extra [fn] can be passed to provide for more granular control over the resulting string. [fn] is passed 4 arguments: the adjusted value, unit, offset in milliseconds, and a localization object. As an alternate syntax, [locale] can also be passed as the first (and only) parameter. For more, see @date_format. + * @example + * + * Date.create('90 seconds ago').relative() -> 1 minute ago + * Date.create('January').relative() -> ex. 5 months ago + * Date.create('January').relative('ja') -> 3ヶ月前 + * Date.create('120 minutes ago').relative(function(val,unit,ms,loc) { + * // value = 2, unit = 3, ms = -7200, loc = [current locale object] + * }); -> ex. 5 months ago + * + ***/ + 'relative': function(f, localeCode) { + if(isString(f)) { + localeCode = f; + f = null; + } + return formatDate(this, f, true, localeCode); + }, + + /*** + * @method is(, [margin] = 0) + * @returns Boolean + * @short Returns true if the date is . + * @extra will accept a date object, timestamp, or text format. %is% additionally understands more generalized expressions like month/weekday names, 'today', etc, and compares to the precision implied in . [margin] allows an extra margin of error in milliseconds. For more, see @date_format. + * @example + * + * Date.create().is('July') -> true or false? + * Date.create().is('1776') -> false + * Date.create().is('today') -> true + * Date.create().is('weekday') -> true or false? + * Date.create().is('July 4, 1776') -> false + * Date.create().is(-6106093200000) -> false + * Date.create().is(new Date(1776, 6, 4)) -> false + * + ***/ + 'is': function(d, margin, utc) { + var tmp, comp; + if(!this.isValid()) return; + if(isString(d)) { + d = d.trim().toLowerCase(); + comp = this.clone().utc(utc); + switch(true) { + case d === 'future': return this.getTime() > new date().getTime(); + case d === 'past': return this.getTime() < new date().getTime(); + case d === 'weekday': return callDateGet(comp, 'Day') > 0 && callDateGet(comp, 'Day') < 6; + case d === 'weekend': return callDateGet(comp, 'Day') === 0 || callDateGet(comp, 'Day') === 6; + case (tmp = English['weekdays'].indexOf(d) % 7) > -1: return callDateGet(comp, 'Day') === tmp; + case (tmp = English['months'].indexOf(d) % 12) > -1: return callDateGet(comp, 'Month') === tmp; + } + } + return compareDate(this, d, margin, utc); + }, + + /*** + * @method reset([unit] = 'hours') + * @returns Date + * @short Resets the unit passed and all smaller units. Default is "hours", effectively resetting the time. + * @example + * + * Date.create().reset('day') -> Beginning of today + * Date.create().reset('month') -> 1st of the month + * + ***/ + 'reset': function(unit) { + var params = {}, recognized; + unit = unit || 'hours'; + if(unit === 'date') unit = 'days'; + recognized = DateUnits.some(function(u) { + return unit === u.unit || unit === u.unit + 's'; + }); + params[unit] = unit.match(/^days?/) ? 1 : 0; + return recognized ? this.set(params, true) : this; + }, + + /*** + * @method clone() + * @returns Date + * @short Clones the date. + * @example + * + * Date.create().clone() -> Copy of now + * + ***/ + 'clone': function() { + var d = new date(this.getTime()); + d._utc = this._utc; + return d; + } + + }); + + + // Instance aliases + date.extend({ + + /*** + * @method iso() + * @alias toISOString + * + ***/ + 'iso': function() { + return this.toISOString(); + }, + + /*** + * @method getWeekday() + * @returns Number + * @short Alias for %getDay%. + * @set + * getUTCWeekday + * + * @example + * + + Date.create().getWeekday(); -> (ex.) 3 + + Date.create().getUTCWeekday(); -> (ex.) 3 + * + ***/ + 'getWeekday': date.prototype.getDay, + 'getUTCWeekday': date.prototype.getUTCDay + + }); + + + + /*** + * Number module + * + ***/ + + /*** + * @method [unit]() + * @returns Number + * @short Takes the number as a corresponding unit of time and converts to milliseconds. + * @extra Method names can be both singular and plural. Note that as "a month" is ambiguous as a unit of time, %months% will be equivalent to 30.4375 days, the average number in a month. Be careful using %months% if you need exact precision. + * + * @set + * millisecond + * milliseconds + * second + * seconds + * minute + * minutes + * hour + * hours + * day + * days + * week + * weeks + * month + * months + * year + * years + * + * @example + * + * (5).milliseconds() -> 5 + * (10).hours() -> 36000000 + * (1).day() -> 86400000 + * + *** + * @method [unit]Before([d], [locale] = currentLocale) + * @returns Date + * @short Returns a date that is units before [d], where is the number. + * @extra [d] will accept a date object, timestamp, or text format. Note that "months" is ambiguous as a unit of time. If the target date falls on a day that does not exist (ie. August 31 -> February 31), the date will be shifted to the last day of the month. Be careful using %monthsBefore% if you need exact precision. See @date_format for more. + * + * @set + * millisecondBefore + * millisecondsBefore + * secondBefore + * secondsBefore + * minuteBefore + * minutesBefore + * hourBefore + * hoursBefore + * dayBefore + * daysBefore + * weekBefore + * weeksBefore + * monthBefore + * monthsBefore + * yearBefore + * yearsBefore + * + * @example + * + * (5).daysBefore('tuesday') -> 5 days before tuesday of this week + * (1).yearBefore('January 23, 1997') -> January 23, 1996 + * + *** + * @method [unit]Ago() + * @returns Date + * @short Returns a date that is units ago. + * @extra Note that "months" is ambiguous as a unit of time. If the target date falls on a day that does not exist (ie. August 31 -> February 31), the date will be shifted to the last day of the month. Be careful using %monthsAgo% if you need exact precision. + * + * @set + * millisecondAgo + * millisecondsAgo + * secondAgo + * secondsAgo + * minuteAgo + * minutesAgo + * hourAgo + * hoursAgo + * dayAgo + * daysAgo + * weekAgo + * weeksAgo + * monthAgo + * monthsAgo + * yearAgo + * yearsAgo + * + * @example + * + * (5).weeksAgo() -> 5 weeks ago + * (1).yearAgo() -> January 23, 1996 + * + *** + * @method [unit]After([d], [locale] = currentLocale) + * @returns Date + * @short Returns a date units after [d], where is the number. + * @extra [d] will accept a date object, timestamp, or text format. Note that "months" is ambiguous as a unit of time. If the target date falls on a day that does not exist (ie. August 31 -> February 31), the date will be shifted to the last day of the month. Be careful using %monthsAfter% if you need exact precision. See @date_format for more. + * + * @set + * millisecondAfter + * millisecondsAfter + * secondAfter + * secondsAfter + * minuteAfter + * minutesAfter + * hourAfter + * hoursAfter + * dayAfter + * daysAfter + * weekAfter + * weeksAfter + * monthAfter + * monthsAfter + * yearAfter + * yearsAfter + * + * @example + * + * (5).daysAfter('tuesday') -> 5 days after tuesday of this week + * (1).yearAfter('January 23, 1997') -> January 23, 1998 + * + *** + * @method [unit]FromNow() + * @returns Date + * @short Returns a date units from now. + * @extra Note that "months" is ambiguous as a unit of time. If the target date falls on a day that does not exist (ie. August 31 -> February 31), the date will be shifted to the last day of the month. Be careful using %monthsFromNow% if you need exact precision. + * + * @set + * millisecondFromNow + * millisecondsFromNow + * secondFromNow + * secondsFromNow + * minuteFromNow + * minutesFromNow + * hourFromNow + * hoursFromNow + * dayFromNow + * daysFromNow + * weekFromNow + * weeksFromNow + * monthFromNow + * monthsFromNow + * yearFromNow + * yearsFromNow + * + * @example + * + * (5).weeksFromNow() -> 5 weeks ago + * (1).yearFromNow() -> January 23, 1998 + * + ***/ + function buildNumberToDateAlias(u, multiplier) { + var unit = u.unit, methods = {}; + function base() { return round(this * multiplier); } + function after() { return createDate(arguments)[u.addMethod](this); } + function before() { return createDate(arguments)[u.addMethod](-this); } + methods[unit] = base; + methods[unit + 's'] = base; + methods[unit + 'Before'] = before; + methods[unit + 'sBefore'] = before; + methods[unit + 'Ago'] = before; + methods[unit + 'sAgo'] = before; + methods[unit + 'After'] = after; + methods[unit + 'sAfter'] = after; + methods[unit + 'FromNow'] = after; + methods[unit + 'sFromNow'] = after; + number.extend(methods); + } + + number.extend({ + + /*** + * @method duration([locale] = currentLocale) + * @returns String + * @short Takes the number as milliseconds and returns a unit-adjusted localized string. + * @extra This method is the same as %Date#relative% without the localized equivalent of "from now" or "ago". [locale] can be passed as the first (and only) parameter. Note that this method is only available when the dates package is included. + * @example + * + * (500).duration() -> '500 milliseconds' + * (1200).duration() -> '1 second' + * (75).minutes().duration() -> '1 hour' + * (75).minutes().duration('es') -> '1 hora' + * + ***/ + 'duration': function(localeCode) { + return getLocalization(localeCode).getDuration(this); + } + + }); + + + English = CurrentLocalization = date.addLocale('en', { + 'plural': true, + 'timeMarker': 'at', + 'ampm': 'am,pm', + 'months': 'January,February,March,April,May,June,July,August,September,October,November,December', + 'weekdays': 'Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday', + 'units': 'millisecond:|s,second:|s,minute:|s,hour:|s,day:|s,week:|s,month:|s,year:|s', + 'numbers': 'one,two,three,four,five,six,seven,eight,nine,ten', + 'articles': 'a,an,the', + 'tokens': 'the,st|nd|rd|th,of', + 'short': '{Month} {d}, {yyyy}', + 'long': '{Month} {d}, {yyyy} {h}:{mm}{tt}', + 'full': '{Weekday} {Month} {d}, {yyyy} {h}:{mm}:{ss}{tt}', + 'past': '{num} {unit} {sign}', + 'future': '{num} {unit} {sign}', + 'duration': '{num} {unit}', + 'modifiers': [ + { 'name': 'day', 'src': 'yesterday', 'value': -1 }, + { 'name': 'day', 'src': 'today', 'value': 0 }, + { 'name': 'day', 'src': 'tomorrow', 'value': 1 }, + { 'name': 'sign', 'src': 'ago|before', 'value': -1 }, + { 'name': 'sign', 'src': 'from now|after|from|in|later', 'value': 1 }, + { 'name': 'edge', 'src': 'last day', 'value': -2 }, + { 'name': 'edge', 'src': 'end', 'value': -1 }, + { 'name': 'edge', 'src': 'first day|beginning', 'value': 1 }, + { 'name': 'shift', 'src': 'last', 'value': -1 }, + { 'name': 'shift', 'src': 'the|this', 'value': 0 }, + { 'name': 'shift', 'src': 'next', 'value': 1 } + ], + 'dateParse': [ + '{num} {unit} {sign}', + '{sign} {num} {unit}', + '{month} {year}', + '{shift} {unit=5-7}', + '{0?} {date}{1}', + '{0?} {edge} of {shift?} {unit=4-7?}{month?}{year?}' + ], + 'timeParse': [ + '{0} {num}{1} {day} of {month} {year?}', + '{weekday?} {month} {date}{1?} {year?}', + '{date} {month} {year}', + '{shift} {weekday}', + '{shift} week {weekday}', + '{weekday} {2?} {shift} week', + '{num} {unit=4-5} {sign} {day}', + '{0?} {date}{1} of {month}', + '{0?}{month?} {date?}{1?} of {shift} {unit=6-7}' + ] + }); + + buildDateUnits(); + buildDateMethods(); + buildCoreInputFormats(); + buildDateOutputShortcuts(); + buildAsianDigits(); + buildRelativeAliases(); + buildUTCAliases(); + setDateProperties(); + + + /*** + * @package DateRange + * @dependency date + * @description Date Ranges define a range of time. They can enumerate over specific points within that range, and be manipulated and compared. + * + ***/ + + var DateRange = function(start, end) { + this.start = date.create(start); + this.end = date.create(end); + }; + + // 'toString' doesn't appear in a for..in loop in IE even though + // hasOwnProperty reports true, so extend() can't be used here. + // Also tried simply setting the prototype = {} up front for all + // methods but GCC very oddly started dropping properties in the + // object randomly (maybe because of the global scope?) hence + // the need for the split logic here. + DateRange.prototype.toString = function() { + /*** + * @method toString() + * @returns String + * @short Returns a string representation of the DateRange. + * @example + * + * Date.range('2003', '2005').toString() -> January 1, 2003..January 1, 2005 + * + ***/ + return this.isValid() ? this.start.full() + '..' + this.end.full() : 'Invalid DateRange'; + }; + + extend(DateRange, true, false, { + + /*** + * @method isValid() + * @returns Boolean + * @short Returns true if the DateRange is valid, false otherwise. + * @example + * + * Date.range('2003', '2005').isValid() -> true + * Date.range('2005', '2003').isValid() -> false + * + ***/ + 'isValid': function() { + return this.start < this.end; + }, + + /*** + * @method duration() + * @returns Number + * @short Return the duration of the DateRange in milliseconds. + * @example + * + * Date.range('2003', '2005').duration() -> 94694400000 + * + ***/ + 'duration': function() { + return this.isValid() ? this.end.getTime() - this.start.getTime() : NaN; + }, + + /*** + * @method contains() + * @returns Boolean + * @short Returns true if is contained inside the DateRange. may be a date or another DateRange. + * @example + * + * Date.range('2003', '2005').contains(Date.create('2004')) -> true + * + ***/ + 'contains': function(obj) { + var self = this, arr = obj.start && obj.end ? [obj.start, obj.end] : [obj]; + return arr.every(function(d) { + return d >= self.start && d <= self.end; + }); + }, + + /*** + * @method every(, [fn]) + * @returns Array + * @short Iterates through the DateRange for every , calling [fn] if it is passed. Returns an array of each increment visited. + * @extra When is a number, increments will be to the exact millisecond. can also be a string in the format %{number} {unit}s%, in which case it will increment in the unit specified. Note that a discrepancy exists in the case of months, as %(2).months()% is an approximation. Stepping through the actual months by passing %"2 months"% is usually preferable in this case. + * @example + * + * Date.range('2003-01', '2003-03').every("2 months") -> [...] + * + ***/ + 'every': function(increment, fn) { + var current = this.start.clone(), result = [], index = 0, params, isDay; + if(isString(increment)) { + current.advance(getDateParamsFromString(increment, 0), true); + params = getDateParamsFromString(increment); + isDay = increment.toLowerCase() === 'day'; + } else { + params = { 'milliseconds': increment }; + } + while(current <= this.end) { + result.push(current); + if(fn) fn(current, index); + if(isDay && callDateGet(current, 'Hours') === 23) { + // When DST traversal happens at 00:00 hours, the time is effectively + // pushed back to 23:00, meaning 1) 00:00 for that day does not exist, + // and 2) there is no difference between 23:00 and 00:00, as you are + // "jumping" around in time. Hours here will be reset before the date + // is advanced and the date will never in fact advance, so set the hours + // directly ahead to the next day to avoid this problem. + current = current.clone(); + callDateSet(current, 'Hours', 48); + } else { + current = current.clone().advance(params, true); + } + index++; + } + return result; + }, + + /*** + * @method union() + * @returns DateRange + * @short Returns a new DateRange with the earliest starting point as its start, and the latest ending point as its end. If the two ranges do not intersect this will effectively remove the "gap" between them. + * @example + * + * Date.range('2003=01', '2005-01').union(Date.range('2004-01', '2006-01')) -> Jan 1, 2003..Jan 1, 2006 + * + ***/ + 'union': function(range) { + return new DateRange( + this.start < range.start ? this.start : range.start, + this.end > range.end ? this.end : range.end + ); + }, + + /*** + * @method intersect() + * @returns DateRange + * @short Returns a new DateRange with the latest starting point as its start, and the earliest ending point as its end. If the two ranges do not intersect this will effectively produce an invalid range. + * @example + * + * Date.range('2003-01', '2005-01').intersect(Date.range('2004-01', '2006-01')) -> Jan 1, 2004..Jan 1, 2005 + * + ***/ + 'intersect': function(range) { + return new DateRange( + this.start > range.start ? this.start : range.start, + this.end < range.end ? this.end : range.end + ); + } + + }); + + /*** + * @method each[Unit]([fn]) + * @returns Date + * @short Increments through the date range for each [unit], calling [fn] if it is passed. Returns an array of each increment visited. + * + * @set + * eachMillisecond + * eachSecond + * eachMinute + * eachHour + * eachDay + * eachWeek + * eachMonth + * eachYear + * + * @example + * + * Date.range('2003-01', '2003-02').eachMonth() -> [...] + * Date.range('2003-01-15', '2003-01-16').eachDay() -> [...] + * + ***/ + extendSimilar(DateRange, true, false, 'Millisecond,Second,Minute,Hour,Day,Week,Month,Year', function(methods, name) { + methods['each' + name] = function(fn) { return this.every(name, fn); } + }); + + + /*** + * Date module + ***/ + + extend(date, false, false, { + + /*** + * @method Date.range([start], [end]) + * @returns DateRange + * @short Creates a new date range. + * @extra If either [start] or [end] are null, they will default to the current date. + * + ***/ + 'range': function(start, end) { + return new DateRange(start, end); + } + + }); + + + /*** + * @package Function + * @dependency core + * @description Lazy, throttled, and memoized functions, delayed functions and handling of timers, argument currying. + * + ***/ + + function setDelay(fn, ms, after, scope, args) { + var index; + if(!fn.timers) fn.timers = []; + if(!isNumber(ms)) ms = 0; + fn.timers.push(setTimeout(function(){ + fn.timers.splice(index, 1); + after.apply(scope, args || []); + }, ms)); + index = fn.timers.length; + } + + extend(Function, true, false, { + + /*** + * @method lazy([ms] = 1, [limit] = Infinity) + * @returns Function + * @short Creates a lazy function that, when called repeatedly, will queue execution and wait [ms] milliseconds to execute again. + * @extra Lazy functions will always execute as many times as they are called up to [limit], after which point subsequent calls will be ignored (if it is set to a finite number). Compare this to %throttle%, which will execute only once per [ms] milliseconds. %lazy% is useful when you need to be sure that every call to a function is executed, but in a non-blocking manner. Calling %cancel% on a lazy function will clear the entire queue. Note that [ms] can also be a fraction. + * @example + * + * (function() { + * // Executes immediately. + * }).lazy()(); + * (3).times(function() { + * // Executes 3 times, with each execution 20ms later than the last. + * }.lazy(20)); + * (100).times(function() { + * // Executes 50 times, with each execution 20ms later than the last. + * }.lazy(20, 50)); + * + ***/ + 'lazy': function(ms, limit) { + var fn = this, queue = [], lock = false, execute, rounded, perExecution; + ms = ms || 1; + limit = limit || Infinity; + rounded = ceil(ms); + perExecution = round(rounded / ms); + execute = function() { + if(lock || queue.length == 0) return; + var max = math.max(queue.length - perExecution, 0); + while(queue.length > max) { + // Getting uber-meta here... + Function.prototype.apply.apply(fn, queue.shift()); + } + setDelay(lazy, rounded, function() { + lock = false; + execute(); + }); + lock = true; + } + function lazy() { + // The first call is immediate, so having 1 in the queue + // implies two calls have already taken place. + if(lock && queue.length > limit - 2) return; + queue.push([this, arguments]); + execute(); + } + return lazy; + }, + + /*** + * @method delay([ms] = 0, [arg1], ...) + * @returns Function + * @short Executes the function after milliseconds. + * @extra Returns a reference to itself. %delay% is also a way to execute non-blocking operations that will wait until the CPU is free. Delayed functions can be canceled using the %cancel% method. Can also curry arguments passed in after . + * @example + * + * (function(arg1) { + * // called 1s later + * }).delay(1000, 'arg1'); + * + ***/ + 'delay': function(ms) { + var fn = this; + var args = multiArgs(arguments).slice(1); + setDelay(fn, ms, fn, fn, args); + return fn; + }, + + /*** + * @method throttle() + * @returns Function + * @short Creates a "throttled" version of the function that will only be executed once per milliseconds. + * @extra This is functionally equivalent to calling %lazy% with a [limit] of %1%. %throttle% is appropriate when you want to make sure a function is only executed at most once for a given duration. Compare this to %lazy%, which will queue rapid calls and execute them later. + * @example + * + * (3).times(function() { + * // called only once. will wait 50ms until it responds again + * }.throttle(50)); + * + ***/ + 'throttle': function(ms) { + return this.lazy(ms, 1); + }, + + /*** + * @method debounce() + * @returns Function + * @short Creates a "debounced" function that postpones its execution until after milliseconds have passed. + * @extra This method is useful to execute a function after things have "settled down". A good example of this is when a user tabs quickly through form fields, execution of a heavy operation should happen after a few milliseconds when they have "settled" on a field. + * @example + * + * var fn = (function(arg1) { + * // called once 50ms later + * }).debounce(50); fn() fn() fn(); + * + ***/ + 'debounce': function(ms) { + var fn = this; + function debounced() { + debounced.cancel(); + setDelay(debounced, ms, fn, this, arguments); + }; + return debounced; + }, + + /*** + * @method cancel() + * @returns Function + * @short Cancels a delayed function scheduled to be run. + * @extra %delay%, %lazy%, %throttle%, and %debounce% can all set delays. + * @example + * + * (function() { + * alert('hay'); // Never called + * }).delay(500).cancel(); + * + ***/ + 'cancel': function() { + if(isArray(this.timers)) { + while(this.timers.length > 0) { + clearTimeout(this.timers.shift()); + } + } + return this; + }, + + /*** + * @method after([num] = 1) + * @returns Function + * @short Creates a function that will execute after [num] calls. + * @extra %after% is useful for running a final callback after a series of asynchronous operations, when the order in which the operations will complete is unknown. + * @example + * + * var fn = (function() { + * // Will be executed once only + * }).after(3); fn(); fn(); fn(); + * + ***/ + 'after': function(num) { + var fn = this, counter = 0, storedArguments = []; + if(!isNumber(num)) { + num = 1; + } else if(num === 0) { + fn.call(); + return fn; + } + return function() { + var ret; + storedArguments.push(multiArgs(arguments)); + counter++; + if(counter == num) { + ret = fn.call(this, storedArguments); + counter = 0; + storedArguments = []; + return ret; + } + } + }, + + /*** + * @method once() + * @returns Function + * @short Creates a function that will execute only once and store the result. + * @extra %once% is useful for creating functions that will cache the result of an expensive operation and use it on subsequent calls. Also it can be useful for creating initialization functions that only need to be run once. + * @example + * + * var fn = (function() { + * // Will be executed once only + * }).once(); fn(); fn(); fn(); + * + ***/ + 'once': function() { + var fn = this; + return function() { + return hasOwnProperty(fn, 'memo') ? fn['memo'] : fn['memo'] = fn.apply(this, arguments); + } + }, + + /*** + * @method fill(, , ...) + * @returns Function + * @short Returns a new version of the function which when called will have some of its arguments pre-emptively filled in, also known as "currying". + * @extra Arguments passed to a "filled" function are generally appended to the curried arguments. However, if %undefined% is passed as any of the arguments to %fill%, it will be replaced, when the "filled" function is executed. This allows currying of arguments even when they occur toward the end of an argument list (the example demonstrates this much more clearly). + * @example + * + * var delayOneSecond = setTimeout.fill(undefined, 1000); + * delayOneSecond(function() { + * // Will be executed 1s later + * }); + * + ***/ + 'fill': function() { + var fn = this, curried = multiArgs(arguments); + return function() { + var args = multiArgs(arguments); + curried.forEach(function(arg, index) { + if(arg != null || index >= args.length) args.splice(index, 0, arg); + }); + return fn.apply(this, args); + } + } + + + }); + + + /*** + * @package Number + * @dependency core + * @description Number formatting, rounding (with precision), and ranges. Aliases to Math methods. + * + ***/ + + + function abbreviateNumber(num, roundTo, str, mid, limit, bytes) { + var fixed = num.toFixed(20), + decimalPlace = fixed.search(/\./), + numeralPlace = fixed.search(/[1-9]/), + significant = decimalPlace - numeralPlace, + unit, i, divisor; + if(significant > 0) { + significant -= 1; + } + i = math.max(math.min((significant / 3).floor(), limit === false ? str.length : limit), -mid); + unit = str.charAt(i + mid - 1); + if(significant < -9) { + i = -3; + roundTo = significant.abs() - 9; + unit = str.slice(0,1); + } + divisor = bytes ? (2).pow(10 * i) : (10).pow(i * 3); + return (num / divisor).round(roundTo || 0).format() + unit.trim(); + } + + + extend(number, false, false, { + + /*** + * @method Number.random([n1], [n2]) + * @returns Number + * @short Returns a random integer between [n1] and [n2]. + * @extra If only 1 number is passed, the other will be 0. If none are passed, the number will be either 0 or 1. + * @example + * + * Number.random(50, 100) -> ex. 85 + * Number.random(50) -> ex. 27 + * Number.random() -> ex. 0 + * + ***/ + 'random': function(n1, n2) { + var min, max; + if(arguments.length == 1) n2 = n1, n1 = 0; + min = math.min(n1 || 0, isUndefined(n2) ? 1 : n2); + max = math.max(n1 || 0, isUndefined(n2) ? 1 : n2) + 1; + return floor((math.random() * (max - min)) + min); + } + + }); + + extend(number, true, false, { + + /*** + * @method log( = Math.E) + * @returns Number + * @short Returns the logarithm of the number with base , or natural logarithm of the number if is undefined. + * @example + * + * (64).log(2) -> 6 + * (9).log(3) -> 2 + * (5).log() -> 1.6094379124341003 + * + ***/ + + 'log': function(base) { + return math.log(this) / (base ? math.log(base) : 1); + }, + + /*** + * @method abbr([precision] = 0) + * @returns String + * @short Returns an abbreviated form of the number. + * @extra [precision] will round to the given precision. + * @example + * + * (1000).abbr() -> "1k" + * (1000000).abbr() -> "1m" + * (1280).abbr(1) -> "1.3k" + * + ***/ + 'abbr': function(precision) { + return abbreviateNumber(this, precision, 'kmbt', 0, 4); + }, + + /*** + * @method metric([precision] = 0, [limit] = 1) + * @returns String + * @short Returns the number as a string in metric notation. + * @extra [precision] will round to the given precision. Both very large numbers and very small numbers are supported. [limit] is the upper limit for the units. The default is %1%, which is "kilo". If [limit] is %false%, the upper limit will be "exa". The lower limit is "nano", and cannot be changed. + * @example + * + * (1000).metric() -> "1k" + * (1000000).metric() -> "1,000k" + * (1000000).metric(0, false) -> "1M" + * (1249).metric(2) + 'g' -> "1.25kg" + * (0.025).metric() + 'm' -> "25mm" + * + ***/ + 'metric': function(precision, limit) { + return abbreviateNumber(this, precision, 'nμm kMGTPE', 4, isUndefined(limit) ? 1 : limit); + }, + + /*** + * @method bytes([precision] = 0, [limit] = 4) + * @returns String + * @short Returns an abbreviated form of the number, considered to be "Bytes". + * @extra [precision] will round to the given precision. [limit] is the upper limit for the units. The default is %4%, which is "terabytes" (TB). If [limit] is %false%, the upper limit will be "exa". + * @example + * + * (1000).bytes() -> "1kB" + * (1000).bytes(2) -> "0.98kB" + * ((10).pow(20)).bytes() -> "90,949,470TB" + * ((10).pow(20)).bytes(0, false) -> "87EB" + * + ***/ + 'bytes': function(precision, limit) { + return abbreviateNumber(this, precision, 'kMGTPE', 0, isUndefined(limit) ? 4 : limit, true) + 'B'; + }, + + /*** + * @method isInteger() + * @returns Boolean + * @short Returns true if the number has no trailing decimal. + * @example + * + * (420).isInteger() -> true + * (4.5).isInteger() -> false + * + ***/ + 'isInteger': function() { + return this % 1 == 0; + }, + + /*** + * @method isOdd() + * @returns Boolean + * @short Returns true if the number is odd. + * @example + * + * (3).isOdd() -> true + * (18).isOdd() -> false + * + ***/ + 'isOdd': function() { + return !this.isMultipleOf(2); + }, + + /*** + * @method isEven() + * @returns Boolean + * @short Returns true if the number is even. + * @example + * + * (6).isEven() -> true + * (17).isEven() -> false + * + ***/ + 'isEven': function() { + return this.isMultipleOf(2); + }, + + /*** + * @method isMultipleOf() + * @returns Boolean + * @short Returns true if the number is a multiple of . + * @example + * + * (6).isMultipleOf(2) -> true + * (17).isMultipleOf(2) -> false + * (32).isMultipleOf(4) -> true + * (34).isMultipleOf(4) -> false + * + ***/ + 'isMultipleOf': function(num) { + return this % num === 0; + }, + + + /*** + * @method format([place] = 0, [thousands] = ',', [decimal] = '.') + * @returns String + * @short Formats the number to a readable string. + * @extra If [place] is %undefined%, will automatically determine the place. [thousands] is the character used for the thousands separator. [decimal] is the character used for the decimal point. + * @example + * + * (56782).format() -> '56,782' + * (56782).format(2) -> '56,782.00' + * (4388.43).format(2, ' ') -> '4 388.43' + * (4388.43).format(2, '.', ',') -> '4.388,43' + * + ***/ + 'format': function(place, thousands, decimal) { + var str, split, method, after, r = /(\d+)(\d{3})/; + if(string(thousands).match(/\d/)) throw new TypeError('Thousands separator cannot contain numbers.'); + str = isNumber(place) ? round(this, place || 0).toFixed(math.max(place, 0)) : this.toString(); + thousands = thousands || ','; + decimal = decimal || '.'; + split = str.split('.'); + str = split[0]; + after = split[1] || ''; + while (str.match(r)) { + str = str.replace(r, '$1' + thousands + '$2'); + } + if(after.length > 0) { + str += decimal + repeatString((place || 0) - after.length, '0') + after; + } + return str; + }, + + /*** + * @method hex([pad] = 1) + * @returns String + * @short Converts the number to hexidecimal. + * @extra [pad] will pad the resulting string to that many places. + * @example + * + * (255).hex() -> 'ff'; + * (255).hex(4) -> '00ff'; + * (23654).hex() -> '5c66'; + * + ***/ + 'hex': function(pad) { + return this.pad(pad || 1, false, 16); + }, + + /*** + * @method upto(, [fn], [step] = 1) + * @returns Array + * @short Returns an array containing numbers from the number up to . + * @extra Optionally calls [fn] callback for each number in that array. [step] allows multiples greater than 1. + * @example + * + * (2).upto(6) -> [2, 3, 4, 5, 6] + * (2).upto(6, function(n) { + * // This function is called 5 times receiving n as the value. + * }); + * (2).upto(8, null, 2) -> [2, 4, 6, 8] + * + ***/ + 'upto': function(num, fn, step) { + return getRange(this, num, fn, step || 1); + }, + + /*** + * @method downto(, [fn], [step] = 1) + * @returns Array + * @short Returns an array containing numbers from the number down to . + * @extra Optionally calls [fn] callback for each number in that array. [step] allows multiples greater than 1. + * @example + * + * (8).downto(3) -> [8, 7, 6, 5, 4, 3] + * (8).downto(3, function(n) { + * // This function is called 6 times receiving n as the value. + * }); + * (8).downto(2, null, 2) -> [8, 6, 4, 2] + * + ***/ + 'downto': function(num, fn, step) { + return getRange(this, num, fn, -(step || 1)); + }, + + /*** + * @method times() + * @returns Number + * @short Calls a number of times equivalent to the number. + * @example + * + * (8).times(function(i) { + * // This function is called 8 times. + * }); + * + ***/ + 'times': function(fn) { + if(fn) { + for(var i = 0; i < this; i++) { + fn.call(this, i); + } + } + return this.toNumber(); + }, + + /*** + * @method chr() + * @returns String + * @short Returns a string at the code point of the number. + * @example + * + * (65).chr() -> "A" + * (75).chr() -> "K" + * + ***/ + 'chr': function() { + return string.fromCharCode(this); + }, + + /*** + * @method pad( = 0, [sign] = false, [base] = 10) + * @returns String + * @short Pads a number with "0" to . + * @extra [sign] allows you to force the sign as well (+05, etc). [base] can change the base for numeral conversion. + * @example + * + * (5).pad(2) -> '05' + * (-5).pad(4) -> '-0005' + * (82).pad(3, true) -> '+082' + * + ***/ + 'pad': function(place, sign, base) { + return padNumber(this, place, sign, base); + }, + + /*** + * @method ordinalize() + * @returns String + * @short Returns an ordinalized (English) string, i.e. "1st", "2nd", etc. + * @example + * + * (1).ordinalize() -> '1st'; + * (2).ordinalize() -> '2nd'; + * (8).ordinalize() -> '8th'; + * + ***/ + 'ordinalize': function() { + var suffix, num = this.abs(), last = parseInt(num.toString().slice(-2)); + return this + getOrdinalizedSuffix(last); + }, + + /*** + * @method toNumber() + * @returns Number + * @short Returns a number. This is mostly for compatibility reasons. + * @example + * + * (420).toNumber() -> 420 + * + ***/ + 'toNumber': function() { + return parseFloat(this, 10); + } + + }); + + /*** + * @method round( = 0) + * @returns Number + * @short Shortcut for %Math.round% that also allows a . + * + * @example + * + * (3.241).round() -> 3 + * (-3.841).round() -> -4 + * (3.241).round(2) -> 3.24 + * (3748).round(-2) -> 3800 + * + *** + * @method ceil( = 0) + * @returns Number + * @short Shortcut for %Math.ceil% that also allows a . + * + * @example + * + * (3.241).ceil() -> 4 + * (-3.241).ceil() -> -3 + * (3.241).ceil(2) -> 3.25 + * (3748).ceil(-2) -> 3800 + * + *** + * @method floor( = 0) + * @returns Number + * @short Shortcut for %Math.floor% that also allows a . + * + * @example + * + * (3.241).floor() -> 3 + * (-3.841).floor() -> -4 + * (3.241).floor(2) -> 3.24 + * (3748).floor(-2) -> 3700 + * + *** + * @method [math]() + * @returns Number + * @short Math related functions are mapped as shortcuts to numbers and are identical. Note that %Number#log% provides some special defaults. + * + * @set + * abs + * sin + * asin + * cos + * acos + * tan + * atan + * sqrt + * exp + * pow + * + * @example + * + * (3).pow(3) -> 27 + * (-3).abs() -> 3 + * (1024).sqrt() -> 32 + * + ***/ + + function buildNumber() { + extendSimilar(number, true, false, 'round,floor,ceil', function(methods, name) { + methods[name] = function(precision) { + return round(this, precision, name); + } + }); + extendSimilar(number, true, false, 'abs,pow,sin,asin,cos,acos,tan,atan,exp,pow,sqrt', function(methods, name) { + methods[name] = function(a, b) { + return math[name](this, a, b); + } + }); + } + + buildNumber(); + + + /*** + * @package Object + * @dependency core + * @description Object manipulation, type checking (isNumber, isString, ...), extended objects with hash-like methods available as instance methods. + * + * Much thanks to kangax for his informative aricle about how problems with instanceof and constructor + * http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/ + * + ***/ + + var ObjectTypeMethods = 'isObject,isNaN'.split(','); + var ObjectHashMethods = 'keys,values,each,merge,clone,equal,watch,tap,has'.split(','); + + function setParamsObject(obj, param, value, deep) { + var reg = /^(.+?)(\[.*\])$/, paramIsArray, match, allKeys, key; + if(deep !== false && (match = param.match(reg))) { + key = match[1]; + allKeys = match[2].replace(/^\[|\]$/g, '').split(']['); + allKeys.forEach(function(k) { + paramIsArray = !k || k.match(/^\d+$/); + if(!key && isArray(obj)) key = obj.length; + if(!obj[key]) { + obj[key] = paramIsArray ? [] : {}; + } + obj = obj[key]; + key = k; + }); + if(!key && paramIsArray) key = obj.length.toString(); + setParamsObject(obj, key, value); + } else if(value.match(/^[\d.]+$/)) { + obj[param] = parseFloat(value); + } else if(value === 'true') { + obj[param] = true; + } else if(value === 'false') { + obj[param] = false; + } else { + obj[param] = value; + } + } + + + /*** + * @method Object.is[Type]() + * @returns Boolean + * @short Returns true if is an object of that type. + * @extra %isObject% will return false on anything that is not an object literal, including instances of inherited classes. Note also that %isNaN% will ONLY return true if the object IS %NaN%. It does not mean the same as browser native %isNaN%, which returns true for anything that is "not a number". + * + * @set + * isArray + * isObject + * isBoolean + * isDate + * isFunction + * isNaN + * isNumber + * isString + * isRegExp + * + * @example + * + * Object.isArray([1,2,3]) -> true + * Object.isDate(3) -> false + * Object.isRegExp(/wasabi/) -> true + * Object.isObject({ broken:'wear' }) -> true + * + ***/ + function buildTypeMethods() { + extendSimilar(object, false, false, ClassNames, function(methods, name) { + var method = 'is' + name; + ObjectTypeMethods.push(method); + methods[method] = function(obj) { + return className(obj) === '[object '+name+']'; + } + }); + } + + function buildObjectExtend() { + extend(object, false, function(){ return arguments.length === 0; }, { + 'extend': function() { + buildObjectInstanceMethods(ObjectTypeMethods.concat(ObjectHashMethods), object); + } + }); + } + + extend(object, false, true, { + /*** + * @method watch(, , ) + * @returns Nothing + * @short Watches a property of and runs when it changes. + * @extra is passed three arguments: the property , the old value, and the new value. The return value of [fn] will be set as the new value. This method is useful for things such as validating or cleaning the value when it is set. Warning: this method WILL NOT work in browsers that don't support %Object.defineProperty%. This notably includes IE 8 and below, and Opera. This is the only method in Sugar that is not fully compatible with all browsers. %watch% is available as an instance method on extended objects. + * @example + * + * Object.watch({ foo: 'bar' }, 'foo', function(prop, oldVal, newVal) { + * // Will be run when the property 'foo' is set on the object. + * }); + * Object.extended().watch({ foo: 'bar' }, 'foo', function(prop, oldVal, newVal) { + * // Will be run when the property 'foo' is set on the object. + * }); + * + ***/ + 'watch': function(obj, prop, fn) { + if(!definePropertySupport) return; + var value = obj[prop]; + object.defineProperty(obj, prop, { + 'enumerable' : true, + 'configurable': true, + 'get': function() { + return value; + }, + 'set': function(to) { + value = fn.call(obj, prop, value, to); + } + }); + } + }); + + extend(object, false, function(arg1, arg2) { return isFunction(arg2); }, { + + /*** + * @method keys(, [fn]) + * @returns Array + * @short Returns an array containing the keys in . Optionally calls [fn] for each key. + * @extra This method is provided for browsers that don't support it natively, and additionally is enhanced to accept the callback [fn]. Returned keys are in no particular order. %keys% is available as an instance method on extended objects. + * @example + * + * Object.keys({ broken: 'wear' }) -> ['broken'] + * Object.keys({ broken: 'wear' }, function(key, value) { + * // Called once for each key. + * }); + * Object.extended({ broken: 'wear' }).keys() -> ['broken'] + * + ***/ + 'keys': function(obj, fn) { + var keys = object.keys(obj); + keys.forEach(function(key) { + fn.call(obj, key, obj[key]); + }); + return keys; + } + + }); + + extend(object, false, false, { + + 'isObject': function(obj) { + return isObject(obj); + }, + + 'isNaN': function(obj) { + // This is only true of NaN + return isNumber(obj) && obj.valueOf() !== obj.valueOf(); + }, + + /*** + * @method equal(
            , ) + * @returns Boolean + * @short Returns true if and are equal. + * @extra %equal% in Sugar is "egal", meaning the values are equal if they are "not observably distinguishable". Note that on extended objects the name is %equals% for readability. + * @example + * + * Object.equal({a:2}, {a:2}) -> true + * Object.equal({a:2}, {a:3}) -> false + * Object.extended({a:2}).equals({a:3}) -> false + * + ***/ + 'equal': function(a, b) { + return isEqual(a, b); + }, + + /*** + * @method Object.extended( = {}) + * @returns Extended object + * @short Creates a new object, equivalent to %new Object()% or %{}%, but with extended methods. + * @extra See extended objects for more. + * @example + * + * Object.extended() + * Object.extended({ happy:true, pappy:false }).keys() -> ['happy','pappy'] + * Object.extended({ happy:true, pappy:false }).values() -> [true, false] + * + ***/ + 'extended': function(obj) { + return new Hash(obj); + }, + + /*** + * @method merge(, , [deep] = false, [resolve] = true) + * @returns Merged object + * @short Merges all the properties of into . + * @extra Merges are shallow unless [deep] is %true%. Properties of will win in the case of conflicts, unless [resolve] is %false%. [resolve] can also be a function that resolves the conflict. In this case it will be passed 3 arguments, %key%, %targetVal%, and %sourceVal%, with the context set to . This will allow you to solve conflict any way you want, ie. adding two numbers together, etc. %merge% is available as an instance method on extended objects. + * @example + * + * Object.merge({a:1},{b:2}) -> { a:1, b:2 } + * Object.merge({a:1},{a:2}, false, false) -> { a:1 } + + Object.merge({a:1},{a:2}, false, function(key, a, b) { + * return a + b; + * }); -> { a:3 } + * Object.extended({a:1}).merge({b:2}) -> { a:1, b:2 } + * + ***/ + 'merge': function(target, source, deep, resolve) { + var key, val; + // Strings cannot be reliably merged thanks to + // their properties not being enumerable in < IE8. + if(target && typeof source != 'string') { + for(key in source) { + if(!hasOwnProperty(source, key) || !target) continue; + val = source[key]; + // Conflict! + if(isDefined(target[key])) { + // Do not merge. + if(resolve === false) { + continue; + } + // Use the result of the callback as the result. + if(isFunction(resolve)) { + val = resolve.call(source, key, target[key], source[key]) + } + } + // Deep merging. + if(deep === true && val && isObjectPrimitive(val)) { + if(isDate(val)) { + val = new date(val.getTime()); + } else if(isRegExp(val)) { + val = new regexp(val.source, getRegExpFlags(val)); + } else { + if(!target[key]) target[key] = array.isArray(val) ? [] : {}; + object.merge(target[key], source[key], deep, resolve); + continue; + } + } + target[key] = val; + } + } + return target; + }, + + /*** + * @method values(, [fn]) + * @returns Array + * @short Returns an array containing the values in . Optionally calls [fn] for each value. + * @extra Returned values are in no particular order. %values% is available as an instance method on extended objects. + * @example + * + * Object.values({ broken: 'wear' }) -> ['wear'] + * Object.values({ broken: 'wear' }, function(value) { + * // Called once for each value. + * }); + * Object.extended({ broken: 'wear' }).values() -> ['wear'] + * + ***/ + 'values': function(obj, fn) { + var values = []; + iterateOverObject(obj, function(k,v) { + values.push(v); + if(fn) fn.call(obj,v); + }); + return values; + }, + + /*** + * @method clone( = {}, [deep] = false) + * @returns Cloned object + * @short Creates a clone (copy) of . + * @extra Default is a shallow clone, unless [deep] is true. %clone% is available as an instance method on extended objects. + * @example + * + * Object.clone({foo:'bar'}) -> { foo: 'bar' } + * Object.clone() -> {} + * Object.extended({foo:'bar'}).clone() -> { foo: 'bar' } + * + ***/ + 'clone': function(obj, deep) { + if(!isObjectPrimitive(obj)) return obj; + if(array.isArray(obj)) return obj.concat(); + var target = obj instanceof Hash ? new Hash() : {}; + return object.merge(target, obj, deep); + }, + + /*** + * @method Object.fromQueryString(, [deep] = true) + * @returns Object + * @short Converts the query string of a URL into an object. + * @extra If [deep] is %false%, conversion will only accept shallow params (ie. no object or arrays with %[]% syntax) as these are not universally supported. + * @example + * + * Object.fromQueryString('foo=bar&broken=wear') -> { foo: 'bar', broken: 'wear' } + * Object.fromQueryString('foo[]=1&foo[]=2') -> { foo: [1,2] } + * + ***/ + 'fromQueryString': function(str, deep) { + var result = object.extended(), split; + str = str && str.toString ? str.toString() : ''; + str.replace(/^.*?\?/, '').split('&').forEach(function(p) { + var split = p.split('='); + if(split.length !== 2) return; + setParamsObject(result, split[0], decodeURIComponent(split[1]), deep); + }); + return result; + }, + + /*** + * @method tap(, ) + * @returns Object + * @short Runs and returns . + * @extra A string can also be used as a shortcut to a method. This method is used to run an intermediary function in the middle of method chaining. As a standalone method on the Object class it doesn't have too much use. The power of %tap% comes when using extended objects or modifying the Object prototype with Object.extend(). + * @example + * + * Object.extend(); + * [2,4,6].map(Math.exp).tap(function(){ arr.pop(); }).map(Math.round); -> [7,55] + * [2,4,6].map(Math.exp).tap('pop').map(Math.round); -> [7,55] + * + ***/ + 'tap': function(obj, arg) { + var fn = arg; + if(!isFunction(arg)) { + fn = function() { + if(arg) obj[arg](); + } + } + fn.call(obj, obj); + return obj; + }, + + /*** + * @method has(, ) + * @returns Boolean + * @short Checks if has using hasOwnProperty from Object.prototype. + * @extra This method is considered safer than %Object#hasOwnProperty% when using objects as hashes. See http://www.devthought.com/2012/01/18/an-object-is-not-a-hash/ for more. + * @example + * + * Object.has({ foo: 'bar' }, 'foo') -> true + * Object.has({ foo: 'bar' }, 'baz') -> false + * Object.has({ hasOwnProperty: true }, 'foo') -> false + * + ***/ + 'has': function (obj, key) { + return hasOwnProperty(obj, key); + } + + }); + + + buildTypeMethods(); + buildObjectExtend(); + buildObjectInstanceMethods(ObjectHashMethods, Hash); + + + /*** + * @package RegExp + * @dependency core + * @description Escaping regexes and manipulating their flags. + * + * Note here that methods on the RegExp class like .exec and .test will fail in the current version of SpiderMonkey being + * used by CouchDB when using shorthand regex notation like /foo/. This is the reason for the intermixed use of shorthand + * and compiled regexes here. If you're using JS in CouchDB, it is safer to ALWAYS compile your regexes from a string. + * + ***/ + + function uniqueRegExpFlags(flags) { + return flags.split('').sort().join('').replace(/([gimy])\1+/g, '$1'); + } + + extend(regexp, false, false, { + + /*** + * @method RegExp.escape( = '') + * @returns String + * @short Escapes all RegExp tokens in a string. + * @example + * + * RegExp.escape('really?') -> 'really\?' + * RegExp.escape('yes.') -> 'yes\.' + * RegExp.escape('(not really)') -> '\(not really\)' + * + ***/ + 'escape': function(str) { + return escapeRegExp(str); + } + + }); + + extend(regexp, true, false, { + + /*** + * @method getFlags() + * @returns String + * @short Returns the flags of the regex as a string. + * @example + * + * /texty/gim.getFlags('testy') -> 'gim' + * + ***/ + 'getFlags': function() { + return getRegExpFlags(this); + }, + + /*** + * @method setFlags() + * @returns RegExp + * @short Sets the flags on a regex and retuns a copy. + * @example + * + * /texty/.setFlags('gim') -> now has global, ignoreCase, and multiline set + * + ***/ + 'setFlags': function(flags) { + return regexp(this.source, flags); + }, + + /*** + * @method addFlag() + * @returns RegExp + * @short Adds to the regex. + * @example + * + * /texty/.addFlag('g') -> now has global flag set + * + ***/ + 'addFlag': function(flag) { + return this.setFlags(getRegExpFlags(this, flag)); + }, + + /*** + * @method removeFlag() + * @returns RegExp + * @short Removes from the regex. + * @example + * + * /texty/g.removeFlag('g') -> now has global flag removed + * + ***/ + 'removeFlag': function(flag) { + return this.setFlags(getRegExpFlags(this).replace(flag, '')); + } + + }); + + + + /*** + * @package String + * @dependency core + * @description String manupulation, escaping, encoding, truncation, and:conversion. + * + ***/ + + function getAcronym(word) { + var inflector = string.Inflector; + var word = inflector && inflector.acronyms[word]; + if(isString(word)) { + return word; + } + } + + function padString(str, p, left, right) { + var padding = string(p); + if(padding != p) { + padding = ''; + } + if(!isNumber(left)) left = 1; + if(!isNumber(right)) right = 1; + return padding.repeat(left) + str + padding.repeat(right); + } + + function chr(num) { + return string.fromCharCode(num); + } + + var btoa, atob; + + function buildBase64(key) { + if(this.btoa) { + btoa = this.btoa; + atob = this.atob; + return; + } + var base64reg = /[^A-Za-z0-9\+\/\=]/g; + btoa = function(str) { + var output = ''; + var chr1, chr2, chr3; + var enc1, enc2, enc3, enc4; + var i = 0; + do { + chr1 = str.charCodeAt(i++); + chr2 = str.charCodeAt(i++); + chr3 = str.charCodeAt(i++); + enc1 = chr1 >> 2; + enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); + enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); + enc4 = chr3 & 63; + if (isNaN(chr2)) { + enc3 = enc4 = 64; + } else if (isNaN(chr3)) { + enc4 = 64; + } + output = output + key.charAt(enc1) + key.charAt(enc2) + key.charAt(enc3) + key.charAt(enc4); + chr1 = chr2 = chr3 = ''; + enc1 = enc2 = enc3 = enc4 = ''; + } while (i < str.length); + return output; + } + atob = function(input) { + var output = ''; + var chr1, chr2, chr3; + var enc1, enc2, enc3, enc4; + var i = 0; + if(input.match(base64reg)) { + throw new Error('String contains invalid base64 characters'); + } + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ''); + do { + enc1 = key.indexOf(input.charAt(i++)); + enc2 = key.indexOf(input.charAt(i++)); + enc3 = key.indexOf(input.charAt(i++)); + enc4 = key.indexOf(input.charAt(i++)); + chr1 = (enc1 << 2) | (enc2 >> 4); + chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); + chr3 = ((enc3 & 3) << 6) | enc4; + output = output + chr(chr1); + if (enc3 != 64) { + output = output + chr(chr2); + } + if (enc4 != 64) { + output = output + chr(chr3); + } + chr1 = chr2 = chr3 = ''; + enc1 = enc2 = enc3 = enc4 = ''; + } while (i < input.length); + return output; + } + } + + + + extend(string, true, false, { + + /*** + * @method escapeRegExp() + * @returns String + * @short Escapes all RegExp tokens in the string. + * @example + * + * 'really?'.escapeRegExp() -> 'really\?' + * 'yes.'.escapeRegExp() -> 'yes\.' + * '(not really)'.escapeRegExp() -> '\(not really\)' + * + ***/ + 'escapeRegExp': function() { + return escapeRegExp(this); + }, + + /*** + * @method escapeURL([param] = false) + * @returns String + * @short Escapes characters in a string to make a valid URL. + * @extra If [param] is true, it will also escape valid URL characters for use as a URL parameter. + * @example + * + * 'http://foo.com/"bar"'.escapeURL() -> 'http://foo.com/%22bar%22' + * 'http://foo.com/"bar"'.escapeURL(true) -> 'http%3A%2F%2Ffoo.com%2F%22bar%22' + * + ***/ + 'escapeURL': function(param) { + return param ? encodeURIComponent(this) : encodeURI(this); + }, + + /*** + * @method unescapeURL([partial] = false) + * @returns String + * @short Restores escaped characters in a URL escaped string. + * @extra If [partial] is true, it will only unescape non-valid URL characters. [partial] is included here for completeness, but should very rarely be needed. + * @example + * + * 'http%3A%2F%2Ffoo.com%2Fthe%20bar'.unescapeURL() -> 'http://foo.com/the bar' + * 'http%3A%2F%2Ffoo.com%2Fthe%20bar'.unescapeURL(true) -> 'http%3A%2F%2Ffoo.com%2Fthe bar' + * + ***/ + 'unescapeURL': function(param) { + return param ? decodeURI(this) : decodeURIComponent(this); + }, + + /*** + * @method escapeHTML() + * @returns String + * @short Converts HTML characters to their entity equivalents. + * @example + * + * '

            some text

            '.escapeHTML() -> '<p>some text</p>' + * 'one & two'.escapeHTML() -> 'one & two' + * + ***/ + 'escapeHTML': function() { + return this.replace(/&/g, '&').replace(//g, '>'); + }, + + /*** + * @method unescapeHTML([partial] = false) + * @returns String + * @short Restores escaped HTML characters. + * @example + * + * '<p>some text</p>'.unescapeHTML() -> '

            some text

            ' + * 'one & two'.unescapeHTML() -> 'one & two' + * + ***/ + 'unescapeHTML': function() { + return this.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); + }, + + /*** + * @method encodeBase64() + * @returns String + * @short Encodes the string into base64 encoding. + * @extra This method wraps the browser native %btoa% when available, and uses a custom implementation when not available. + * @example + * + * 'gonna get encoded!'.encodeBase64() -> 'Z29ubmEgZ2V0IGVuY29kZWQh' + * 'http://twitter.com/'.encodeBase64() -> 'aHR0cDovL3R3aXR0ZXIuY29tLw==' + * + ***/ + 'encodeBase64': function() { + return btoa(this); + }, + + /*** + * @method decodeBase64() + * @returns String + * @short Decodes the string from base64 encoding. + * @extra This method wraps the browser native %atob% when available, and uses a custom implementation when not available. + * @example + * + * 'aHR0cDovL3R3aXR0ZXIuY29tLw=='.decodeBase64() -> 'http://twitter.com/' + * 'anVzdCBnb3QgZGVjb2RlZA=='.decodeBase64() -> 'just got decoded!' + * + ***/ + 'decodeBase64': function() { + return atob(this); + }, + + /*** + * @method each([search] = single character, [fn]) + * @returns Array + * @short Runs callback [fn] against each occurence of [search]. + * @extra Returns an array of matches. [search] may be either a string or regex, and defaults to every character in the string. + * @example + * + * 'jumpy'.each() -> ['j','u','m','p','y'] + * 'jumpy'.each(/[r-z]/) -> ['u','y'] + * 'jumpy'.each(/[r-z]/, function(m) { + * // Called twice: "u", "y" + * }); + * + ***/ + 'each': function(search, fn) { + var match, i; + if(isFunction(search)) { + fn = search; + search = /[\s\S]/g; + } else if(!search) { + search = /[\s\S]/g + } else if(isString(search)) { + search = regexp(escapeRegExp(search), 'gi'); + } else if(isRegExp(search)) { + search = regexp(search.source, getRegExpFlags(search, 'g')); + } + match = this.match(search) || []; + if(fn) { + for(i = 0; i < match.length; i++) { + match[i] = fn.call(this, match[i], i, match) || match[i]; + } + } + return match; + }, + + /*** + * @method shift() + * @returns Array + * @short Shifts each character in the string places in the character map. + * @example + * + * 'a'.shift(1) -> 'b' + * 'ク'.shift(1) -> 'グ' + * + ***/ + 'shift': function(n) { + var result = ''; + n = n || 0; + this.codes(function(c) { + result += chr(c + n); + }); + return result; + }, + + /*** + * @method codes([fn]) + * @returns Array + * @short Runs callback [fn] against each character code in the string. Returns an array of character codes. + * @example + * + * 'jumpy'.codes() -> [106,117,109,112,121] + * 'jumpy'.codes(function(c) { + * // Called 5 times: 106, 117, 109, 112, 121 + * }); + * + ***/ + 'codes': function(fn) { + var codes = []; + for(var i=0; i ['j','u','m','p','y'] + * 'jumpy'.chars(function(c) { + * // Called 5 times: "j","u","m","p","y" + * }); + * + ***/ + 'chars': function(fn) { + return this.each(fn); + }, + + /*** + * @method words([fn]) + * @returns Array + * @short Runs callback [fn] against each word in the string. Returns an array of words. + * @extra A "word" here is defined as any sequence of non-whitespace characters. + * @example + * + * 'broken wear'.words() -> ['broken','wear'] + * 'broken wear'.words(function(w) { + * // Called twice: "broken", "wear" + * }); + * + ***/ + 'words': function(fn) { + return this.trim().each(/\S+/g, fn); + }, + + /*** + * @method lines([fn]) + * @returns Array + * @short Runs callback [fn] against each line in the string. Returns an array of lines. + * @example + * + * 'broken wear\nand\njumpy jump'.lines() -> ['broken wear','and','jumpy jump'] + * 'broken wear\nand\njumpy jump'.lines(function(l) { + * // Called three times: "broken wear", "and", "jumpy jump" + * }); + * + ***/ + 'lines': function(fn) { + return this.trim().each(/^.*$/gm, fn); + }, + + /*** + * @method paragraphs([fn]) + * @returns Array + * @short Runs callback [fn] against each paragraph in the string. Returns an array of paragraphs. + * @extra A paragraph here is defined as a block of text bounded by two or more line breaks. + * @example + * + * 'Once upon a time.\n\nIn the land of oz...'.paragraphs() -> ['Once upon a time.','In the land of oz...'] + * 'Once upon a time.\n\nIn the land of oz...'.paragraphs(function(p) { + * // Called twice: "Once upon a time.", "In teh land of oz..." + * }); + * + ***/ + 'paragraphs': function(fn) { + var paragraphs = this.trim().split(/[\r\n]{2,}/); + paragraphs = paragraphs.map(function(p) { + if(fn) var s = fn.call(p); + return s ? s : p; + }); + return paragraphs; + }, + + /*** + * @method startsWith(, [case] = true) + * @returns Boolean + * @short Returns true if the string starts with . + * @extra may be either a string or regex. Case sensitive if [case] is true. + * @example + * + * 'hello'.startsWith('hell') -> true + * 'hello'.startsWith(/[a-h]/) -> true + * 'hello'.startsWith('HELL') -> false + * 'hello'.startsWith('HELL', false) -> true + * + ***/ + 'startsWith': function(reg, c) { + if(isUndefined(c)) c = true; + var source = isRegExp(reg) ? reg.source.replace('^', '') : escapeRegExp(reg); + return regexp('^' + source, c ? '' : 'i').test(this); + }, + + /*** + * @method endsWith(, [case] = true) + * @returns Boolean + * @short Returns true if the string ends with . + * @extra may be either a string or regex. Case sensitive if [case] is true. + * @example + * + * 'jumpy'.endsWith('py') -> true + * 'jumpy'.endsWith(/[q-z]/) -> true + * 'jumpy'.endsWith('MPY') -> false + * 'jumpy'.endsWith('MPY', false) -> true + * + ***/ + 'endsWith': function(reg, c) { + if(isUndefined(c)) c = true; + var source = isRegExp(reg) ? reg.source.replace('$', '') : escapeRegExp(reg); + return regexp(source + '$', c ? '' : 'i').test(this); + }, + + /*** + * @method isBlank() + * @returns Boolean + * @short Returns true if the string has a length of 0 or contains only whitespace. + * @example + * + * ''.isBlank() -> true + * ' '.isBlank() -> true + * 'noway'.isBlank() -> false + * + ***/ + 'isBlank': function() { + return this.trim().length === 0; + }, + + /*** + * @method has() + * @returns Boolean + * @short Returns true if the string matches . + * @extra may be a string or regex. + * @example + * + * 'jumpy'.has('py') -> true + * 'broken'.has(/[a-n]/) -> true + * 'broken'.has(/[s-z]/) -> false + * + ***/ + 'has': function(find) { + return this.search(isRegExp(find) ? find : escapeRegExp(find)) !== -1; + }, + + + /*** + * @method add(, [index] = length) + * @returns String + * @short Adds at [index]. Negative values are also allowed. + * @extra %insert% is provided as an alias, and is generally more readable when using an index. + * @example + * + * 'schfifty'.add(' five') -> schfifty five + * 'dopamine'.insert('e', 3) -> dopeamine + * 'spelling eror'.insert('r', -3) -> spelling error + * + ***/ + 'add': function(str, index) { + index = isUndefined(index) ? this.length : index; + return this.slice(0, index) + str + this.slice(index); + }, + + /*** + * @method remove() + * @returns String + * @short Removes any part of the string that matches . + * @extra can be a string or a regex. + * @example + * + * 'schfifty five'.remove('f') -> 'schity ive' + * 'schfifty five'.remove(/[a-f]/g) -> 'shity iv' + * + ***/ + 'remove': function(f) { + return this.replace(f, ''); + }, + + /*** + * @method reverse() + * @returns String + * @short Reverses the string. + * @example + * + * 'jumpy'.reverse() -> 'ypmuj' + * 'lucky charms'.reverse() -> 'smrahc ykcul' + * + ***/ + 'reverse': function() { + return this.split('').reverse().join(''); + }, + + /*** + * @method compact() + * @returns String + * @short Compacts all white space in the string to a single space and trims the ends. + * @example + * + * 'too \n much \n space'.compact() -> 'too much space' + * 'enough \n '.compact() -> 'enought' + * + ***/ + 'compact': function() { + return this.trim().replace(/([\r\n\s ])+/g, function(match, whitespace){ + return whitespace === ' ' ? whitespace : ' '; + }); + }, + + /*** + * @method at(, [loop] = true) + * @returns String or Array + * @short Gets the character(s) at a given index. + * @extra When [loop] is true, overshooting the end of the string (or the beginning) will begin counting from the other end. As an alternate syntax, passing multiple indexes will get the characters at those indexes. + * @example + * + * 'jumpy'.at(0) -> 'j' + * 'jumpy'.at(2) -> 'm' + * 'jumpy'.at(5) -> 'j' + * 'jumpy'.at(5, false) -> '' + * 'jumpy'.at(-1) -> 'y' + * 'lucky charms'.at(2,4,6,8) -> ['u','k','y',c'] + * + ***/ + 'at': function() { + return entryAtIndex(this, arguments, true); + }, + + /*** + * @method from([index] = 0) + * @returns String + * @short Returns a section of the string starting from [index]. + * @example + * + * 'lucky charms'.from() -> 'lucky charms' + * 'lucky charms'.from(7) -> 'harms' + * + ***/ + 'from': function(num) { + return this.slice(num); + }, + + /*** + * @method to([index] = end) + * @returns String + * @short Returns a section of the string ending at [index]. + * @example + * + * 'lucky charms'.to() -> 'lucky charms' + * 'lucky charms'.to(7) -> 'lucky ch' + * + ***/ + 'to': function(num) { + if(isUndefined(num)) num = this.length; + return this.slice(0, num); + }, + + /*** + * @method dasherize() + * @returns String + * @short Converts underscores and camel casing to hypens. + * @example + * + * 'a_farewell_to_arms'.dasherize() -> 'a-farewell-to-arms' + * 'capsLock'.dasherize() -> 'caps-lock' + * + ***/ + 'dasherize': function() { + return this.underscore().replace(/_/g, '-'); + }, + + /*** + * @method underscore() + * @returns String + * @short Converts hyphens and camel casing to underscores. + * @example + * + * 'a-farewell-to-arms'.underscore() -> 'a_farewell_to_arms' + * 'capsLock'.underscore() -> 'caps_lock' + * + ***/ + 'underscore': function() { + return this + .replace(/[-\s]+/g, '_') + .replace(string.Inflector && string.Inflector.acronymRegExp, function(acronym, index) { + return (index > 0 ? '_' : '') + acronym.toLowerCase(); + }) + .replace(/([A-Z\d]+)([A-Z][a-z])/g,'$1_$2') + .replace(/([a-z\d])([A-Z])/g,'$1_$2') + .toLowerCase(); + }, + + /*** + * @method camelize([first] = true) + * @returns String + * @short Converts underscores and hyphens to camel case. If [first] is true the first letter will also be capitalized. + * @extra If the Inflections package is included acryonyms can also be defined that will be used when camelizing. + * @example + * + * 'caps_lock'.camelize() -> 'CapsLock' + * 'moz-border-radius'.camelize() -> 'MozBorderRadius' + * 'moz-border-radius'.camelize(false) -> 'mozBorderRadius' + * + ***/ + 'camelize': function(first) { + return this.underscore().replace(/(^|_)([^_]+)/g, function(match, pre, word, index) { + var acronym = getAcronym(word), capitalize = first !== false || index > 0; + if(acronym) return capitalize ? acronym : acronym.toLowerCase(); + return capitalize ? word.capitalize() : word; + }); + }, + + /*** + * @method spacify() + * @returns String + * @short Converts camel case, underscores, and hyphens to a properly spaced string. + * @example + * + * 'camelCase'.spacify() -> 'camel case' + * 'an-ugly-string'.spacify() -> 'an ugly string' + * 'oh-no_youDid-not'.spacify().capitalize(true) -> 'something else' + * + ***/ + 'spacify': function() { + return this.underscore().replace(/_/g, ' '); + }, + + /*** + * @method stripTags([tag1], [tag2], ...) + * @returns String + * @short Strips all HTML tags from the string. + * @extra Tags to strip may be enumerated in the parameters, otherwise will strip all. + * @example + * + * '

            just some text

            '.stripTags() -> 'just some text' + * '

            just some text

            '.stripTags('p') -> 'just some text' + * + ***/ + 'stripTags': function() { + var str = this, args = arguments.length > 0 ? arguments : ['']; + multiArgs(args, function(tag) { + str = str.replace(regexp('<\/?' + escapeRegExp(tag) + '[^<>]*>', 'gi'), ''); + }); + return str; + }, + + /*** + * @method removeTags([tag1], [tag2], ...) + * @returns String + * @short Removes all HTML tags and their contents from the string. + * @extra Tags to remove may be enumerated in the parameters, otherwise will remove all. + * @example + * + * '

            just some text

            '.removeTags() -> '' + * '

            just some text

            '.removeTags('b') -> '

            just text

            ' + * + ***/ + 'removeTags': function() { + var str = this, args = arguments.length > 0 ? arguments : ['\\S+']; + multiArgs(args, function(t) { + var reg = regexp('<(' + t + ')[^<>]*(?:\\/>|>.*?<\\/\\1>)', 'gi'); + str = str.replace(reg, ''); + }); + return str; + }, + + /*** + * @method truncate(, [split] = true, [from] = 'right', [ellipsis] = '...') + * @returns Object + * @short Truncates a string. + * @extra If [split] is %false%, will not split words up, and instead discard the word where the truncation occurred. [from] can also be %"middle"% or %"left"%. + * @example + * + * 'just sittin on the dock of the bay'.truncate(20) -> 'just sittin on the do...' + * 'just sittin on the dock of the bay'.truncate(20, false) -> 'just sittin on the...' + * 'just sittin on the dock of the bay'.truncate(20, true, 'middle') -> 'just sitt...of the bay' + * 'just sittin on the dock of the bay'.truncate(20, true, 'left') -> '...the dock of the bay' + * + ***/ + 'truncate': function(length, split, from, ellipsis) { + var pos, + prepend = '', + append = '', + str = this.toString(), + chars = '[' + getTrimmableCharacters() + ']+', + space = '[^' + getTrimmableCharacters() + ']*', + reg = regexp(chars + space + '$'); + ellipsis = isUndefined(ellipsis) ? '...' : string(ellipsis); + if(str.length <= length) { + return str; + } + switch(from) { + case 'left': + pos = str.length - length; + prepend = ellipsis; + str = str.slice(pos); + reg = regexp('^' + space + chars); + break; + case 'middle': + pos = floor(length / 2); + append = ellipsis + str.slice(str.length - pos).trimLeft(); + str = str.slice(0, pos); + break; + default: + pos = length; + append = ellipsis; + str = str.slice(0, pos); + } + if(split === false && this.slice(pos, pos + 1).match(/\S/)) { + str = str.remove(reg); + } + return prepend + str + append; + }, + + /*** + * @method pad[Side]( = '', [num] = 1) + * @returns String + * @short Pads either/both sides of the string. + * @extra [num] is the number of characters on each side, and [padding] is the character to pad with. + * + * @set + * pad + * padLeft + * padRight + * + * @example + * + * 'wasabi'.pad('-') -> '-wasabi-' + * 'wasabi'.pad('-', 2) -> '--wasabi--' + * 'wasabi'.padLeft('-', 2) -> '--wasabi' + * 'wasabi'.padRight('-', 2) -> 'wasabi--' + * + ***/ + 'pad': function(padding, num) { + return repeatString(num, padding) + this + repeatString(num, padding); + }, + + 'padLeft': function(padding, num) { + return repeatString(num, padding) + this; + }, + + 'padRight': function(padding, num) { + return this + repeatString(num, padding); + }, + + /*** + * @method first([n] = 1) + * @returns String + * @short Returns the first [n] characters of the string. + * @example + * + * 'lucky charms'.first() -> 'l' + * 'lucky charms'.first(3) -> 'luc' + * + ***/ + 'first': function(num) { + if(isUndefined(num)) num = 1; + return this.substr(0, num); + }, + + /*** + * @method last([n] = 1) + * @returns String + * @short Returns the last [n] characters of the string. + * @example + * + * 'lucky charms'.last() -> 's' + * 'lucky charms'.last(3) -> 'rms' + * + ***/ + 'last': function(num) { + if(isUndefined(num)) num = 1; + var start = this.length - num < 0 ? 0 : this.length - num; + return this.substr(start); + }, + + /*** + * @method repeat([num] = 0) + * @returns String + * @short Returns the string repeated [num] times. + * @example + * + * 'jumpy'.repeat(2) -> 'jumpyjumpy' + * 'a'.repeat(5) -> 'aaaaa' + * + ***/ + 'repeat': function(num) { + var str = '', i = 0; + if(isNumber(num) && num > 0) { + while(i < num) { + str += this; + i++; + } + } + return str; + }, + + /*** + * @method toNumber([base] = 10) + * @returns Number + * @short Converts the string into a number. + * @extra Any value with a "." fill be converted to a floating point value, otherwise an integer. + * @example + * + * '153'.toNumber() -> 153 + * '12,000'.toNumber() -> 12000 + * '10px'.toNumber() -> 10 + * 'ff'.toNumber(16) -> 255 + * + ***/ + 'toNumber': function(base) { + var str = this.replace(/,/g, ''); + return str.match(/\./) ? parseFloat(str) : parseInt(str, base || 10); + }, + + /*** + * @method capitalize([all] = false) + * @returns String + * @short Capitalizes the first character in the string. + * @extra If [all] is true, all words in the string will be capitalized. + * @example + * + * 'hello'.capitalize() -> 'Hello' + * 'hello kitty'.capitalize() -> 'Hello kitty' + * 'hello kitty'.capitalize(true) -> 'Hello Kitty' + * + * + ***/ + 'capitalize': function(all) { + var lastResponded; + return this.toLowerCase().replace(all ? /[\s\S]/g : /^\S/, function(lower) { + var upper = lower.toUpperCase(), result; + result = lastResponded ? lower : upper; + lastResponded = upper !== lower; + return result; + }); + }, + + /*** + * @method assign(, , ...) + * @returns String + * @short Assigns variables to tokens in a string. + * @extra If an object is passed, it's properties can be assigned using the object's keys. If a non-object (string, number, etc.) is passed it can be accessed by the argument number beginning with 1 (as with regex tokens). Multiple objects can be passed and will be merged together (original objects are unaffected). + * @example + * + * 'Welcome, Mr. {name}.'.assign({ name: 'Franklin' }) -> 'Welcome, Mr. Franklin.' + * 'You are {1} years old today.'.assign(14) -> 'You are 14 years old today.' + * '{n} and {r}'.assign({ n: 'Cheech' }, { r: 'Chong' }) -> 'Cheech and Chong' + * + ***/ + 'assign': function() { + var assign = {}; + multiArgs(arguments, function(a, i) { + if(isObject(a)) { + simpleMerge(assign, a); + } else { + assign[i + 1] = a; + } + }); + return this.replace(/\{([^{]+?)\}/g, function(m, key) { + return hasOwnProperty(assign, key) ? assign[key] : m; + }); + }, + + /*** + * @method namespace([init] = global) + * @returns Mixed + * @short Finds the namespace or property indicated by the string. + * @extra [init] can be passed to provide a starting context, otherwise the global context will be used. If any level returns a falsy value, that will be the final result. + * @example + * + * 'Path.To.Namespace'.namespace() -> Path.To.Namespace + * '$.fn'.namespace() -> $.fn + * + ***/ + 'namespace': function(context) { + context = context || globalContext; + iterateOverObject(this.split('.'), function(i,s) { + return !!(context = context[s]); + }); + return context; + } + + }); + + + // Aliases + + extend(string, true, false, { + + /*** + * @method insert() + * @alias add + * + ***/ + 'insert': string.prototype.add + }); + + buildBase64('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='); + + + /*** + * + * @package Inflections + * @dependency string + * @description Pluralization similar to ActiveSupport including uncountable words and acronyms. Humanized and URL-friendly strings. + * + ***/ + + /*** + * String module + * + ***/ + + + var plurals = [], + singulars = [], + uncountables = [], + humans = [], + acronyms = {}, + Downcased, + Inflector; + + function removeFromArray(arr, find) { + var index = arr.indexOf(find); + if(index > -1) { + arr.splice(index, 1); + } + } + + function removeFromUncountablesAndAddTo(arr, rule, replacement) { + if(isString(rule)) { + removeFromArray(uncountables, rule); + } + removeFromArray(uncountables, replacement); + arr.unshift({ rule: rule, replacement: replacement }) + } + + function paramMatchesType(param, type) { + return param == type || param == 'all' || !param; + } + + function isUncountable(word) { + return uncountables.some(function(uncountable) { + return new regexp('\\b' + uncountable + '$', 'i').test(word); + }); + } + + function inflect(word, pluralize) { + word = isString(word) ? word.toString() : ''; + if(word.isBlank() || isUncountable(word)) { + return word; + } else { + return runReplacements(word, pluralize ? plurals : singulars); + } + } + + function runReplacements(word, table) { + iterateOverObject(table, function(i, inflection) { + if(word.match(inflection.rule)) { + word = word.replace(inflection.rule, inflection.replacement); + return false; + } + }); + return word; + } + + function capitalize(word) { + return word.replace(/^\W*[a-z]/, function(w){ + return w.toUpperCase(); + }); + } + + Inflector = { + + /* + * Specifies a new acronym. An acronym must be specified as it will appear in a camelized string. An underscore + * string that contains the acronym will retain the acronym when passed to %camelize%, %humanize%, or %titleize%. + * A camelized string that contains the acronym will maintain the acronym when titleized or humanized, and will + * convert the acronym into a non-delimited single lowercase word when passed to String#underscore. + * + * Examples: + * String.Inflector.acronym('HTML') + * 'html'.titleize() -> 'HTML' + * 'html'.camelize() -> 'HTML' + * 'MyHTML'.underscore() -> 'my_html' + * + * The acronym, however, must occur as a delimited unit and not be part of another word for conversions to recognize it: + * + * String.Inflector.acronym('HTTP') + * 'my_http_delimited'.camelize() -> 'MyHTTPDelimited' + * 'https'.camelize() -> 'Https', not 'HTTPs' + * 'HTTPS'.underscore() -> 'http_s', not 'https' + * + * String.Inflector.acronym('HTTPS') + * 'https'.camelize() -> 'HTTPS' + * 'HTTPS'.underscore() -> 'https' + * + * Note: Acronyms that are passed to %pluralize% will no longer be recognized, since the acronym will not occur as + * a delimited unit in the pluralized result. To work around this, you must specify the pluralized form as an + * acronym as well: + * + * String.Inflector.acronym('API') + * 'api'.pluralize().camelize() -> 'Apis' + * + * String.Inflector.acronym('APIs') + * 'api'.pluralize().camelize() -> 'APIs' + * + * %acronym% may be used to specify any word that contains an acronym or otherwise needs to maintain a non-standard + * capitalization. The only restriction is that the word must begin with a capital letter. + * + * Examples: + * String.Inflector.acronym('RESTful') + * 'RESTful'.underscore() -> 'restful' + * 'RESTfulController'.underscore() -> 'restful_controller' + * 'RESTfulController'.titleize() -> 'RESTful Controller' + * 'restful'.camelize() -> 'RESTful' + * 'restful_controller'.camelize() -> 'RESTfulController' + * + * String.Inflector.acronym('McDonald') + * 'McDonald'.underscore() -> 'mcdonald' + * 'mcdonald'.camelize() -> 'McDonald' + */ + 'acronym': function(word) { + acronyms[word.toLowerCase()] = word; + var all = object.keys(acronyms).map(function(key) { + return acronyms[key]; + }); + Inflector.acronymRegExp = regexp(all.join('|'), 'g'); + }, + + /* + * Specifies a new pluralization rule and its replacement. The rule can either be a string or a regular expression. + * The replacement should always be a string that may include references to the matched data from the rule. + */ + 'plural': function(rule, replacement) { + removeFromUncountablesAndAddTo(plurals, rule, replacement); + }, + + /* + * Specifies a new singularization rule and its replacement. The rule can either be a string or a regular expression. + * The replacement should always be a string that may include references to the matched data from the rule. + */ + 'singular': function(rule, replacement) { + removeFromUncountablesAndAddTo(singulars, rule, replacement); + }, + + /* + * Specifies a new irregular that applies to both pluralization and singularization at the same time. This can only be used + * for strings, not regular expressions. You simply pass the irregular in singular and plural form. + * + * Examples: + * String.Inflector.irregular('octopus', 'octopi') + * String.Inflector.irregular('person', 'people') + */ + 'irregular': function(singular, plural) { + var singularFirst = singular.first(), + singularRest = singular.from(1), + pluralFirst = plural.first(), + pluralRest = plural.from(1), + pluralFirstUpper = pluralFirst.toUpperCase(), + pluralFirstLower = pluralFirst.toLowerCase(), + singularFirstUpper = singularFirst.toUpperCase(), + singularFirstLower = singularFirst.toLowerCase(); + removeFromArray(uncountables, singular); + removeFromArray(uncountables, plural); + if(singularFirstUpper == pluralFirstUpper) { + Inflector.plural(new regexp('({1}){2}$'.assign(singularFirst, singularRest), 'i'), '$1' + pluralRest); + Inflector.plural(new regexp('({1}){2}$'.assign(pluralFirst, pluralRest), 'i'), '$1' + pluralRest); + Inflector.singular(new regexp('({1}){2}$'.assign(pluralFirst, pluralRest), 'i'), '$1' + singularRest); + } else { + Inflector.plural(new regexp('{1}{2}$'.assign(singularFirstUpper, singularRest)), pluralFirstUpper + pluralRest); + Inflector.plural(new regexp('{1}{2}$'.assign(singularFirstLower, singularRest)), pluralFirstLower + pluralRest); + Inflector.plural(new regexp('{1}{2}$'.assign(pluralFirstUpper, pluralRest)), pluralFirstUpper + pluralRest); + Inflector.plural(new regexp('{1}{2}$'.assign(pluralFirstLower, pluralRest)), pluralFirstLower + pluralRest); + Inflector.singular(new regexp('{1}{2}$'.assign(pluralFirstUpper, pluralRest)), singularFirstUpper + singularRest); + Inflector.singular(new regexp('{1}{2}$'.assign(pluralFirstLower, pluralRest)), singularFirstLower + singularRest); + } + }, + + /* + * Add uncountable words that shouldn't be attempted inflected. + * + * Examples: + * String.Inflector.uncountable('money') + * String.Inflector.uncountable('money', 'information') + * String.Inflector.uncountable(['money', 'information', 'rice']) + */ + 'uncountable': function(first) { + var add = array.isArray(first) ? first : multiArgs(arguments); + uncountables = uncountables.concat(add); + }, + + /* + * Specifies a humanized form of a string by a regular expression rule or by a string mapping. + * When using a regular expression based replacement, the normal humanize formatting is called after the replacement. + * When a string is used, the human form should be specified as desired (example: 'The name', not 'the_name') + * + * Examples: + * String.Inflector.human(/_cnt$/i, '_count') + * String.Inflector.human('legacy_col_person_name', 'Name') + */ + 'human': function(rule, replacement) { + humans.unshift({ rule: rule, replacement: replacement }) + }, + + + /* + * Clears the loaded inflections within a given scope (default is 'all'). + * Options are: 'all', 'plurals', 'singulars', 'uncountables', 'humans'. + * + * Examples: + * String.Inflector.clear('all') + * String.Inflector.clear('plurals') + */ + 'clear': function(type) { + if(paramMatchesType(type, 'singulars')) singulars = []; + if(paramMatchesType(type, 'plurals')) plurals = []; + if(paramMatchesType(type, 'uncountables')) uncountables = []; + if(paramMatchesType(type, 'humans')) humans = []; + if(paramMatchesType(type, 'acronyms')) acronyms = {}; + } + + }; + + Downcased = [ + 'and', 'or', 'nor', 'a', 'an', 'the', 'so', 'but', 'to', 'of', 'at', + 'by', 'from', 'into', 'on', 'onto', 'off', 'out', 'in', 'over', + 'with', 'for' + ]; + + Inflector.plural(/$/, 's'); + Inflector.plural(/s$/gi, 's'); + Inflector.plural(/(ax|test)is$/gi, '$1es'); + Inflector.plural(/(octop|vir|fung|foc|radi|alumn)(i|us)$/gi, '$1i'); + Inflector.plural(/(census|alias|status)$/gi, '$1es'); + Inflector.plural(/(bu)s$/gi, '$1ses'); + Inflector.plural(/(buffal|tomat)o$/gi, '$1oes'); + Inflector.plural(/([ti])um$/gi, '$1a'); + Inflector.plural(/([ti])a$/gi, '$1a'); + Inflector.plural(/sis$/gi, 'ses'); + Inflector.plural(/f+e?$/gi, 'ves'); + Inflector.plural(/(cuff|roof)$/gi, '$1s'); + Inflector.plural(/([ht]ive)$/gi, '$1s'); + Inflector.plural(/([^aeiouy]o)$/gi, '$1es'); + Inflector.plural(/([^aeiouy]|qu)y$/gi, '$1ies'); + Inflector.plural(/(x|ch|ss|sh)$/gi, '$1es'); + Inflector.plural(/(matr|vert|ind)(?:ix|ex)$/gi, '$1ices'); + Inflector.plural(/([ml])ouse$/gi, '$1ice'); + Inflector.plural(/([ml])ice$/gi, '$1ice'); + Inflector.plural(/^(ox)$/gi, '$1en'); + Inflector.plural(/^(oxen)$/gi, '$1'); + Inflector.plural(/(quiz)$/gi, '$1zes'); + Inflector.plural(/(phot|cant|hom|zer|pian|portic|pr|quart|kimon)o$/gi, '$1os'); + Inflector.plural(/(craft)$/gi, '$1'); + Inflector.plural(/([ft])[eo]{2}(th?)$/gi, '$1ee$2'); + + Inflector.singular(/s$/gi, ''); + Inflector.singular(/([pst][aiu]s)$/gi, '$1'); + Inflector.singular(/([aeiouy])ss$/gi, '$1ss'); + Inflector.singular(/(n)ews$/gi, '$1ews'); + Inflector.singular(/([ti])a$/gi, '$1um'); + Inflector.singular(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/gi, '$1$2sis'); + Inflector.singular(/(^analy)ses$/gi, '$1sis'); + Inflector.singular(/(i)(f|ves)$/i, '$1fe'); + Inflector.singular(/([aeolr]f?)(f|ves)$/i, '$1f'); + Inflector.singular(/([ht]ive)s$/gi, '$1'); + Inflector.singular(/([^aeiouy]|qu)ies$/gi, '$1y'); + Inflector.singular(/(s)eries$/gi, '$1eries'); + Inflector.singular(/(m)ovies$/gi, '$1ovie'); + Inflector.singular(/(x|ch|ss|sh)es$/gi, '$1'); + Inflector.singular(/([ml])(ous|ic)e$/gi, '$1ouse'); + Inflector.singular(/(bus)(es)?$/gi, '$1'); + Inflector.singular(/(o)es$/gi, '$1'); + Inflector.singular(/(shoe)s?$/gi, '$1'); + Inflector.singular(/(cris|ax|test)[ie]s$/gi, '$1is'); + Inflector.singular(/(octop|vir|fung|foc|radi|alumn)(i|us)$/gi, '$1us'); + Inflector.singular(/(census|alias|status)(es)?$/gi, '$1'); + Inflector.singular(/^(ox)(en)?/gi, '$1'); + Inflector.singular(/(vert|ind)(ex|ices)$/gi, '$1ex'); + Inflector.singular(/(matr)(ix|ices)$/gi, '$1ix'); + Inflector.singular(/(quiz)(zes)?$/gi, '$1'); + Inflector.singular(/(database)s?$/gi, '$1'); + Inflector.singular(/ee(th?)$/gi, 'oo$1'); + + Inflector.irregular('person', 'people'); + Inflector.irregular('man', 'men'); + Inflector.irregular('child', 'children'); + Inflector.irregular('sex', 'sexes'); + Inflector.irregular('move', 'moves'); + Inflector.irregular('save', 'saves'); + Inflector.irregular('save', 'saves'); + Inflector.irregular('cow', 'kine'); + Inflector.irregular('goose', 'geese'); + Inflector.irregular('zombie', 'zombies'); + + Inflector.uncountable('equipment,information,rice,money,species,series,fish,sheep,jeans'.split(',')); + + + extend(string, true, false, { + + /*** + * @method pluralize() + * @returns String + * @short Returns the plural form of the word in the string. + * @example + * + * 'post'.pluralize() -> 'posts' + * 'octopus'.pluralize() -> 'octopi' + * 'sheep'.pluralize() -> 'sheep' + * 'words'.pluralize() -> 'words' + * 'CamelOctopus'.pluralize() -> 'CamelOctopi' + * + ***/ + 'pluralize': function() { + return inflect(this, true); + }, + + /*** + * @method singularize() + * @returns String + * @short The reverse of String#pluralize. Returns the singular form of a word in a string. + * @example + * + * 'posts'.singularize() -> 'post' + * 'octopi'.singularize() -> 'octopus' + * 'sheep'.singularize() -> 'sheep' + * 'word'.singularize() -> 'word' + * 'CamelOctopi'.singularize() -> 'CamelOctopus' + * + ***/ + 'singularize': function() { + return inflect(this, false); + }, + + /*** + * @method humanize() + * @returns String + * @short Creates a human readable string. + * @extra Capitalizes the first word and turns underscores into spaces and strips a trailing '_id', if any. Like String#titleize, this is meant for creating pretty output. + * @example + * + * 'employee_salary'.humanize() -> 'Employee salary' + * 'author_id'.humanize() -> 'Author' + * + ***/ + 'humanize': function() { + var str = runReplacements(this, humans); + str = str.replace(/_id$/g, ''); + str = str.replace(/(_)?([a-z\d]*)/gi, function(match, _, word){ + return (_ ? ' ' : '') + (acronyms[word] || word.toLowerCase()); + }); + return capitalize(str); + }, + + /*** + * @method titleize() + * @returns String + * @short Creates a title version of the string. + * @extra Capitalizes all the words and replaces some characters in the string to create a nicer looking title. String#titleize is meant for creating pretty output. + * @example + * + * 'man from the boondocks'.titleize() -> 'Man from the Boondocks' + * 'x-men: the last stand'.titleize() -> 'X Men: The Last Stand' + * 'TheManWithoutAPast'.titleize() -> 'The Man Without a Past' + * 'raiders_of_the_lost_ark'.titleize() -> 'Raiders of the Lost Ark' + * + ***/ + 'titleize': function() { + var fullStopPunctuation = /[.:;!]$/, hasPunctuation, lastHadPunctuation, isFirstOrLast; + return this.spacify().humanize().words(function(word, index, words) { + hasPunctuation = fullStopPunctuation.test(word); + isFirstOrLast = index == 0 || index == words.length - 1 || hasPunctuation || lastHadPunctuation; + lastHadPunctuation = hasPunctuation; + if(isFirstOrLast || Downcased.indexOf(word) === -1) { + return capitalize(word); + } else { + return word; + } + }).join(' '); + }, + + /*** + * @method parameterize() + * @returns String + * @short Replaces special characters in a string so that it may be used as part of a pretty URL. + * @example + * + * 'hell, no!'.parameterize() -> 'hell-no' + * + ***/ + 'parameterize': function(separator) { + var str = this; + if(separator === undefined) separator = '-'; + if(str.normalize) { + str = str.normalize(); + } + str = str.replace(/[^a-z0-9\-_]+/gi, separator) + if(separator) { + str = str.replace(new regexp('^{sep}+|{sep}+$|({sep}){sep}+'.assign({ 'sep': escapeRegExp(separator) }), 'g'), '$1'); + } + return encodeURI(str.toLowerCase()); + } + + }); + + string.Inflector = Inflector; + string.Inflector.acronyms = acronyms; + + + /*** + * + * @package Language + * @dependency string + * @description Normalizing accented characters, character width conversion, Hiragana and Katakana conversions. + * + ***/ + + /*** + * String module + * + ***/ + + + + var NormalizeMap, + NormalizeReg = '', + NormalizeSource; + + + /*** + * @method has[Script]() + * @returns Boolean + * @short Returns true if the string contains any characters in that script. + * + * @set + * hasArabic + * hasCyrillic + * hasGreek + * hasHangul + * hasHan + * hasKanji + * hasHebrew + * hasHiragana + * hasKana + * hasKatakana + * hasLatin + * hasThai + * hasDevanagari + * + * @example + * + * 'أتكلم'.hasArabic() -> true + * 'визит'.hasCyrillic() -> true + * '잘 먹겠습니다!'.hasHangul() -> true + * 'ミックスです'.hasKatakana() -> true + * "l'année".hasLatin() -> true + * + *** + * @method is[Script]() + * @returns Boolean + * @short Returns true if the string contains only characters in that script. Whitespace is ignored. + * + * @set + * isArabic + * isCyrillic + * isGreek + * isHangul + * isHan + * isKanji + * isHebrew + * isHiragana + * isKana + * isKatakana + * isKatakana + * isThai + * isDevanagari + * + * @example + * + * 'أتكلم'.isArabic() -> true + * 'визит'.isCyrillic() -> true + * '잘 먹겠습니다!'.isHangul() -> true + * 'ミックスです'.isKatakana() -> false + * "l'année".isLatin() -> true + * + ***/ + var unicodeScripts = [ + { names: ['Arabic'], source: '\u0600-\u06FF' }, + { names: ['Cyrillic'], source: '\u0400-\u04FF' }, + { names: ['Devanagari'], source: '\u0900-\u097F' }, + { names: ['Greek'], source: '\u0370-\u03FF' }, + { names: ['Hangul'], source: '\uAC00-\uD7AF\u1100-\u11FF' }, + { names: ['Han','Kanji'], source: '\u4E00-\u9FFF\uF900-\uFAFF' }, + { names: ['Hebrew'], source: '\u0590-\u05FF' }, + { names: ['Hiragana'], source: '\u3040-\u309F\u30FB-\u30FC' }, + { names: ['Kana'], source: '\u3040-\u30FF\uFF61-\uFF9F' }, + { names: ['Katakana'], source: '\u30A0-\u30FF\uFF61-\uFF9F' }, + { names: ['Latin'], source: '\u0001-\u007F\u0080-\u00FF\u0100-\u017F\u0180-\u024F' }, + { names: ['Thai'], source: '\u0E00-\u0E7F' } + ]; + + function buildUnicodeScripts() { + unicodeScripts.forEach(function(s) { + var is = regexp('^['+s.source+'\\s]+$'); + var has = regexp('['+s.source+']'); + s.names.forEach(function(name) { + defineProperty(string.prototype, 'is' + name, function() { return is.test(this.trim()); }); + defineProperty(string.prototype, 'has' + name, function() { return has.test(this); }); + }); + }); + } + + // Support for converting character widths and katakana to hiragana. + + var widthConversionRanges = [ + { type: 'a', shift: 65248, start: 65, end: 90 }, + { type: 'a', shift: 65248, start: 97, end: 122 }, + { type: 'n', shift: 65248, start: 48, end: 57 }, + { type: 'p', shift: 65248, start: 33, end: 47 }, + { type: 'p', shift: 65248, start: 58, end: 64 }, + { type: 'p', shift: 65248, start: 91, end: 96 }, + { type: 'p', shift: 65248, start: 123, end: 126 } + ]; + + var WidthConversionTable; + var allHankaku = /[\u0020-\u00A5]|[\uFF61-\uFF9F][゙゚]?/g; + var allZenkaku = /[\u3000-\u301C]|[\u301A-\u30FC]|[\uFF01-\uFF60]|[\uFFE0-\uFFE6]/g; + var hankakuPunctuation = '。、「」¥¢£'; + var zenkakuPunctuation = '。、「」¥¢£'; + var voicedKatakana = /[カキクケコサシスセソタチツテトハヒフヘホ]/; + var semiVoicedKatakana = /[ハヒフヘホヲ]/; + var hankakuKatakana = 'アイウエオァィゥェォカキクケコサシスセソタチツッテトナニヌネノハヒフヘホマミムメモヤャユュヨョラリルレロワヲンー・'; + var zenkakuKatakana = 'アイウエオァィゥェォカキクケコサシスセソタチツッテトナニヌネノハヒフヘホマミムメモヤャユュヨョラリルレロワヲンー・'; + + function convertCharacterWidth(str, args, reg, type) { + if(!WidthConversionTable) { + buildWidthConversionTables(); + } + var mode = multiArgs(args).join(''), table = WidthConversionTable[type]; + mode = mode.replace(/all/, '').replace(/(\w)lphabet|umbers?|atakana|paces?|unctuation/g, '$1'); + return str.replace(reg, function(c) { + if(table[c] && (!mode || mode.has(table[c].type))) { + return table[c].to; + } else { + return c; + } + }); + } + + function buildWidthConversionTables() { + var hankaku; + WidthConversionTable = { + 'zenkaku': {}, + 'hankaku': {} + }; + widthConversionRanges.forEach(function(r) { + getRange(r.start, r.end, function(n) { + setWidthConversion(r.type, chr(n), chr(n + r.shift)); + }); + }); + zenkakuKatakana.each(function(c, i) { + hankaku = hankakuKatakana.charAt(i); + setWidthConversion('k', hankaku, c); + if(c.match(voicedKatakana)) { + setWidthConversion('k', hankaku + '゙', c.shift(1)); + } + if(c.match(semiVoicedKatakana)) { + setWidthConversion('k', hankaku + '゚', c.shift(2)); + } + }); + zenkakuPunctuation.each(function(c, i) { + setWidthConversion('p', hankakuPunctuation.charAt(i), c); + }); + setWidthConversion('k', 'ヴ', 'ヴ'); + setWidthConversion('k', 'ヺ', 'ヺ'); + setWidthConversion('s', ' ', ' '); + } + + function setWidthConversion(type, half, full) { + WidthConversionTable['zenkaku'][half] = { type: type, to: full }; + WidthConversionTable['hankaku'][full] = { type: type, to: half }; + } + + + + + function buildNormalizeMap() { + NormalizeMap = {}; + iterateOverObject(NormalizeSource, function(normalized, str) { + str.split('').forEach(function(character) { + NormalizeMap[character] = normalized; + }); + NormalizeReg += str; + }); + NormalizeReg = regexp('[' + NormalizeReg + ']', 'g'); + } + + NormalizeSource = { + 'A': 'AⒶAÀÁÂẦẤẪẨÃĀĂẰẮẴẲȦǠÄǞẢÅǺǍȀȂẠẬẶḀĄȺⱯ', + 'B': 'BⒷBḂḄḆɃƂƁ', + 'C': 'CⒸCĆĈĊČÇḈƇȻꜾ', + 'D': 'DⒹDḊĎḌḐḒḎĐƋƊƉꝹ', + 'E': 'EⒺEÈÉÊỀẾỄỂẼĒḔḖĔĖËẺĚȄȆẸỆȨḜĘḘḚƐƎ', + 'F': 'FⒻFḞƑꝻ', + 'G': 'GⒼGǴĜḠĞĠǦĢǤƓꞠꝽꝾ', + 'H': 'HⒽHĤḢḦȞḤḨḪĦⱧⱵꞍ', + 'I': 'IⒾIÌÍÎĨĪĬİÏḮỈǏȈȊỊĮḬƗ', + 'J': 'JⒿJĴɈ', + 'K': 'KⓀKḰǨḲĶḴƘⱩꝀꝂꝄꞢ', + 'L': 'LⓁLĿĹĽḶḸĻḼḺŁȽⱢⱠꝈꝆꞀ', + 'M': 'MⓂMḾṀṂⱮƜ', + 'N': 'NⓃNǸŃÑṄŇṆŅṊṈȠƝꞐꞤ', + 'O': 'OⓄOÒÓÔỒỐỖỔÕṌȬṎŌṐṒŎȮȰÖȪỎŐǑȌȎƠỜỚỠỞỢỌỘǪǬØǾƆƟꝊꝌ', + 'P': 'PⓅPṔṖƤⱣꝐꝒꝔ', + 'Q': 'QⓆQꝖꝘɊ', + 'R': 'RⓇRŔṘŘȐȒṚṜŖṞɌⱤꝚꞦꞂ', + 'S': 'SⓈSẞŚṤŜṠŠṦṢṨȘŞⱾꞨꞄ', + 'T': 'TⓉTṪŤṬȚŢṰṮŦƬƮȾꞆ', + 'U': 'UⓊUÙÚÛŨṸŪṺŬÜǛǗǕǙỦŮŰǓȔȖƯỪỨỮỬỰỤṲŲṶṴɄ', + 'V': 'VⓋVṼṾƲꝞɅ', + 'W': 'WⓌWẀẂŴẆẄẈⱲ', + 'X': 'XⓍXẊẌ', + 'Y': 'YⓎYỲÝŶỸȲẎŸỶỴƳɎỾ', + 'Z': 'ZⓏZŹẐŻŽẒẔƵȤⱿⱫꝢ', + 'a': 'aⓐaẚàáâầấẫẩãāăằắẵẳȧǡäǟảåǻǎȁȃạậặḁąⱥɐ', + 'b': 'bⓑbḃḅḇƀƃɓ', + 'c': 'cⓒcćĉċčçḉƈȼꜿↄ', + 'd': 'dⓓdḋďḍḑḓḏđƌɖɗꝺ', + 'e': 'eⓔeèéêềếễểẽēḕḗĕėëẻěȅȇẹệȩḝęḙḛɇɛǝ', + 'f': 'fⓕfḟƒꝼ', + 'g': 'gⓖgǵĝḡğġǧģǥɠꞡᵹꝿ', + 'h': 'hⓗhĥḣḧȟḥḩḫẖħⱨⱶɥ', + 'i': 'iⓘiìíîĩīĭïḯỉǐȉȋịįḭɨı', + 'j': 'jⓙjĵǰɉ', + 'k': 'kⓚkḱǩḳķḵƙⱪꝁꝃꝅꞣ', + 'l': 'lⓛlŀĺľḷḹļḽḻſłƚɫⱡꝉꞁꝇ', + 'm': 'mⓜmḿṁṃɱɯ', + 'n': 'nⓝnǹńñṅňṇņṋṉƞɲʼnꞑꞥ', + 'o': 'oⓞoòóôồốỗổõṍȭṏōṑṓŏȯȱöȫỏőǒȍȏơờớỡởợọộǫǭøǿɔꝋꝍɵ', + 'p': 'pⓟpṕṗƥᵽꝑꝓꝕ', + 'q': 'qⓠqɋꝗꝙ', + 'r': 'rⓡrŕṙřȑȓṛṝŗṟɍɽꝛꞧꞃ', + 's': 'sⓢsśṥŝṡšṧṣṩșşȿꞩꞅẛ', + 't': 'tⓣtṫẗťṭțţṱṯŧƭʈⱦꞇ', + 'u': 'uⓤuùúûũṹūṻŭüǜǘǖǚủůűǔȕȗưừứữửựụṳųṷṵʉ', + 'v': 'vⓥvṽṿʋꝟʌ', + 'w': 'wⓦwẁẃŵẇẅẘẉⱳ', + 'x': 'xⓧxẋẍ', + 'y': 'yⓨyỳýŷỹȳẏÿỷẙỵƴɏỿ', + 'z': 'zⓩzźẑżžẓẕƶȥɀⱬꝣ', + 'AA': 'Ꜳ', + 'AE': 'ÆǼǢ', + 'AO': 'Ꜵ', + 'AU': 'Ꜷ', + 'AV': 'ꜸꜺ', + 'AY': 'Ꜽ', + 'DZ': 'DZDŽ', + 'Dz': 'DzDž', + 'LJ': 'LJ', + 'Lj': 'Lj', + 'NJ': 'NJ', + 'Nj': 'Nj', + 'OI': 'Ƣ', + 'OO': 'Ꝏ', + 'OU': 'Ȣ', + 'TZ': 'Ꜩ', + 'VY': 'Ꝡ', + 'aa': 'ꜳ', + 'ae': 'æǽǣ', + 'ao': 'ꜵ', + 'au': 'ꜷ', + 'av': 'ꜹꜻ', + 'ay': 'ꜽ', + 'dz': 'dzdž', + 'hv': 'ƕ', + 'lj': 'lj', + 'nj': 'nj', + 'oi': 'ƣ', + 'ou': 'ȣ', + 'oo': 'ꝏ', + 'ss': 'ß', + 'tz': 'ꜩ', + 'vy': 'ꝡ' + }; + + extend(string, true, false, { + /*** + * @method normalize() + * @returns String + * @short Returns the string with accented and non-standard Latin-based characters converted into ASCII approximate equivalents. + * @example + * + * 'á'.normalize() -> 'a' + * 'Ménage à trois'.normalize() -> 'Menage a trois' + * 'Volkswagen'.normalize() -> 'Volkswagen' + * 'FULLWIDTH'.normalize() -> 'FULLWIDTH' + * + ***/ + 'normalize': function() { + if(!NormalizeMap) { + buildNormalizeMap(); + } + return this.replace(NormalizeReg, function(character) { + return NormalizeMap[character]; + }); + }, + + /*** + * @method hankaku([mode] = 'all') + * @returns String + * @short Converts full-width characters (zenkaku) to half-width (hankaku). + * @extra [mode] accepts any combination of "a" (alphabet), "n" (numbers), "k" (katakana), "s" (spaces), "p" (punctuation), or "all". + * @example + * + * 'タロウ YAMADAです!'.hankaku() -> 'タロウ YAMADAです!' + * 'タロウ YAMADAです!'.hankaku('a') -> 'タロウ YAMADAです!' + * 'タロウ YAMADAです!'.hankaku('alphabet') -> 'タロウ YAMADAです!' + * 'タロウです! 25歳です!'.hankaku('katakana', 'numbers') -> 'タロウです! 25歳です!' + * 'タロウです! 25歳です!'.hankaku('k', 'n') -> 'タロウです! 25歳です!' + * 'タロウです! 25歳です!'.hankaku('kn') -> 'タロウです! 25歳です!' + * 'タロウです! 25歳です!'.hankaku('sp') -> 'タロウです! 25歳です!' + * + ***/ + 'hankaku': function() { + return convertCharacterWidth(this, arguments, allZenkaku, 'hankaku'); + }, + + /*** + * @method zenkaku([mode] = 'all') + * @returns String + * @short Converts half-width characters (hankaku) to full-width (zenkaku). + * @extra [mode] accepts any combination of "a" (alphabet), "n" (numbers), "k" (katakana), "s" (spaces), "p" (punctuation), or "all". + * @example + * + * 'タロウ YAMADAです!'.zenkaku() -> 'タロウ YAMADAです!' + * 'タロウ YAMADAです!'.zenkaku('a') -> 'タロウ YAMADAです!' + * 'タロウ YAMADAです!'.zenkaku('alphabet') -> 'タロウ YAMADAです!' + * 'タロウです! 25歳です!'.zenkaku('katakana', 'numbers') -> 'タロウです! 25歳です!' + * 'タロウです! 25歳です!'.zenkaku('k', 'n') -> 'タロウです! 25歳です!' + * 'タロウです! 25歳です!'.zenkaku('kn') -> 'タロウです! 25歳です!' + * 'タロウです! 25歳です!'.zenkaku('sp') -> 'タロウです! 25歳です!' + * + ***/ + 'zenkaku': function() { + return convertCharacterWidth(this, arguments, allHankaku, 'zenkaku'); + }, + + /*** + * @method hiragana([all] = true) + * @returns String + * @short Converts katakana into hiragana. + * @extra If [all] is false, only full-width katakana will be converted. + * @example + * + * 'カタカナ'.hiragana() -> 'かたかな' + * 'コンニチハ'.hiragana() -> 'こんにちは' + * 'カタカナ'.hiragana() -> 'かたかな' + * 'カタカナ'.hiragana(false) -> 'カタカナ' + * + ***/ + 'hiragana': function(all) { + var str = this; + if(all !== false) { + str = str.zenkaku('k'); + } + return str.replace(/[\u30A1-\u30F6]/g, function(c) { + return c.shift(-96); + }); + }, + + /*** + * @method katakana() + * @returns String + * @short Converts hiragana into katakana. + * @example + * + * 'かたかな'.katakana() -> 'カタカナ' + * 'こんにちは'.katakana() -> 'コンニチハ' + * + ***/ + 'katakana': function() { + return this.replace(/[\u3041-\u3096]/g, function(c) { + return c.shift(96); + }); + } + + + }); + + buildUnicodeScripts(); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('da'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('da', { + 'plural': true, + 'months': 'januar,februar,marts,april,maj,juni,juli,august,september,oktober,november,december', + 'weekdays': 'søndag|sondag,mandag,tirsdag,onsdag,torsdag,fredag,lørdag|lordag', + 'units': 'millisekund:|er,sekund:|er,minut:|ter,tim:e|er,dag:|e,ug:e|er|en,måned:|er|en+maaned:|er|en,år:||et+aar:||et', + 'numbers': 'en|et,to,tre,fire,fem,seks,syv,otte,ni,ti', + 'tokens': 'den,for', + 'articles': 'den', + 'short':'d. {d}. {month} {yyyy}', + 'long': 'den {d}. {month} {yyyy} {H}:{mm}', + 'full': '{Weekday} den {d}. {month} {yyyy} {H}:{mm}:{ss}', + 'past': '{num} {unit} {sign}', + 'future': '{sign} {num} {unit}', + 'duration': '{num} {unit}', + 'ampm': 'am,pm', + 'modifiers': [ + { 'name': 'day', 'src': 'forgårs|i forgårs|forgaars|i forgaars', 'value': -2 }, + { 'name': 'day', 'src': 'i går|igår|i gaar|igaar', 'value': -1 }, + { 'name': 'day', 'src': 'i dag|idag', 'value': 0 }, + { 'name': 'day', 'src': 'i morgen|imorgen', 'value': 1 }, + { 'name': 'day', 'src': 'over morgon|overmorgen|i over morgen|i overmorgen|iovermorgen', 'value': 2 }, + { 'name': 'sign', 'src': 'siden', 'value': -1 }, + { 'name': 'sign', 'src': 'om', 'value': 1 }, + { 'name': 'shift', 'src': 'i sidste|sidste', 'value': -1 }, + { 'name': 'shift', 'src': 'denne', 'value': 0 }, + { 'name': 'shift', 'src': 'næste|naeste', 'value': 1 } + ], + 'dateParse': [ + '{num} {unit} {sign}', + '{sign} {num} {unit}', + '{1?} {num} {unit} {sign}', + '{shift} {unit=5-7}' + ], + 'timeParse': [ + '{0?} {weekday?} {date?} {month} {year}', + '{date} {month}', + '{shift} {weekday}' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('de'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('de', { + 'plural': true, + 'capitalizeUnit': true, + 'months': 'Januar,Februar,März|Marz,April,Mai,Juni,Juli,August,September,Oktober,November,Dezember', + 'weekdays': 'Sonntag,Montag,Dienstag,Mittwoch,Donnerstag,Freitag,Samstag', + 'units': 'Millisekunde:|n,Sekunde:|n,Minute:|n,Stunde:|n,Tag:|en,Woche:|n,Monat:|en,Jahr:|en', + 'numbers': 'ein:|e|er|en|em,zwei,drei,vier,fuenf,sechs,sieben,acht,neun,zehn', + 'tokens': 'der', + 'short':'{d}. {Month} {yyyy}', + 'long': '{d}. {Month} {yyyy} {H}:{mm}', + 'full': '{Weekday} {d}. {Month} {yyyy} {H}:{mm}:{ss}', + 'past': '{sign} {num} {unit}', + 'future': '{sign} {num} {unit}', + 'duration': '{num} {unit}', + 'timeMarker': 'um', + 'ampm': 'am,pm', + 'modifiers': [ + { 'name': 'day', 'src': 'vorgestern', 'value': -2 }, + { 'name': 'day', 'src': 'gestern', 'value': -1 }, + { 'name': 'day', 'src': 'heute', 'value': 0 }, + { 'name': 'day', 'src': 'morgen', 'value': 1 }, + { 'name': 'day', 'src': 'übermorgen|ubermorgen|uebermorgen', 'value': 2 }, + { 'name': 'sign', 'src': 'vor:|her', 'value': -1 }, + { 'name': 'sign', 'src': 'in', 'value': 1 }, + { 'name': 'shift', 'src': 'letzte:|r|n|s', 'value': -1 }, + { 'name': 'shift', 'src': 'nächste:|r|n|s+nachste:|r|n|s+naechste:|r|n|s+kommende:n|r', 'value': 1 } + ], + 'dateParse': [ + '{sign} {num} {unit}', + '{num} {unit} {sign}', + '{shift} {unit=5-7}' + ], + 'timeParse': [ + '{weekday?} {date?} {month} {year?}', + '{shift} {weekday}' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('es'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('es', { + 'plural': true, + 'months': 'enero,febrero,marzo,abril,mayo,junio,julio,agosto,septiembre,octubre,noviembre,diciembre', + 'weekdays': 'domingo,lunes,martes,miércoles|miercoles,jueves,viernes,sábado|sabado', + 'units': 'milisegundo:|s,segundo:|s,minuto:|s,hora:|s,día|días|dia|dias,semana:|s,mes:|es,año|años|ano|anos', + 'numbers': 'uno,dos,tres,cuatro,cinco,seis,siete,ocho,nueve,diez', + 'tokens': 'el,de', + 'short':'{d} {month} {yyyy}', + 'long': '{d} {month} {yyyy} {H}:{mm}', + 'full': '{Weekday} {d} {month} {yyyy} {H}:{mm}:{ss}', + 'past': '{sign} {num} {unit}', + 'future': '{num} {unit} {sign}', + 'duration': '{num} {unit}', + 'timeMarker': 'a las', + 'ampm': 'am,pm', + 'modifiers': [ + { 'name': 'day', 'src': 'anteayer', 'value': -2 }, + { 'name': 'day', 'src': 'ayer', 'value': -1 }, + { 'name': 'day', 'src': 'hoy', 'value': 0 }, + { 'name': 'day', 'src': 'mañana|manana', 'value': 1 }, + { 'name': 'sign', 'src': 'hace', 'value': -1 }, + { 'name': 'sign', 'src': 'de ahora', 'value': 1 }, + { 'name': 'shift', 'src': 'pasad:o|a', 'value': -1 }, + { 'name': 'shift', 'src': 'próximo|próxima|proximo|proxima', 'value': 1 } + ], + 'dateParse': [ + '{sign} {num} {unit}', + '{num} {unit} {sign}', + '{0?} {unit=5-7} {shift}', + '{0?} {shift} {unit=5-7}' + ], + 'timeParse': [ + '{shift} {weekday}', + '{weekday} {shift}', + '{date?} {1?} {month} {1?} {year?}' + ] +}); +Date.addLocale('fi', { + 'plural': true, + 'timeMarker': 'kello', + 'ampm': ',', + 'months': 'tammikuu,helmikuu,maaliskuu,huhtikuu,toukokuu,kesäkuu,heinäkuu,elokuu,syyskuu,lokakuu,marraskuu,joulukuu', + 'weekdays': 'sunnuntai,maanantai,tiistai,keskiviikko,torstai,perjantai,lauantai', + 'units': 'millisekun:ti|tia|teja|tina|nin,sekun:ti|tia|teja|tina|nin,minuut:ti|tia|teja|tina|in,tun:ti|tia|teja|tina|nin,päiv:ä|ää|iä|änä|än,viik:ko|koa|koja|on|kona,kuukau:si|sia|tta|den|tena,vuo:si|sia|tta|den|tena', + 'numbers': 'yksi|ensimmäinen,kaksi|toinen,kolm:e|as,neljä:s,vii:si|des,kuu:si|des,seitsemä:n|s,kahdeksa:n|s,yhdeksä:n|s,kymmene:n|s', + 'articles': '', + 'optionals': '', + 'short': '{d}. {month}ta {yyyy}', + 'long': '{d}. {month}ta {yyyy} kello {H}.{mm}', + 'full': '{Weekday}na {d}. {month}ta {yyyy} kello {H}.{mm}', + 'relative': function(num, unit, ms, format) { + var units = this['units']; + function numberWithUnit(mult) { + return (num === 1 ? '' : num + ' ') + units[(8 * mult) + unit]; + } + switch(format) { + case 'duration': return numberWithUnit(0); + case 'past': return numberWithUnit(num > 1 ? 1 : 0) + ' sitten'; + case 'future': return numberWithUnit(4) + ' päästä'; + } + }, + 'modifiers': [ + { 'name': 'day', 'src': 'toissa päivänä|toissa päiväistä', 'value': -2 }, + { 'name': 'day', 'src': 'eilen|eilistä', 'value': -1 }, + { 'name': 'day', 'src': 'tänään', 'value': 0 }, + { 'name': 'day', 'src': 'huomenna|huomista', 'value': 1 }, + { 'name': 'day', 'src': 'ylihuomenna|ylihuomista', 'value': 2 }, + { 'name': 'sign', 'src': 'sitten|aiemmin', 'value': -1 }, + { 'name': 'sign', 'src': 'päästä|kuluttua|myöhemmin', 'value': 1 }, + { 'name': 'edge', 'src': 'viimeinen|viimeisenä', 'value': -2 }, + { 'name': 'edge', 'src': 'lopussa', 'value': -1 }, + { 'name': 'edge', 'src': 'ensimmäinen|ensimmäisenä', 'value': 1 }, + { 'name': 'shift', 'src': 'edellinen|edellisenä|edeltävä|edeltävänä|viime|toissa', 'value': -1 }, + { 'name': 'shift', 'src': 'tänä|tämän', 'value': 0 }, + { 'name': 'shift', 'src': 'seuraava|seuraavana|tuleva|tulevana|ensi', 'value': 1 } + ], + 'dateParse': [ + '{num} {unit} {sign}', + '{sign} {num} {unit}', + '{num} {unit=4-5} {sign} {day}', + '{month} {year}', + '{shift} {unit=5-7}' + ], + 'timeParse': [ + '{0} {num}{1} {day} of {month} {year?}', + '{weekday?} {month} {date}{1} {year?}', + '{date} {month} {year}', + '{shift} {weekday}', + '{shift} week {weekday}', + '{weekday} {2} {shift} week', + '{0} {date}{1} of {month}', + '{0}{month?} {date?}{1} of {shift} {unit=6-7}' + ] +}); +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('fr'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('fr', { + 'plural': true, + 'months': 'janvier,février|fevrier,mars,avril,mai,juin,juillet,août,septembre,octobre,novembre,décembre|decembre', + 'weekdays': 'dimanche,lundi,mardi,mercredi,jeudi,vendredi,samedi', + 'units': 'milliseconde:|s,seconde:|s,minute:|s,heure:|s,jour:|s,semaine:|s,mois,an:|s|née|nee', + 'numbers': 'un:|e,deux,trois,quatre,cinq,six,sept,huit,neuf,dix', + 'tokens': ["l'|la|le"], + 'short':'{d} {month} {yyyy}', + 'long': '{d} {month} {yyyy} {H}:{mm}', + 'full': '{Weekday} {d} {month} {yyyy} {H}:{mm}:{ss}', + 'past': '{sign} {num} {unit}', + 'future': '{sign} {num} {unit}', + 'duration': '{num} {unit}', + 'timeMarker': 'à', + 'ampm': 'am,pm', + 'modifiers': [ + { 'name': 'day', 'src': 'hier', 'value': -1 }, + { 'name': 'day', 'src': "aujourd'hui", 'value': 0 }, + { 'name': 'day', 'src': 'demain', 'value': 1 }, + { 'name': 'sign', 'src': 'il y a', 'value': -1 }, + { 'name': 'sign', 'src': "dans|d'ici", 'value': 1 }, + { 'name': 'shift', 'src': 'derni:èr|er|ère|ere', 'value': -1 }, + { 'name': 'shift', 'src': 'prochain:|e', 'value': 1 } + ], + 'dateParse': [ + '{sign} {num} {unit}', + '{sign} {num} {unit}', + '{0?} {unit=5-7} {shift}' + ], + 'timeParse': [ + '{0?} {date?} {month} {year?}', + '{0?} {weekday} {shift}' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('it'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('it', { + 'plural': true, + 'months': 'Gennaio,Febbraio,Marzo,Aprile,Maggio,Giugno,Luglio,Agosto,Settembre,Ottobre,Novembre,Dicembre', + 'weekdays': 'Domenica,Luned:ì|i,Marted:ì|i,Mercoled:ì|i,Gioved:ì|i,Venerd:ì|i,Sabato', + 'units': 'millisecond:o|i,second:o|i,minut:o|i,or:a|e,giorn:o|i,settiman:a|e,mes:e|i,ann:o|i', + 'numbers': "un:|a|o|',due,tre,quattro,cinque,sei,sette,otto,nove,dieci", + 'tokens': "l'|la|il", + 'short':'{d} {Month} {yyyy}', + 'long': '{d} {Month} {yyyy} {H}:{mm}', + 'full': '{Weekday} {d} {Month} {yyyy} {H}:{mm}:{ss}', + 'past': '{num} {unit} {sign}', + 'future': '{num} {unit} {sign}', + 'duration': '{num} {unit}', + 'timeMarker': 'alle', + 'ampm': 'am,pm', + 'modifiers': [ + { 'name': 'day', 'src': 'ieri', 'value': -1 }, + { 'name': 'day', 'src': 'oggi', 'value': 0 }, + { 'name': 'day', 'src': 'domani', 'value': 1 }, + { 'name': 'day', 'src': 'dopodomani', 'value': 2 }, + { 'name': 'sign', 'src': 'fa', 'value': -1 }, + { 'name': 'sign', 'src': 'da adesso', 'value': 1 }, + { 'name': 'shift', 'src': 'scors:o|a', 'value': -1 }, + { 'name': 'shift', 'src': 'prossim:o|a', 'value': 1 } + ], + 'dateParse': [ + '{num} {unit} {sign}', + '{0?} {unit=5-7} {shift}', + '{0?} {shift} {unit=5-7}' + ], + 'timeParse': [ + '{weekday?} {date?} {month} {year?}', + '{shift} {weekday}' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('ja'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('ja', { + 'monthSuffix': '月', + 'weekdays': '日曜日,月曜日,火曜日,水曜日,木曜日,金曜日,土曜日', + 'units': 'ミリ秒,秒,分,時間,日,週間|週,ヶ月|ヵ月|月,年', + 'short': '{yyyy}年{M}月{d}日', + 'long': '{yyyy}年{M}月{d}日 {H}時{mm}分', + 'full': '{yyyy}年{M}月{d}日 {Weekday} {H}時{mm}分{ss}秒', + 'past': '{num}{unit}{sign}', + 'future': '{num}{unit}{sign}', + 'duration': '{num}{unit}', + 'timeSuffixes': '時,分,秒', + 'ampm': '午前,午後', + 'modifiers': [ + { 'name': 'day', 'src': '一昨日', 'value': -2 }, + { 'name': 'day', 'src': '昨日', 'value': -1 }, + { 'name': 'day', 'src': '今日', 'value': 0 }, + { 'name': 'day', 'src': '明日', 'value': 1 }, + { 'name': 'day', 'src': '明後日', 'value': 2 }, + { 'name': 'sign', 'src': '前', 'value': -1 }, + { 'name': 'sign', 'src': '後', 'value': 1 }, + { 'name': 'shift', 'src': '去|先', 'value': -1 }, + { 'name': 'shift', 'src': '来', 'value': 1 } + ], + 'dateParse': [ + '{num}{unit}{sign}' + ], + 'timeParse': [ + '{shift}{unit=5-7}{weekday?}', + '{year}年{month?}月?{date?}日?', + '{month}月{date?}日?', + '{date}日' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('ko'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('ko', { + 'digitDate': true, + 'monthSuffix': '월', + 'weekdays': '일요일,월요일,화요일,수요일,목요일,금요일,토요일', + 'units': '밀리초,초,분,시간,일,주,개월|달,년', + 'numbers': '일|한,이,삼,사,오,육,칠,팔,구,십', + 'short': '{yyyy}년{M}월{d}일', + 'long': '{yyyy}년{M}월{d}일 {H}시{mm}분', + 'full': '{yyyy}년{M}월{d}일 {Weekday} {H}시{mm}분{ss}초', + 'past': '{num}{unit} {sign}', + 'future': '{num}{unit} {sign}', + 'duration': '{num}{unit}', + 'timeSuffixes': '시,분,초', + 'ampm': '오전,오후', + 'modifiers': [ + { 'name': 'day', 'src': '그저께', 'value': -2 }, + { 'name': 'day', 'src': '어제', 'value': -1 }, + { 'name': 'day', 'src': '오늘', 'value': 0 }, + { 'name': 'day', 'src': '내일', 'value': 1 }, + { 'name': 'day', 'src': '모레', 'value': 2 }, + { 'name': 'sign', 'src': '전', 'value': -1 }, + { 'name': 'sign', 'src': '후', 'value': 1 }, + { 'name': 'shift', 'src': '지난|작', 'value': -1 }, + { 'name': 'shift', 'src': '이번', 'value': 0 }, + { 'name': 'shift', 'src': '다음|내', 'value': 1 } + ], + 'dateParse': [ + '{num}{unit} {sign}', + '{shift?} {unit=5-7}' + ], + 'timeParse': [ + '{shift} {unit=5?} {weekday}', + '{year}년{month?}월?{date?}일?', + '{month}월{date?}일?', + '{date}일' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('nl'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('nl', { + 'plural': true, + 'months': 'januari,februari,maart,april,mei,juni,juli,augustus,september,oktober,november,december', + 'weekdays': 'zondag|zo,maandag|ma,dinsdag|di,woensdag|woe|wo,donderdag|do,vrijdag|vrij|vr,zaterdag|za', + 'units': 'milliseconde:|n,seconde:|n,minu:ut|ten,uur,dag:|en,we:ek|ken,maand:|en,jaar', + 'numbers': 'een,twee,drie,vier,vijf,zes,zeven,acht,negen', + 'tokens': '', + 'short':'{d} {Month} {yyyy}', + 'long': '{d} {Month} {yyyy} {H}:{mm}', + 'full': '{Weekday} {d} {Month} {yyyy} {H}:{mm}:{ss}', + 'past': '{num} {unit} {sign}', + 'future': '{num} {unit} {sign}', + 'duration': '{num} {unit}', + 'timeMarker': "'s|om", + 'modifiers': [ + { 'name': 'day', 'src': 'gisteren', 'value': -1 }, + { 'name': 'day', 'src': 'vandaag', 'value': 0 }, + { 'name': 'day', 'src': 'morgen', 'value': 1 }, + { 'name': 'day', 'src': 'overmorgen', 'value': 2 }, + { 'name': 'sign', 'src': 'geleden', 'value': -1 }, + { 'name': 'sign', 'src': 'vanaf nu', 'value': 1 }, + { 'name': 'shift', 'src': 'laatste|vorige|afgelopen', 'value': -1 }, + { 'name': 'shift', 'src': 'volgend:|e', 'value': 1 } + ], + 'dateParse': [ + '{num} {unit} {sign}', + '{0?} {unit=5-7} {shift}', + '{0?} {shift} {unit=5-7}' + ], + 'timeParse': [ + '{weekday?} {date?} {month} {year?}', + '{shift} {weekday}' + ] +}); +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('pl'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.optionals. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('pl', { + 'plural': true, + 'months': 'Styczeń|Stycznia,Luty|Lutego,Marzec|Marca,Kwiecień|Kwietnia,Maj|Maja,Czerwiec|Czerwca,Lipiec|Lipca,Sierpień|Sierpnia,Wrzesień|Września,Październik|Października,Listopad|Listopada,Grudzień|Grudnia', + 'weekdays': 'Niedziela|Niedzielę,Poniedziałek,Wtorek,Środ:a|ę,Czwartek,Piątek,Sobota|Sobotę', + 'units': 'milisekund:a|y|,sekund:a|y|,minut:a|y|,godzin:a|y|,dzień|dni,tydzień|tygodnie|tygodni,miesiące|miesiące|miesięcy,rok|lata|lat', + 'numbers': 'jeden|jedną,dwa|dwie,trzy,cztery,pięć,sześć,siedem,osiem,dziewięć,dziesięć', + 'optionals': 'w|we,roku', + 'short': '{d} {Month} {yyyy}', + 'long': '{d} {Month} {yyyy} {H}:{mm}', + 'full' : '{Weekday}, {d} {Month} {yyyy} {H}:{mm}:{ss}', + 'past': '{num} {unit} {sign}', + 'future': '{sign} {num} {unit}', + 'duration': '{num} {unit}', + 'timeMarker':'o', + 'ampm': 'am,pm', + 'modifiers': [ + { 'name': 'day', 'src': 'przedwczoraj', 'value': -2 }, + { 'name': 'day', 'src': 'wczoraj', 'value': -1 }, + { 'name': 'day', 'src': 'dzisiaj|dziś', 'value': 0 }, + { 'name': 'day', 'src': 'jutro', 'value': 1 }, + { 'name': 'day', 'src': 'pojutrze', 'value': 2 }, + { 'name': 'sign', 'src': 'temu|przed', 'value': -1 }, + { 'name': 'sign', 'src': 'za', 'value': 1 }, + { 'name': 'shift', 'src': 'zeszły|zeszła|ostatni|ostatnia', 'value': -1 }, + { 'name': 'shift', 'src': 'następny|następna|następnego|przyszły|przyszła|przyszłego', 'value': 1 } + ], + 'dateParse': [ + '{num} {unit} {sign}', + '{sign} {num} {unit}', + '{month} {year}', + '{shift} {unit=5-7}', + '{0} {shift?} {weekday}' + ], + 'timeParse': [ + '{date} {month} {year?} {1}', + '{0} {shift?} {weekday}' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('pt'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('pt', { + 'plural': true, + 'months': 'janeiro,fevereiro,março,abril,maio,junho,julho,agosto,setembro,outubro,novembro,dezembro', + 'weekdays': 'domingo,segunda-feira,terça-feira,quarta-feira,quinta-feira,sexta-feira,sábado|sabado', + 'units': 'milisegundo:|s,segundo:|s,minuto:|s,hora:|s,dia:|s,semana:|s,mês|mêses|mes|meses,ano:|s', + 'numbers': 'um,dois,três|tres,quatro,cinco,seis,sete,oito,nove,dez,uma,duas', + 'tokens': 'a,de', + 'short':'{d} de {month} de {yyyy}', + 'long': '{d} de {month} de {yyyy} {H}:{mm}', + 'full': '{Weekday}, {d} de {month} de {yyyy} {H}:{mm}:{ss}', + 'past': '{num} {unit} {sign}', + 'future': '{sign} {num} {unit}', + 'duration': '{num} {unit}', + 'timeMarker': 'às', + 'ampm': 'am,pm', + 'modifiers': [ + { 'name': 'day', 'src': 'anteontem', 'value': -2 }, + { 'name': 'day', 'src': 'ontem', 'value': -1 }, + { 'name': 'day', 'src': 'hoje', 'value': 0 }, + { 'name': 'day', 'src': 'amanh:ã|a', 'value': 1 }, + { 'name': 'sign', 'src': 'atrás|atras|há|ha', 'value': -1 }, + { 'name': 'sign', 'src': 'daqui a', 'value': 1 }, + { 'name': 'shift', 'src': 'passad:o|a', 'value': -1 }, + { 'name': 'shift', 'src': 'próximo|próxima|proximo|proxima', 'value': 1 } + ], + 'dateParse': [ + '{num} {unit} {sign}', + '{sign} {num} {unit}', + '{0?} {unit=5-7} {shift}', + '{0?} {shift} {unit=5-7}' + ], + 'timeParse': [ + '{date?} {1?} {month} {1?} {year?}', + '{0?} {shift} {weekday}' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('ru'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('ru', { + 'months': 'Январ:я|ь,Феврал:я|ь,Март:а|,Апрел:я|ь,Ма:я|й,Июн:я|ь,Июл:я|ь,Август:а|,Сентябр:я|ь,Октябр:я|ь,Ноябр:я|ь,Декабр:я|ь', + 'weekdays': 'Воскресенье,Понедельник,Вторник,Среда,Четверг,Пятница,Суббота', + 'units': 'миллисекунд:а|у|ы|,секунд:а|у|ы|,минут:а|у|ы|,час:||а|ов,день|день|дня|дней,недел:я|ю|и|ь|е,месяц:||а|ев|е,год|год|года|лет|году', + 'numbers': 'од:ин|ну,дв:а|е,три,четыре,пять,шесть,семь,восемь,девять,десять', + 'tokens': 'в|на,года', + 'short':'{d} {month} {yyyy} года', + 'long': '{d} {month} {yyyy} года {H}:{mm}', + 'full': '{Weekday} {d} {month} {yyyy} года {H}:{mm}:{ss}', + 'relative': function(num, unit, ms, format) { + var numberWithUnit, last = num.toString().slice(-1); + switch(true) { + case num >= 11 && num <= 15: mult = 3; break; + case last == 1: mult = 1; break; + case last >= 2 && last <= 4: mult = 2; break; + default: mult = 3; + } + numberWithUnit = num + ' ' + this['units'][(mult * 8) + unit]; + switch(format) { + case 'duration': return numberWithUnit; + case 'past': return numberWithUnit + ' назад'; + case 'future': return 'через ' + numberWithUnit; + } + }, + 'timeMarker': 'в', + 'ampm': ' утра, вечера', + 'modifiers': [ + { 'name': 'day', 'src': 'позавчера', 'value': -2 }, + { 'name': 'day', 'src': 'вчера', 'value': -1 }, + { 'name': 'day', 'src': 'сегодня', 'value': 0 }, + { 'name': 'day', 'src': 'завтра', 'value': 1 }, + { 'name': 'day', 'src': 'послезавтра', 'value': 2 }, + { 'name': 'sign', 'src': 'назад', 'value': -1 }, + { 'name': 'sign', 'src': 'через', 'value': 1 }, + { 'name': 'shift', 'src': 'прошл:ый|ой|ом', 'value': -1 }, + { 'name': 'shift', 'src': 'следующ:ий|ей|ем', 'value': 1 } + ], + 'dateParse': [ + '{num} {unit} {sign}', + '{sign} {num} {unit}', + '{month} {year}', + '{0?} {shift} {unit=5-7}' + ], + 'timeParse': [ + '{date} {month} {year?} {1?}', + '{0?} {shift} {weekday}' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('sv'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('sv', { + 'plural': true, + 'months': 'januari,februari,mars,april,maj,juni,juli,augusti,september,oktober,november,december', + 'weekdays': 'söndag|sondag,måndag:|en+mandag:|en,tisdag,onsdag,torsdag,fredag,lördag|lordag', + 'units': 'millisekund:|er,sekund:|er,minut:|er,timm:e|ar,dag:|ar,veck:a|or|an,månad:|er|en+manad:|er|en,år:||et+ar:||et', + 'numbers': 'en|ett,två|tva,tre,fyra,fem,sex,sju,åtta|atta,nio,tio', + 'tokens': 'den,för|for', + 'articles': 'den', + 'short':'den {d} {month} {yyyy}', + 'long': 'den {d} {month} {yyyy} {H}:{mm}', + 'full': '{Weekday} den {d} {month} {yyyy} {H}:{mm}:{ss}', + 'past': '{num} {unit} {sign}', + 'future': '{sign} {num} {unit}', + 'duration': '{num} {unit}', + 'ampm': 'am,pm', + 'modifiers': [ + { 'name': 'day', 'src': 'förrgår|i förrgår|iförrgår|forrgar|i forrgar|iforrgar', 'value': -2 }, + { 'name': 'day', 'src': 'går|i går|igår|gar|i gar|igar', 'value': -1 }, + { 'name': 'day', 'src': 'dag|i dag|idag', 'value': 0 }, + { 'name': 'day', 'src': 'morgon|i morgon|imorgon', 'value': 1 }, + { 'name': 'day', 'src': 'över morgon|övermorgon|i över morgon|i övermorgon|iövermorgon|over morgon|overmorgon|i over morgon|i overmorgon|iovermorgon', 'value': 2 }, + { 'name': 'sign', 'src': 'sedan|sen', 'value': -1 }, + { 'name': 'sign', 'src': 'om', 'value': 1 }, + { 'name': 'shift', 'src': 'i förra|förra|i forra|forra', 'value': -1 }, + { 'name': 'shift', 'src': 'denna', 'value': 0 }, + { 'name': 'shift', 'src': 'nästa|nasta', 'value': 1 } + ], + 'dateParse': [ + '{num} {unit} {sign}', + '{sign} {num} {unit}', + '{1?} {num} {unit} {sign}', + '{shift} {unit=5-7}' + ], + 'timeParse': [ + '{0?} {weekday?} {date?} {month} {year}', + '{date} {month}', + '{shift} {weekday}' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('zh-CN'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + +Date.addLocale('zh-CN', { + 'variant': true, + 'monthSuffix': '月', + 'weekdays': '星期日|周日,星期一|周一,星期二|周二,星期三|周三,星期四|周四,星期五|周五,星期六|周六', + 'units': '毫秒,秒钟,分钟,小时,天,个星期|周,个月,年', + 'tokens': '日|号', + 'short':'{yyyy}年{M}月{d}日', + 'long': '{yyyy}年{M}月{d}日 {tt}{h}:{mm}', + 'full': '{yyyy}年{M}月{d}日 {weekday} {tt}{h}:{mm}:{ss}', + 'past': '{num}{unit}{sign}', + 'future': '{num}{unit}{sign}', + 'duration': '{num}{unit}', + 'timeSuffixes': '点|时,分钟?,秒', + 'ampm': '上午,下午', + 'modifiers': [ + { 'name': 'day', 'src': '前天', 'value': -2 }, + { 'name': 'day', 'src': '昨天', 'value': -1 }, + { 'name': 'day', 'src': '今天', 'value': 0 }, + { 'name': 'day', 'src': '明天', 'value': 1 }, + { 'name': 'day', 'src': '后天', 'value': 2 }, + { 'name': 'sign', 'src': '前', 'value': -1 }, + { 'name': 'sign', 'src': '后', 'value': 1 }, + { 'name': 'shift', 'src': '上|去', 'value': -1 }, + { 'name': 'shift', 'src': '这', 'value': 0 }, + { 'name': 'shift', 'src': '下|明', 'value': 1 } + ], + 'dateParse': [ + '{num}{unit}{sign}', + '{shift}{unit=5-7}' + ], + 'timeParse': [ + '{shift}{weekday}', + '{year}年{month?}月?{date?}{0?}', + '{month}月{date?}{0?}', + '{date}[日号]' + ] +}); + +/* + * + * Date.addLocale() adds this locale to Sugar. + * To set the locale globally, simply call: + * + * Date.setLocale('zh-TW'); + * + * var locale = Date.getLocale() will return this object, which + * can be tweaked to change the behavior of parsing/formatting in the locales. + * + * locale.addFormat adds a date format (see this file for examples). + * Special tokens in the date format will be parsed out into regex tokens: + * + * {0} is a reference to an entry in locale.tokens. Output: (?:the)? + * {unit} is a reference to all units. Output: (day|week|month|...) + * {unit3} is a reference to a specific unit. Output: (hour) + * {unit3-5} is a reference to a subset of the units array. Output: (hour|day|week) + * {unit?} "?" makes that token optional. Output: (day|week|month)? + * + * {day} Any reference to tokens in the modifiers array will include all with the same name. Output: (yesterday|today|tomorrow) + * + * All spaces are optional and will be converted to "\s*" + * + * Locale arrays months, weekdays, units, numbers, as well as the "src" field for + * all entries in the modifiers array follow a special format indicated by a colon: + * + * minute:|s = minute|minutes + * thicke:n|r = thicken|thicker + * + * Additionally in the months, weekdays, units, and numbers array these will be added at indexes that are multiples + * of the relevant number for retrieval. For example having "sunday:|s" in the units array will result in: + * + * units: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sundays'] + * + * When matched, the index will be found using: + * + * units.indexOf(match) % 7; + * + * Resulting in the correct index with any number of alternates for that entry. + * + */ + + //'zh-TW': '1;月;年;;星期日|週日,星期一|週一,星期二|週二,星期三|週三,星期四|週四,星期五|週五,星期六|週六;毫秒,秒鐘,分鐘,小時,天,個星期|週,個月,年;;;日|號;;上午,下午;點|時,分鐘?,秒;{num}{unit}{sign},{shift}{unit=5-7};{shift}{weekday},{year}年{month?}月?{date?}{0},{month}月{date?}{0},{date}{0};{yyyy}年{M}月{d}日 {Weekday};{tt}{h}:{mm}:{ss};前天,昨天,今天,明天,後天;,前,,後;,上|去,這,下|明', + +Date.addLocale('zh-TW', { + 'monthSuffix': '月', + 'weekdays': '星期日|週日,星期一|週一,星期二|週二,星期三|週三,星期四|週四,星期五|週五,星期六|週六', + 'units': '毫秒,秒鐘,分鐘,小時,天,個星期|週,個月,年', + 'tokens': '日|號', + 'short':'{yyyy}年{M}月{d}日', + 'long': '{yyyy}年{M}月{d}日 {tt}{h}:{mm}', + 'full': '{yyyy}年{M}月{d}日 {Weekday} {tt}{h}:{mm}:{ss}', + 'past': '{num}{unit}{sign}', + 'future': '{num}{unit}{sign}', + 'duration': '{num}{unit}', + 'timeSuffixes': '點|時,分鐘?,秒', + 'ampm': '上午,下午', + 'modifiers': [ + { 'name': 'day', 'src': '前天', 'value': -2 }, + { 'name': 'day', 'src': '昨天', 'value': -1 }, + { 'name': 'day', 'src': '今天', 'value': 0 }, + { 'name': 'day', 'src': '明天', 'value': 1 }, + { 'name': 'day', 'src': '後天', 'value': 2 }, + { 'name': 'sign', 'src': '前', 'value': -1 }, + { 'name': 'sign', 'src': '後', 'value': 1 }, + { 'name': 'shift', 'src': '上|去', 'value': -1 }, + { 'name': 'shift', 'src': '這', 'value': 0 }, + { 'name': 'shift', 'src': '下|明', 'value': 1 } + ], + 'dateParse': [ + '{num}{unit}{sign}', + '{shift}{unit=5-7}' + ], + 'timeParse': [ + '{shift}{weekday}', + '{year}年{month?}月?{date?}{0?}', + '{month}月{date?}{0?}', + '{date}[日號]' + ] +}); + +})(); \ No newline at end of file diff --git a/app/assets/javascripts/external/twitter-text-1.5.0.js b/app/assets/javascripts/external/twitter-text-1.5.0.js new file mode 100644 index 00000000000..4822276ed3b --- /dev/null +++ b/app/assets/javascripts/external/twitter-text-1.5.0.js @@ -0,0 +1,1294 @@ +/*! + * twitter-text-js 1.5.0 + * + * Copyright 2011 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this work except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +if (typeof window === "undefined" || window === null) { + window = { twttr: {} }; +} +if (window.twttr == null) { + window.twttr = {}; +} +if (typeof twttr === "undefined" || twttr === null) { + twttr = {}; +} + +(function() { + twttr.txt = {}; + twttr.txt.regexen = {}; + + var HTML_ENTITIES = { + '&': '&', + '>': '>', + '<': '<', + '"': '"', + "'": ''' + }; + + // HTML escaping + twttr.txt.htmlEscape = function(text) { + return text && text.replace(/[&"'><]/g, function(character) { + return HTML_ENTITIES[character]; + }); + }; + + // Builds a RegExp + function regexSupplant(regex, flags) { + flags = flags || ""; + if (typeof regex !== "string") { + if (regex.global && flags.indexOf("g") < 0) { + flags += "g"; + } + if (regex.ignoreCase && flags.indexOf("i") < 0) { + flags += "i"; + } + if (regex.multiline && flags.indexOf("m") < 0) { + flags += "m"; + } + + regex = regex.source; + } + + return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) { + var newRegex = twttr.txt.regexen[name] || ""; + if (typeof newRegex !== "string") { + newRegex = newRegex.source; + } + return newRegex; + }), flags); + } + + twttr.txt.regexSupplant = regexSupplant; + + // simple string interpolation + function stringSupplant(str, values) { + return str.replace(/#\{(\w+)\}/g, function(match, name) { + return values[name] || ""; + }); + } + + twttr.txt.stringSupplant = stringSupplant; + + function addCharsToCharClass(charClass, start, end) { + var s = String.fromCharCode(start); + if (end !== start) { + s += "-" + String.fromCharCode(end); + } + charClass.push(s); + return charClass; + } + + twttr.txt.addCharsToCharClass = addCharsToCharClass; + + // Space is more than %20, U+3000 for example is the full-width space used with Kanji. Provide a short-hand + // to access both the list of characters and a pattern suitible for use with String#split + // Taken from: ActiveSupport::Multibyte::Handlers::UTF8Handler::UNICODE_WHITESPACE + var fromCode = String.fromCharCode; + var UNICODE_SPACES = [ + fromCode(0x0020), // White_Space # Zs SPACE + fromCode(0x0085), // White_Space # Cc + fromCode(0x00A0), // White_Space # Zs NO-BREAK SPACE + fromCode(0x1680), // White_Space # Zs OGHAM SPACE MARK + fromCode(0x180E), // White_Space # Zs MONGOLIAN VOWEL SEPARATOR + fromCode(0x2028), // White_Space # Zl LINE SEPARATOR + fromCode(0x2029), // White_Space # Zp PARAGRAPH SEPARATOR + fromCode(0x202F), // White_Space # Zs NARROW NO-BREAK SPACE + fromCode(0x205F), // White_Space # Zs MEDIUM MATHEMATICAL SPACE + fromCode(0x3000) // White_Space # Zs IDEOGRAPHIC SPACE + ]; + addCharsToCharClass(UNICODE_SPACES, 0x009, 0x00D); // White_Space # Cc [5] .. + addCharsToCharClass(UNICODE_SPACES, 0x2000, 0x200A); // White_Space # Zs [11] EN QUAD..HAIR SPACE + + var INVALID_CHARS = [ + fromCode(0xFFFE), + fromCode(0xFEFF), // BOM + fromCode(0xFFFF) // Special + ]; + addCharsToCharClass(INVALID_CHARS, 0x202A, 0x202E); // Directional change + + twttr.txt.regexen.spaces_group = regexSupplant(UNICODE_SPACES.join("")); + twttr.txt.regexen.spaces = regexSupplant("[" + UNICODE_SPACES.join("") + "]"); + twttr.txt.regexen.invalid_chars_group = regexSupplant(INVALID_CHARS.join("")); + twttr.txt.regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/; + + var nonLatinHashtagChars = []; + // Cyrillic + addCharsToCharClass(nonLatinHashtagChars, 0x0400, 0x04ff); // Cyrillic + addCharsToCharClass(nonLatinHashtagChars, 0x0500, 0x0527); // Cyrillic Supplement + addCharsToCharClass(nonLatinHashtagChars, 0x2de0, 0x2dff); // Cyrillic Extended A + addCharsToCharClass(nonLatinHashtagChars, 0xa640, 0xa69f); // Cyrillic Extended B + // Hebrew + addCharsToCharClass(nonLatinHashtagChars, 0x0591, 0x05bf); // Hebrew + addCharsToCharClass(nonLatinHashtagChars, 0x05c1, 0x05c2); + addCharsToCharClass(nonLatinHashtagChars, 0x05c4, 0x05c5); + addCharsToCharClass(nonLatinHashtagChars, 0x05c7, 0x05c7); + addCharsToCharClass(nonLatinHashtagChars, 0x05d0, 0x05ea); + addCharsToCharClass(nonLatinHashtagChars, 0x05f0, 0x05f4); + addCharsToCharClass(nonLatinHashtagChars, 0xfb12, 0xfb28); // Hebrew Presentation Forms + addCharsToCharClass(nonLatinHashtagChars, 0xfb2a, 0xfb36); + addCharsToCharClass(nonLatinHashtagChars, 0xfb38, 0xfb3c); + addCharsToCharClass(nonLatinHashtagChars, 0xfb3e, 0xfb3e); + addCharsToCharClass(nonLatinHashtagChars, 0xfb40, 0xfb41); + addCharsToCharClass(nonLatinHashtagChars, 0xfb43, 0xfb44); + addCharsToCharClass(nonLatinHashtagChars, 0xfb46, 0xfb4f); + // Arabic + addCharsToCharClass(nonLatinHashtagChars, 0x0610, 0x061a); // Arabic + addCharsToCharClass(nonLatinHashtagChars, 0x0620, 0x065f); + addCharsToCharClass(nonLatinHashtagChars, 0x066e, 0x06d3); + addCharsToCharClass(nonLatinHashtagChars, 0x06d5, 0x06dc); + addCharsToCharClass(nonLatinHashtagChars, 0x06de, 0x06e8); + addCharsToCharClass(nonLatinHashtagChars, 0x06ea, 0x06ef); + addCharsToCharClass(nonLatinHashtagChars, 0x06fa, 0x06fc); + addCharsToCharClass(nonLatinHashtagChars, 0x06ff, 0x06ff); + addCharsToCharClass(nonLatinHashtagChars, 0x0750, 0x077f); // Arabic Supplement + addCharsToCharClass(nonLatinHashtagChars, 0x08a0, 0x08a0); // Arabic Extended A + addCharsToCharClass(nonLatinHashtagChars, 0x08a2, 0x08ac); + addCharsToCharClass(nonLatinHashtagChars, 0x08e4, 0x08fe); + addCharsToCharClass(nonLatinHashtagChars, 0xfb50, 0xfbb1); // Arabic Pres. Forms A + addCharsToCharClass(nonLatinHashtagChars, 0xfbd3, 0xfd3d); + addCharsToCharClass(nonLatinHashtagChars, 0xfd50, 0xfd8f); + addCharsToCharClass(nonLatinHashtagChars, 0xfd92, 0xfdc7); + addCharsToCharClass(nonLatinHashtagChars, 0xfdf0, 0xfdfb); + addCharsToCharClass(nonLatinHashtagChars, 0xfe70, 0xfe74); // Arabic Pres. Forms B + addCharsToCharClass(nonLatinHashtagChars, 0xfe76, 0xfefc); + addCharsToCharClass(nonLatinHashtagChars, 0x200c, 0x200c); // Zero-Width Non-Joiner + // Thai + addCharsToCharClass(nonLatinHashtagChars, 0x0e01, 0x0e3a); + addCharsToCharClass(nonLatinHashtagChars, 0x0e40, 0x0e4e); + // Hangul (Korean) + addCharsToCharClass(nonLatinHashtagChars, 0x1100, 0x11ff); // Hangul Jamo + addCharsToCharClass(nonLatinHashtagChars, 0x3130, 0x3185); // Hangul Compatibility Jamo + addCharsToCharClass(nonLatinHashtagChars, 0xA960, 0xA97F); // Hangul Jamo Extended-A + addCharsToCharClass(nonLatinHashtagChars, 0xAC00, 0xD7AF); // Hangul Syllables + addCharsToCharClass(nonLatinHashtagChars, 0xD7B0, 0xD7FF); // Hangul Jamo Extended-B + addCharsToCharClass(nonLatinHashtagChars, 0xFFA1, 0xFFDC); // half-width Hangul + // Japanese and Chinese + addCharsToCharClass(nonLatinHashtagChars, 0x30A1, 0x30FA); // Katakana (full-width) + addCharsToCharClass(nonLatinHashtagChars, 0x30FC, 0x30FE); // Katakana Chouon and iteration marks (full-width) + addCharsToCharClass(nonLatinHashtagChars, 0xFF66, 0xFF9F); // Katakana (half-width) + addCharsToCharClass(nonLatinHashtagChars, 0xFF70, 0xFF70); // Katakana Chouon (half-width) + addCharsToCharClass(nonLatinHashtagChars, 0xFF10, 0xFF19); // \ + addCharsToCharClass(nonLatinHashtagChars, 0xFF21, 0xFF3A); // - Latin (full-width) + addCharsToCharClass(nonLatinHashtagChars, 0xFF41, 0xFF5A); // / + addCharsToCharClass(nonLatinHashtagChars, 0x3041, 0x3096); // Hiragana + addCharsToCharClass(nonLatinHashtagChars, 0x3099, 0x309E); // Hiragana voicing and iteration mark + addCharsToCharClass(nonLatinHashtagChars, 0x3400, 0x4DBF); // Kanji (CJK Extension A) + addCharsToCharClass(nonLatinHashtagChars, 0x4E00, 0x9FFF); // Kanji (Unified) + // -- Disabled as it breaks the Regex. + //addCharsToCharClass(nonLatinHashtagChars, 0x20000, 0x2A6DF); // Kanji (CJK Extension B) + addCharsToCharClass(nonLatinHashtagChars, 0x2A700, 0x2B73F); // Kanji (CJK Extension C) + addCharsToCharClass(nonLatinHashtagChars, 0x2B740, 0x2B81F); // Kanji (CJK Extension D) + addCharsToCharClass(nonLatinHashtagChars, 0x2F800, 0x2FA1F); // Kanji (CJK supplement) + addCharsToCharClass(nonLatinHashtagChars, 0x3003, 0x3003); // Kanji iteration mark + addCharsToCharClass(nonLatinHashtagChars, 0x3005, 0x3005); // Kanji iteration mark + addCharsToCharClass(nonLatinHashtagChars, 0x303B, 0x303B); // Han iteration mark + + twttr.txt.regexen.nonLatinHashtagChars = regexSupplant(nonLatinHashtagChars.join("")); + + var latinAccentChars = []; + // Latin accented characters (subtracted 0xD7 from the range, it's a confusable multiplication sign. Looks like "x") + addCharsToCharClass(latinAccentChars, 0x00c0, 0x00d6); + addCharsToCharClass(latinAccentChars, 0x00d8, 0x00f6); + addCharsToCharClass(latinAccentChars, 0x00f8, 0x00ff); + // Latin Extended A and B + addCharsToCharClass(latinAccentChars, 0x0100, 0x024f); + // assorted IPA Extensions + addCharsToCharClass(latinAccentChars, 0x0253, 0x0254); + addCharsToCharClass(latinAccentChars, 0x0256, 0x0257); + addCharsToCharClass(latinAccentChars, 0x0259, 0x0259); + addCharsToCharClass(latinAccentChars, 0x025b, 0x025b); + addCharsToCharClass(latinAccentChars, 0x0263, 0x0263); + addCharsToCharClass(latinAccentChars, 0x0268, 0x0268); + addCharsToCharClass(latinAccentChars, 0x026f, 0x026f); + addCharsToCharClass(latinAccentChars, 0x0272, 0x0272); + addCharsToCharClass(latinAccentChars, 0x0289, 0x0289); + addCharsToCharClass(latinAccentChars, 0x028b, 0x028b); + // Okina for Hawaiian (it *is* a letter character) + addCharsToCharClass(latinAccentChars, 0x02bb, 0x02bb); + // Combining diacritics + addCharsToCharClass(latinAccentChars, 0x0300, 0x036f); + // Latin Extended Additional + addCharsToCharClass(latinAccentChars, 0x1e00, 0x1eff); + twttr.txt.regexen.latinAccentChars = regexSupplant(latinAccentChars.join("")); + + // A hashtag must contain characters, numbers and underscores, but not all numbers. + twttr.txt.regexen.hashSigns = /[##]/; + twttr.txt.regexen.hashtagAlpha = regexSupplant(/[a-z_#{latinAccentChars}#{nonLatinHashtagChars}]/i); + twttr.txt.regexen.hashtagAlphaNumeric = regexSupplant(/[a-z0-9_#{latinAccentChars}#{nonLatinHashtagChars}]/i); + twttr.txt.regexen.endHashtagMatch = regexSupplant(/^(?:#{hashSigns}|:\/\/)/); + twttr.txt.regexen.hashtagBoundary = regexSupplant(/(?:^|$|[^&a-z0-9_#{latinAccentChars}#{nonLatinHashtagChars}])/); + twttr.txt.regexen.validHashtag = regexSupplant(/(#{hashtagBoundary})(#{hashSigns})(#{hashtagAlphaNumeric}*#{hashtagAlpha}#{hashtagAlphaNumeric}*)/gi); + + // Mention related regex collection + twttr.txt.regexen.validMentionPrecedingChars = /(?:^|[^a-zA-Z0-9_!#$%&*@@]|RT:?)/; + twttr.txt.regexen.atSigns = /[@@]/; + twttr.txt.regexen.validMentionOrList = regexSupplant( + '(#{validMentionPrecedingChars})' + // $1: Preceding character + '(#{atSigns})' + // $2: At mark + '([a-zA-Z0-9_]{1,20})' + // $3: Screen name + '(\/[a-zA-Z][a-zA-Z0-9_\-]{0,24})?' // $4: List (optional) + , 'g'); + twttr.txt.regexen.validReply = regexSupplant(/^(?:#{spaces})*#{atSigns}([a-zA-Z0-9_]{1,20})/); + twttr.txt.regexen.endMentionMatch = regexSupplant(/^(?:#{atSigns}|[#{latinAccentChars}]|:\/\/)/); + + // URL related regex collection + twttr.txt.regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/); + twttr.txt.regexen.invalidUrlWithoutProtocolPrecedingChars = /[-_.\/]$/; + twttr.txt.regexen.invalidDomainChars = stringSupplant("#{punct}#{spaces_group}#{invalid_chars_group}", twttr.txt.regexen); + twttr.txt.regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/); + twttr.txt.regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/); + twttr.txt.regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/); + twttr.txt.regexen.validGTLD = regexSupplant(/(?:(?:aero|asia|biz|cat|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel|xxx)(?=[^0-9a-zA-Z]|$))/); + twttr.txt.regexen.validCCTLD = regexSupplant(/(?:(?: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|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|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|ss|st|su|sv|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)(?=[^0-9a-zA-Z]|$))/); + twttr.txt.regexen.validPunycode = regexSupplant(/(?:xn--[0-9a-z]+)/); + twttr.txt.regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/); + twttr.txt.regexen.validAsciiDomain = regexSupplant(/(?:(?:[a-z0-9#{latinAccentChars}]+)\.)+(?:#{validGTLD}|#{validCCTLD}|#{validPunycode})/gi); + twttr.txt.regexen.invalidShortDomain = regexSupplant(/^#{validDomainName}#{validCCTLD}$/); + + twttr.txt.regexen.validPortNumber = regexSupplant(/[0-9]+/); + + twttr.txt.regexen.validGeneralUrlPathChars = regexSupplant(/[a-z0-9!\*';:=\+,\.\$\/%#\[\]\-_~|&#{latinAccentChars}]/i); + // Allow URL paths to contain balanced parens + // 1. Used in Wikipedia URLs like /Primer_(film) + // 2. Used in IIS sessions like /S(dfd346)/ + twttr.txt.regexen.validUrlBalancedParens = regexSupplant(/\(#{validGeneralUrlPathChars}+\)/i); + // Valid end-of-path chracters (so /foo. does not gobble the period). + // 1. Allow =&# for empty URL parameters and other URL-join artifacts + twttr.txt.regexen.validUrlPathEndingChars = regexSupplant(/[\+\-a-z0-9=_#\/#{latinAccentChars}]|(?:#{validUrlBalancedParens})/i); + // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/ + twttr.txt.regexen.validUrlPath = regexSupplant('(?:' + + '(?:' + + '#{validGeneralUrlPathChars}*' + + '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' + + '#{validUrlPathEndingChars}'+ + ')|(?:@#{validGeneralUrlPathChars}+\/)'+ + ')', 'i'); + + twttr.txt.regexen.validUrlQueryChars = /[a-z0-9!?\*'\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i; + twttr.txt.regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i; + twttr.txt.regexen.extractUrl = regexSupplant( + '(' + // $1 total match + '(#{validUrlPrecedingChars})' + // $2 Preceeding chracter + '(' + // $3 URL + '(https?:\\/\\/)?' + // $4 Protocol (optional) + '(#{validDomain})' + // $5 Domain(s) + '(?::(#{validPortNumber}))?' + // $6 Port number (optional) + '(\\/#{validUrlPath}*)?' + // $7 URL Path + '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $8 Query String + ')' + + ')' + , 'gi'); + + twttr.txt.regexen.validTcoUrl = /^https?:\/\/t\.co\/[a-z0-9]+/i; + + // cashtag related regex + twttr.txt.regexen.cashtag = /[a-z]{1,6}(?:[._][a-z]{1,2})?/i; + twttr.txt.regexen.validCashtag = regexSupplant('(?:^|#{spaces})\\$(#{cashtag})(?=$|\\s|[#{punct}])', 'gi'); + + // These URL validation pattern strings are based on the ABNF from RFC 3986 + twttr.txt.regexen.validateUrlUnreserved = /[a-z0-9\-._~]/i; + twttr.txt.regexen.validateUrlPctEncoded = /(?:%[0-9a-f]{2})/i; + twttr.txt.regexen.validateUrlSubDelims = /[!$&'()*+,;=]/i; + twttr.txt.regexen.validateUrlPchar = regexSupplant('(?:' + + '#{validateUrlUnreserved}|' + + '#{validateUrlPctEncoded}|' + + '#{validateUrlSubDelims}|' + + '[:|@]' + + ')', 'i'); + + twttr.txt.regexen.validateUrlScheme = /(?:[a-z][a-z0-9+\-.]*)/i; + twttr.txt.regexen.validateUrlUserinfo = regexSupplant('(?:' + + '#{validateUrlUnreserved}|' + + '#{validateUrlPctEncoded}|' + + '#{validateUrlSubDelims}|' + + ':' + + ')*', 'i'); + + twttr.txt.regexen.validateUrlDecOctet = /(?:[0-9]|(?:[1-9][0-9])|(?:1[0-9]{2})|(?:2[0-4][0-9])|(?:25[0-5]))/i; + twttr.txt.regexen.validateUrlIpv4 = regexSupplant(/(?:#{validateUrlDecOctet}(?:\.#{validateUrlDecOctet}){3})/i); + + // Punting on real IPv6 validation for now + twttr.txt.regexen.validateUrlIpv6 = /(?:\[[a-f0-9:\.]+\])/i; + + // Also punting on IPvFuture for now + twttr.txt.regexen.validateUrlIp = regexSupplant('(?:' + + '#{validateUrlIpv4}|' + + '#{validateUrlIpv6}' + + ')', 'i'); + + // This is more strict than the rfc specifies + twttr.txt.regexen.validateUrlSubDomainSegment = /(?:[a-z0-9](?:[a-z0-9_\-]*[a-z0-9])?)/i; + twttr.txt.regexen.validateUrlDomainSegment = /(?:[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?)/i; + twttr.txt.regexen.validateUrlDomainTld = /(?:[a-z](?:[a-z0-9\-]*[a-z0-9])?)/i; + twttr.txt.regexen.validateUrlDomain = regexSupplant(/(?:(?:#{validateUrlSubDomainSegment]}\.)*(?:#{validateUrlDomainSegment]}\.)#{validateUrlDomainTld})/i); + + twttr.txt.regexen.validateUrlHost = regexSupplant('(?:' + + '#{validateUrlIp}|' + + '#{validateUrlDomain}' + + ')', 'i'); + + // Unencoded internationalized domains - this doesn't check for invalid UTF-8 sequences + twttr.txt.regexen.validateUrlUnicodeSubDomainSegment = /(?:(?:[a-z0-9]|[^\u0000-\u007f])(?:(?:[a-z0-9_\-]|[^\u0000-\u007f])*(?:[a-z0-9]|[^\u0000-\u007f]))?)/i; + twttr.txt.regexen.validateUrlUnicodeDomainSegment = /(?:(?:[a-z0-9]|[^\u0000-\u007f])(?:(?:[a-z0-9\-]|[^\u0000-\u007f])*(?:[a-z0-9]|[^\u0000-\u007f]))?)/i; + twttr.txt.regexen.validateUrlUnicodeDomainTld = /(?:(?:[a-z]|[^\u0000-\u007f])(?:(?:[a-z0-9\-]|[^\u0000-\u007f])*(?:[a-z0-9]|[^\u0000-\u007f]))?)/i; + twttr.txt.regexen.validateUrlUnicodeDomain = regexSupplant(/(?:(?:#{validateUrlUnicodeSubDomainSegment}\.)*(?:#{validateUrlUnicodeDomainSegment}\.)#{validateUrlUnicodeDomainTld})/i); + + twttr.txt.regexen.validateUrlUnicodeHost = regexSupplant('(?:' + + '#{validateUrlIp}|' + + '#{validateUrlUnicodeDomain}' + + ')', 'i'); + + twttr.txt.regexen.validateUrlPort = /[0-9]{1,5}/; + + twttr.txt.regexen.validateUrlUnicodeAuthority = regexSupplant( + '(?:(#{validateUrlUserinfo})@)?' + // $1 userinfo + '(#{validateUrlUnicodeHost})' + // $2 host + '(?::(#{validateUrlPort}))?' //$3 port + , "i"); + + twttr.txt.regexen.validateUrlAuthority = regexSupplant( + '(?:(#{validateUrlUserinfo})@)?' + // $1 userinfo + '(#{validateUrlHost})' + // $2 host + '(?::(#{validateUrlPort}))?' // $3 port + , "i"); + + twttr.txt.regexen.validateUrlPath = regexSupplant(/(\/#{validateUrlPchar}*)*/i); + twttr.txt.regexen.validateUrlQuery = regexSupplant(/(#{validateUrlPchar}|\/|\?)*/i); + twttr.txt.regexen.validateUrlFragment = regexSupplant(/(#{validateUrlPchar}|\/|\?)*/i); + + // Modified version of RFC 3986 Appendix B + twttr.txt.regexen.validateUrlUnencoded = regexSupplant( + '^' + // Full URL + '(?:' + + '([^:/?#]+):\\/\\/' + // $1 Scheme + ')?' + + '([^/?#]*)' + // $2 Authority + '([^?#]*)' + // $3 Path + '(?:' + + '\\?([^#]*)' + // $4 Query + ')?' + + '(?:' + + '#(.*)' + // $5 Fragment + ')?$' + , "i"); + + + // Default CSS class for auto-linked lists (along with the url class) + var DEFAULT_LIST_CLASS = "tweet-url list-slug"; + // Default CSS class for auto-linked usernames (along with the url class) + var DEFAULT_USERNAME_CLASS = "tweet-url username"; + // Default CSS class for auto-linked hashtags (along with the url class) + var DEFAULT_HASHTAG_CLASS = "tweet-url hashtag"; + // Default CSS class for auto-linked cashtags (along with the url class) + var DEFAULT_CASHTAG_CLASS = "tweet-url cashtag"; + // Options which should not be passed as HTML attributes + var OPTIONS_NOT_ATTRIBUTES = {'urlClass':true, 'listClass':true, 'usernameClass':true, 'hashtagClass':true, 'cashtagClass':true, + 'usernameUrlBase':true, 'listUrlBase':true, 'hashtagUrlBase':true, 'cashtagUrlBase':true, + 'usernameUrlBlock':true, 'listUrlBlock':true, 'hashtagUrlBlock':true, 'linkUrlBlock':true, + 'usernameIncludeSymbol':true, 'suppressLists':true, 'suppressNoFollow':true, + 'suppressDataScreenName':true, 'urlEntities':true, 'symbolTag':true, 'textWithSymbolTag':true, 'urlTarget':true, + 'invisibleTagAttrs':true, 'linkAttributeBlock':true, 'linkTextBlock': true + }; + var BOOLEAN_ATTRIBUTES = {'disabled':true, 'readonly':true, 'multiple':true, 'checked':true}; + + // Simple object cloning function for simple objects + function clone(o) { + var r = {}; + for (var k in o) { + if (o.hasOwnProperty(k)) { + r[k] = o[k]; + } + } + + return r; + } + + twttr.txt.tagAttrs = function(attributes) { + var htmlAttrs = ""; + for (var k in attributes) { + var v = attributes[k]; + if (BOOLEAN_ATTRIBUTES[k]) { + v = v ? k : null; + } + if (v == null) continue; + htmlAttrs += " " + twttr.txt.htmlEscape(k) + "=\"" + twttr.txt.htmlEscape(v.toString()) + "\""; + } + return htmlAttrs; + }; + + twttr.txt.linkToText = function(entity, text, attributes, options) { + if (!options.suppressNoFollow) { + attributes.rel = "nofollow"; + } + // if linkAttributeBlock is specified, call it to modify the attributes + if (options.linkAttributeBlock) { + options.linkAttributeBlock(entity, attributes); + } + // if linkTextBlock is specified, call it to get a new/modified link text + if (options.linkTextBlock) { + text = options.linkTextBlock(entity, text); + } + var d = { + text: text, + attr: twttr.txt.tagAttrs(attributes) + }; + return stringSupplant("#{text}
            ", d); + }; + + twttr.txt.linkToTextWithSymbol = function(entity, symbol, text, attributes, options) { + var taggedSymbol = options.symbolTag ? "<" + options.symbolTag + ">" + symbol + "" : symbol; + text = twttr.txt.htmlEscape(text); + var taggedText = options.textWithSymbolTag ? "<" + options.textWithSymbolTag + ">" + text + "" : text; + + if (options.usernameIncludeSymbol || !symbol.match(twttr.txt.regexen.atSigns)) { + return twttr.txt.linkToText(entity, taggedSymbol + taggedText, attributes, options); + } else { + return taggedSymbol + twttr.txt.linkToText(entity, taggedText, attributes, options); + } + }; + + twttr.txt.linkToHashtag = function(entity, text, options) { + var hash = text.substring(entity.indices[0], entity.indices[0] + 1); + var hashtag = twttr.txt.htmlEscape(entity.hashtag); + var attrs = clone(options.htmlAttrs || {}); + attrs.href = options.hashtagUrlBase + hashtag; + attrs.title = "#" + hashtag; + attrs["class"] = options.hashtagClass; + + return twttr.txt.linkToTextWithSymbol(entity, hash, hashtag, attrs, options); + }; + + twttr.txt.linkToCashtag = function(entity, text, options) { + var cashtag = twttr.txt.htmlEscape(entity.cashtag); + var attrs = clone(options.htmlAttrs || {}); + attrs.href = options.cashtagUrlBase + cashtag; + attrs.title = "$" + cashtag; + attrs["class"] = options.cashtagClass; + + return twttr.txt.linkToTextWithSymbol(entity, "$", cashtag, attrs, options); + }; + + twttr.txt.linkToMentionAndList = function(entity, text, options) { + var at = text.substring(entity.indices[0], entity.indices[0] + 1); + var user = twttr.txt.htmlEscape(entity.screenName); + var slashListname = twttr.txt.htmlEscape(entity.listSlug); + var isList = entity.listSlug && !options.suppressLists; + var attrs = clone(options.htmlAttrs || {}); + attrs["class"] = (isList ? options.listClass : options.usernameClass); + attrs.href = isList ? options.listUrlBase + user + slashListname : options.usernameUrlBase + user; + if (!isList && !options.suppressDataScreenName) { + attrs['data-screen-name'] = user; + } + + return twttr.txt.linkToTextWithSymbol(entity, at, isList ? user + slashListname : user, attrs, options); + }; + + twttr.txt.linkToUrl = function(entity, text, options) { + var url = entity.url; + var displayUrl = url; + var linkText = twttr.txt.htmlEscape(displayUrl); + + // If the caller passed a urlEntities object (provided by a Twitter API + // response with include_entities=true), we use that to render the display_url + // for each URL instead of it's underlying t.co URL. + var urlEntity = (options.urlEntities && options.urlEntities[url]) || entity; + if (urlEntity.display_url) { + linkText = twttr.txt.linkTextWithEntity(urlEntity, options); + } + + var attrs = clone(options.htmlAttrs || {}); + attrs.href = url; + + // set class only if urlClass is specified. + if (options.urlClass) { + attrs["class"] = options.urlClass; + } + + // set target only if urlTarget is specified. + if (options.urlTarget) { + attrs.target = options.urlTarget; + } + + if (!options.title && urlEntity.display_url) { + attrs.title = urlEntity.expanded_url; + } + + return twttr.txt.linkToText(entity, linkText, attrs, options); + }; + + twttr.txt.linkTextWithEntity = function (entity, options) { + var displayUrl = entity.display_url; + var expandedUrl = entity.expanded_url; + + // Goal: If a user copies and pastes a tweet containing t.co'ed link, the resulting paste + // should contain the full original URL (expanded_url), not the display URL. + // + // Method: Whenever possible, we actually emit HTML that contains expanded_url, and use + // font-size:0 to hide those parts that should not be displayed (because they are not part of display_url). + // Elements with font-size:0 get copied even though they are not visible. + // Note that display:none doesn't work here. Elements with display:none don't get copied. + // + // Additionally, we want to *display* ellipses, but we don't want them copied. To make this happen we + // wrap the ellipses in a tco-ellipsis class and provide an onCopy handler that sets display:none on + // everything with the tco-ellipsis class. + // + // Exception: pic.twitter.com images, for which expandedUrl = "https://twitter.com/#!/username/status/1234/photo/1 + // For those URLs, display_url is not a substring of expanded_url, so we don't do anything special to render the elided parts. + // For a pic.twitter.com URL, the only elided part will be the "https://", so this is fine. + + var displayUrlSansEllipses = displayUrl.replace(/…/g, ""); // We have to disregard ellipses for matching + // Note: we currently only support eliding parts of the URL at the beginning or the end. + // Eventually we may want to elide parts of the URL in the *middle*. If so, this code will + // become more complicated. We will probably want to create a regexp out of display URL, + // replacing every ellipsis with a ".*". + if (expandedUrl.indexOf(displayUrlSansEllipses) != -1) { + var displayUrlIndex = expandedUrl.indexOf(displayUrlSansEllipses); + var v = { + displayUrlSansEllipses: displayUrlSansEllipses, + // Portion of expandedUrl that precedes the displayUrl substring + beforeDisplayUrl: expandedUrl.substr(0, displayUrlIndex), + // Portion of expandedUrl that comes after displayUrl + afterDisplayUrl: expandedUrl.substr(displayUrlIndex + displayUrlSansEllipses.length), + precedingEllipsis: displayUrl.match(/^…/) ? "…" : "", + followingEllipsis: displayUrl.match(/…$/) ? "…" : "" + }; + for (var k in v) { + if (v.hasOwnProperty(k)) { + v[k] = twttr.txt.htmlEscape(v[k]); + } + } + // As an example: The user tweets "hi http://longdomainname.com/foo" + // This gets shortened to "hi http://t.co/xyzabc", with display_url = "…nname.com/foo" + // This will get rendered as: + // + // … + // + // http://longdomai + // + // + // nname.com/foo + // + // + //   + // … + // + v['invisible'] = options.invisibleTagAttrs; + return stringSupplant("#{precedingEllipsis} #{beforeDisplayUrl}#{displayUrlSansEllipses}#{afterDisplayUrl} #{followingEllipsis}", v); + } + return displayUrl; + }; + + twttr.txt.autoLinkEntities = function(text, entities, options) { + options = clone(options || {}); + + options.hashtagClass = options.hashtagClass || DEFAULT_HASHTAG_CLASS; + options.hashtagUrlBase = options.hashtagUrlBase || "https://twitter.com/#!/search?q=%23"; + options.cashtagClass = options.cashtagClass || DEFAULT_CASHTAG_CLASS; + options.cashtagUrlBase = options.cashtagUrlBase || "https://twitter.com/#!/search?q=%24"; + options.listClass = options.listClass || DEFAULT_LIST_CLASS; + options.usernameClass = options.usernameClass || DEFAULT_USERNAME_CLASS; + options.usernameUrlBase = options.usernameUrlBase || "https://twitter.com/"; + options.listUrlBase = options.listUrlBase || "https://twitter.com/"; + options.htmlAttrs = twttr.txt.extractHtmlAttrsFromOptions(options); + options.invisibleTagAttrs = options.invisibleTagAttrs || "style='position:absolute;left:-9999px;'"; + + // remap url entities to hash + var urlEntities, i, len; + if(options.urlEntities) { + urlEntities = {}; + for(i = 0, len = options.urlEntities.length; i < len; i++) { + urlEntities[options.urlEntities[i].url] = options.urlEntities[i]; + } + options.urlEntities = urlEntities; + } + + var result = ""; + var beginIndex = 0; + + // sort entities by start index + entities.sort(function(a,b){ return a.indices[0] - b.indices[0]; }); + + for (var i = 0; i < entities.length; i++) { + var entity = entities[i]; + result += text.substring(beginIndex, entity.indices[0]); + + if (entity.url) { + result += twttr.txt.linkToUrl(entity, text, options); + } else if (entity.hashtag) { + result += twttr.txt.linkToHashtag(entity, text, options); + } else if (entity.screenName) { + result += twttr.txt.linkToMentionAndList(entity, text, options); + } else if (entity.cashtag) { + result += twttr.txt.linkToCashtag(entity, text, options); + } + beginIndex = entity.indices[1]; + } + result += text.substring(beginIndex, text.length); + return result; + }; + + twttr.txt.autoLinkWithJSON = function(text, json, options) { + // concatenate all entities + var entities = []; + for (var key in json) { + entities = entities.concat(json[key]); + } + // map JSON entity to twitter-text entity + for (var i = 0; i < entities.length; i++) { + entity = entities[i]; + if (entity.screen_name) { + // this is @mention + entity.screenName = entity.screen_name; + } else if (entity.text) { + // this is #hashtag + entity.hashtag = entity.text; + } + } + // modify indices to UTF-16 + twttr.txt.modifyIndicesFromUnicodeToUTF16(text, entities); + + return twttr.txt.autoLinkEntities(text, entities, options); + }; + + twttr.txt.extractHtmlAttrsFromOptions = function(options) { + var htmlAttrs = {}; + for (var k in options) { + var v = options[k]; + if (OPTIONS_NOT_ATTRIBUTES[k]) continue; + if (BOOLEAN_ATTRIBUTES[k]) { + v = v ? k : null; + } + if (v == null) continue; + htmlAttrs[k] = v; + } + return htmlAttrs; + }; + + twttr.txt.autoLink = function(text, options) { + var entities = twttr.txt.extractEntitiesWithIndices(text, {extractUrlWithoutProtocol: false}); + return twttr.txt.autoLinkEntities(text, entities, options); + }; + + twttr.txt.autoLinkUsernamesOrLists = function(text, options) { + var entities = twttr.txt.extractMentionsOrListsWithIndices(text); + return twttr.txt.autoLinkEntities(text, entities, options); + }; + + twttr.txt.autoLinkHashtags = function(text, options) { + var entities = twttr.txt.extractHashtagsWithIndices(text); + return twttr.txt.autoLinkEntities(text, entities, options); + }; + + twttr.txt.autoLinkCashtags = function(text, options) { + var entities = twttr.txt.extractCashtagsWithIndices(text); + return twttr.txt.autoLinkEntities(text, entities, options); + }; + + twttr.txt.autoLinkUrlsCustom = function(text, options) { + var entities = twttr.txt.extractUrlsWithIndices(text, {extractUrlWithoutProtocol: false}); + return twttr.txt.autoLinkEntities(text, entities, options); + }; + + twttr.txt.removeOverlappingEntities = function(entities) { + entities.sort(function(a,b){ return a.indices[0] - b.indices[0]; }); + + var prev = entities[0]; + for (var i = 1; i < entities.length; i++) { + if (prev.indices[1] > entities[i].indices[0]) { + entities.splice(i, 1); + i--; + } else { + prev = entities[i]; + } + } + }; + + twttr.txt.extractEntitiesWithIndices = function(text, options) { + var entities = twttr.txt.extractUrlsWithIndices(text, options) + .concat(twttr.txt.extractMentionsOrListsWithIndices(text)) + .concat(twttr.txt.extractHashtagsWithIndices(text, {checkUrlOverlap: false})) + .concat(twttr.txt.extractCashtagsWithIndices(text)); + + if (entities.length == 0) { + return []; + } + + twttr.txt.removeOverlappingEntities(entities); + return entities; + }; + + twttr.txt.extractMentions = function(text) { + var screenNamesOnly = [], + screenNamesWithIndices = twttr.txt.extractMentionsWithIndices(text); + + for (var i = 0; i < screenNamesWithIndices.length; i++) { + var screenName = screenNamesWithIndices[i].screenName; + screenNamesOnly.push(screenName); + } + + return screenNamesOnly; + }; + + twttr.txt.extractMentionsWithIndices = function(text) { + var mentions = []; + var mentionsOrLists = twttr.txt.extractMentionsOrListsWithIndices(text); + + for (var i = 0 ; i < mentionsOrLists.length; i++) { + mentionOrList = mentionsOrLists[i]; + if (mentionOrList.listSlug == '') { + mentions.push({ + screenName: mentionOrList.screenName, + indices: mentionOrList.indices + }); + } + } + + return mentions; + }; + + /** + * Extract list or user mentions. + * (Presence of listSlug indicates a list) + */ + twttr.txt.extractMentionsOrListsWithIndices = function(text) { + if (!text || !text.match(twttr.txt.regexen.atSigns)) { + return []; + } + + var possibleNames = [], + position = 0; + + text.replace(twttr.txt.regexen.validMentionOrList, function(match, before, atSign, screenName, slashListname, offset, chunk) { + var after = chunk.slice(offset + match.length); + if (!after.match(twttr.txt.regexen.endMentionMatch)) { + slashListname = slashListname || ''; + var startPosition = text.indexOf(atSign + screenName + slashListname, position); + position = startPosition + screenName.length + slashListname.length + 1; + possibleNames.push({ + screenName: screenName, + listSlug: slashListname, + indices: [startPosition, position] + }); + } + }); + + return possibleNames; + }; + + + twttr.txt.extractReplies = function(text) { + if (!text) { + return null; + } + + var possibleScreenName = text.match(twttr.txt.regexen.validReply); + if (!possibleScreenName || + RegExp.rightContext.match(twttr.txt.regexen.endMentionMatch)) { + return null; + } + + return possibleScreenName[1]; + }; + + twttr.txt.extractUrls = function(text, options) { + var urlsOnly = [], + urlsWithIndices = twttr.txt.extractUrlsWithIndices(text, options); + + for (var i = 0; i < urlsWithIndices.length; i++) { + urlsOnly.push(urlsWithIndices[i].url); + } + + return urlsOnly; + }; + + twttr.txt.extractUrlsWithIndices = function(text, options) { + if (!options) { + options = {extractUrlsWithoutProtocol: true}; + } + + if (!text || (options.extractUrlsWithoutProtocol ? !text.match(/\./) : !text.match(/:/))) { + return []; + } + + var urls = []; + + while (twttr.txt.regexen.extractUrl.exec(text)) { + var before = RegExp.$2, url = RegExp.$3, protocol = RegExp.$4, domain = RegExp.$5, path = RegExp.$7; + var endPosition = twttr.txt.regexen.extractUrl.lastIndex, + startPosition = endPosition - url.length; + + // if protocol is missing and domain contains non-ASCII characters, + // extract ASCII-only domains. + if (!protocol) { + if (!options.extractUrlsWithoutProtocol + || before.match(twttr.txt.regexen.invalidUrlWithoutProtocolPrecedingChars)) { + continue; + } + var lastUrl = null, + lastUrlInvalidMatch = false, + asciiEndPosition = 0; + domain.replace(twttr.txt.regexen.validAsciiDomain, function(asciiDomain) { + var asciiStartPosition = domain.indexOf(asciiDomain, asciiEndPosition); + asciiEndPosition = asciiStartPosition + asciiDomain.length; + lastUrl = { + url: asciiDomain, + indices: [startPosition + asciiStartPosition, startPosition + asciiEndPosition] + }; + lastUrlInvalidMatch = asciiDomain.match(twttr.txt.regexen.invalidShortDomain); + if (!lastUrlInvalidMatch) { + urls.push(lastUrl); + } + }); + + // no ASCII-only domain found. Skip the entire URL. + if (lastUrl == null) { + continue; + } + + // lastUrl only contains domain. Need to add path and query if they exist. + if (path) { + if (lastUrlInvalidMatch) { + urls.push(lastUrl); + } + lastUrl.url = url.replace(domain, lastUrl.url); + lastUrl.indices[1] = endPosition; + } + } else { + // In the case of t.co URLs, don't allow additional path characters. + if (url.match(twttr.txt.regexen.validTcoUrl)) { + url = RegExp.lastMatch; + endPosition = startPosition + url.length; + } + urls.push({ + url: url, + indices: [startPosition, endPosition] + }); + } + } + + return urls; + }; + + twttr.txt.extractHashtags = function(text) { + var hashtagsOnly = [], + hashtagsWithIndices = twttr.txt.extractHashtagsWithIndices(text); + + for (var i = 0; i < hashtagsWithIndices.length; i++) { + hashtagsOnly.push(hashtagsWithIndices[i].hashtag); + } + + return hashtagsOnly; + }; + + twttr.txt.extractHashtagsWithIndices = function(text, options) { + if (!options) { + options = {checkUrlOverlap: true}; + } + + if (!text || !text.match(twttr.txt.regexen.hashSigns)) { + return []; + } + + var tags = [], + position = 0; + + text.replace(twttr.txt.regexen.validHashtag, function(match, before, hash, hashText, offset, chunk) { + var after = chunk.slice(offset + match.length); + if (after.match(twttr.txt.regexen.endHashtagMatch)) + return; + var startPosition = text.indexOf(hash + hashText, position); + position = startPosition + hashText.length + 1; + tags.push({ + hashtag: hashText, + indices: [startPosition, position] + }); + }); + + if (options.checkUrlOverlap) { + // also extract URL entities + var urls = twttr.txt.extractUrlsWithIndices(text); + if (urls.length > 0) { + var entities = tags.concat(urls); + // remove overlap + twttr.txt.removeOverlappingEntities(entities); + // only push back hashtags + tags = []; + for (var i = 0; i < entities.length; i++) { + if (entities[i].hashtag) { + tags.push(entities[i]); + } + } + } + } + + return tags; + }; + + twttr.txt.extractCashtags = function(text) { + var cashtagsOnly = [], + cashtagsWithIndices = twttr.txt.extractCashtagsWithIndices(text); + + for (var i = 0; i < cashtagsWithIndices.length; i++) { + cashtagsOnly.push(cashtagsWithIndices[i].cashtag); + } + + return cashtagsOnly; + }; + + twttr.txt.extractCashtagsWithIndices = function(text) { + if (!text || text.indexOf("$") == -1) { + return []; + } + + var tags = [], + position = 0; + + text.replace(twttr.txt.regexen.validCashtag, function(match, cashtag, offset, chunk) { + // cashtag doesn't contain $ sign, so need to decrement index by 1. + var startPosition = text.indexOf(cashtag, position) - 1; + position = startPosition + cashtag.length + 1; + tags.push({ + cashtag: cashtag, + indices: [startPosition, position] + }); + }); + + return tags; + }; + + twttr.txt.modifyIndicesFromUnicodeToUTF16 = function(text, entities) { + twttr.txt.convertUnicodeIndices(text, entities, false); + }; + + twttr.txt.modifyIndicesFromUTF16ToUnicode = function(text, entities) { + twttr.txt.convertUnicodeIndices(text, entities, true); + }; + + twttr.txt.convertUnicodeIndices = function(text, entities, indicesInUTF16) { + if (entities.length == 0) { + return; + } + + var charIndex = 0; + var codePointIndex = 0; + + // sort entities by start index + entities.sort(function(a,b){ return a.indices[0] - b.indices[0]; }); + var entityIndex = 0; + var entity = entities[0]; + + while (charIndex < text.length) { + if (entity.indices[0] == (indicesInUTF16 ? charIndex : codePointIndex)) { + var len = entity.indices[1] - entity.indices[0]; + entity.indices[0] = indicesInUTF16 ? codePointIndex : charIndex; + entity.indices[1] = entity.indices[0] + len; + + entityIndex++; + if (entityIndex == entities.length) { + // no more entity + break; + } + entity = entities[entityIndex]; + } + + var c = text.charCodeAt(charIndex); + if (0xD800 <= c && c <= 0xDBFF && charIndex < text.length - 1) { + // Found high surrogate char + c = text.charCodeAt(charIndex + 1); + if (0xDC00 <= c && c <= 0xDFFF) { + // Found surrogate pair + charIndex++; + } + } + codePointIndex++; + charIndex++; + } + }; + + // this essentially does text.split(/<|>/) + // except that won't work in IE, where empty strings are ommitted + // so "<>".split(/<|>/) => [] in IE, but is ["", "", ""] in all others + // but "<<".split("<") => ["", "", ""] + twttr.txt.splitTags = function(text) { + var firstSplits = text.split("<"), + secondSplits, + allSplits = [], + split; + + for (var i = 0; i < firstSplits.length; i += 1) { + split = firstSplits[i]; + if (!split) { + allSplits.push(""); + } else { + secondSplits = split.split(">"); + for (var j = 0; j < secondSplits.length; j += 1) { + allSplits.push(secondSplits[j]); + } + } + } + + return allSplits; + }; + + twttr.txt.hitHighlight = function(text, hits, options) { + var defaultHighlightTag = "em"; + + hits = hits || []; + options = options || {}; + + if (hits.length === 0) { + return text; + } + + var tagName = options.tag || defaultHighlightTag, + tags = ["<" + tagName + ">", ""], + chunks = twttr.txt.splitTags(text), + i, + j, + result = "", + chunkIndex = 0, + chunk = chunks[0], + prevChunksLen = 0, + chunkCursor = 0, + startInChunk = false, + chunkChars = chunk, + flatHits = [], + index, + hit, + tag, + placed, + hitSpot; + + for (i = 0; i < hits.length; i += 1) { + for (j = 0; j < hits[i].length; j += 1) { + flatHits.push(hits[i][j]); + } + } + + for (index = 0; index < flatHits.length; index += 1) { + hit = flatHits[index]; + tag = tags[index % 2]; + placed = false; + + while (chunk != null && hit >= prevChunksLen + chunk.length) { + result += chunkChars.slice(chunkCursor); + if (startInChunk && hit === prevChunksLen + chunkChars.length) { + result += tag; + placed = true; + } + + if (chunks[chunkIndex + 1]) { + result += "<" + chunks[chunkIndex + 1] + ">"; + } + + prevChunksLen += chunkChars.length; + chunkCursor = 0; + chunkIndex += 2; + chunk = chunks[chunkIndex]; + chunkChars = chunk; + startInChunk = false; + } + + if (!placed && chunk != null) { + hitSpot = hit - prevChunksLen; + result += chunkChars.slice(chunkCursor, hitSpot) + tag; + chunkCursor = hitSpot; + if (index % 2 === 0) { + startInChunk = true; + } else { + startInChunk = false; + } + } else if(!placed) { + placed = true; + result += tag; + } + } + + if (chunk != null) { + if (chunkCursor < chunkChars.length) { + result += chunkChars.slice(chunkCursor); + } + for (index = chunkIndex + 1; index < chunks.length; index += 1) { + result += (index % 2 === 0 ? chunks[index] : "<" + chunks[index] + ">"); + } + } + + return result; + }; + + var MAX_LENGTH = 140; + + // Characters not allowed in Tweets + var INVALID_CHARACTERS = [ + // BOM + fromCode(0xFFFE), + fromCode(0xFEFF), + + // Special + fromCode(0xFFFF), + + // Directional Change + fromCode(0x202A), + fromCode(0x202B), + fromCode(0x202C), + fromCode(0x202D), + fromCode(0x202E) + ]; + + // Returns the length of Tweet text with consideration to t.co URL replacement + twttr.txt.getTweetLength = function(text, options) { + if (!options) { + options = { + short_url_length: 20, + short_url_length_https: 21 + }; + } + var textLength = text.length; + var urlsWithIndices = twttr.txt.extractUrlsWithIndices(text); + + for (var i = 0; i < urlsWithIndices.length; i++) { + // Subtract the length of the original URL + textLength += urlsWithIndices[i].indices[0] - urlsWithIndices[i].indices[1]; + + // Add 21 characters for URL starting with https:// + // Otherwise add 20 characters + if (urlsWithIndices[i].url.toLowerCase().match(/^https:\/\//)) { + textLength += options.short_url_length_https; + } else { + textLength += options.short_url_length; + } + } + + return textLength; + }; + + // Check the text for any reason that it may not be valid as a Tweet. This is meant as a pre-validation + // before posting to api.twitter.com. There are several server-side reasons for Tweets to fail but this pre-validation + // will allow quicker feedback. + // + // Returns false if this text is valid. Otherwise one of the following strings will be returned: + // + // "too_long": if the text is too long + // "empty": if the text is nil or empty + // "invalid_characters": if the text contains non-Unicode or any of the disallowed Unicode characters + twttr.txt.isInvalidTweet = function(text) { + if (!text) { + return "empty"; + } + + // Determine max length independent of URL length + if (twttr.txt.getTweetLength(text) > MAX_LENGTH) { + return "too_long"; + } + + for (var i = 0; i < INVALID_CHARACTERS.length; i++) { + if (text.indexOf(INVALID_CHARACTERS[i]) >= 0) { + return "invalid_characters"; + } + } + + return false; + }; + + twttr.txt.isValidTweetText = function(text) { + return !twttr.txt.isInvalidTweet(text); + }; + + twttr.txt.isValidUsername = function(username) { + if (!username) { + return false; + } + + var extracted = twttr.txt.extractMentions(username); + + // Should extract the username minus the @ sign, hence the .slice(1) + return extracted.length === 1 && extracted[0] === username.slice(1); + }; + + var VALID_LIST_RE = regexSupplant(/^#{validMentionOrList}$/); + + twttr.txt.isValidList = function(usernameList) { + var match = usernameList.match(VALID_LIST_RE); + + // Must have matched and had nothing before or after + return !!(match && match[1] == "" && match[4]); + }; + + twttr.txt.isValidHashtag = function(hashtag) { + if (!hashtag) { + return false; + } + + var extracted = twttr.txt.extractHashtags(hashtag); + + // Should extract the hashtag minus the # sign, hence the .slice(1) + return extracted.length === 1 && extracted[0] === hashtag.slice(1); + }; + + twttr.txt.isValidUrl = function(url, unicodeDomains, requireProtocol) { + if (unicodeDomains == null) { + unicodeDomains = true; + } + + if (requireProtocol == null) { + requireProtocol = true; + } + + if (!url) { + return false; + } + + var urlParts = url.match(twttr.txt.regexen.validateUrlUnencoded); + + if (!urlParts || urlParts[0] !== url) { + return false; + } + + var scheme = urlParts[1], + authority = urlParts[2], + path = urlParts[3], + query = urlParts[4], + fragment = urlParts[5]; + + if (!( + (!requireProtocol || (isValidMatch(scheme, twttr.txt.regexen.validateUrlScheme) && scheme.match(/^https?$/i))) && + isValidMatch(path, twttr.txt.regexen.validateUrlPath) && + isValidMatch(query, twttr.txt.regexen.validateUrlQuery, true) && + isValidMatch(fragment, twttr.txt.regexen.validateUrlFragment, true) + )) { + return false; + } + + return (unicodeDomains && isValidMatch(authority, twttr.txt.regexen.validateUrlUnicodeAuthority)) || + (!unicodeDomains && isValidMatch(authority, twttr.txt.regexen.validateUrlAuthority)); + }; + + function isValidMatch(string, regex, optional) { + if (!optional) { + // RegExp["$&"] is the text of the last match + // blank strings are ok, but are falsy, so we check stringiness instead of truthiness + return ((typeof string === "string") && string.match(regex) && RegExp["$&"] === string); + } + + // RegExp["$&"] is the text of the last match + return (!string || (string.match(regex) && RegExp["$&"] === string)); + } + + if (typeof module != 'undefined' && module.exports) { + module.exports = twttr.txt; + } + +}()); diff --git a/app/assets/javascripts/external_production/ember.js b/app/assets/javascripts/external_production/ember.js new file mode 100644 index 00000000000..f16bfd4bcb9 --- /dev/null +++ b/app/assets/javascripts/external_production/ember.js @@ -0,0 +1,26480 @@ +(function() { +var define, requireModule; + +(function() { + var registry = {}, seen = {}; + + define = function(name, deps, callback) { + registry[name] = { deps: deps, callback: callback }; + }; + + requireModule = function(name) { + if (seen[name]) { return seen[name]; } + seen[name] = {}; + + var mod = registry[name], + deps = mod.deps, + callback = mod.callback, + reified = [], + exports; + + for (var i=0, l=deps.length; i= 0) { + intersection.push(element); + } + }); + + return intersection; + } +}; + +})(); + + + +(function() { +/*jshint newcap:false*/ +/** +@module ember-metal +*/ + +// NOTE: There is a bug in jshint that doesn't recognize `Object()` without `new` +// as being ok unless both `newcap:false` and not `use strict`. +// https://github.com/jshint/jshint/issues/392 + +// Testing this is not ideal, but we want to use native functions +// if available, but not to use versions created by libraries like Prototype +var isNativeFunc = function(func) { + // This should probably work in all browsers likely to have ES5 array methods + return func && Function.prototype.toString.call(func).indexOf('[native code]') > -1; +}; + +// From: https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/array/map +var arrayMap = isNativeFunc(Array.prototype.map) ? Array.prototype.map : function(fun /*, thisp */) { + //"use strict"; + + if (this === void 0 || this === null) { + throw new TypeError(); + } + + var t = Object(this); + var len = t.length >>> 0; + if (typeof fun !== "function") { + throw new TypeError(); + } + + var res = new Array(len); + var thisp = arguments[1]; + for (var i = 0; i < len; i++) { + if (i in t) { + res[i] = fun.call(thisp, t[i], i, t); + } + } + + return res; +}; + +// From: https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/array/foreach +var arrayForEach = isNativeFunc(Array.prototype.forEach) ? Array.prototype.forEach : function(fun /*, thisp */) { + //"use strict"; + + if (this === void 0 || this === null) { + throw new TypeError(); + } + + var t = Object(this); + var len = t.length >>> 0; + if (typeof fun !== "function") { + throw new TypeError(); + } + + var thisp = arguments[1]; + for (var i = 0; i < len; i++) { + if (i in t) { + fun.call(thisp, t[i], i, t); + } + } +}; + +var arrayIndexOf = isNativeFunc(Array.prototype.indexOf) ? Array.prototype.indexOf : function (obj, fromIndex) { + if (fromIndex === null || fromIndex === undefined) { fromIndex = 0; } + else if (fromIndex < 0) { fromIndex = Math.max(0, this.length + fromIndex); } + for (var i = fromIndex, j = this.length; i < j; i++) { + if (this[i] === obj) { return i; } + } + return -1; +}; + +Ember.ArrayPolyfills = { + map: arrayMap, + forEach: arrayForEach, + indexOf: arrayIndexOf +}; + +if (Ember.SHIM_ES5) { + if (!Array.prototype.map) { + Array.prototype.map = arrayMap; + } + + if (!Array.prototype.forEach) { + Array.prototype.forEach = arrayForEach; + } + + if (!Array.prototype.indexOf) { + Array.prototype.indexOf = arrayIndexOf; + } +} + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +/* + JavaScript (before ES6) does not have a Map implementation. Objects, + which are often used as dictionaries, may only have Strings as keys. + + Because Ember has a way to get a unique identifier for every object + via `Ember.guidFor`, we can implement a performant Map with arbitrary + keys. Because it is commonly used in low-level bookkeeping, Map is + implemented as a pure JavaScript object for performance. + + This implementation follows the current iteration of the ES6 proposal for + maps (http://wiki.ecmascript.org/doku.php?id=harmony:simple_maps_and_sets), + with two exceptions. First, because we need our implementation to be pleasant + on older browsers, we do not use the `delete` name (using `remove` instead). + Second, as we do not have the luxury of in-VM iteration, we implement a + forEach method for iteration. + + Map is mocked out to look like an Ember object, so you can do + `Ember.Map.create()` for symmetry with other Ember classes. +*/ +var guidFor = Ember.guidFor, + indexOf = Ember.ArrayPolyfills.indexOf; + +var copy = function(obj) { + var output = {}; + + for (var prop in obj) { + if (obj.hasOwnProperty(prop)) { output[prop] = obj[prop]; } + } + + return output; +}; + +var copyMap = function(original, newObject) { + var keys = original.keys.copy(), + values = copy(original.values); + + newObject.keys = keys; + newObject.values = values; + + return newObject; +}; + +/** + This class is used internally by Ember and Ember Data. + Please do not use it at this time. We plan to clean it up + and add many tests soon. + + @class OrderedSet + @namespace Ember + @constructor + @private +*/ +var OrderedSet = Ember.OrderedSet = function() { + this.clear(); +}; + +/** + @method create + @static + @return {Ember.OrderedSet} +*/ +OrderedSet.create = function() { + return new OrderedSet(); +}; + + +OrderedSet.prototype = { + /** + @method clear + */ + clear: function() { + this.presenceSet = {}; + this.list = []; + }, + + /** + @method add + @param obj + */ + add: function(obj) { + var guid = guidFor(obj), + presenceSet = this.presenceSet, + list = this.list; + + if (guid in presenceSet) { return; } + + presenceSet[guid] = true; + list.push(obj); + }, + + /** + @method remove + @param obj + */ + remove: function(obj) { + var guid = guidFor(obj), + presenceSet = this.presenceSet, + list = this.list; + + delete presenceSet[guid]; + + var index = indexOf.call(list, obj); + if (index > -1) { + list.splice(index, 1); + } + }, + + /** + @method isEmpty + @return {Boolean} + */ + isEmpty: function() { + return this.list.length === 0; + }, + + /** + @method has + @param obj + @return {Boolean} + */ + has: function(obj) { + var guid = guidFor(obj), + presenceSet = this.presenceSet; + + return guid in presenceSet; + }, + + /** + @method forEach + @param {Function} function + @param target + */ + forEach: function(fn, self) { + // allow mutation during iteration + var list = this.list.slice(); + + for (var i = 0, j = list.length; i < j; i++) { + fn.call(self, list[i]); + } + }, + + /** + @method toArray + @return {Array} + */ + toArray: function() { + return this.list.slice(); + }, + + /** + @method copy + @return {Ember.OrderedSet} + */ + copy: function() { + var set = new OrderedSet(); + + set.presenceSet = copy(this.presenceSet); + set.list = this.list.slice(); + + return set; + } +}; + +/** + A Map stores values indexed by keys. Unlike JavaScript's + default Objects, the keys of a Map can be any JavaScript + object. + + Internally, a Map has two data structures: + + 1. `keys`: an OrderedSet of all of the existing keys + 2. `values`: a JavaScript Object indexed by the `Ember.guidFor(key)` + + When a key/value pair is added for the first time, we + add the key to the `keys` OrderedSet, and create or + replace an entry in `values`. When an entry is deleted, + we delete its entry in `keys` and `values`. + + @class Map + @namespace Ember + @private + @constructor +*/ +var Map = Ember.Map = function() { + this.keys = Ember.OrderedSet.create(); + this.values = {}; +}; + +/** + @method create + @static +*/ +Map.create = function() { + return new Map(); +}; + +Map.prototype = { + /** + Retrieve the value associated with a given key. + + @method get + @param {anything} key + @return {anything} the value associated with the key, or `undefined` + */ + get: function(key) { + var values = this.values, + guid = guidFor(key); + + return values[guid]; + }, + + /** + Adds a value to the map. If a value for the given key has already been + provided, the new value will replace the old value. + + @method set + @param {anything} key + @param {anything} value + */ + set: function(key, value) { + var keys = this.keys, + values = this.values, + guid = guidFor(key); + + keys.add(key); + values[guid] = value; + }, + + /** + Removes a value from the map for an associated key. + + @method remove + @param {anything} key + @return {Boolean} true if an item was removed, false otherwise + */ + remove: function(key) { + // don't use ES6 "delete" because it will be annoying + // to use in browsers that are not ES6 friendly; + var keys = this.keys, + values = this.values, + guid = guidFor(key), + value; + + if (values.hasOwnProperty(guid)) { + keys.remove(key); + value = values[guid]; + delete values[guid]; + return true; + } else { + return false; + } + }, + + /** + Check whether a key is present. + + @method has + @param {anything} key + @return {Boolean} true if the item was present, false otherwise + */ + has: function(key) { + var values = this.values, + guid = guidFor(key); + + return values.hasOwnProperty(guid); + }, + + /** + Iterate over all the keys and values. Calls the function once + for each key, passing in the key and value, in that order. + + The keys are guaranteed to be iterated over in insertion order. + + @method forEach + @param {Function} callback + @param {anything} self if passed, the `this` value inside the + callback. By default, `this` is the map. + */ + forEach: function(callback, self) { + var keys = this.keys, + values = this.values; + + keys.forEach(function(key) { + var guid = guidFor(key); + callback.call(self, key, values[guid]); + }); + }, + + /** + @method copy + @return {Ember.Map} + */ + copy: function() { + return copyMap(this, new Map()); + } +}; + +/** + @class MapWithDefault + @namespace Ember + @extends Ember.Map + @private + @constructor + @param [options] + @param {anything} [options.defaultValue] +*/ +var MapWithDefault = Ember.MapWithDefault = function(options) { + Map.call(this); + this.defaultValue = options.defaultValue; +}; + +/** + @method create + @static + @param [options] + @param {anything} [options.defaultValue] + @return {Ember.MapWithDefault|Ember.Map} If options are passed, returns + `Ember.MapWithDefault` otherwise returns `Ember.Map` +*/ +MapWithDefault.create = function(options) { + if (options) { + return new MapWithDefault(options); + } else { + return new Map(); + } +}; + +MapWithDefault.prototype = Ember.create(Map.prototype); + +/** + Retrieve the value associated with a given key. + + @method get + @param {anything} key + @return {anything} the value associated with the key, or the default value +*/ +MapWithDefault.prototype.get = function(key) { + var hasValue = this.has(key); + + if (hasValue) { + return Map.prototype.get.call(this, key); + } else { + var defaultValue = this.defaultValue(key); + this.set(key, defaultValue); + return defaultValue; + } +}; + +/** + @method copy + @return {Ember.MapWithDefault} +*/ +MapWithDefault.prototype.copy = function() { + return copyMap(this, new MapWithDefault({ + defaultValue: this.defaultValue + })); +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +var META_KEY = Ember.META_KEY, get, set; + +var MANDATORY_SETTER = Ember.ENV.MANDATORY_SETTER; + +var IS_GLOBAL = /^([A-Z$]|([0-9][A-Z$]))/; +var IS_GLOBAL_PATH = /^([A-Z$]|([0-9][A-Z$])).*[\.\*]/; +var HAS_THIS = /^this[\.\*]/; +var FIRST_KEY = /^([^\.\*]+)/; + +// .......................................................... +// GET AND SET +// +// If we are on a platform that supports accessors we can get use those. +// Otherwise simulate accessors by looking up the property directly on the +// object. + +/** + Gets the value of a property on an object. If the property is computed, + the function will be invoked. If the property is not defined but the + object implements the `unknownProperty` method then that will be invoked. + + If you plan to run on IE8 and older browsers then you should use this + method anytime you want to retrieve a property on an object that you don't + know for sure is private. (Properties beginning with an underscore '_' + are considered private.) + + On all newer browsers, you only need to use this method to retrieve + properties if the property might not be defined on the object and you want + to respect the `unknownProperty` handler. Otherwise you can ignore this + method. + + Note that if the object itself is `undefined`, this method will throw + an error. + + @method get + @for Ember + @param {Object} obj The object to retrieve from. + @param {String} keyName The property key to retrieve + @return {Object} the property value or `null`. +*/ +get = function get(obj, keyName) { + // Helpers that operate with 'this' within an #each + if (keyName === '') { + return obj; + } + + if (!keyName && 'string'===typeof obj) { + keyName = obj; + obj = null; + } + + if (!obj || keyName.indexOf('.') !== -1) { + + return getPath(obj, keyName); + } + + + var meta = obj[META_KEY], desc = meta && meta.descs[keyName], ret; + if (desc) { + return desc.get(obj, keyName); + } else { + if (MANDATORY_SETTER && meta && meta.watching[keyName] > 0) { + ret = meta.values[keyName]; + } else { + ret = obj[keyName]; + } + + if (ret === undefined && + 'object' === typeof obj && !(keyName in obj) && 'function' === typeof obj.unknownProperty) { + return obj.unknownProperty(keyName); + } + + return ret; + } +}; + +/** + Sets the value of a property on an object, respecting computed properties + and notifying observers and other listeners of the change. If the + property is not defined but the object implements the `unknownProperty` + method then that will be invoked as well. + + If you plan to run on IE8 and older browsers then you should use this + method anytime you want to set a property on an object that you don't + know for sure is private. (Properties beginning with an underscore '_' + are considered private.) + + On all newer browsers, you only need to use this method to set + properties if the property might not be defined on the object and you want + to respect the `unknownProperty` handler. Otherwise you can ignore this + method. + + @method set + @for Ember + @param {Object} obj The object to modify. + @param {String} keyName The property key to set + @param {Object} value The value to set + @return {Object} the passed value. +*/ +set = function set(obj, keyName, value, tolerant) { + if (typeof obj === 'string') { + + value = keyName; + keyName = obj; + obj = null; + } + + if (!obj || keyName.indexOf('.') !== -1) { + return setPath(obj, keyName, value, tolerant); + } + + + + var meta = obj[META_KEY], desc = meta && meta.descs[keyName], + isUnknown, currentValue; + if (desc) { + desc.set(obj, keyName, value); + } else { + isUnknown = 'object' === typeof obj && !(keyName in obj); + + // setUnknownProperty is called if `obj` is an object, + // the property does not already exist, and the + // `setUnknownProperty` method exists on the object + if (isUnknown && 'function' === typeof obj.setUnknownProperty) { + obj.setUnknownProperty(keyName, value); + } else if (meta && meta.watching[keyName] > 0) { + if (MANDATORY_SETTER) { + currentValue = meta.values[keyName]; + } else { + currentValue = obj[keyName]; + } + // only trigger a change if the value has changed + if (value !== currentValue) { + Ember.propertyWillChange(obj, keyName); + if (MANDATORY_SETTER) { + if (currentValue === undefined && !(keyName in obj)) { + Ember.defineProperty(obj, keyName, null, value); // setup mandatory setter + } else { + meta.values[keyName] = value; + } + } else { + obj[keyName] = value; + } + Ember.propertyDidChange(obj, keyName); + } + } else { + obj[keyName] = value; + } + } + return value; +}; + +// Currently used only by Ember Data tests +if (Ember.config.overrideAccessors) { + Ember.get = get; + Ember.set = set; + Ember.config.overrideAccessors(); + get = Ember.get; + set = Ember.set; +} + +function firstKey(path) { + return path.match(FIRST_KEY)[0]; +} + +// assumes path is already normalized +function normalizeTuple(target, path) { + var hasThis = HAS_THIS.test(path), + isGlobal = !hasThis && IS_GLOBAL_PATH.test(path), + key; + + if (!target || isGlobal) target = Ember.lookup; + if (hasThis) path = path.slice(5); + + if (target === Ember.lookup) { + key = firstKey(path); + target = get(target, key); + path = path.slice(key.length+1); + } + + // must return some kind of path to be valid else other things will break. + if (!path || path.length===0) throw new Error('Invalid Path'); + + return [ target, path ]; +} + +function getPath(root, path) { + var hasThis, parts, tuple, idx, len; + + // If there is no root and path is a key name, return that + // property from the global object. + // E.g. get('Ember') -> Ember + if (root === null && path.indexOf('.') === -1) { return get(Ember.lookup, path); } + + // detect complicated paths and normalize them + hasThis = HAS_THIS.test(path); + + if (!root || hasThis) { + tuple = normalizeTuple(root, path); + root = tuple[0]; + path = tuple[1]; + tuple.length = 0; + } + + parts = path.split("."); + len = parts.length; + for (idx=0; root && idx 0; + + if (existingDesc instanceof Ember.Descriptor) { + existingDesc.teardown(obj, keyName); + } + + if (desc instanceof Ember.Descriptor) { + value = desc; + + descs[keyName] = desc; + if (MANDATORY_SETTER && watching) { + objectDefineProperty(obj, keyName, { + configurable: true, + enumerable: true, + writable: true, + value: undefined // make enumerable + }); + } else { + obj[keyName] = undefined; // make enumerable + } + desc.setup(obj, keyName); + } else { + descs[keyName] = undefined; // shadow descriptor in proto + if (desc == null) { + value = data; + + if (MANDATORY_SETTER && watching) { + meta.values[keyName] = data; + objectDefineProperty(obj, keyName, { + configurable: true, + enumerable: true, + set: MANDATORY_SETTER_FUNCTION, + get: DEFAULT_GETTER_FUNCTION(keyName) + }); + } else { + obj[keyName] = data; + } + } else { + value = desc; + + // compatibility with ES5 + objectDefineProperty(obj, keyName, desc); + } + } + + // if key is being watched, override chains that + // were initialized with the prototype + if (watching) { Ember.overrideChains(obj, keyName, meta); } + + // The `value` passed to the `didDefineProperty` hook is + // either the descriptor or data, whichever was passed. + if (obj.didDefineProperty) { obj.didDefineProperty(obj, keyName, value); } + + return this; +}; + + +})(); + + + +(function() { +// Ember.tryFinally +/** +@module ember-metal +*/ + +var AFTER_OBSERVERS = ':change'; +var BEFORE_OBSERVERS = ':before'; + +var guidFor = Ember.guidFor; + +var deferred = 0; + +/* + this.observerSet = { + [senderGuid]: { // variable name: `keySet` + [keyName]: listIndex + } + }, + this.observers = [ + { + sender: obj, + keyName: keyName, + eventName: eventName, + listeners: [ + [target, method, onceFlag, suspendedFlag] + ] + }, + ... + ] +*/ +function ObserverSet() { + this.clear(); +} + +ObserverSet.prototype.add = function(sender, keyName, eventName) { + var observerSet = this.observerSet, + observers = this.observers, + senderGuid = Ember.guidFor(sender), + keySet = observerSet[senderGuid], + index; + + if (!keySet) { + observerSet[senderGuid] = keySet = {}; + } + index = keySet[keyName]; + if (index === undefined) { + index = observers.push({ + sender: sender, + keyName: keyName, + eventName: eventName, + listeners: [] + }) - 1; + keySet[keyName] = index; + } + return observers[index].listeners; +}; + +ObserverSet.prototype.flush = function() { + var observers = this.observers, i, len, observer, sender; + this.clear(); + for (i=0, len=observers.length; i < len; ++i) { + observer = observers[i]; + sender = observer.sender; + if (sender.isDestroying || sender.isDestroyed) { continue; } + Ember.sendEvent(sender, observer.eventName, [sender, observer.keyName], observer.listeners); + } +}; + +ObserverSet.prototype.clear = function() { + this.observerSet = {}; + this.observers = []; +}; + +var beforeObserverSet = new ObserverSet(), observerSet = new ObserverSet(); + +/** + @method beginPropertyChanges + @chainable +*/ +Ember.beginPropertyChanges = function() { + deferred++; +}; + +/** + @method endPropertyChanges +*/ +Ember.endPropertyChanges = function() { + deferred--; + if (deferred<=0) { + beforeObserverSet.clear(); + observerSet.flush(); + } +}; + +/** + Make a series of property changes together in an + exception-safe way. + + ```javascript + Ember.changeProperties(function() { + obj1.set('foo', mayBlowUpWhenSet); + obj2.set('bar', baz); + }); + ``` + + @method changeProperties + @param {Function} callback + @param [binding] +*/ +Ember.changeProperties = function(cb, binding){ + Ember.beginPropertyChanges(); + Ember.tryFinally(cb, Ember.endPropertyChanges, binding); +}; + +/** + Set a list of properties on an object. These properties are set inside + a single `beginPropertyChanges` and `endPropertyChanges` batch, so + observers will be buffered. + + @method setProperties + @param target + @param {Hash} properties + @return target +*/ +Ember.setProperties = function(self, hash) { + Ember.changeProperties(function(){ + for(var prop in hash) { + if (hash.hasOwnProperty(prop)) Ember.set(self, prop, hash[prop]); + } + }); + return self; +}; + + +function changeEvent(keyName) { + return keyName+AFTER_OBSERVERS; +} + +function beforeEvent(keyName) { + return keyName+BEFORE_OBSERVERS; +} + +/** + @method addObserver + @param obj + @param {String} path + @param {Object|Function} targetOrMethod + @param {Function|String} [method] +*/ +Ember.addObserver = function(obj, path, target, method) { + Ember.addListener(obj, changeEvent(path), target, method); + Ember.watch(obj, path); + return this; +}; + +Ember.observersFor = function(obj, path) { + return Ember.listenersFor(obj, changeEvent(path)); +}; + +/** + @method removeObserver + @param obj + @param {String} path + @param {Object|Function} targetOrMethod + @param {Function|String} [method] +*/ +Ember.removeObserver = function(obj, path, target, method) { + Ember.unwatch(obj, path); + Ember.removeListener(obj, changeEvent(path), target, method); + return this; +}; + +/** + @method addBeforeObserver + @param obj + @param {String} path + @param {Object|Function} targetOrMethod + @param {Function|String} [method] +*/ +Ember.addBeforeObserver = function(obj, path, target, method) { + Ember.addListener(obj, beforeEvent(path), target, method); + Ember.watch(obj, path); + return this; +}; + +// Suspend observer during callback. +// +// This should only be used by the target of the observer +// while it is setting the observed path. +Ember._suspendBeforeObserver = function(obj, path, target, method, callback) { + return Ember._suspendListener(obj, beforeEvent(path), target, method, callback); +}; + +Ember._suspendObserver = function(obj, path, target, method, callback) { + return Ember._suspendListener(obj, changeEvent(path), target, method, callback); +}; + +var map = Ember.ArrayPolyfills.map; + +Ember._suspendBeforeObservers = function(obj, paths, target, method, callback) { + var events = map.call(paths, beforeEvent); + return Ember._suspendListeners(obj, events, target, method, callback); +}; + +Ember._suspendObservers = function(obj, paths, target, method, callback) { + var events = map.call(paths, changeEvent); + return Ember._suspendListeners(obj, events, target, method, callback); +}; + +Ember.beforeObserversFor = function(obj, path) { + return Ember.listenersFor(obj, beforeEvent(path)); +}; + +/** + @method removeBeforeObserver + @param obj + @param {String} path + @param {Object|Function} targetOrMethod + @param {Function|String} [method] +*/ +Ember.removeBeforeObserver = function(obj, path, target, method) { + Ember.unwatch(obj, path); + Ember.removeListener(obj, beforeEvent(path), target, method); + return this; +}; + +Ember.notifyBeforeObservers = function(obj, keyName) { + if (obj.isDestroying) { return; } + + var eventName = beforeEvent(keyName), listeners, listenersDiff; + if (deferred) { + listeners = beforeObserverSet.add(obj, keyName, eventName); + listenersDiff = Ember.listenersDiff(obj, eventName, listeners); + Ember.sendEvent(obj, eventName, [obj, keyName], listenersDiff); + } else { + Ember.sendEvent(obj, eventName, [obj, keyName]); + } +}; + +Ember.notifyObservers = function(obj, keyName) { + if (obj.isDestroying) { return; } + + var eventName = changeEvent(keyName), listeners; + if (deferred) { + listeners = observerSet.add(obj, keyName, eventName); + Ember.listenersUnion(obj, eventName, listeners); + } else { + Ember.sendEvent(obj, eventName, [obj, keyName]); + } +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +var guidFor = Ember.guidFor, // utils.js + metaFor = Ember.meta, // utils.js + get = Ember.get, // accessors.js + set = Ember.set, // accessors.js + normalizeTuple = Ember.normalizeTuple, // accessors.js + GUID_KEY = Ember.GUID_KEY, // utils.js + META_KEY = Ember.META_KEY, // utils.js + // circular reference observer depends on Ember.watch + // we should move change events to this file or its own property_events.js + notifyObservers = Ember.notifyObservers, // observer.js + forEach = Ember.ArrayPolyfills.forEach, // array.js + FIRST_KEY = /^([^\.\*]+)/, + IS_PATH = /[\.\*]/; + +var MANDATORY_SETTER = Ember.ENV.MANDATORY_SETTER, +o_defineProperty = Ember.platform.defineProperty; + +function firstKey(path) { + return path.match(FIRST_KEY)[0]; +} + +// returns true if the passed path is just a keyName +function isKeyName(path) { + return path==='*' || !IS_PATH.test(path); +} + +// .......................................................... +// DEPENDENT KEYS +// + +function iterDeps(method, obj, depKey, seen, meta) { + + var guid = guidFor(obj); + if (!seen[guid]) seen[guid] = {}; + if (seen[guid][depKey]) return; + seen[guid][depKey] = true; + + var deps = meta.deps; + deps = deps && deps[depKey]; + if (deps) { + for(var key in deps) { + var desc = meta.descs[key]; + if (desc && desc._suspended === obj) continue; + method(obj, key); + } + } +} + + +var WILL_SEEN, DID_SEEN; + +// called whenever a property is about to change to clear the cache of any dependent keys (and notify those properties of changes, etc...) +function dependentKeysWillChange(obj, depKey, meta) { + if (obj.isDestroying) { return; } + + var seen = WILL_SEEN, top = !seen; + if (top) { seen = WILL_SEEN = {}; } + iterDeps(propertyWillChange, obj, depKey, seen, meta); + if (top) { WILL_SEEN = null; } +} + +// called whenever a property has just changed to update dependent keys +function dependentKeysDidChange(obj, depKey, meta) { + if (obj.isDestroying) { return; } + + var seen = DID_SEEN, top = !seen; + if (top) { seen = DID_SEEN = {}; } + iterDeps(propertyDidChange, obj, depKey, seen, meta); + if (top) { DID_SEEN = null; } +} + +// .......................................................... +// CHAIN +// + +function addChainWatcher(obj, keyName, node) { + if (!obj || ('object' !== typeof obj)) { return; } // nothing to do + + var m = metaFor(obj), nodes = m.chainWatchers; + + if (!m.hasOwnProperty('chainWatchers')) { + nodes = m.chainWatchers = {}; + } + + if (!nodes[keyName]) { nodes[keyName] = []; } + nodes[keyName].push(node); + Ember.watch(obj, keyName); +} + +function removeChainWatcher(obj, keyName, node) { + if (!obj || 'object' !== typeof obj) { return; } // nothing to do + + var m = metaFor(obj, false); + if (!m.hasOwnProperty('chainWatchers')) { return; } // nothing to do + + var nodes = m.chainWatchers; + + if (nodes[keyName]) { + nodes = nodes[keyName]; + for (var i = 0, l = nodes.length; i < l; i++) { + if (nodes[i] === node) { nodes.splice(i, 1); } + } + } + Ember.unwatch(obj, keyName); +} + +var pendingQueue = []; + +// attempts to add the pendingQueue chains again. If some of them end up +// back in the queue and reschedule is true, schedules a timeout to try +// again. +function flushPendingChains() { + if (pendingQueue.length === 0) { return; } // nothing to do + + var queue = pendingQueue; + pendingQueue = []; + + forEach.call(queue, function(q) { q[0].add(q[1]); }); + +} + +function isProto(pvalue) { + return metaFor(pvalue, false).proto === pvalue; +} + +// A ChainNode watches a single key on an object. If you provide a starting +// value for the key then the node won't actually watch it. For a root node +// pass null for parent and key and object for value. +var ChainNode = function(parent, key, value) { + var obj; + this._parent = parent; + this._key = key; + + // _watching is true when calling get(this._parent, this._key) will + // return the value of this node. + // + // It is false for the root of a chain (because we have no parent) + // and for global paths (because the parent node is the object with + // the observer on it) + this._watching = value===undefined; + + this._value = value; + this._paths = {}; + if (this._watching) { + this._object = parent.value(); + if (this._object) { addChainWatcher(this._object, this._key, this); } + } + + // Special-case: the EachProxy relies on immediate evaluation to + // establish its observers. + // + // TODO: Replace this with an efficient callback that the EachProxy + // can implement. + if (this._parent && this._parent._key === '@each') { + this.value(); + } +}; + +var ChainNodePrototype = ChainNode.prototype; + +ChainNodePrototype.value = function() { + if (this._value === undefined && this._watching) { + var obj = this._parent.value(); + this._value = (obj && !isProto(obj)) ? get(obj, this._key) : undefined; + } + return this._value; +}; + +ChainNodePrototype.destroy = function() { + if (this._watching) { + var obj = this._object; + if (obj) { removeChainWatcher(obj, this._key, this); } + this._watching = false; // so future calls do nothing + } +}; + +// copies a top level object only +ChainNodePrototype.copy = function(obj) { + var ret = new ChainNode(null, null, obj), + paths = this._paths, path; + for (path in paths) { + if (paths[path] <= 0) { continue; } // this check will also catch non-number vals. + ret.add(path); + } + return ret; +}; + +// called on the root node of a chain to setup watchers on the specified +// path. +ChainNodePrototype.add = function(path) { + var obj, tuple, key, src, paths; + + paths = this._paths; + paths[path] = (paths[path] || 0) + 1; + + obj = this.value(); + tuple = normalizeTuple(obj, path); + + // the path was a local path + if (tuple[0] && tuple[0] === obj) { + path = tuple[1]; + key = firstKey(path); + path = path.slice(key.length+1); + + // global path, but object does not exist yet. + // put into a queue and try to connect later. + } else if (!tuple[0]) { + pendingQueue.push([this, path]); + tuple.length = 0; + return; + + // global path, and object already exists + } else { + src = tuple[0]; + key = path.slice(0, 0-(tuple[1].length+1)); + path = tuple[1]; + } + + tuple.length = 0; + this.chain(key, path, src); +}; + +// called on the root node of a chain to teardown watcher on the specified +// path +ChainNodePrototype.remove = function(path) { + var obj, tuple, key, src, paths; + + paths = this._paths; + if (paths[path] > 0) { paths[path]--; } + + obj = this.value(); + tuple = normalizeTuple(obj, path); + if (tuple[0] === obj) { + path = tuple[1]; + key = firstKey(path); + path = path.slice(key.length+1); + } else { + src = tuple[0]; + key = path.slice(0, 0-(tuple[1].length+1)); + path = tuple[1]; + } + + tuple.length = 0; + this.unchain(key, path); +}; + +ChainNodePrototype.count = 0; + +ChainNodePrototype.chain = function(key, path, src) { + var chains = this._chains, node; + if (!chains) { chains = this._chains = {}; } + + node = chains[key]; + if (!node) { node = chains[key] = new ChainNode(this, key, src); } + node.count++; // count chains... + + // chain rest of path if there is one + if (path && path.length>0) { + key = firstKey(path); + path = path.slice(key.length+1); + node.chain(key, path); // NOTE: no src means it will observe changes... + } +}; + +ChainNodePrototype.unchain = function(key, path) { + var chains = this._chains, node = chains[key]; + + // unchain rest of path first... + if (path && path.length>1) { + key = firstKey(path); + path = path.slice(key.length+1); + node.unchain(key, path); + } + + // delete node if needed. + node.count--; + if (node.count<=0) { + delete chains[node._key]; + node.destroy(); + } + +}; + +ChainNodePrototype.willChange = function() { + var chains = this._chains; + if (chains) { + for(var key in chains) { + if (!chains.hasOwnProperty(key)) { continue; } + chains[key].willChange(); + } + } + + if (this._parent) { this._parent.chainWillChange(this, this._key, 1); } +}; + +ChainNodePrototype.chainWillChange = function(chain, path, depth) { + if (this._key) { path = this._key + '.' + path; } + + if (this._parent) { + this._parent.chainWillChange(this, path, depth+1); + } else { + if (depth > 1) { Ember.propertyWillChange(this.value(), path); } + path = 'this.' + path; + if (this._paths[path] > 0) { Ember.propertyWillChange(this.value(), path); } + } +}; + +ChainNodePrototype.chainDidChange = function(chain, path, depth) { + if (this._key) { path = this._key + '.' + path; } + if (this._parent) { + this._parent.chainDidChange(this, path, depth+1); + } else { + if (depth > 1) { Ember.propertyDidChange(this.value(), path); } + path = 'this.' + path; + if (this._paths[path] > 0) { Ember.propertyDidChange(this.value(), path); } + } +}; + +ChainNodePrototype.didChange = function(suppressEvent) { + // invalidate my own value first. + if (this._watching) { + var obj = this._parent.value(); + if (obj !== this._object) { + removeChainWatcher(this._object, this._key, this); + this._object = obj; + addChainWatcher(obj, this._key, this); + } + this._value = undefined; + + // Special-case: the EachProxy relies on immediate evaluation to + // establish its observers. + if (this._parent && this._parent._key === '@each') + this.value(); + } + + // then notify chains... + var chains = this._chains; + if (chains) { + for(var key in chains) { + if (!chains.hasOwnProperty(key)) { continue; } + chains[key].didChange(suppressEvent); + } + } + + if (suppressEvent) { return; } + + // and finally tell parent about my path changing... + if (this._parent) { this._parent.chainDidChange(this, this._key, 1); } +}; + +// get the chains for the current object. If the current object has +// chains inherited from the proto they will be cloned and reconfigured for +// the current object. +function chainsFor(obj) { + var m = metaFor(obj), ret = m.chains; + if (!ret) { + ret = m.chains = new ChainNode(null, null, obj); + } else if (ret.value() !== obj) { + ret = m.chains = ret.copy(obj); + } + return ret; +} + +Ember.overrideChains = function(obj, keyName, m) { + chainsDidChange(obj, keyName, m, true); +}; + +function chainsWillChange(obj, keyName, m, arg) { + if (!m.hasOwnProperty('chainWatchers')) { return; } // nothing to do + + var nodes = m.chainWatchers; + + nodes = nodes[keyName]; + if (!nodes) { return; } + + for(var i = 0, l = nodes.length; i < l; i++) { + nodes[i].willChange(arg); + } +} + +function chainsDidChange(obj, keyName, m, arg) { + if (!m.hasOwnProperty('chainWatchers')) { return; } // nothing to do + + var nodes = m.chainWatchers; + + nodes = nodes[keyName]; + if (!nodes) { return; } + + // looping in reverse because the chainWatchers array can be modified inside didChange + for (var i = nodes.length - 1; i >= 0; i--) { + nodes[i].didChange(arg); + } +} + +// .......................................................... +// WATCH +// + +/** + @private + + Starts watching a property on an object. Whenever the property changes, + invokes `Ember.propertyWillChange` and `Ember.propertyDidChange`. This is the + primitive used by observers and dependent keys; usually you will never call + this method directly but instead use higher level methods like + `Ember.addObserver()` + + @method watch + @for Ember + @param obj + @param {String} keyName +*/ +Ember.watch = function(obj, keyName) { + // can't watch length on Array - it is special... + if (keyName === 'length' && Ember.typeOf(obj) === 'array') { return this; } + + var m = metaFor(obj), watching = m.watching, desc; + + // activate watching first time + if (!watching[keyName]) { + watching[keyName] = 1; + if (isKeyName(keyName)) { + desc = m.descs[keyName]; + if (desc && desc.willWatch) { desc.willWatch(obj, keyName); } + + if ('function' === typeof obj.willWatchProperty) { + obj.willWatchProperty(keyName); + } + + if (MANDATORY_SETTER && keyName in obj) { + m.values[keyName] = obj[keyName]; + o_defineProperty(obj, keyName, { + configurable: true, + enumerable: true, + set: Ember.MANDATORY_SETTER_FUNCTION, + get: Ember.DEFAULT_GETTER_FUNCTION(keyName) + }); + } + } else { + chainsFor(obj).add(keyName); + } + + } else { + watching[keyName] = (watching[keyName] || 0) + 1; + } + return this; +}; + +Ember.isWatching = function isWatching(obj, key) { + var meta = obj[META_KEY]; + return (meta && meta.watching[key]) > 0; +}; + +Ember.watch.flushPending = flushPendingChains; + +Ember.unwatch = function(obj, keyName) { + // can't watch length on Array - it is special... + if (keyName === 'length' && Ember.typeOf(obj) === 'array') { return this; } + + var m = metaFor(obj), watching = m.watching, desc; + + if (watching[keyName] === 1) { + watching[keyName] = 0; + + if (isKeyName(keyName)) { + desc = m.descs[keyName]; + if (desc && desc.didUnwatch) { desc.didUnwatch(obj, keyName); } + + if ('function' === typeof obj.didUnwatchProperty) { + obj.didUnwatchProperty(keyName); + } + + if (MANDATORY_SETTER && keyName in obj) { + o_defineProperty(obj, keyName, { + configurable: true, + enumerable: true, + writable: true, + value: m.values[keyName] + }); + delete m.values[keyName]; + } + } else { + chainsFor(obj).remove(keyName); + } + + } else if (watching[keyName]>1) { + watching[keyName]--; + } + + return this; +}; + +/** + @private + + Call on an object when you first beget it from another object. This will + setup any chained watchers on the object instance as needed. This method is + safe to call multiple times. + + @method rewatch + @for Ember + @param obj +*/ +Ember.rewatch = function(obj) { + var m = metaFor(obj, false), chains = m.chains; + + // make sure the object has its own guid. + if (GUID_KEY in obj && !obj.hasOwnProperty(GUID_KEY)) { + Ember.generateGuid(obj, 'ember'); + } + + // make sure any chained watchers update. + if (chains && chains.value() !== obj) { + m.chains = chains.copy(obj); + } + + return this; +}; + +Ember.finishChains = function(obj) { + var m = metaFor(obj, false), chains = m.chains; + if (chains) { + if (chains.value() !== obj) { + m.chains = chains = chains.copy(obj); + } + chains.didChange(true); + } +}; + +// .......................................................... +// PROPERTY CHANGES +// + +/** + This function is called just before an object property is about to change. + It will notify any before observers and prepare caches among other things. + + Normally you will not need to call this method directly but if for some + reason you can't directly watch a property you can invoke this method + manually along with `Ember.propertyDidChange()` which you should call just + after the property value changes. + + @method propertyWillChange + @for Ember + @param {Object} obj The object with the property that will change + @param {String} keyName The property key (or path) that will change. + @return {void} +*/ +function propertyWillChange(obj, keyName, value) { + var m = metaFor(obj, false), + watching = m.watching[keyName] > 0 || keyName === 'length', + proto = m.proto, + desc = m.descs[keyName]; + + if (!watching) { return; } + if (proto === obj) { return; } + if (desc && desc.willChange) { desc.willChange(obj, keyName); } + dependentKeysWillChange(obj, keyName, m); + chainsWillChange(obj, keyName, m); + Ember.notifyBeforeObservers(obj, keyName); +} + +Ember.propertyWillChange = propertyWillChange; + +/** + This function is called just after an object property has changed. + It will notify any observers and clear caches among other things. + + Normally you will not need to call this method directly but if for some + reason you can't directly watch a property you can invoke this method + manually along with `Ember.propertyWilLChange()` which you should call just + before the property value changes. + + @method propertyDidChange + @for Ember + @param {Object} obj The object with the property that will change + @param {String} keyName The property key (or path) that will change. + @return {void} +*/ +function propertyDidChange(obj, keyName) { + var m = metaFor(obj, false), + watching = m.watching[keyName] > 0 || keyName === 'length', + proto = m.proto, + desc = m.descs[keyName]; + + if (proto === obj) { return; } + + // shouldn't this mean that we're watching this key? + if (desc && desc.didChange) { desc.didChange(obj, keyName); } + if (!watching && keyName !== 'length') { return; } + + dependentKeysDidChange(obj, keyName, m); + chainsDidChange(obj, keyName, m); + Ember.notifyObservers(obj, keyName); +} + +Ember.propertyDidChange = propertyDidChange; + +var NODE_STACK = []; + +/** + Tears down the meta on an object so that it can be garbage collected. + Multiple calls will have no effect. + + @method destroy + @for Ember + @param {Object} obj the object to destroy + @return {void} +*/ +Ember.destroy = function (obj) { + var meta = obj[META_KEY], node, nodes, key, nodeObject; + if (meta) { + obj[META_KEY] = null; + // remove chainWatchers to remove circular references that would prevent GC + node = meta.chains; + if (node) { + NODE_STACK.push(node); + // process tree + while (NODE_STACK.length > 0) { + node = NODE_STACK.pop(); + // push children + nodes = node._chains; + if (nodes) { + for (key in nodes) { + if (nodes.hasOwnProperty(key)) { + NODE_STACK.push(nodes[key]); + } + } + } + // remove chainWatcher in node object + if (node._watching) { + nodeObject = node._object; + if (nodeObject) { + removeChainWatcher(nodeObject, node._key, node); + } + } + } + } + } +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + + + +var get = Ember.get, + set = Ember.set, + metaFor = Ember.meta, + guidFor = Ember.guidFor, + a_slice = [].slice, + o_create = Ember.create, + META_KEY = Ember.META_KEY, + watch = Ember.watch, + unwatch = Ember.unwatch; + +// .......................................................... +// DEPENDENT KEYS +// + +// data structure: +// meta.deps = { +// 'depKey': { +// 'keyName': count, +// } +// } + +/* + This function returns a map of unique dependencies for a + given object and key. +*/ +function keysForDep(obj, depsMeta, depKey) { + var keys = depsMeta[depKey]; + if (!keys) { + // if there are no dependencies yet for a the given key + // create a new empty list of dependencies for the key + keys = depsMeta[depKey] = {}; + } else if (!depsMeta.hasOwnProperty(depKey)) { + // otherwise if the dependency list is inherited from + // a superclass, clone the hash + keys = depsMeta[depKey] = o_create(keys); + } + return keys; +} + +/* return obj[META_KEY].deps */ +function metaForDeps(obj, meta) { + var deps = meta.deps; + // If the current object has no dependencies... + if (!deps) { + // initialize the dependencies with a pointer back to + // the current object + deps = meta.deps = {}; + } else if (!meta.hasOwnProperty('deps')) { + // otherwise if the dependencies are inherited from the + // object's superclass, clone the deps + deps = meta.deps = o_create(deps); + } + return deps; +} + +function addDependentKeys(desc, obj, keyName, meta) { + // the descriptor has a list of dependent keys, so + // add all of its dependent keys. + var depKeys = desc._dependentKeys, depsMeta, idx, len, depKey, keys; + if (!depKeys) return; + + depsMeta = metaForDeps(obj, meta); + + for(idx = 0, len = depKeys.length; idx < len; idx++) { + depKey = depKeys[idx]; + // Lookup keys meta for depKey + keys = keysForDep(obj, depsMeta, depKey); + // Increment the number of times depKey depends on keyName. + keys[keyName] = (keys[keyName] || 0) + 1; + // Watch the depKey + watch(obj, depKey); + } +} + +function removeDependentKeys(desc, obj, keyName, meta) { + // the descriptor has a list of dependent keys, so + // add all of its dependent keys. + var depKeys = desc._dependentKeys, depsMeta, idx, len, depKey, keys; + if (!depKeys) return; + + depsMeta = metaForDeps(obj, meta); + + for(idx = 0, len = depKeys.length; idx < len; idx++) { + depKey = depKeys[idx]; + // Lookup keys meta for depKey + keys = keysForDep(obj, depsMeta, depKey); + // Increment the number of times depKey depends on keyName. + keys[keyName] = (keys[keyName] || 0) - 1; + // Watch the depKey + unwatch(obj, depKey); + } +} + +// .......................................................... +// COMPUTED PROPERTY +// + +/** + @class ComputedProperty + @namespace Ember + @extends Ember.Descriptor + @constructor +*/ +function ComputedProperty(func, opts) { + this.func = func; + this._cacheable = (opts && opts.cacheable !== undefined) ? opts.cacheable : true; + this._dependentKeys = opts && opts.dependentKeys; +} + +Ember.ComputedProperty = ComputedProperty; +ComputedProperty.prototype = new Ember.Descriptor(); + +var ComputedPropertyPrototype = ComputedProperty.prototype; + +/** + Call on a computed property to set it into cacheable mode. When in this + mode the computed property will automatically cache the return value of + your function until one of the dependent keys changes. + + ```javascript + MyApp.president = Ember.Object.create({ + fullName: function() { + return this.get('firstName') + ' ' + this.get('lastName'); + + // After calculating the value of this function, Ember will + // return that value without re-executing this function until + // one of the dependent properties change. + }.property('firstName', 'lastName') + }); + ``` + + Properties are cacheable by default. + + @method cacheable + @param {Boolean} aFlag optional set to `false` to disable caching + @chainable +*/ +ComputedPropertyPrototype.cacheable = function(aFlag) { + this._cacheable = aFlag !== false; + return this; +}; + +/** + Call on a computed property to set it into non-cached mode. When in this + mode the computed property will not automatically cache the return value. + + ```javascript + MyApp.outsideService = Ember.Object.create({ + value: function() { + return OutsideService.getValue(); + }.property().volatile() + }); + ``` + + @method volatile + @chainable +*/ +ComputedPropertyPrototype.volatile = function() { + return this.cacheable(false); +}; + +/** + Sets the dependent keys on this computed property. Pass any number of + arguments containing key paths that this computed property depends on. + + ```javascript + MyApp.president = Ember.Object.create({ + fullName: Ember.computed(function() { + return this.get('firstName') + ' ' + this.get('lastName'); + + // Tell Ember that this computed property depends on firstName + // and lastName + }).property('firstName', 'lastName') + }); + ``` + + @method property + @param {String} path* zero or more property paths + @chainable +*/ +ComputedPropertyPrototype.property = function() { + var args = []; + for (var i = 0, l = arguments.length; i < l; i++) { + args.push(arguments[i]); + } + this._dependentKeys = args; + return this; +}; + +/** + In some cases, you may want to annotate computed properties with additional + metadata about how they function or what values they operate on. For example, + computed property functions may close over variables that are then no longer + available for introspection. + + You can pass a hash of these values to a computed property like this: + + ``` + person: function() { + var personId = this.get('personId'); + return App.Person.create({ id: personId }); + }.property().meta({ type: App.Person }) + ``` + + The hash that you pass to the `meta()` function will be saved on the + computed property descriptor under the `_meta` key. Ember runtime + exposes a public API for retrieving these values from classes, + via the `metaForProperty()` function. + + @method meta + @param {Hash} meta + @chainable +*/ + +ComputedPropertyPrototype.meta = function(meta) { + if (arguments.length === 0) { + return this._meta || {}; + } else { + this._meta = meta; + return this; + } +}; + +/* impl descriptor API */ +ComputedPropertyPrototype.willWatch = function(obj, keyName) { + // watch already creates meta for this instance + var meta = obj[META_KEY]; + + if (!(keyName in meta.cache)) { + addDependentKeys(this, obj, keyName, meta); + } +}; + +ComputedPropertyPrototype.didUnwatch = function(obj, keyName) { + var meta = obj[META_KEY]; + + if (!(keyName in meta.cache)) { + // unwatch already creates meta for this instance + removeDependentKeys(this, obj, keyName, meta); + } +}; + +/* impl descriptor API */ +ComputedPropertyPrototype.didChange = function(obj, keyName) { + // _suspended is set via a CP.set to ensure we don't clear + // the cached value set by the setter + if (this._cacheable && this._suspended !== obj) { + var meta = metaFor(obj); + if (keyName in meta.cache) { + delete meta.cache[keyName]; + if (!meta.watching[keyName]) { + removeDependentKeys(this, obj, keyName, meta); + } + } + } +}; + +/* impl descriptor API */ +ComputedPropertyPrototype.get = function(obj, keyName) { + var ret, cache, meta; + if (this._cacheable) { + meta = metaFor(obj); + cache = meta.cache; + if (keyName in cache) { return cache[keyName]; } + ret = cache[keyName] = this.func.call(obj, keyName); + if (!meta.watching[keyName]) { + addDependentKeys(this, obj, keyName, meta); + } + } else { + ret = this.func.call(obj, keyName); + } + return ret; +}; + +/* impl descriptor API */ +ComputedPropertyPrototype.set = function(obj, keyName, value) { + var cacheable = this._cacheable, + func = this.func, + meta = metaFor(obj, cacheable), + watched = meta.watching[keyName], + oldSuspended = this._suspended, + hadCachedValue = false, + cache = meta.cache, + cachedValue, ret; + + this._suspended = obj; + + try { + if (cacheable && cache.hasOwnProperty(keyName)) { + cachedValue = cache[keyName]; + hadCachedValue = true; + } + + // Check if the CP has been wrapped + if (func.wrappedFunction) { func = func.wrappedFunction; } + + // For backwards-compatibility with computed properties + // that check for arguments.length === 2 to determine if + // they are being get or set, only pass the old cached + // value if the computed property opts into a third + // argument. + if (func.length === 3) { + ret = func.call(obj, keyName, value, cachedValue); + } else if (func.length === 2) { + ret = func.call(obj, keyName, value); + } else { + Ember.defineProperty(obj, keyName, null, cachedValue); + Ember.set(obj, keyName, value); + return; + } + + if (hadCachedValue && cachedValue === ret) { return; } + + if (watched) { Ember.propertyWillChange(obj, keyName); } + + if (hadCachedValue) { + delete cache[keyName]; + } + + if (cacheable) { + if (!watched && !hadCachedValue) { + addDependentKeys(this, obj, keyName, meta); + } + cache[keyName] = ret; + } + + if (watched) { Ember.propertyDidChange(obj, keyName); } + } finally { + this._suspended = oldSuspended; + } + return ret; +}; + +/* called when property is defined */ +ComputedPropertyPrototype.setup = function(obj, keyName) { + var meta = obj[META_KEY]; + if (meta && meta.watching[keyName]) { + addDependentKeys(this, obj, keyName, metaFor(obj)); + } +}; + +/* called before property is overridden */ +ComputedPropertyPrototype.teardown = function(obj, keyName) { + var meta = metaFor(obj); + + if (meta.watching[keyName] || keyName in meta.cache) { + removeDependentKeys(this, obj, keyName, meta); + } + + if (this._cacheable) { delete meta.cache[keyName]; } + + return null; // no value to restore +}; + + +/** + This helper returns a new property descriptor that wraps the passed + computed property function. You can use this helper to define properties + with mixins or via `Ember.defineProperty()`. + + The function you pass will be used to both get and set property values. + The function should accept two parameters, key and value. If value is not + undefined you should set the value first. In either case return the + current value of the property. + + @method computed + @for Ember + @param {Function} func The computed property function. + @return {Ember.ComputedProperty} property descriptor instance +*/ +Ember.computed = function(func) { + var args; + + if (arguments.length > 1) { + args = a_slice.call(arguments, 0, -1); + func = a_slice.call(arguments, -1)[0]; + } + + var cp = new ComputedProperty(func); + + if (args) { + cp.property.apply(cp, args); + } + + return cp; +}; + +/** + Returns the cached value for a property, if one exists. + This can be useful for peeking at the value of a computed + property that is generated lazily, without accidentally causing + it to be created. + + @method cacheFor + @for Ember + @param {Object} obj the object whose property you want to check + @param {String} key the name of the property whose cached value you want + to return +*/ +Ember.cacheFor = function cacheFor(obj, key) { + var cache = metaFor(obj, false).cache; + + if (cache && key in cache) { + return cache[key]; + } +}; + +/** + @method computed.not + @for Ember + @param {String} dependentKey +*/ +Ember.computed.not = function(dependentKey) { + return Ember.computed(dependentKey, function(key) { + return !get(this, dependentKey); + }); +}; + +/** + @method computed.empty + @for Ember + @param {String} dependentKey +*/ +Ember.computed.empty = function(dependentKey) { + return Ember.computed(dependentKey, function(key) { + var val = get(this, dependentKey); + return val === undefined || val === null || val === '' || (Ember.isArray(val) && get(val, 'length') === 0); + }); +}; + +/** + @method computed.bool + @for Ember + @param {String} dependentKey +*/ +Ember.computed.bool = function(dependentKey) { + return Ember.computed(dependentKey, function(key) { + return !!get(this, dependentKey); + }); +}; + +/** + @method computed.alias + @for Ember + @param {String} dependentKey +*/ +Ember.computed.alias = function(dependentKey) { + return Ember.computed(dependentKey, function(key, value){ + if (arguments.length === 1) { + return get(this, dependentKey); + } else { + set(this, dependentKey, value); + return value; + } + }); +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +var o_create = Ember.create, + metaFor = Ember.meta, + metaPath = Ember.metaPath, + META_KEY = Ember.META_KEY; + +/* + The event system uses a series of nested hashes to store listeners on an + object. When a listener is registered, or when an event arrives, these + hashes are consulted to determine which target and action pair to invoke. + + The hashes are stored in the object's meta hash, and look like this: + + // Object's meta hash + { + listeners: { // variable name: `listenerSet` + "foo:changed": [ // variable name: `actions` + [target, method, onceFlag, suspendedFlag] + ] + } + } + +*/ + +function indexOf(array, target, method) { + var index = -1; + for (var i = 0, l = array.length; i < l; i++) { + if (target === array[i][0] && method === array[i][1]) { index = i; break; } + } + return index; +} + +function actionsFor(obj, eventName) { + var meta = metaFor(obj, true), + actions; + + if (!meta.listeners) { meta.listeners = {}; } + + if (!meta.hasOwnProperty('listeners')) { + // setup inherited copy of the listeners object + meta.listeners = o_create(meta.listeners); + } + + actions = meta.listeners[eventName]; + + // if there are actions, but the eventName doesn't exist in our listeners, then copy them from the prototype + if (actions && !meta.listeners.hasOwnProperty(eventName)) { + actions = meta.listeners[eventName] = meta.listeners[eventName].slice(); + } else if (!actions) { + actions = meta.listeners[eventName] = []; + } + + return actions; +} + +function actionsUnion(obj, eventName, otherActions) { + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName]; + + if (!actions) { return; } + for (var i = actions.length - 1; i >= 0; i--) { + var target = actions[i][0], + method = actions[i][1], + once = actions[i][2], + suspended = actions[i][3], + actionIndex = indexOf(otherActions, target, method); + + if (actionIndex === -1) { + otherActions.push([target, method, once, suspended]); + } + } +} + +function actionsDiff(obj, eventName, otherActions) { + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName], + diffActions = []; + + if (!actions) { return; } + for (var i = actions.length - 1; i >= 0; i--) { + var target = actions[i][0], + method = actions[i][1], + once = actions[i][2], + suspended = actions[i][3], + actionIndex = indexOf(otherActions, target, method); + + if (actionIndex !== -1) { continue; } + + otherActions.push([target, method, once, suspended]); + diffActions.push([target, method, once, suspended]); + } + + return diffActions; +} + +/** + Add an event listener + + @method addListener + @for Ember + @param obj + @param {String} eventName + @param {Object|Function} targetOrMethod A target object or a function + @param {Function|String} method A function or the name of a function to be called on `target` +*/ +function addListener(obj, eventName, target, method, once) { + + + if (!method && 'function' === typeof target) { + method = target; + target = null; + } + + var actions = actionsFor(obj, eventName), + actionIndex = indexOf(actions, target, method); + + if (actionIndex !== -1) { return; } + + actions.push([target, method, once, undefined]); + + if ('function' === typeof obj.didAddListener) { + obj.didAddListener(eventName, target, method); + } +} + +/** + Remove an event listener + + Arguments should match those passed to {{#crossLink "Ember/addListener"}}{{/crossLink}} + + @method removeListener + @for Ember + @param obj + @param {String} eventName + @param {Object|Function} targetOrMethod A target object or a function + @param {Function|String} method A function or the name of a function to be called on `target` +*/ +function removeListener(obj, eventName, target, method) { + + + if (!method && 'function' === typeof target) { + method = target; + target = null; + } + + function _removeListener(target, method, once) { + var actions = actionsFor(obj, eventName), + actionIndex = indexOf(actions, target, method); + + // action doesn't exist, give up silently + if (actionIndex === -1) { return; } + + actions.splice(actionIndex, 1); + + if ('function' === typeof obj.didRemoveListener) { + obj.didRemoveListener(eventName, target, method); + } + } + + if (method) { + _removeListener(target, method); + } else { + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName]; + + if (!actions) { return; } + for (var i = actions.length - 1; i >= 0; i--) { + _removeListener(actions[i][0], actions[i][1]); + } + } +} + +/** + @private + + Suspend listener during callback. + + This should only be used by the target of the event listener + when it is taking an action that would cause the event, e.g. + an object might suspend its property change listener while it is + setting that property. + + @method suspendListener + @for Ember + @param obj + @param {String} eventName + @param {Object|Function} targetOrMethod A target object or a function + @param {Function|String} method A function or the name of a function to be called on `target` + @param {Function} callback +*/ +function suspendListener(obj, eventName, target, method, callback) { + if (!method && 'function' === typeof target) { + method = target; + target = null; + } + + var actions = actionsFor(obj, eventName), + actionIndex = indexOf(actions, target, method), + action; + + if (actionIndex !== -1) { + action = actions[actionIndex].slice(); // copy it, otherwise we're modifying a shared object + action[3] = true; // mark the action as suspended + actions[actionIndex] = action; // replace the shared object with our copy + } + + function tryable() { return callback.call(target); } + function finalizer() { if (action) { action[3] = undefined; } } + + return Ember.tryFinally(tryable, finalizer); +} + +/** + @private + + Suspend listener during callback. + + This should only be used by the target of the event listener + when it is taking an action that would cause the event, e.g. + an object might suspend its property change listener while it is + setting that property. + + @method suspendListener + @for Ember + @param obj + @param {Array} eventName Array of event names + @param {Object|Function} targetOrMethod A target object or a function + @param {Function|String} method A function or the name of a function to be called on `target` + @param {Function} callback +*/ +function suspendListeners(obj, eventNames, target, method, callback) { + if (!method && 'function' === typeof target) { + method = target; + target = null; + } + + var suspendedActions = [], + eventName, actions, action, i, l; + + for (i=0, l=eventNames.length; i= 0; i--) { // looping in reverse for once listeners + if (!actions[i] || actions[i][3] === true) { continue; } + + var target = actions[i][0], + method = actions[i][1], + once = actions[i][2]; + + if (once) { removeListener(obj, eventName, target, method); } + if (!target) { target = obj; } + if ('string' === typeof method) { method = target[method]; } + if (params) { + method.apply(target, params); + } else { + method.apply(target); + } + } + return true; +} + +/** + @private + @method hasListeners + @for Ember + @param obj + @param {String} eventName +*/ +function hasListeners(obj, eventName) { + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName]; + + return !!(actions && actions.length); +} + +/** + @private + @method listenersFor + @for Ember + @param obj + @param {String} eventName +*/ +function listenersFor(obj, eventName) { + var ret = []; + var meta = obj[META_KEY], + actions = meta && meta.listeners && meta.listeners[eventName]; + + if (!actions) { return ret; } + + for (var i = 0, l = actions.length; i < l; i++) { + var target = actions[i][0], + method = actions[i][1]; + ret.push([target, method]); + } + + return ret; +} + +Ember.addListener = addListener; +Ember.removeListener = removeListener; +Ember._suspendListener = suspendListener; +Ember._suspendListeners = suspendListeners; +Ember.sendEvent = sendEvent; +Ember.hasListeners = hasListeners; +Ember.watchedEvents = watchedEvents; +Ember.listenersFor = listenersFor; +Ember.listenersDiff = actionsDiff; +Ember.listenersUnion = actionsUnion; + +})(); + + + +(function() { +// Ember.Logger +// Ember.watch.flushPending +// Ember.beginPropertyChanges, Ember.endPropertyChanges +// Ember.guidFor, Ember.tryFinally + +/** +@module ember-metal +*/ + +// .......................................................... +// HELPERS +// + +var slice = [].slice, + forEach = Ember.ArrayPolyfills.forEach; + +// invokes passed params - normalizing so you can pass target/func, +// target/string or just func +function invoke(target, method, args, ignore) { + + if (method === undefined) { + method = target; + target = undefined; + } + + if ('string' === typeof method) { method = target[method]; } + if (args && ignore > 0) { + args = args.length > ignore ? slice.call(args, ignore) : null; + } + + return Ember.handleErrors(function() { + // IE8's Function.prototype.apply doesn't accept undefined/null arguments. + return method.apply(target || this, args || []); + }, this); +} + + +// .......................................................... +// RUNLOOP +// + +var timerMark; // used by timers... + +/** +Ember RunLoop (Private) + +@class RunLoop +@namespace Ember +@private +@constructor +*/ +var RunLoop = function(prev) { + this._prev = prev || null; + this.onceTimers = {}; +}; + +RunLoop.prototype = { + /** + @method end + */ + end: function() { + this.flush(); + }, + + /** + @method prev + */ + prev: function() { + return this._prev; + }, + + // .......................................................... + // Delayed Actions + // + + /** + @method schedule + @param {String} queueName + @param target + @param method + */ + schedule: function(queueName, target, method) { + var queues = this._queues, queue; + if (!queues) { queues = this._queues = {}; } + queue = queues[queueName]; + if (!queue) { queue = queues[queueName] = []; } + + var args = arguments.length > 3 ? slice.call(arguments, 3) : null; + queue.push({ target: target, method: method, args: args }); + return this; + }, + + /** + @method flush + @param {String} queueName + */ + flush: function(queueName) { + var queueNames, idx, len, queue, log; + + if (!this._queues) { return this; } // nothing to do + + function iter(item) { + invoke(item.target, item.method, item.args); + } + + function tryable() { + forEach.call(queue, iter); + } + + Ember.watch.flushPending(); // make sure all chained watchers are setup + + if (queueName) { + while (this._queues && (queue = this._queues[queueName])) { + this._queues[queueName] = null; + + // the sync phase is to allow property changes to propagate. don't + // invoke observers until that is finished. + if (queueName === 'sync') { + log = Ember.LOG_BINDINGS; + if (log) { Ember.Logger.log('Begin: Flush Sync Queue'); } + + Ember.beginPropertyChanges(); + + Ember.tryFinally(tryable, Ember.endPropertyChanges); + + if (log) { Ember.Logger.log('End: Flush Sync Queue'); } + + } else { + forEach.call(queue, iter); + } + } + + } else { + queueNames = Ember.run.queues; + len = queueNames.length; + idx = 0; + + outerloop: + while (idx < len) { + queueName = queueNames[idx]; + queue = this._queues && this._queues[queueName]; + delete this._queues[queueName]; + + if (queue) { + // the sync phase is to allow property changes to propagate. don't + // invoke observers until that is finished. + if (queueName === 'sync') { + log = Ember.LOG_BINDINGS; + if (log) { Ember.Logger.log('Begin: Flush Sync Queue'); } + + Ember.beginPropertyChanges(); + + Ember.tryFinally(tryable, Ember.endPropertyChanges); + + if (log) { Ember.Logger.log('End: Flush Sync Queue'); } + } else { + forEach.call(queue, iter); + } + } + + // Loop through prior queues + for (var i = 0; i <= idx; i++) { + if (this._queues && this._queues[queueNames[i]]) { + // Start over at the first queue with contents + idx = i; + continue outerloop; + } + } + + idx++; + } + } + + timerMark = null; + + return this; + } + +}; + +Ember.RunLoop = RunLoop; + +// .......................................................... +// Ember.run - this is ideally the only public API the dev sees +// + +/** + Runs the passed target and method inside of a RunLoop, ensuring any + deferred actions including bindings and views updates are flushed at the + end. + + Normally you should not need to invoke this method yourself. However if + you are implementing raw event handlers when interfacing with other + libraries or plugins, you should probably wrap all of your code inside this + call. + + ```javascript + Ember.run(function(){ + // code to be execute within a RunLoop + }); + ``` + + @class run + @namespace Ember + @static + @constructor + @param {Object} [target] target of method to call + @param {Function|String} method Method to invoke. + May be a function or a string. If you pass a string + then it will be looked up on the passed target. + @param {Object} [args*] Any additional arguments you wish to pass to the method. + @return {Object} return value from invoking the passed function. +*/ +Ember.run = function(target, method) { + var loop, + args = arguments; + run.begin(); + + function tryable() { + if (target || method) { + return invoke(target, method, args, 2); + } + } + + return Ember.tryFinally(tryable, run.end); +}; + +var run = Ember.run; + + +/** + Begins a new RunLoop. Any deferred actions invoked after the begin will + be buffered until you invoke a matching call to `Ember.run.end()`. This is + an lower-level way to use a RunLoop instead of using `Ember.run()`. + + ```javascript + Ember.run.begin(); + // code to be execute within a RunLoop + Ember.run.end(); + ``` + + @method begin + @return {void} +*/ +Ember.run.begin = function() { + run.currentRunLoop = new RunLoop(run.currentRunLoop); +}; + +/** + Ends a RunLoop. This must be called sometime after you call + `Ember.run.begin()` to flush any deferred actions. This is a lower-level way + to use a RunLoop instead of using `Ember.run()`. + + ```javascript + Ember.run.begin(); + // code to be execute within a RunLoop + Ember.run.end(); + ``` + + @method end + @return {void} +*/ +Ember.run.end = function() { + + + function tryable() { run.currentRunLoop.end(); } + function finalizer() { run.currentRunLoop = run.currentRunLoop.prev(); } + + Ember.tryFinally(tryable, finalizer); +}; + +/** + Array of named queues. This array determines the order in which queues + are flushed at the end of the RunLoop. You can define your own queues by + simply adding the queue name to this array. Normally you should not need + to inspect or modify this property. + + @property queues + @type Array + @default ['sync', 'actions', 'destroy', 'timers'] +*/ +Ember.run.queues = ['sync', 'actions', 'destroy', 'timers']; + +/** + Adds the passed target/method and any optional arguments to the named + queue to be executed at the end of the RunLoop. If you have not already + started a RunLoop when calling this method one will be started for you + automatically. + + At the end of a RunLoop, any methods scheduled in this way will be invoked. + Methods will be invoked in an order matching the named queues defined in + the `run.queues` property. + + ```javascript + Ember.run.schedule('timers', this, function(){ + // this will be executed at the end of the RunLoop, when timers are run + console.log("scheduled on timers queue"); + }); + + Ember.run.schedule('sync', this, function(){ + // this will be executed at the end of the RunLoop, when bindings are synced + console.log("scheduled on sync queue"); + }); + + // Note the functions will be run in order based on the run queues order. Output would be: + // scheduled on sync queue + // scheduled on timers queue + ``` + + @method schedule + @param {String} queue The name of the queue to schedule against. + Default queues are 'sync' and 'actions' + @param {Object} [target] target object to use as the context when invoking a method. + @param {String|Function} method The method to invoke. If you pass a string it + will be resolved on the target object at the time the scheduled item is + invoked allowing you to change the target function. + @param {Object} [arguments*] Optional arguments to be passed to the queued method. + @return {void} +*/ +Ember.run.schedule = function(queue, target, method) { + var loop = run.autorun(); + loop.schedule.apply(loop, arguments); +}; + +var scheduledAutorun; +function autorun() { + scheduledAutorun = null; + if (run.currentRunLoop) { run.end(); } +} + +// Used by global test teardown +Ember.run.hasScheduledTimers = function() { + return !!(scheduledAutorun || scheduledLater || scheduledNext); +}; + +// Used by global test teardown +Ember.run.cancelTimers = function () { + if (scheduledAutorun) { + clearTimeout(scheduledAutorun); + scheduledAutorun = null; + } + if (scheduledLater) { + clearTimeout(scheduledLater); + scheduledLater = null; + } + if (scheduledNext) { + clearTimeout(scheduledNext); + scheduledNext = null; + } + timers = {}; +}; + +/** + Begins a new RunLoop if necessary and schedules a timer to flush the + RunLoop at a later time. This method is used by parts of Ember to + ensure the RunLoop always finishes. You normally do not need to call this + method directly. Instead use `Ember.run()` + + @method autorun + @example + Ember.run.autorun(); + @return {Ember.RunLoop} the new current RunLoop +*/ +Ember.run.autorun = function() { + if (!run.currentRunLoop) { + + + run.begin(); + + if (!scheduledAutorun) { + scheduledAutorun = setTimeout(autorun, 1); + } + } + + return run.currentRunLoop; +}; + +/** + Immediately flushes any events scheduled in the 'sync' queue. Bindings + use this queue so this method is a useful way to immediately force all + bindings in the application to sync. + + You should call this method anytime you need any changed state to propagate + throughout the app immediately without repainting the UI. + + ```javascript + Ember.run.sync(); + ``` + + @method sync + @return {void} +*/ +Ember.run.sync = function() { + run.autorun(); + run.currentRunLoop.flush('sync'); +}; + +// .......................................................... +// TIMERS +// + +var timers = {}; // active timers... + +var scheduledLater; +function invokeLaterTimers() { + scheduledLater = null; + var now = (+ new Date()), earliest = -1; + for (var key in timers) { + if (!timers.hasOwnProperty(key)) { continue; } + var timer = timers[key]; + if (timer && timer.expires) { + if (now >= timer.expires) { + delete timers[key]; + invoke(timer.target, timer.method, timer.args, 2); + } else { + if (earliest<0 || (timer.expires < earliest)) earliest=timer.expires; + } + } + } + + // schedule next timeout to fire... + if (earliest > 0) { scheduledLater = setTimeout(invokeLaterTimers, earliest-(+ new Date())); } +} + +/** + Invokes the passed target/method and optional arguments after a specified + period if time. The last parameter of this method must always be a number + of milliseconds. + + You should use this method whenever you need to run some action after a + period of time instead of using `setTimeout()`. This method will ensure that + items that expire during the same script execution cycle all execute + together, which is often more efficient than using a real setTimeout. + + ```javascript + Ember.run.later(myContext, function(){ + // code here will execute within a RunLoop in about 500ms with this == myContext + }, 500); + ``` + + @method later + @param {Object} [target] target of method to invoke + @param {Function|String} method The method to invoke. + If you pass a string it will be resolved on the + target at the time the method is invoked. + @param {Object} [args*] Optional arguments to pass to the timeout. + @param {Number} wait + Number of milliseconds to wait. + @return {String} a string you can use to cancel the timer in + {{#crossLink "Ember/run.cancel"}}{{/crossLink}} later. +*/ +Ember.run.later = function(target, method) { + var args, expires, timer, guid, wait; + + // setTimeout compatibility... + if (arguments.length===2 && 'function' === typeof target) { + wait = method; + method = target; + target = undefined; + args = [target, method]; + } else { + args = slice.call(arguments); + wait = args.pop(); + } + + expires = (+ new Date()) + wait; + timer = { target: target, method: method, expires: expires, args: args }; + guid = Ember.guidFor(timer); + timers[guid] = timer; + run.once(timers, invokeLaterTimers); + return guid; +}; + +function invokeOnceTimer(guid, onceTimers) { + if (onceTimers[this.tguid]) { delete onceTimers[this.tguid][this.mguid]; } + if (timers[guid]) { invoke(this.target, this.method, this.args); } + delete timers[guid]; +} + +function scheduleOnce(queue, target, method, args) { + var tguid = Ember.guidFor(target), + mguid = Ember.guidFor(method), + onceTimers = run.autorun().onceTimers, + guid = onceTimers[tguid] && onceTimers[tguid][mguid], + timer; + + if (guid && timers[guid]) { + timers[guid].args = args; // replace args + } else { + timer = { + target: target, + method: method, + args: args, + tguid: tguid, + mguid: mguid + }; + + guid = Ember.guidFor(timer); + timers[guid] = timer; + if (!onceTimers[tguid]) { onceTimers[tguid] = {}; } + onceTimers[tguid][mguid] = guid; // so it isn't scheduled more than once + + run.schedule(queue, timer, invokeOnceTimer, guid, onceTimers); + } + + return guid; +} + +/** + Schedules an item to run one time during the current RunLoop. Calling + this method with the same target/method combination will have no effect. + + Note that although you can pass optional arguments these will not be + considered when looking for duplicates. New arguments will replace previous + calls. + + ```javascript + Ember.run(function(){ + var doFoo = function() { foo(); } + Ember.run.once(myContext, doFoo); + Ember.run.once(myContext, doFoo); + // doFoo will only be executed once at the end of the RunLoop + }); + ``` + + @method once + @param {Object} [target] target of method to invoke + @param {Function|String} method The method to invoke. + If you pass a string it will be resolved on the + target at the time the method is invoked. + @param {Object} [args*] Optional arguments to pass to the timeout. + @return {Object} timer +*/ +Ember.run.once = function(target, method) { + return scheduleOnce('actions', target, method, slice.call(arguments, 2)); +}; + +Ember.run.scheduleOnce = function(queue, target, method, args) { + return scheduleOnce(queue, target, method, slice.call(arguments, 3)); +}; + +var scheduledNext; +function invokeNextTimers() { + scheduledNext = null; + for(var key in timers) { + if (!timers.hasOwnProperty(key)) { continue; } + var timer = timers[key]; + if (timer.next) { + delete timers[key]; + invoke(timer.target, timer.method, timer.args, 2); + } + } +} + +/** + Schedules an item to run after control has been returned to the system. + This is often equivalent to calling `setTimeout(function() {}, 1)`. + + ```javascript + Ember.run.next(myContext, function(){ + // code to be executed in the next RunLoop, which will be scheduled after the current one + }); + ``` + + @method next + @param {Object} [target] target of method to invoke + @param {Function|String} method The method to invoke. + If you pass a string it will be resolved on the + target at the time the method is invoked. + @param {Object} [args*] Optional arguments to pass to the timeout. + @return {Object} timer +*/ +Ember.run.next = function(target, method) { + var guid, + timer = { + target: target, + method: method, + args: slice.call(arguments), + next: true + }; + + guid = Ember.guidFor(timer); + timers[guid] = timer; + + if (!scheduledNext) { scheduledNext = setTimeout(invokeNextTimers, 1); } + return guid; +}; + +/** + Cancels a scheduled item. Must be a value returned by `Ember.run.later()`, + `Ember.run.once()`, or `Ember.run.next()`. + + ```javascript + var runNext = Ember.run.next(myContext, function(){ + // will not be executed + }); + Ember.run.cancel(runNext); + + var runLater = Ember.run.later(myContext, function(){ + // will not be executed + }, 500); + Ember.run.cancel(runLater); + + var runOnce = Ember.run.once(myContext, function(){ + // will not be executed + }); + Ember.run.cancel(runOnce); + ``` + + @method cancel + @param {Object} timer Timer object to cancel + @return {void} +*/ +Ember.run.cancel = function(timer) { + delete timers[timer]; +}; + +})(); + + + +(function() { +// Ember.Logger +// get, set, trySet +// guidFor, isArray, meta +// addObserver, removeObserver +// Ember.run.schedule +/** +@module ember-metal +*/ + +// .......................................................... +// CONSTANTS +// + +/** + Debug parameter you can turn on. This will log all bindings that fire to + the console. This should be disabled in production code. Note that you + can also enable this from the console or temporarily. + + @property LOG_BINDINGS + @for Ember + @type Boolean + @default false +*/ +Ember.LOG_BINDINGS = false || !!Ember.ENV.LOG_BINDINGS; + +var get = Ember.get, + set = Ember.set, + guidFor = Ember.guidFor, + isGlobalPath = Ember.isGlobalPath; + + +function getWithGlobals(obj, path) { + return get(isGlobalPath(path) ? Ember.lookup : obj, path); +} + +// .......................................................... +// BINDING +// + +var Binding = function(toPath, fromPath) { + this._direction = 'fwd'; + this._from = fromPath; + this._to = toPath; + this._directionMap = Ember.Map.create(); +}; + +/** +@class Binding +@namespace Ember +*/ + +Binding.prototype = { + /** + This copies the Binding so it can be connected to another object. + + @method copy + @return {Ember.Binding} + */ + copy: function () { + var copy = new Binding(this._to, this._from); + if (this._oneWay) { copy._oneWay = true; } + return copy; + }, + + // .......................................................... + // CONFIG + // + + /** + This will set `from` property path to the specified value. It will not + attempt to resolve this property path to an actual object until you + connect the binding. + + The binding will search for the property path starting at the root object + you pass when you `connect()` the binding. It follows the same rules as + `get()` - see that method for more information. + + @method from + @param {String} propertyPath the property path to connect to + @return {Ember.Binding} `this` + */ + from: function(path) { + this._from = path; + return this; + }, + + /** + This will set the `to` property path to the specified value. It will not + attempt to resolve this property path to an actual object until you + connect the binding. + + The binding will search for the property path starting at the root object + you pass when you `connect()` the binding. It follows the same rules as + `get()` - see that method for more information. + + @method to + @param {String|Tuple} propertyPath A property path or tuple + @return {Ember.Binding} `this` + */ + to: function(path) { + this._to = path; + return this; + }, + + /** + Configures the binding as one way. A one-way binding will relay changes + on the `from` side to the `to` side, but not the other way around. This + means that if you change the `to` side directly, the `from` side may have + a different value. + + @method oneWay + @return {Ember.Binding} `this` + */ + oneWay: function() { + this._oneWay = true; + return this; + }, + + toString: function() { + var oneWay = this._oneWay ? '[oneWay]' : ''; + return "Ember.Binding<" + guidFor(this) + ">(" + this._from + " -> " + this._to + ")" + oneWay; + }, + + // .......................................................... + // CONNECT AND SYNC + // + + /** + Attempts to connect this binding instance so that it can receive and relay + changes. This method will raise an exception if you have not set the + from/to properties yet. + + @method connect + @param {Object} obj The root object for this binding. + @return {Ember.Binding} `this` + */ + connect: function(obj) { + + + var fromPath = this._from, toPath = this._to; + Ember.trySet(obj, toPath, getWithGlobals(obj, fromPath)); + + // add an observer on the object to be notified when the binding should be updated + Ember.addObserver(obj, fromPath, this, this.fromDidChange); + + // if the binding is a two-way binding, also set up an observer on the target + if (!this._oneWay) { Ember.addObserver(obj, toPath, this, this.toDidChange); } + + this._readyToSync = true; + + return this; + }, + + /** + Disconnects the binding instance. Changes will no longer be relayed. You + will not usually need to call this method. + + @method disconnect + @param {Object} obj The root object you passed when connecting the binding. + @return {Ember.Binding} `this` + */ + disconnect: function(obj) { + + + var twoWay = !this._oneWay; + + // remove an observer on the object so we're no longer notified of + // changes that should update bindings. + Ember.removeObserver(obj, this._from, this, this.fromDidChange); + + // if the binding is two-way, remove the observer from the target as well + if (twoWay) { Ember.removeObserver(obj, this._to, this, this.toDidChange); } + + this._readyToSync = false; // disable scheduled syncs... + return this; + }, + + // .......................................................... + // PRIVATE + // + + /* called when the from side changes */ + fromDidChange: function(target) { + this._scheduleSync(target, 'fwd'); + }, + + /* called when the to side changes */ + toDidChange: function(target) { + this._scheduleSync(target, 'back'); + }, + + _scheduleSync: function(obj, dir) { + var directionMap = this._directionMap; + var existingDir = directionMap.get(obj); + + // if we haven't scheduled the binding yet, schedule it + if (!existingDir) { + Ember.run.schedule('sync', this, this._sync, obj); + directionMap.set(obj, dir); + } + + // If both a 'back' and 'fwd' sync have been scheduled on the same object, + // default to a 'fwd' sync so that it remains deterministic. + if (existingDir === 'back' && dir === 'fwd') { + directionMap.set(obj, 'fwd'); + } + }, + + _sync: function(obj) { + var log = Ember.LOG_BINDINGS; + + // don't synchronize destroyed objects or disconnected bindings + if (obj.isDestroyed || !this._readyToSync) { return; } + + // get the direction of the binding for the object we are + // synchronizing from + var directionMap = this._directionMap; + var direction = directionMap.get(obj); + + var fromPath = this._from, toPath = this._to; + + directionMap.remove(obj); + + // if we're synchronizing from the remote object... + if (direction === 'fwd') { + var fromValue = getWithGlobals(obj, this._from); + if (log) { + Ember.Logger.log(' ', this.toString(), '->', fromValue, obj); + } + if (this._oneWay) { + Ember.trySet(obj, toPath, fromValue); + } else { + Ember._suspendObserver(obj, toPath, this, this.toDidChange, function () { + Ember.trySet(obj, toPath, fromValue); + }); + } + // if we're synchronizing *to* the remote object + } else if (direction === 'back') { + var toValue = get(obj, this._to); + if (log) { + Ember.Logger.log(' ', this.toString(), '<-', toValue, obj); + } + Ember._suspendObserver(obj, fromPath, this, this.fromDidChange, function () { + Ember.trySet(Ember.isGlobalPath(fromPath) ? Ember.lookup : obj, fromPath, toValue); + }); + } + } + +}; + +function mixinProperties(to, from) { + for (var key in from) { + if (from.hasOwnProperty(key)) { + to[key] = from[key]; + } + } +} + +mixinProperties(Binding, { + + /** + See {{#crossLink "Ember.Binding/from"}}{{/crossLink}} + + @method from + @static + */ + from: function() { + var C = this, binding = new C(); + return binding.from.apply(binding, arguments); + }, + + /** + See {{#crossLink "Ember.Binding/to"}}{{/crossLink}} + + @method to + @static + */ + to: function() { + var C = this, binding = new C(); + return binding.to.apply(binding, arguments); + }, + + /** + Creates a new Binding instance and makes it apply in a single direction. + A one-way binding will relay changes on the `from` side object (supplied + as the `from` argument) the `to` side, but not the other way around. + This means that if you change the "to" side directly, the "from" side may have + a different value. + + See {{#crossLink "Binding/oneWay"}}{{/crossLink}} + + @method oneWay + @param {String} from from path. + @param {Boolean} [flag] (Optional) passing nothing here will make the + binding `oneWay`. You can instead pass `false` to disable `oneWay`, making the + binding two way again. + */ + oneWay: function(from, flag) { + var C = this, binding = new C(null, from); + return binding.oneWay(flag); + } + +}); + +/** + An `Ember.Binding` connects the properties of two objects so that whenever + the value of one property changes, the other property will be changed also. + + ## Automatic Creation of Bindings with `/^*Binding/`-named Properties + + You do not usually create Binding objects directly but instead describe + bindings in your class or object definition using automatic binding + detection. + + Properties ending in a `Binding` suffix will be converted to `Ember.Binding` + instances. The value of this property should be a string representing a path + to another object or a custom binding instanced created using Binding helpers + (see "Customizing Your Bindings"): + + ``` + valueBinding: "MyApp.someController.title" + ``` + + This will create a binding from `MyApp.someController.title` to the `value` + property of your object instance automatically. Now the two values will be + kept in sync. + + ## One Way Bindings + + One especially useful binding customization you can use is the `oneWay()` + helper. This helper tells Ember that you are only interested in + receiving changes on the object you are binding from. For example, if you + are binding to a preference and you want to be notified if the preference + has changed, but your object will not be changing the preference itself, you + could do: + + ``` + bigTitlesBinding: Ember.Binding.oneWay("MyApp.preferencesController.bigTitles") + ``` + + This way if the value of `MyApp.preferencesController.bigTitles` changes the + `bigTitles` property of your object will change also. However, if you + change the value of your `bigTitles` property, it will not update the + `preferencesController`. + + One way bindings are almost twice as fast to setup and twice as fast to + execute because the binding only has to worry about changes to one side. + + You should consider using one way bindings anytime you have an object that + may be created frequently and you do not intend to change a property; only + to monitor it for changes. (such as in the example above). + + ## Adding Bindings Manually + + All of the examples above show you how to configure a custom binding, but the + result of these customizations will be a binding template, not a fully active + Binding instance. The binding will actually become active only when you + instantiate the object the binding belongs to. It is useful however, to + understand what actually happens when the binding is activated. + + For a binding to function it must have at least a `from` property and a `to` + property. The `from` property path points to the object/key that you want to + bind from while the `to` path points to the object/key you want to bind to. + + When you define a custom binding, you are usually describing the property + you want to bind from (such as `MyApp.someController.value` in the examples + above). When your object is created, it will automatically assign the value + you want to bind `to` based on the name of your binding key. In the + examples above, during init, Ember objects will effectively call + something like this on your binding: + + ```javascript + binding = Ember.Binding.from(this.valueBinding).to("value"); + ``` + + This creates a new binding instance based on the template you provide, and + sets the to path to the `value` property of the new object. Now that the + binding is fully configured with a `from` and a `to`, it simply needs to be + connected to become active. This is done through the `connect()` method: + + ```javascript + binding.connect(this); + ``` + + Note that when you connect a binding you pass the object you want it to be + connected to. This object will be used as the root for both the from and + to side of the binding when inspecting relative paths. This allows the + binding to be automatically inherited by subclassed objects as well. + + Now that the binding is connected, it will observe both the from and to side + and relay changes. + + If you ever needed to do so (you almost never will, but it is useful to + understand this anyway), you could manually create an active binding by + using the `Ember.bind()` helper method. (This is the same method used by + to setup your bindings on objects): + + ```javascript + Ember.bind(MyApp.anotherObject, "value", "MyApp.someController.value"); + ``` + + Both of these code fragments have the same effect as doing the most friendly + form of binding creation like so: + + ```javascript + MyApp.anotherObject = Ember.Object.create({ + valueBinding: "MyApp.someController.value", + + // OTHER CODE FOR THIS OBJECT... + }); + ``` + + Ember's built in binding creation method makes it easy to automatically + create bindings for you. You should always use the highest-level APIs + available, even if you understand how it works underneath. + + @class Binding + @namespace Ember + @since Ember 0.9 +*/ +Ember.Binding = Binding; + + +/** + Global helper method to create a new binding. Just pass the root object + along with a `to` and `from` path to create and connect the binding. + + @method bind + @for Ember + @param {Object} obj The root object of the transform. + @param {String} to The path to the 'to' side of the binding. + Must be relative to obj. + @param {String} from The path to the 'from' side of the binding. + Must be relative to obj or a global path. + @return {Ember.Binding} binding instance +*/ +Ember.bind = function(obj, to, from) { + return new Ember.Binding(to, from).connect(obj); +}; + +/** + @method oneWay + @for Ember + @param {Object} obj The root object of the transform. + @param {String} to The path to the 'to' side of the binding. + Must be relative to obj. + @param {String} from The path to the 'from' side of the binding. + Must be relative to obj or a global path. + @return {Ember.Binding} binding instance +*/ +Ember.oneWay = function(obj, to, from) { + return new Ember.Binding(to, from).oneWay().connect(obj); +}; + +})(); + + + +(function() { +/** +@module ember-metal +*/ + +var Mixin, REQUIRED, Alias, + a_map = Ember.ArrayPolyfills.map, + a_indexOf = Ember.ArrayPolyfills.indexOf, + a_forEach = Ember.ArrayPolyfills.forEach, + a_slice = [].slice, + EMPTY_META = {}, // dummy for non-writable meta + o_create = Ember.create, + defineProperty = Ember.defineProperty, + guidFor = Ember.guidFor; + +function mixinsMeta(obj) { + var m = Ember.meta(obj, true), ret = m.mixins; + if (!ret) { + ret = m.mixins = {}; + } else if (!m.hasOwnProperty('mixins')) { + ret = m.mixins = o_create(ret); + } + return ret; +} + +function initMixin(mixin, args) { + if (args && args.length > 0) { + mixin.mixins = a_map.call(args, function(x) { + if (x instanceof Mixin) { return x; } + + // Note: Manually setup a primitive mixin here. This is the only + // way to actually get a primitive mixin. This way normal creation + // of mixins will give you combined mixins... + var mixin = new Mixin(); + mixin.properties = x; + return mixin; + }); + } + return mixin; +} + +function isMethod(obj) { + return 'function' === typeof obj && + obj.isMethod !== false && + obj !== Boolean && obj !== Object && obj !== Number && obj !== Array && obj !== Date && obj !== String; +} + +var CONTINUE = {}; + +function mixinProperties(mixinsMeta, mixin) { + var guid; + + if (mixin instanceof Mixin) { + guid = guidFor(mixin); + if (mixinsMeta[guid]) { return CONTINUE; } + mixinsMeta[guid] = mixin; + return mixin.properties; + } else { + return mixin; // apply anonymous mixin properties + } +} + +function concatenatedProperties(props, values, base) { + var concats; + + // reset before adding each new mixin to pickup concats from previous + concats = values.concatenatedProperties || base.concatenatedProperties; + if (props.concatenatedProperties) { + concats = concats ? concats.concat(props.concatenatedProperties) : props.concatenatedProperties; + } + + return concats; +} + +function giveDescriptorSuper(meta, key, property, values, descs) { + var superProperty; + + // Computed properties override methods, and do not call super to them + if (values[key] === undefined) { + // Find the original descriptor in a parent mixin + superProperty = descs[key]; + } + + // If we didn't find the original descriptor in a parent mixin, find + // it on the original object. + superProperty = superProperty || meta.descs[key]; + + if (!superProperty || !(superProperty instanceof Ember.ComputedProperty)) { + return property; + } + + // Since multiple mixins may inherit from the same parent, we need + // to clone the computed property so that other mixins do not receive + // the wrapped version. + property = o_create(property); + property.func = Ember.wrap(property.func, superProperty.func); + + return property; +} + +function giveMethodSuper(obj, key, method, values, descs) { + var superMethod; + + // Methods overwrite computed properties, and do not call super to them. + if (descs[key] === undefined) { + // Find the original method in a parent mixin + superMethod = values[key]; + } + + // If we didn't find the original value in a parent mixin, find it in + // the original object + superMethod = superMethod || obj[key]; + + // Only wrap the new method if the original method was a function + if ('function' !== typeof superMethod) { + return method; + } + + return Ember.wrap(method, superMethod); +} + +function applyConcatenatedProperties(obj, key, value, values) { + var baseValue = values[key] || obj[key]; + + if (baseValue) { + if ('function' === typeof baseValue.concat) { + return baseValue.concat(value); + } else { + return Ember.makeArray(baseValue).concat(value); + } + } else { + return Ember.makeArray(value); + } +} + +function addNormalizedProperty(base, key, value, meta, descs, values, concats) { + if (value instanceof Ember.Descriptor) { + if (value === REQUIRED && descs[key]) { return CONTINUE; } + + // Wrap descriptor function to implement + // _super() if needed + if (value.func) { + value = giveDescriptorSuper(meta, key, value, values, descs); + } + + descs[key] = value; + values[key] = undefined; + } else { + // impl super if needed... + if (isMethod(value)) { + value = giveMethodSuper(base, key, value, values, descs); + } else if ((concats && a_indexOf.call(concats, key) >= 0) || key === 'concatenatedProperties') { + value = applyConcatenatedProperties(base, key, value, values); + } + + descs[key] = undefined; + values[key] = value; + } +} + +function mergeMixins(mixins, m, descs, values, base) { + var mixin, props, key, concats, meta; + + function removeKeys(keyName) { + delete descs[keyName]; + delete values[keyName]; + } + + for(var i=0, l=mixins.length; i= 0) { + if (_detect(mixins[loc], targetMixin, seen)) { return true; } + } + return false; +} + +/** + @method detect + @param obj + @return {Boolean} +*/ +MixinPrototype.detect = function(obj) { + if (!obj) { return false; } + if (obj instanceof Mixin) { return _detect(obj, this, {}); } + var mixins = Ember.meta(obj, false).mixins; + if (mixins) { + return !!mixins[guidFor(this)]; + } + return false; +}; + +MixinPrototype.without = function() { + var ret = new Mixin(this); + ret._without = a_slice.call(arguments); + return ret; +}; + +function _keys(ret, mixin, seen) { + if (seen[guidFor(mixin)]) { return; } + seen[guidFor(mixin)] = true; + + if (mixin.properties) { + var props = mixin.properties; + for (var key in props) { + if (props.hasOwnProperty(key)) { ret[key] = true; } + } + } else if (mixin.mixins) { + a_forEach.call(mixin.mixins, function(x) { _keys(ret, x, seen); }); + } +} + +MixinPrototype.keys = function() { + var keys = {}, seen = {}, ret = []; + _keys(keys, this, seen); + for(var key in keys) { + if (keys.hasOwnProperty(key)) { ret.push(key); } + } + return ret; +}; + +// returns the mixins currently applied to the specified object +// TODO: Make Ember.mixin +Mixin.mixins = function(obj) { + var mixins = Ember.meta(obj, false).mixins, ret = []; + + if (!mixins) { return ret; } + + for (var key in mixins) { + var mixin = mixins[key]; + + // skip primitive mixins since these are always anonymous + if (!mixin.properties) { ret.push(mixin); } + } + + return ret; +}; + +REQUIRED = new Ember.Descriptor(); +REQUIRED.toString = function() { return '(Required Property)'; }; + +/** + Denotes a required property for a mixin + + @method required + @for Ember +*/ +Ember.required = function() { + return REQUIRED; +}; + +Alias = function(methodName) { + this.methodName = methodName; +}; +Alias.prototype = new Ember.Descriptor(); + +/** + Makes a property or method available via an additional name. + + ```javascript + App.PaintSample = Ember.Object.extend({ + color: 'red', + colour: Ember.alias('color'), + name: function(){ + return "Zed"; + }, + moniker: Ember.alias("name") + }); + + var paintSample = App.PaintSample.create() + paintSample.get('colour'); // 'red' + paintSample.moniker(); // 'Zed' + ``` + + @method alias + @for Ember + @param {String} methodName name of the method or property to alias + @return {Ember.Descriptor} + @deprecated Use `Ember.aliasMethod` or `Ember.computed.alias` instead +*/ +Ember.alias = function(methodName) { + return new Alias(methodName); +}; + +Ember.deprecateFunc("Ember.alias is deprecated. Please use Ember.aliasMethod or Ember.computed.alias instead.", Ember.alias); + +/** + Makes a method available via an additional name. + + ```javascript + App.Person = Ember.Object.extend({ + name: function(){ + return 'Tomhuda Katzdale'; + }, + moniker: Ember.aliasMethod('name') + }); + + var goodGuy = App.Person.create() + ``` + + @method aliasMethod + @for Ember + @param {String} methodName name of the method to alias + @return {Ember.Descriptor} +*/ +Ember.aliasMethod = function(methodName) { + return new Alias(methodName); +}; + +// .......................................................... +// OBSERVER HELPER +// + +/** + @method observer + @for Ember + @param {Function} func + @param {String} propertyNames* + @return func +*/ +Ember.observer = function(func) { + var paths = a_slice.call(arguments, 1); + func.__ember_observes__ = paths; + return func; +}; + +// If observers ever become asynchronous, Ember.immediateObserver +// must remain synchronous. +/** + @method immediateObserver + @for Ember + @param {Function} func + @param {String} propertyNames* + @return func +*/ +Ember.immediateObserver = function() { + for (var i=0, l=arguments.length; i w. +*/ +Ember.compare = function compare(v, w) { + if (v === w) { return 0; } + + var type1 = Ember.typeOf(v); + var type2 = Ember.typeOf(w); + + var Comparable = Ember.Comparable; + if (Comparable) { + if (type1==='instance' && Comparable.detect(v.constructor)) { + return v.constructor.compare(v, w); + } + + if (type2 === 'instance' && Comparable.detect(w.constructor)) { + return 1-w.constructor.compare(w, v); + } + } + + // If we haven't yet generated a reverse-mapping of Ember.ORDER_DEFINITION, + // do so now. + var mapping = Ember.ORDER_DEFINITION_MAPPING; + if (!mapping) { + var order = Ember.ORDER_DEFINITION; + mapping = Ember.ORDER_DEFINITION_MAPPING = {}; + var idx, len; + for (idx = 0, len = order.length; idx < len; ++idx) { + mapping[order[idx]] = idx; + } + + // We no longer need Ember.ORDER_DEFINITION. + delete Ember.ORDER_DEFINITION; + } + + var type1Index = mapping[type1]; + var type2Index = mapping[type2]; + + if (type1Index < type2Index) { return -1; } + if (type1Index > type2Index) { return 1; } + + // types are equal - so we have to check values now + switch (type1) { + case 'boolean': + case 'number': + if (v < w) { return -1; } + if (v > w) { return 1; } + return 0; + + case 'string': + var comp = v.localeCompare(w); + if (comp < 0) { return -1; } + if (comp > 0) { return 1; } + return 0; + + case 'array': + var vLen = v.length; + var wLen = w.length; + var l = Math.min(vLen, wLen); + var r = 0; + var i = 0; + while (r === 0 && i < l) { + r = compare(v[i],w[i]); + i++; + } + if (r !== 0) { return r; } + + // all elements are equal now + // shorter array should be ordered first + if (vLen < wLen) { return -1; } + if (vLen > wLen) { return 1; } + // arrays are equal now + return 0; + + case 'instance': + if (Ember.Comparable && Ember.Comparable.detect(v)) { + return v.compare(v, w); + } + return 0; + + case 'date': + var vNum = v.getTime(); + var wNum = w.getTime(); + if (vNum < wNum) { return -1; } + if (vNum > wNum) { return 1; } + return 0; + + default: + return 0; + } +}; + +function _copy(obj, deep, seen, copies) { + var ret, loc, key; + + // primitive data types are immutable, just return them. + if ('object' !== typeof obj || obj===null) return obj; + + // avoid cyclical loops + if (deep && (loc=indexOf(seen, obj))>=0) return copies[loc]; + + + // IMPORTANT: this specific test will detect a native array only. Any other + // object will need to implement Copyable. + if (Ember.typeOf(obj) === 'array') { + ret = obj.slice(); + if (deep) { + loc = ret.length; + while(--loc>=0) ret[loc] = _copy(ret[loc], deep, seen, copies); + } + } else if (Ember.Copyable && Ember.Copyable.detect(obj)) { + ret = obj.copy(deep, seen, copies); + } else { + ret = {}; + for(key in obj) { + if (!obj.hasOwnProperty(key)) continue; + + // Prevents browsers that don't respect non-enumerability from + // copying internal Ember properties + if (key.substring(0,2) === '__') continue; + + ret[key] = deep ? _copy(obj[key], deep, seen, copies) : obj[key]; + } + } + + if (deep) { + seen.push(obj); + copies.push(ret); + } + + return ret; +} + +/** + Creates a clone of the passed object. This function can take just about + any type of object and create a clone of it, including primitive values + (which are not actually cloned because they are immutable). + + If the passed object implements the `clone()` method, then this function + will simply call that method and return the result. + + @method copy + @for Ember + @param {Object} object The object to clone + @param {Boolean} deep If true, a deep copy of the object is made + @return {Object} The cloned object +*/ +Ember.copy = function(obj, deep) { + // fast paths + if ('object' !== typeof obj || obj===null) return obj; // can't copy primitives + if (Ember.Copyable && Ember.Copyable.detect(obj)) return obj.copy(deep); + return _copy(obj, deep, deep ? [] : null, deep ? [] : null); +}; + +/** + Convenience method to inspect an object. This method will attempt to + convert the object into a useful string description. + + It is a pretty simple implementation. If you want something more robust, + use something like JSDump: https://github.com/NV/jsDump + + @method inspect + @for Ember + @param {Object} obj The object you want to inspect. + @return {String} A description of the object +*/ +Ember.inspect = function(obj) { + if (typeof obj !== 'object' || obj === null) { + return obj + ''; + } + + var v, ret = []; + for(var key in obj) { + if (obj.hasOwnProperty(key)) { + v = obj[key]; + if (v === 'toString') { continue; } // ignore useless items + if (Ember.typeOf(v) === 'function') { v = "function() { ... }"; } + ret.push(key + ": " + v); + } + } + return "{" + ret.join(", ") + "}"; +}; + +/** + Compares two objects, returning true if they are logically equal. This is + a deeper comparison than a simple triple equal. For sets it will compare the + internal objects. For any other object that implements `isEqual()` it will + respect that method. + + ```javascript + Ember.isEqual('hello', 'hello'); // true + Ember.isEqual(1, 2); // false + Ember.isEqual([4,2], [4,2]); // false + ``` + + @method isEqual + @for Ember + @param {Object} a first object to compare + @param {Object} b second object to compare + @return {Boolean} +*/ +Ember.isEqual = function(a, b) { + if (a && 'function'===typeof a.isEqual) return a.isEqual(b); + return a === b; +}; + +// Used by Ember.compare +Ember.ORDER_DEFINITION = Ember.ENV.ORDER_DEFINITION || [ + 'undefined', + 'null', + 'boolean', + 'number', + 'string', + 'array', + 'object', + 'instance', + 'function', + 'class', + 'date' +]; + +/** + Returns all of the keys defined on an object or hash. This is useful + when inspecting objects for debugging. On browsers that support it, this + uses the native `Object.keys` implementation. + + @method keys + @for Ember + @param {Object} obj + @return {Array} Array containing keys of obj +*/ +Ember.keys = Object.keys; + +if (!Ember.keys) { + Ember.keys = function(obj) { + var ret = []; + for(var key in obj) { + if (obj.hasOwnProperty(key)) { ret.push(key); } + } + return ret; + }; +} + +// .......................................................... +// ERROR +// + +var errorProps = ['description', 'fileName', 'lineNumber', 'message', 'name', 'number', 'stack']; + +/** + A subclass of the JavaScript Error object for use in Ember. + + @class Error + @namespace Ember + @extends Error + @constructor +*/ +Ember.Error = function() { + var tmp = Error.prototype.constructor.apply(this, arguments); + + // Unfortunately errors are not enumerable in Chrome (at least), so `for prop in tmp` doesn't work. + for (var idx = 0; idx < errorProps.length; idx++) { + this[errorProps[idx]] = tmp[errorProps[idx]]; + } +}; + +Ember.Error.prototype = Ember.create(Error.prototype); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var STRING_DASHERIZE_REGEXP = (/[ _]/g); +var STRING_DASHERIZE_CACHE = {}; +var STRING_DECAMELIZE_REGEXP = (/([a-z])([A-Z])/g); +var STRING_CAMELIZE_REGEXP = (/(\-|_|\.|\s)+(.)?/g); +var STRING_UNDERSCORE_REGEXP_1 = (/([a-z\d])([A-Z]+)/g); +var STRING_UNDERSCORE_REGEXP_2 = (/\-|\s+/g); + +/** + Defines the hash of localized strings for the current language. Used by + the `Ember.String.loc()` helper. To localize, add string values to this + hash. + + @property STRINGS + @for Ember + @type Hash +*/ +Ember.STRINGS = {}; + +/** + Defines string helper methods including string formatting and localization. + Unless `Ember.EXTEND_PROTOTYPES.String` is `false` these methods will also be + added to the `String.prototype` as well. + + @class String + @namespace Ember + @static +*/ +Ember.String = { + + /** + Apply formatting options to the string. This will look for occurrences + of "%@" in your string and substitute them with the arguments you pass into + this method. If you want to control the specific order of replacement, + you can add a number after the key as well to indicate which argument + you want to insert. + + Ordered insertions are most useful when building loc strings where values + you need to insert may appear in different orders. + + ```javascript + "Hello %@ %@".fmt('John', 'Doe'); // "Hello John Doe" + "Hello %@2, %@1".fmt('John', 'Doe'); // "Hello Doe, John" + ``` + + @method fmt + @param {Object...} [args] + @return {String} formatted string + */ + fmt: function(str, formats) { + // first, replace any ORDERED replacements. + var idx = 0; // the current index for non-numerical replacements + return str.replace(/%@([0-9]+)?/g, function(s, argIndex) { + argIndex = (argIndex) ? parseInt(argIndex,0) - 1 : idx++ ; + s = formats[argIndex]; + return ((s === null) ? '(null)' : (s === undefined) ? '' : s).toString(); + }) ; + }, + + /** + Formats the passed string, but first looks up the string in the localized + strings hash. This is a convenient way to localize text. See + `Ember.String.fmt()` for more information on formatting. + + Note that it is traditional but not required to prefix localized string + keys with an underscore or other character so you can easily identify + localized strings. + + ```javascript + Ember.STRINGS = { + '_Hello World': 'Bonjour le monde', + '_Hello %@ %@': 'Bonjour %@ %@' + }; + + Ember.String.loc("_Hello World"); // 'Bonjour le monde'; + Ember.String.loc("_Hello %@ %@", ["John", "Smith"]); // "Bonjour John Smith"; + ``` + + @method loc + @param {String} str The string to format + @param {Array} formats Optional array of parameters to interpolate into string. + @return {String} formatted string + */ + loc: function(str, formats) { + str = Ember.STRINGS[str] || str; + return Ember.String.fmt(str, formats) ; + }, + + /** + Splits a string into separate units separated by spaces, eliminating any + empty strings in the process. This is a convenience method for split that + is mostly useful when applied to the `String.prototype`. + + ```javascript + Ember.String.w("alpha beta gamma").forEach(function(key) { + console.log(key); + }); + + // > alpha + // > beta + // > gamma + ``` + + @method w + @param {String} str The string to split + @return {String} split string + */ + w: function(str) { return str.split(/\s+/); }, + + /** + Converts a camelized string into all lower case separated by underscores. + + ```javascript + 'innerHTML'.decamelize(); // 'inner_html' + 'action_name'.decamelize(); // 'action_name' + 'css-class-name'.decamelize(); // 'css-class-name' + 'my favorite items'.decamelize(); // 'my favorite items' + ``` + + @method decamelize + @param {String} str The string to decamelize. + @return {String} the decamelized string. + */ + decamelize: function(str) { + return str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase(); + }, + + /** + Replaces underscores or spaces with dashes. + + ```javascript + 'innerHTML'.dasherize(); // 'inner-html' + 'action_name'.dasherize(); // 'action-name' + 'css-class-name'.dasherize(); // 'css-class-name' + 'my favorite items'.dasherize(); // 'my-favorite-items' + ``` + + @method dasherize + @param {String} str The string to dasherize. + @return {String} the dasherized string. + */ + dasherize: function(str) { + var cache = STRING_DASHERIZE_CACHE, + ret = cache[str]; + + if (ret) { + return ret; + } else { + ret = Ember.String.decamelize(str).replace(STRING_DASHERIZE_REGEXP,'-'); + cache[str] = ret; + } + + return ret; + }, + + /** + Returns the lowerCaseCamel form of a string. + + ```javascript + 'innerHTML'.camelize(); // 'innerHTML' + 'action_name'.camelize(); // 'actionName' + 'css-class-name'.camelize(); // 'cssClassName' + 'my favorite items'.camelize(); // 'myFavoriteItems' + ``` + + @method camelize + @param {String} str The string to camelize. + @return {String} the camelized string. + */ + camelize: function(str) { + return str.replace(STRING_CAMELIZE_REGEXP, function(match, separator, chr) { + return chr ? chr.toUpperCase() : ''; + }); + }, + + /** + Returns the UpperCamelCase form of a string. + + ```javascript + 'innerHTML'.classify(); // 'InnerHTML' + 'action_name'.classify(); // 'ActionName' + 'css-class-name'.classify(); // 'CssClassName' + 'my favorite items'.classify(); // 'MyFavoriteItems' + ``` + + @method classify + @param {String} str the string to classify + @return {String} the classified string + */ + classify: function(str) { + var parts = str.split("."), + out = []; + + for (var i=0, l=parts.length; i 'InnerHTML' + 'action_name'.capitalize() => 'Action_name' + 'css-class-name'.capitalize() => 'Css-class-name' + 'my favorite items'.capitalize() => 'My favorite items' + + @method capitalize + @param {String} str + @return {String} + */ + capitalize: function(str) { + return str.charAt(0).toUpperCase() + str.substr(1); + } + +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + + +var fmt = Ember.String.fmt, + w = Ember.String.w, + loc = Ember.String.loc, + camelize = Ember.String.camelize, + decamelize = Ember.String.decamelize, + dasherize = Ember.String.dasherize, + underscore = Ember.String.underscore, + capitalize = Ember.String.capitalize, + classify = Ember.String.classify; + +if (Ember.EXTEND_PROTOTYPES === true || Ember.EXTEND_PROTOTYPES.String) { + + /** + See {{#crossLink "Ember.String/fmt"}}{{/crossLink}} + + @method fmt + @for String + */ + String.prototype.fmt = function() { + return fmt(this, arguments); + }; + + /** + See {{#crossLink "Ember.String/w"}}{{/crossLink}} + + @method w + @for String + */ + String.prototype.w = function() { + return w(this); + }; + + /** + See {{#crossLink "Ember.String/loc"}}{{/crossLink}} + + @method loc + @for String + */ + String.prototype.loc = function() { + return loc(this, arguments); + }; + + /** + See {{#crossLink "Ember.String/camelize"}}{{/crossLink}} + + @method camelize + @for String + */ + String.prototype.camelize = function() { + return camelize(this); + }; + + /** + See {{#crossLink "Ember.String/decamelize"}}{{/crossLink}} + + @method decamelize + @for String + */ + String.prototype.decamelize = function() { + return decamelize(this); + }; + + /** + See {{#crossLink "Ember.String/dasherize"}}{{/crossLink}} + + @method dasherize + @for String + */ + String.prototype.dasherize = function() { + return dasherize(this); + }; + + /** + See {{#crossLink "Ember.String/underscore"}}{{/crossLink}} + + @method underscore + @for String + */ + String.prototype.underscore = function() { + return underscore(this); + }; + + /** + See {{#crossLink "Ember.String/classify"}}{{/crossLink}} + + @method classify + @for String + */ + String.prototype.classify = function() { + return classify(this); + }; + + /** + See {{#crossLink "Ember.String/capitalize"}}{{/crossLink}} + + @method capitalize + @for String + */ + String.prototype.capitalize = function() { + return capitalize(this); + }; + +} + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var a_slice = Array.prototype.slice; + +if (Ember.EXTEND_PROTOTYPES === true || Ember.EXTEND_PROTOTYPES.Function) { + + /** + The `property` extension of Javascript's Function prototype is available + when `Ember.EXTEND_PROTOTYPES` or `Ember.EXTEND_PROTOTYPES.Function` is + `true`, which is the default. + + Computed properties allow you to treat a function like a property: + + ```javascript + MyApp.president = Ember.Object.create({ + firstName: "Barack", + lastName: "Obama", + + fullName: function() { + return this.get('firstName') + ' ' + this.get('lastName'); + + // Call this flag to mark the function as a property + }.property() + }); + + MyApp.president.get('fullName'); // "Barack Obama" + ``` + + Treating a function like a property is useful because they can work with + bindings, just like any other property. + + Many computed properties have dependencies on other properties. For + example, in the above example, the `fullName` property depends on + `firstName` and `lastName` to determine its value. You can tell Ember + about these dependencies like this: + + ```javascript + MyApp.president = Ember.Object.create({ + firstName: "Barack", + lastName: "Obama", + + fullName: function() { + return this.get('firstName') + ' ' + this.get('lastName'); + + // Tell Ember.js that this computed property depends on firstName + // and lastName + }.property('firstName', 'lastName') + }); + ``` + + Make sure you list these dependencies so Ember knows when to update + bindings that connect to a computed property. Changing a dependency + will not immediately trigger an update of the computed property, but + will instead clear the cache so that it is updated when the next `get` + is called on the property. + + See {{#crossLink "Ember.ComputedProperty"}}{{/crossLink}}, + {{#crossLink "Ember/computed"}}{{/crossLink}} + + @method property + @for Function + */ + Function.prototype.property = function() { + var ret = Ember.computed(this); + return ret.property.apply(ret, arguments); + }; + + /** + The `observes` extension of Javascript's Function prototype is available + when `Ember.EXTEND_PROTOTYPES` or `Ember.EXTEND_PROTOTYPES.Function` is + true, which is the default. + + You can observe property changes simply by adding the `observes` + call to the end of your method declarations in classes that you write. + For example: + + ```javascript + Ember.Object.create({ + valueObserver: function() { + // Executes whenever the "value" property changes + }.observes('value') + }); + ``` + + See {{#crossLink "Ember.Observable/observes"}}{{/crossLink}} + + @method observes + @for Function + */ + Function.prototype.observes = function() { + this.__ember_observes__ = a_slice.call(arguments); + return this; + }; + + /** + The `observesBefore` extension of Javascript's Function prototype is + available when `Ember.EXTEND_PROTOTYPES` or + `Ember.EXTEND_PROTOTYPES.Function` is true, which is the default. + + You can get notified when a property changes is about to happen by + by adding the `observesBefore` call to the end of your method + declarations in classes that you write. For example: + + ```javascript + Ember.Object.create({ + valueObserver: function() { + // Executes whenever the "value" property is about to change + }.observesBefore('value') + }); + ``` + + See {{#crossLink "Ember.Observable/observesBefore"}}{{/crossLink}} + + @method observesBefore + @for Function + */ + Function.prototype.observesBefore = function() { + this.__ember_observesBefore__ = a_slice.call(arguments); + return this; + }; + +} + + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +// .......................................................... +// HELPERS +// + +var get = Ember.get, set = Ember.set; +var a_slice = Array.prototype.slice; +var a_indexOf = Ember.EnumerableUtils.indexOf; + +var contexts = []; + +function popCtx() { + return contexts.length===0 ? {} : contexts.pop(); +} + +function pushCtx(ctx) { + contexts.push(ctx); + return null; +} + +function iter(key, value) { + var valueProvided = arguments.length === 2; + + function i(item) { + var cur = get(item, key); + return valueProvided ? value===cur : !!cur; + } + return i ; +} + +/** + This mixin defines the common interface implemented by enumerable objects + in Ember. Most of these methods follow the standard Array iteration + API defined up to JavaScript 1.8 (excluding language-specific features that + cannot be emulated in older versions of JavaScript). + + This mixin is applied automatically to the Array class on page load, so you + can use any of these methods on simple arrays. If Array already implements + one of these methods, the mixin will not override them. + + ## Writing Your Own Enumerable + + To make your own custom class enumerable, you need two items: + + 1. You must have a length property. This property should change whenever + the number of items in your enumerable object changes. If you using this + with an `Ember.Object` subclass, you should be sure to change the length + property using `set().` + + 2. If you must implement `nextObject().` See documentation. + + Once you have these two methods implement, apply the `Ember.Enumerable` mixin + to your class and you will be able to enumerate the contents of your object + like any other collection. + + ## Using Ember Enumeration with Other Libraries + + Many other libraries provide some kind of iterator or enumeration like + facility. This is often where the most common API conflicts occur. + Ember's API is designed to be as friendly as possible with other + libraries by implementing only methods that mostly correspond to the + JavaScript 1.8 API. + + @class Enumerable + @namespace Ember + @extends Ember.Mixin + @since Ember 0.9 +*/ +Ember.Enumerable = Ember.Mixin.create( + /** @scope Ember.Enumerable.prototype */ { + + // compatibility + isEnumerable: true, + + /** + Implement this method to make your class enumerable. + + This method will be call repeatedly during enumeration. The index value + will always begin with 0 and increment monotonically. You don't have to + rely on the index value to determine what object to return, but you should + always check the value and start from the beginning when you see the + requested index is 0. + + The `previousObject` is the object that was returned from the last call + to `nextObject` for the current iteration. This is a useful way to + manage iteration if you are tracing a linked list, for example. + + Finally the context parameter will always contain a hash you can use as + a "scratchpad" to maintain any other state you need in order to iterate + properly. The context object is reused and is not reset between + iterations so make sure you setup the context with a fresh state whenever + the index parameter is 0. + + Generally iterators will continue to call `nextObject` until the index + reaches the your current length-1. If you run out of data before this + time for some reason, you should simply return undefined. + + The default implementation of this method simply looks up the index. + This works great on any Array-like objects. + + @method nextObject + @param {Number} index the current index of the iteration + @param {Object} previousObject the value returned by the last call to + `nextObject`. + @param {Object} context a context object you can use to maintain state. + @return {Object} the next object in the iteration or undefined + */ + nextObject: Ember.required(Function), + + /** + Helper method returns the first object from a collection. This is usually + used by bindings and other parts of the framework to extract a single + object if the enumerable contains only one item. + + If you override this method, you should implement it so that it will + always return the same value each time it is called. If your enumerable + contains only one object, this method should always return that object. + If your enumerable is empty, this method should return `undefined`. + + ```javascript + var arr = ["a", "b", "c"]; + arr.firstObject(); // "a" + + var arr = []; + arr.firstObject(); // undefined + ``` + + @property firstObject + @return {Object} the object or undefined + */ + firstObject: Ember.computed(function() { + if (get(this, 'length')===0) return undefined ; + + // handle generic enumerables + var context = popCtx(), ret; + ret = this.nextObject(0, null, context); + pushCtx(context); + return ret ; + }).property('[]'), + + /** + Helper method returns the last object from a collection. If your enumerable + contains only one object, this method should always return that object. + If your enumerable is empty, this method should return `undefined`. + + ```javascript + var arr = ["a", "b", "c"]; + arr.lastObject(); // "c" + + var arr = []; + arr.lastObject(); // undefined + ``` + + @property lastObject + @return {Object} the last object or undefined + */ + lastObject: Ember.computed(function() { + var len = get(this, 'length'); + if (len===0) return undefined ; + var context = popCtx(), idx=0, cur, last = null; + do { + last = cur; + cur = this.nextObject(idx++, last, context); + } while (cur !== undefined); + pushCtx(context); + return last; + }).property('[]'), + + /** + Returns `true` if the passed object can be found in the receiver. The + default version will iterate through the enumerable until the object + is found. You may want to override this with a more efficient version. + + ```javascript + var arr = ["a", "b", "c"]; + arr.contains("a"); // true + arr.contains("z"); // false + ``` + + @method contains + @param {Object} obj The object to search for. + @return {Boolean} `true` if object is found in enumerable. + */ + contains: function(obj) { + return this.find(function(item) { return item===obj; }) !== undefined; + }, + + /** + Iterates through the enumerable, calling the passed function on each + item. This method corresponds to the `forEach()` method defined in + JavaScript 1.6. + + The callback method you provide should have the following signature (all + parameters are optional): + + ```javascript + function(item, index, enumerable); + ``` + + - `item` is the current item in the iteration. + - `index` is the current index in the iteration. + - `enumerable` is the enumerable object itself. + + Note that in addition to a callback, you can also pass an optional target + object that will be set as `this` on the context. This is a good way + to give your iterator function access to the current object. + + @method forEach + @param {Function} callback The callback to execute + @param {Object} [target] The target object to use + @return {Object} receiver + */ + forEach: function(callback, target) { + if (typeof callback !== "function") throw new TypeError() ; + var len = get(this, 'length'), last = null, context = popCtx(); + + if (target === undefined) target = null; + + for(var idx=0;idx1) args = a_slice.call(arguments, 1); + + this.forEach(function(x, idx) { + var method = x && x[methodName]; + if ('function' === typeof method) { + ret[idx] = args ? method.apply(x, args) : method.call(x); + } + }, this); + + return ret; + }, + + /** + Simply converts the enumerable into a genuine array. The order is not + guaranteed. Corresponds to the method implemented by Prototype. + + @method toArray + @return {Array} the enumerable as an array. + */ + toArray: function() { + var ret = []; + this.forEach(function(o, idx) { ret[idx] = o; }); + return ret ; + }, + + /** + Returns a copy of the array with all null elements removed. + + ```javascript + var arr = ["a", null, "c", null]; + arr.compact(); // ["a", "c"] + ``` + + @method compact + @return {Array} the array without null elements. + */ + compact: function() { return this.without(null); }, + + /** + Returns a new enumerable that excludes the passed value. The default + implementation returns an array regardless of the receiver type unless + the receiver does not contain the value. + + ```javascript + var arr = ["a", "b", "a", "c"]; + arr.without("a"); // ["b", "c"] + ``` + + @method without + @param {Object} value + @return {Ember.Enumerable} + */ + without: function(value) { + if (!this.contains(value)) return this; // nothing to do + var ret = [] ; + this.forEach(function(k) { + if (k !== value) ret[ret.length] = k; + }) ; + return ret ; + }, + + /** + Returns a new enumerable that contains only unique values. The default + implementation returns an array regardless of the receiver type. + + ```javascript + var arr = ["a", "a", "b", "b"]; + arr.uniq(); // ["a", "b"] + ``` + + @method uniq + @return {Ember.Enumerable} + */ + uniq: function() { + var ret = []; + this.forEach(function(k){ + if (a_indexOf(ret, k)<0) ret.push(k); + }); + return ret; + }, + + /** + This property will trigger anytime the enumerable's content changes. + You can observe this property to be notified of changes to the enumerables + content. + + For plain enumerables, this property is read only. `Ember.Array` overrides + this method. + + @property [] + @type Ember.Array + */ + '[]': Ember.computed(function(key, value) { + return this; + }), + + // .......................................................... + // ENUMERABLE OBSERVERS + // + + /** + Registers an enumerable observer. Must implement `Ember.EnumerableObserver` + mixin. + + @method addEnumerableObserver + @param target {Object} + @param opts {Hash} + */ + addEnumerableObserver: function(target, opts) { + var willChange = (opts && opts.willChange) || 'enumerableWillChange', + didChange = (opts && opts.didChange) || 'enumerableDidChange'; + + var hasObservers = get(this, 'hasEnumerableObservers'); + if (!hasObservers) Ember.propertyWillChange(this, 'hasEnumerableObservers'); + Ember.addListener(this, '@enumerable:before', target, willChange); + Ember.addListener(this, '@enumerable:change', target, didChange); + if (!hasObservers) Ember.propertyDidChange(this, 'hasEnumerableObservers'); + return this; + }, + + /** + Removes a registered enumerable observer. + + @method removeEnumerableObserver + @param target {Object} + @param [opts] {Hash} + */ + removeEnumerableObserver: function(target, opts) { + var willChange = (opts && opts.willChange) || 'enumerableWillChange', + didChange = (opts && opts.didChange) || 'enumerableDidChange'; + + var hasObservers = get(this, 'hasEnumerableObservers'); + if (hasObservers) Ember.propertyWillChange(this, 'hasEnumerableObservers'); + Ember.removeListener(this, '@enumerable:before', target, willChange); + Ember.removeListener(this, '@enumerable:change', target, didChange); + if (hasObservers) Ember.propertyDidChange(this, 'hasEnumerableObservers'); + return this; + }, + + /** + Becomes true whenever the array currently has observers watching changes + on the array. + + @property hasEnumerableObservers + @type Boolean + */ + hasEnumerableObservers: Ember.computed(function() { + return Ember.hasListeners(this, '@enumerable:change') || Ember.hasListeners(this, '@enumerable:before'); + }), + + + /** + Invoke this method just before the contents of your enumerable will + change. You can either omit the parameters completely or pass the objects + to be removed or added if available or just a count. + + @method enumerableContentWillChange + @param {Ember.Enumerable|Number} removing An enumerable of the objects to + be removed or the number of items to be removed. + @param {Ember.Enumerable|Number} adding An enumerable of the objects to be + added or the number of items to be added. + @chainable + */ + enumerableContentWillChange: function(removing, adding) { + + var removeCnt, addCnt, hasDelta; + + if ('number' === typeof removing) removeCnt = removing; + else if (removing) removeCnt = get(removing, 'length'); + else removeCnt = removing = -1; + + if ('number' === typeof adding) addCnt = adding; + else if (adding) addCnt = get(adding,'length'); + else addCnt = adding = -1; + + hasDelta = addCnt<0 || removeCnt<0 || addCnt-removeCnt!==0; + + if (removing === -1) removing = null; + if (adding === -1) adding = null; + + Ember.propertyWillChange(this, '[]'); + if (hasDelta) Ember.propertyWillChange(this, 'length'); + Ember.sendEvent(this, '@enumerable:before', [this, removing, adding]); + + return this; + }, + + /** + Invoke this method when the contents of your enumerable has changed. + This will notify any observers watching for content changes. If your are + implementing an ordered enumerable (such as an array), also pass the + start and end values where the content changed so that it can be used to + notify range observers. + + @method enumerableContentDidChange + @param {Number} [start] optional start offset for the content change. + For unordered enumerables, you should always pass -1. + @param {Ember.Enumerable|Number} removing An enumerable of the objects to + be removed or the number of items to be removed. + @param {Ember.Enumerable|Number} adding An enumerable of the objects to + be added or the number of items to be added. + @chainable + */ + enumerableContentDidChange: function(removing, adding) { + var notify = this.propertyDidChange, removeCnt, addCnt, hasDelta; + + if ('number' === typeof removing) removeCnt = removing; + else if (removing) removeCnt = get(removing, 'length'); + else removeCnt = removing = -1; + + if ('number' === typeof adding) addCnt = adding; + else if (adding) addCnt = get(adding, 'length'); + else addCnt = adding = -1; + + hasDelta = addCnt<0 || removeCnt<0 || addCnt-removeCnt!==0; + + if (removing === -1) removing = null; + if (adding === -1) adding = null; + + Ember.sendEvent(this, '@enumerable:change', [this, removing, adding]); + if (hasDelta) Ember.propertyDidChange(this, 'length'); + Ember.propertyDidChange(this, '[]'); + + return this ; + } + +}) ; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +// .......................................................... +// HELPERS +// + +var get = Ember.get, set = Ember.set, meta = Ember.meta, map = Ember.EnumerableUtils.map, cacheFor = Ember.cacheFor; + +function none(obj) { return obj===null || obj===undefined; } + +// .......................................................... +// ARRAY +// +/** + This module implements Observer-friendly Array-like behavior. This mixin is + picked up by the Array class as well as other controllers, etc. that want to + appear to be arrays. + + Unlike `Ember.Enumerable,` this mixin defines methods specifically for + collections that provide index-ordered access to their contents. When you + are designing code that needs to accept any kind of Array-like object, you + should use these methods instead of Array primitives because these will + properly notify observers of changes to the array. + + Although these methods are efficient, they do add a layer of indirection to + your application so it is a good idea to use them only when you need the + flexibility of using both true JavaScript arrays and "virtual" arrays such + as controllers and collections. + + You can use the methods defined in this module to access and modify array + contents in a KVO-friendly way. You can also be notified whenever the + membership if an array changes by changing the syntax of the property to + `.observes('*myProperty.[]')`. + + To support `Ember.Array` in your own class, you must override two + primitives to use it: `replace()` and `objectAt()`. + + Note that the Ember.Array mixin also incorporates the `Ember.Enumerable` + mixin. All `Ember.Array`-like objects are also enumerable. + + @class Array + @namespace Ember + @extends Ember.Mixin + @uses Ember.Enumerable + @since Ember 0.9.0 +*/ +Ember.Array = Ember.Mixin.create(Ember.Enumerable, /** @scope Ember.Array.prototype */ { + + // compatibility + isSCArray: true, + + /** + Your array must support the `length` property. Your replace methods should + set this property whenever it changes. + + @property {Number} length + */ + length: Ember.required(), + + /** + Returns the object at the given `index`. If the given `index` is negative + or is greater or equal than the array length, returns `undefined`. + + This is one of the primitives you must implement to support `Ember.Array`. + If your object supports retrieving the value of an array item using `get()` + (i.e. `myArray.get(0)`), then you do not need to implement this method + yourself. + + ```javascript + var arr = ['a', 'b', 'c', 'd']; + arr.objectAt(0); // "a" + arr.objectAt(3); // "d" + arr.objectAt(-1); // undefined + arr.objectAt(4); // undefined + arr.objectAt(5); // undefined + ``` + + @method objectAt + @param {Number} idx The index of the item to return. + */ + objectAt: function(idx) { + if ((idx < 0) || (idx>=get(this, 'length'))) return undefined ; + return get(this, idx); + }, + + /** + This returns the objects at the specified indexes, using `objectAt`. + + ```javascript + var arr = ['a', 'b', 'c', 'd']; + arr.objectsAt([0, 1, 2]); // ["a", "b", "c"] + arr.objectsAt([2, 3, 4]); // ["c", "d", undefined] + ``` + + @method objectsAt + @param {Array} indexes An array of indexes of items to return. + */ + objectsAt: function(indexes) { + var self = this; + return map(indexes, function(idx){ return self.objectAt(idx); }); + }, + + // overrides Ember.Enumerable version + nextObject: function(idx) { + return this.objectAt(idx); + }, + + /** + This is the handler for the special array content property. If you get + this property, it will return this. If you set this property it a new + array, it will replace the current content. + + This property overrides the default property defined in `Ember.Enumerable`. + + @property [] + */ + '[]': Ember.computed(function(key, value) { + if (value !== undefined) this.replace(0, get(this, 'length'), value) ; + return this ; + }), + + firstObject: Ember.computed(function() { + return this.objectAt(0); + }), + + lastObject: Ember.computed(function() { + return this.objectAt(get(this, 'length')-1); + }), + + // optimized version from Enumerable + contains: function(obj){ + return this.indexOf(obj) >= 0; + }, + + // Add any extra methods to Ember.Array that are native to the built-in Array. + /** + Returns a new array that is a slice of the receiver. This implementation + uses the observable array methods to retrieve the objects for the new + slice. + + ```javascript + var arr = ['red', 'green', 'blue']; + arr.slice(0); // ['red', 'green', 'blue'] + arr.slice(0, 2); // ['red', 'green'] + arr.slice(1, 100); // ['green', 'blue'] + ``` + + @method slice + @param beginIndex {Integer} (Optional) index to begin slicing from. + @param endIndex {Integer} (Optional) index to end the slice at. + @return {Array} New array with specified slice + */ + slice: function(beginIndex, endIndex) { + var ret = []; + var length = get(this, 'length') ; + if (none(beginIndex)) beginIndex = 0 ; + if (none(endIndex) || (endIndex > length)) endIndex = length ; + while(beginIndex < endIndex) { + ret[ret.length] = this.objectAt(beginIndex++) ; + } + return ret ; + }, + + /** + Returns the index of the given object's first occurrence. + If no `startAt` argument is given, the starting location to + search is 0. If it's negative, will count backward from + the end of the array. Returns -1 if no match is found. + + ```javascript + var arr = ["a", "b", "c", "d", "a"]; + arr.indexOf("a"); // 0 + arr.indexOf("z"); // -1 + arr.indexOf("a", 2); // 4 + arr.indexOf("a", -1); // 4 + arr.indexOf("b", 3); // -1 + arr.indexOf("a", 100); // -1 + ``` + + @method indexOf + @param {Object} object the item to search for + @param {Number} startAt optional starting location to search, default 0 + @return {Number} index or -1 if not found + */ + indexOf: function(object, startAt) { + var idx, len = get(this, 'length'); + + if (startAt === undefined) startAt = 0; + if (startAt < 0) startAt += len; + + for(idx=startAt;idx= len) startAt = len-1; + if (startAt < 0) startAt += len; + + for(idx=startAt;idx>=0;idx--) { + if (this.objectAt(idx) === object) return idx ; + } + return -1; + }, + + // .......................................................... + // ARRAY OBSERVERS + // + + /** + Adds an array observer to the receiving array. The array observer object + normally must implement two methods: + + * `arrayWillChange(start, removeCount, addCount)` - This method will be + called just before the array is modified. + * `arrayDidChange(start, removeCount, addCount)` - This method will be + called just after the array is modified. + + Both callbacks will be passed the starting index of the change as well a + a count of the items to be removed and added. You can use these callbacks + to optionally inspect the array during the change, clear caches, or do + any other bookkeeping necessary. + + In addition to passing a target, you can also include an options hash + which you can use to override the method names that will be invoked on the + target. + + @method addArrayObserver + @param {Object} target The observer object. + @param {Hash} opts Optional hash of configuration options including + `willChange`, `didChange`, and a `context` option. + @return {Ember.Array} receiver + */ + addArrayObserver: function(target, opts) { + var willChange = (opts && opts.willChange) || 'arrayWillChange', + didChange = (opts && opts.didChange) || 'arrayDidChange'; + + var hasObservers = get(this, 'hasArrayObservers'); + if (!hasObservers) Ember.propertyWillChange(this, 'hasArrayObservers'); + Ember.addListener(this, '@array:before', target, willChange); + Ember.addListener(this, '@array:change', target, didChange); + if (!hasObservers) Ember.propertyDidChange(this, 'hasArrayObservers'); + return this; + }, + + /** + Removes an array observer from the object if the observer is current + registered. Calling this method multiple times with the same object will + have no effect. + + @method removeArrayObserver + @param {Object} target The object observing the array. + @return {Ember.Array} receiver + */ + removeArrayObserver: function(target, opts) { + var willChange = (opts && opts.willChange) || 'arrayWillChange', + didChange = (opts && opts.didChange) || 'arrayDidChange'; + + var hasObservers = get(this, 'hasArrayObservers'); + if (hasObservers) Ember.propertyWillChange(this, 'hasArrayObservers'); + Ember.removeListener(this, '@array:before', target, willChange); + Ember.removeListener(this, '@array:change', target, didChange); + if (hasObservers) Ember.propertyDidChange(this, 'hasArrayObservers'); + return this; + }, + + /** + Becomes true whenever the array currently has observers watching changes + on the array. + + @property Boolean + */ + hasArrayObservers: Ember.computed(function() { + return Ember.hasListeners(this, '@array:change') || Ember.hasListeners(this, '@array:before'); + }), + + /** + If you are implementing an object that supports `Ember.Array`, call this + method just before the array content changes to notify any observers and + invalidate any related properties. Pass the starting index of the change + as well as a delta of the amounts to change. + + @method arrayContentWillChange + @param {Number} startIdx The starting index in the array that will change. + @param {Number} removeAmt The number of items that will be removed. If you + pass `null` assumes 0 + @param {Number} addAmt The number of items that will be added If you + pass `null` assumes 0. + @return {Ember.Array} receiver + */ + arrayContentWillChange: function(startIdx, removeAmt, addAmt) { + + // if no args are passed assume everything changes + if (startIdx===undefined) { + startIdx = 0; + removeAmt = addAmt = -1; + } else { + if (removeAmt === undefined) removeAmt=-1; + if (addAmt === undefined) addAmt=-1; + } + + // Make sure the @each proxy is set up if anyone is observing @each + if (Ember.isWatching(this, '@each')) { get(this, '@each'); } + + Ember.sendEvent(this, '@array:before', [this, startIdx, removeAmt, addAmt]); + + var removing, lim; + if (startIdx>=0 && removeAmt>=0 && get(this, 'hasEnumerableObservers')) { + removing = []; + lim = startIdx+removeAmt; + for(var idx=startIdx;idx=0 && addAmt>=0 && get(this, 'hasEnumerableObservers')) { + adding = []; + lim = startIdx+addAmt; + for(var idx=startIdx;idx b` + + Default implementation raises an exception. + + @method compare + @param a {Object} the first object to compare + @param b {Object} the second object to compare + @return {Integer} the result of the comparison + */ + compare: Ember.required(Function) + +}); + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + + +var get = Ember.get, set = Ember.set; + +/** + Implements some standard methods for copying an object. Add this mixin to + any object you create that can create a copy of itself. This mixin is + added automatically to the built-in array. + + You should generally implement the `copy()` method to return a copy of the + receiver. + + Note that `frozenCopy()` will only work if you also implement + `Ember.Freezable`. + + @class Copyable + @namespace Ember + @extends Ember.Mixin + @since Ember 0.9 +*/ +Ember.Copyable = Ember.Mixin.create( +/** @scope Ember.Copyable.prototype */ { + + /** + Override to return a copy of the receiver. Default implementation raises + an exception. + + @method copy + @param deep {Boolean} if `true`, a deep copy of the object should be made + @return {Object} copy of receiver + */ + copy: Ember.required(Function), + + /** + If the object implements `Ember.Freezable`, then this will return a new + copy if the object is not frozen and the receiver if the object is frozen. + + Raises an exception if you try to call this method on a object that does + not support freezing. + + You should use this method whenever you want a copy of a freezable object + since a freezable object can simply return itself without actually + consuming more memory. + + @method frozenCopy + @return {Object} copy of receiver or receiver + */ + frozenCopy: function() { + if (Ember.Freezable && Ember.Freezable.detect(this)) { + return get(this, 'isFrozen') ? this : this.copy().freeze(); + } else { + throw new Error(Ember.String.fmt("%@ does not support freezing", [this])); + } + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + +var get = Ember.get, set = Ember.set; + +/** + The `Ember.Freezable` mixin implements some basic methods for marking an + object as frozen. Once an object is frozen it should be read only. No changes + may be made the internal state of the object. + + ## Enforcement + + To fully support freezing in your subclass, you must include this mixin and + override any method that might alter any property on the object to instead + raise an exception. You can check the state of an object by checking the + `isFrozen` property. + + Although future versions of JavaScript may support language-level freezing + object objects, that is not the case today. Even if an object is freezable, + it is still technically possible to modify the object, even though it could + break other parts of your application that do not expect a frozen object to + change. It is, therefore, very important that you always respect the + `isFrozen` property on all freezable objects. + + ## Example Usage + + The example below shows a simple object that implement the `Ember.Freezable` + protocol. + + ```javascript + Contact = Ember.Object.extend(Ember.Freezable, { + firstName: null, + lastName: null, + + // swaps the names + swapNames: function() { + if (this.get('isFrozen')) throw Ember.FROZEN_ERROR; + var tmp = this.get('firstName'); + this.set('firstName', this.get('lastName')); + this.set('lastName', tmp); + return this; + } + + }); + + c = Context.create({ firstName: "John", lastName: "Doe" }); + c.swapNames(); // returns c + c.freeze(); + c.swapNames(); // EXCEPTION + ``` + + ## Copying + + Usually the `Ember.Freezable` protocol is implemented in cooperation with the + `Ember.Copyable` protocol, which defines a `frozenCopy()` method that will + return a frozen object, if the object implements this method as well. + + @class Freezable + @namespace Ember + @extends Ember.Mixin + @since Ember 0.9 +*/ +Ember.Freezable = Ember.Mixin.create( +/** @scope Ember.Freezable.prototype */ { + + /** + Set to `true` when the object is frozen. Use this property to detect + whether your object is frozen or not. + + @property isFrozen + @type Boolean + */ + isFrozen: false, + + /** + Freezes the object. Once this method has been called the object should + no longer allow any properties to be edited. + + @method freeze + @return {Object} receiver + */ + freeze: function() { + if (get(this, 'isFrozen')) return this; + set(this, 'isFrozen', true); + return this; + } + +}); + +Ember.FROZEN_ERROR = "Frozen object cannot be modified."; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var forEach = Ember.EnumerableUtils.forEach; + +/** + This mixin defines the API for modifying generic enumerables. These methods + can be applied to an object regardless of whether it is ordered or + unordered. + + Note that an Enumerable can change even if it does not implement this mixin. + For example, a MappedEnumerable cannot be directly modified but if its + underlying enumerable changes, it will change also. + + ## Adding Objects + + To add an object to an enumerable, use the `addObject()` method. This + method will only add the object to the enumerable if the object is not + already present and the object if of a type supported by the enumerable. + + ```javascript + set.addObject(contact); + ``` + + ## Removing Objects + + To remove an object form an enumerable, use the `removeObject()` method. This + will only remove the object if it is already in the enumerable, otherwise + this method has no effect. + + ```javascript + set.removeObject(contact); + ``` + + ## Implementing In Your Own Code + + If you are implementing an object and want to support this API, just include + this mixin in your class and implement the required methods. In your unit + tests, be sure to apply the Ember.MutableEnumerableTests to your object. + + @class MutableEnumerable + @namespace Ember + @extends Ember.Mixin + @uses Ember.Enumerable +*/ +Ember.MutableEnumerable = Ember.Mixin.create(Ember.Enumerable, + /** @scope Ember.MutableEnumerable.prototype */ { + + /** + __Required.__ You must implement this method to apply this mixin. + + Attempts to add the passed object to the receiver if the object is not + already present in the collection. If the object is present, this method + has no effect. + + If the passed object is of a type not supported by the receiver + then this method should raise an exception. + + @method addObject + @param {Object} object The object to add to the enumerable. + @return {Object} the passed object + */ + addObject: Ember.required(Function), + + /** + Adds each object in the passed enumerable to the receiver. + + @method addObjects + @param {Ember.Enumerable} objects the objects to add. + @return {Object} receiver + */ + addObjects: function(objects) { + Ember.beginPropertyChanges(this); + forEach(objects, function(obj) { this.addObject(obj); }, this); + Ember.endPropertyChanges(this); + return this; + }, + + /** + __Required.__ You must implement this method to apply this mixin. + + Attempts to remove the passed object from the receiver collection if the + object is in present in the collection. If the object is not present, + this method has no effect. + + If the passed object is of a type not supported by the receiver + then this method should raise an exception. + + @method removeObject + @param {Object} object The object to remove from the enumerable. + @return {Object} the passed object + */ + removeObject: Ember.required(Function), + + + /** + Removes each objects in the passed enumerable from the receiver. + + @method removeObjects + @param {Ember.Enumerable} objects the objects to remove + @return {Object} receiver + */ + removeObjects: function(objects) { + Ember.beginPropertyChanges(this); + forEach(objects, function(obj) { this.removeObject(obj); }, this); + Ember.endPropertyChanges(this); + return this; + } + +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ +// .......................................................... +// CONSTANTS +// + +var OUT_OF_RANGE_EXCEPTION = "Index out of range" ; +var EMPTY = []; + +// .......................................................... +// HELPERS +// + +var get = Ember.get, set = Ember.set, forEach = Ember.EnumerableUtils.forEach; + +/** + This mixin defines the API for modifying array-like objects. These methods + can be applied only to a collection that keeps its items in an ordered set. + + Note that an Array can change even if it does not implement this mixin. + For example, one might implement a SparseArray that cannot be directly + modified, but if its underlying enumerable changes, it will change also. + + @class MutableArray + @namespace Ember + @extends Ember.Mixin + @uses Ember.Array + @uses Ember.MutableEnumerable +*/ +Ember.MutableArray = Ember.Mixin.create(Ember.Array, Ember.MutableEnumerable, + /** @scope Ember.MutableArray.prototype */ { + + /** + __Required.__ You must implement this method to apply this mixin. + + This is one of the primitives you must implement to support `Ember.Array`. + You should replace amt objects started at idx with the objects in the + passed array. You should also call `this.enumerableContentDidChange()` + + @method replace + @param {Number} idx Starting index in the array to replace. If + idx >= length, then append to the end of the array. + @param {Number} amt Number of elements that should be removed from + the array, starting at *idx*. + @param {Array} objects An array of zero or more objects that should be + inserted into the array at *idx* + */ + replace: Ember.required(), + + /** + Remove all elements from self. This is useful if you + want to reuse an existing array without having to recreate it. + + ```javascript + var colors = ["red", "green", "blue"]; + color.length(); // 3 + colors.clear(); // [] + colors.length(); // 0 + ``` + + @method clear + @return {Ember.Array} An empty Array. + */ + clear: function () { + var len = get(this, 'length'); + if (len === 0) return this; + this.replace(0, len, EMPTY); + return this; + }, + + /** + This will use the primitive `replace()` method to insert an object at the + specified index. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.insertAt(2, "yellow"); // ["red", "green", "yellow", "blue"] + colors.insertAt(5, "orange"); // Error: Index out of range + ``` + + @method insertAt + @param {Number} idx index of insert the object at. + @param {Object} object object to insert + */ + insertAt: function(idx, object) { + if (idx > get(this, 'length')) throw new Error(OUT_OF_RANGE_EXCEPTION) ; + this.replace(idx, 0, [object]) ; + return this ; + }, + + /** + Remove an object at the specified index using the `replace()` primitive + method. You can pass either a single index, or a start and a length. + + If you pass a start and length that is beyond the + length this method will throw an `Ember.OUT_OF_RANGE_EXCEPTION` + + ```javascript + var colors = ["red", "green", "blue", "yellow", "orange"]; + colors.removeAt(0); // ["green", "blue", "yellow", "orange"] + colors.removeAt(2, 2); // ["green", "blue"] + colors.removeAt(4, 2); // Error: Index out of range + ``` + + @method removeAt + @param {Number} start index, start of range + @param {Number} len length of passing range + @return {Object} receiver + */ + removeAt: function(start, len) { + if ('number' === typeof start) { + + if ((start < 0) || (start >= get(this, 'length'))) { + throw new Error(OUT_OF_RANGE_EXCEPTION); + } + + // fast case + if (len === undefined) len = 1; + this.replace(start, len, EMPTY); + } + + return this ; + }, + + /** + Push the object onto the end of the array. Works just like `push()` but it + is KVO-compliant. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.pushObject("black"); // ["red", "green", "blue", "black"] + colors.pushObject(["yellow", "orange"]); // ["red", "green", "blue", "black", ["yellow", "orange"]] + ``` + + @method pushObject + @param {anything} obj object to push + */ + pushObject: function(obj) { + this.insertAt(get(this, 'length'), obj) ; + return obj ; + }, + + /** + Add the objects in the passed numerable to the end of the array. Defers + notifying observers of the change until all objects are added. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.pushObjects("black"); // ["red", "green", "blue", "black"] + colors.pushObjects(["yellow", "orange"]); // ["red", "green", "blue", "black", "yellow", "orange"] + ``` + + @method pushObjects + @param {Ember.Enumerable} objects the objects to add + @return {Ember.Array} receiver + */ + pushObjects: function(objects) { + this.replace(get(this, 'length'), 0, objects); + return this; + }, + + /** + Pop object from array or nil if none are left. Works just like `pop()` but + it is KVO-compliant. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.popObject(); // "blue" + console.log(colors); // ["red", "green"] + ``` + + @method popObject + @return object + */ + popObject: function() { + var len = get(this, 'length') ; + if (len === 0) return null ; + + var ret = this.objectAt(len-1) ; + this.removeAt(len-1, 1) ; + return ret ; + }, + + /** + Shift an object from start of array or nil if none are left. Works just + like `shift()` but it is KVO-compliant. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.shiftObject(); // "red" + console.log(colors); // ["green", "blue"] + ``` + + @method shiftObject + @return object + */ + shiftObject: function() { + if (get(this, 'length') === 0) return null ; + var ret = this.objectAt(0) ; + this.removeAt(0) ; + return ret ; + }, + + /** + Unshift an object to start of array. Works just like `unshift()` but it is + KVO-compliant. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.unshiftObject("yellow"); // ["yellow", "red", "green", "blue"] + colors.unshiftObject(["black", "white"]); // [["black", "white"], "yellow", "red", "green", "blue"] + ``` + + @method unshiftObject + @param {anything} obj object to unshift + */ + unshiftObject: function(obj) { + this.insertAt(0, obj) ; + return obj ; + }, + + /** + Adds the named objects to the beginning of the array. Defers notifying + observers until all objects have been added. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.unshiftObjects(["black", "white"]); // ["black", "white", "red", "green", "blue"] + colors.unshiftObjects("yellow"); // Type Error: 'undefined' is not a function + ``` + + @method unshiftObjects + @param {Ember.Enumerable} objects the objects to add + @return {Ember.Array} receiver + */ + unshiftObjects: function(objects) { + this.replace(0, 0, objects); + return this; + }, + + /** + Reverse objects in the array. Works just like `reverse()` but it is + KVO-compliant. + + @method reverseObjects + @return {Ember.Array} receiver + */ + reverseObjects: function() { + var len = get(this, 'length'); + if (len === 0) return this; + var objects = this.toArray().reverse(); + this.replace(0, len, objects); + return this; + }, + + /** + Replace all the the receiver's content with content of the argument. + If argument is an empty array receiver will be cleared. + + ```javascript + var colors = ["red", "green", "blue"]; + colors.setObjects(["black", "white"]); // ["black", "white"] + colors.setObjects([]); // [] + ``` + + @method setObjects + @param {Ember.Array} objects array whose content will be used for replacing + the content of the receiver + @return {Ember.Array} receiver with the new content + */ + setObjects: function(objects) { + if (objects.length === 0) return this.clear(); + + var len = get(this, 'length'); + this.replace(0, len, objects); + return this; + }, + + // .......................................................... + // IMPLEMENT Ember.MutableEnumerable + // + + removeObject: function(obj) { + var loc = get(this, 'length') || 0; + while(--loc >= 0) { + var curObject = this.objectAt(loc) ; + if (curObject === obj) this.removeAt(loc) ; + } + return this ; + }, + + addObject: function(obj) { + if (!this.contains(obj)) this.pushObject(obj); + return this ; + } + +}); + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set, defineProperty = Ember.defineProperty; + +/** + ## Overview + + This mixin provides properties and property observing functionality, core + features of the Ember object model. + + Properties and observers allow one object to observe changes to a + property on another object. This is one of the fundamental ways that + models, controllers and views communicate with each other in an Ember + application. + + Any object that has this mixin applied can be used in observer + operations. That includes `Ember.Object` and most objects you will + interact with as you write your Ember application. + + Note that you will not generally apply this mixin to classes yourself, + but you will use the features provided by this module frequently, so it + is important to understand how to use it. + + ## Using `get()` and `set()` + + Because of Ember's support for bindings and observers, you will always + access properties using the get method, and set properties using the + set method. This allows the observing objects to be notified and + computed properties to be handled properly. + + More documentation about `get` and `set` are below. + + ## Observing Property Changes + + You typically observe property changes simply by adding the `observes` + call to the end of your method declarations in classes that you write. + For example: + + ```javascript + Ember.Object.create({ + valueObserver: function() { + // Executes whenever the "value" property changes + }.observes('value') + }); + ``` + + Although this is the most common way to add an observer, this capability + is actually built into the `Ember.Object` class on top of two methods + defined in this mixin: `addObserver` and `removeObserver`. You can use + these two methods to add and remove observers yourself if you need to + do so at runtime. + + To add an observer for a property, call: + + ```javascript + object.addObserver('propertyKey', targetObject, targetAction) + ``` + + This will call the `targetAction` method on the `targetObject` to be called + whenever the value of the `propertyKey` changes. + + Note that if `propertyKey` is a computed property, the observer will be + called when any of the property dependencies are changed, even if the + resulting value of the computed property is unchanged. This is necessary + because computed properties are not computed until `get` is called. + + @class Observable + @namespace Ember + @extends Ember.Mixin +*/ +Ember.Observable = Ember.Mixin.create(/** @scope Ember.Observable.prototype */ { + + /** + Retrieves the value of a property from the object. + + This method is usually similar to using `object[keyName]` or `object.keyName`, + however it supports both computed properties and the unknownProperty + handler. + + Because `get` unifies the syntax for accessing all these kinds + of properties, it can make many refactorings easier, such as replacing a + simple property with a computed property, or vice versa. + + ### Computed Properties + + Computed properties are methods defined with the `property` modifier + declared at the end, such as: + + ```javascript + fullName: function() { + return this.getEach('firstName', 'lastName').compact().join(' '); + }.property('firstName', 'lastName') + ``` + + When you call `get` on a computed property, the function will be + called and the return value will be returned instead of the function + itself. + + ### Unknown Properties + + Likewise, if you try to call `get` on a property whose value is + `undefined`, the `unknownProperty()` method will be called on the object. + If this method returns any value other than `undefined`, it will be returned + instead. This allows you to implement "virtual" properties that are + not defined upfront. + + @method get + @param {String} key The property to retrieve + @return {Object} The property value or undefined. + */ + get: function(keyName) { + return get(this, keyName); + }, + + /** + To get multiple properties at once, call `getProperties` + with a list of strings or an array: + + ```javascript + record.getProperties('firstName', 'lastName', 'zipCode'); // { firstName: 'John', lastName: 'Doe', zipCode: '10011' } + ``` + + is equivalent to: + + ```javascript + record.getProperties(['firstName', 'lastName', 'zipCode']); // { firstName: 'John', lastName: 'Doe', zipCode: '10011' } + ``` + + @method getProperties + @param {String...|Array} list of keys to get + @return {Hash} + */ + getProperties: function() { + var ret = {}; + var propertyNames = arguments; + if (arguments.length === 1 && Ember.typeOf(arguments[0]) === 'array') { + propertyNames = arguments[0]; + } + for(var i = 0; i < propertyNames.length; i++) { + ret[propertyNames[i]] = get(this, propertyNames[i]); + } + return ret; + }, + + /** + Sets the provided key or path to the value. + + This method is generally very similar to calling `object[key] = value` or + `object.key = value`, except that it provides support for computed + properties, the `unknownProperty()` method and property observers. + + ### Computed Properties + + If you try to set a value on a key that has a computed property handler + defined (see the `get()` method for an example), then `set()` will call + that method, passing both the value and key instead of simply changing + the value itself. This is useful for those times when you need to + implement a property that is composed of one or more member + properties. + + ### Unknown Properties + + If you try to set a value on a key that is undefined in the target + object, then the `unknownProperty()` handler will be called instead. This + gives you an opportunity to implement complex "virtual" properties that + are not predefined on the object. If `unknownProperty()` returns + undefined, then `set()` will simply set the value on the object. + + ### Property Observers + + In addition to changing the property, `set()` will also register a property + change with the object. Unless you have placed this call inside of a + `beginPropertyChanges()` and `endPropertyChanges(),` any "local" observers + (i.e. observer methods declared on the same object), will be called + immediately. Any "remote" observers (i.e. observer methods declared on + another object) will be placed in a queue and called at a later time in a + coalesced manner. + + ### Chaining + + In addition to property changes, `set()` returns the value of the object + itself so you can do chaining like this: + + ```javascript + record.set('firstName', 'Charles').set('lastName', 'Jolley'); + ``` + + @method set + @param {String} key The property to set + @param {Object} value The value to set or `null`. + @return {Ember.Observable} + */ + set: function(keyName, value) { + set(this, keyName, value); + return this; + }, + + /** + To set multiple properties at once, call `setProperties` + with a Hash: + + ```javascript + record.setProperties({ firstName: 'Charles', lastName: 'Jolley' }); + ``` + + @method setProperties + @param {Hash} hash the hash of keys and values to set + @return {Ember.Observable} + */ + setProperties: function(hash) { + return Ember.setProperties(this, hash); + }, + + /** + Begins a grouping of property changes. + + You can use this method to group property changes so that notifications + will not be sent until the changes are finished. If you plan to make a + large number of changes to an object at one time, you should call this + method at the beginning of the changes to begin deferring change + notifications. When you are done making changes, call + `endPropertyChanges()` to deliver the deferred change notifications and end + deferring. + + @method beginPropertyChanges + @return {Ember.Observable} + */ + beginPropertyChanges: function() { + Ember.beginPropertyChanges(); + return this; + }, + + /** + Ends a grouping of property changes. + + You can use this method to group property changes so that notifications + will not be sent until the changes are finished. If you plan to make a + large number of changes to an object at one time, you should call + `beginPropertyChanges()` at the beginning of the changes to defer change + notifications. When you are done making changes, call this method to + deliver the deferred change notifications and end deferring. + + @method endPropertyChanges + @return {Ember.Observable} + */ + endPropertyChanges: function() { + Ember.endPropertyChanges(); + return this; + }, + + /** + Notify the observer system that a property is about to change. + + Sometimes you need to change a value directly or indirectly without + actually calling `get()` or `set()` on it. In this case, you can use this + method and `propertyDidChange()` instead. Calling these two methods + together will notify all observers that the property has potentially + changed value. + + Note that you must always call `propertyWillChange` and `propertyDidChange` + as a pair. If you do not, it may get the property change groups out of + order and cause notifications to be delivered more often than you would + like. + + @method propertyWillChange + @param {String} key The property key that is about to change. + @return {Ember.Observable} + */ + propertyWillChange: function(keyName){ + Ember.propertyWillChange(this, keyName); + return this; + }, + + /** + Notify the observer system that a property has just changed. + + Sometimes you need to change a value directly or indirectly without + actually calling `get()` or `set()` on it. In this case, you can use this + method and `propertyWillChange()` instead. Calling these two methods + together will notify all observers that the property has potentially + changed value. + + Note that you must always call `propertyWillChange` and `propertyDidChange` + as a pair. If you do not, it may get the property change groups out of + order and cause notifications to be delivered more often than you would + like. + + @method propertyDidChange + @param {String} keyName The property key that has just changed. + @return {Ember.Observable} + */ + propertyDidChange: function(keyName) { + Ember.propertyDidChange(this, keyName); + return this; + }, + + /** + Convenience method to call `propertyWillChange` and `propertyDidChange` in + succession. + + @method notifyPropertyChange + @param {String} keyName The property key to be notified about. + @return {Ember.Observable} + */ + notifyPropertyChange: function(keyName) { + this.propertyWillChange(keyName); + this.propertyDidChange(keyName); + return this; + }, + + addBeforeObserver: function(key, target, method) { + Ember.addBeforeObserver(this, key, target, method); + }, + + /** + Adds an observer on a property. + + This is the core method used to register an observer for a property. + + Once you call this method, anytime the key's value is set, your observer + will be notified. Note that the observers are triggered anytime the + value is set, regardless of whether it has actually changed. Your + observer should be prepared to handle that. + + You can also pass an optional context parameter to this method. The + context will be passed to your observer method whenever it is triggered. + Note that if you add the same target/method pair on a key multiple times + with different context parameters, your observer will only be called once + with the last context you passed. + + ### Observer Methods + + Observer methods you pass should generally have the following signature if + you do not pass a `context` parameter: + + ```javascript + fooDidChange: function(sender, key, value, rev) { }; + ``` + + The sender is the object that changed. The key is the property that + changes. The value property is currently reserved and unused. The rev + is the last property revision of the object when it changed, which you can + use to detect if the key value has really changed or not. + + If you pass a `context` parameter, the context will be passed before the + revision like so: + + ```javascript + fooDidChange: function(sender, key, value, context, rev) { }; + ``` + + Usually you will not need the value, context or revision parameters at + the end. In this case, it is common to write observer methods that take + only a sender and key value as parameters or, if you aren't interested in + any of these values, to write an observer that has no parameters at all. + + @method addObserver + @param {String} key The key to observer + @param {Object} target The target object to invoke + @param {String|Function} method The method to invoke. + @return {Ember.Object} self + */ + addObserver: function(key, target, method) { + Ember.addObserver(this, key, target, method); + }, + + /** + Remove an observer you have previously registered on this object. Pass + the same key, target, and method you passed to `addObserver()` and your + target will no longer receive notifications. + + @method removeObserver + @param {String} key The key to observer + @param {Object} target The target object to invoke + @param {String|Function} method The method to invoke. + @return {Ember.Observable} receiver + */ + removeObserver: function(key, target, method) { + Ember.removeObserver(this, key, target, method); + }, + + /** + Returns `true` if the object currently has observers registered for a + particular key. You can use this method to potentially defer performing + an expensive action until someone begins observing a particular property + on the object. + + @method hasObserverFor + @param {String} key Key to check + @return {Boolean} + */ + hasObserverFor: function(key) { + return Ember.hasListeners(this, key+':change'); + }, + + /** + @deprecated + @method getPath + @param {String} path The property path to retrieve + @return {Object} The property value or undefined. + */ + getPath: function(path) { + + return this.get(path); + }, + + /** + @deprecated + @method setPath + @param {String} path The path to the property that will be set + @param {Object} value The value to set or `null`. + @return {Ember.Observable} + */ + setPath: function(path, value) { + + return this.set(path, value); + }, + + /** + Retrieves the value of a property, or a default value in the case that the + property returns `undefined`. + + ```javascript + person.getWithDefault('lastName', 'Doe'); + ``` + + @method getWithDefault + @param {String} keyName The name of the property to retrieve + @param {Object} defaultValue The value to return if the property value is undefined + @return {Object} The property value or the defaultValue. + */ + getWithDefault: function(keyName, defaultValue) { + return Ember.getWithDefault(this, keyName, defaultValue); + }, + + /** + Set the value of a property to the current value plus some amount. + + ```javascript + person.incrementProperty('age'); + team.incrementProperty('score', 2); + ``` + + @method incrementProperty + @param {String} keyName The name of the property to increment + @param {Object} increment The amount to increment by. Defaults to 1 + @return {Object} The new property value + */ + incrementProperty: function(keyName, increment) { + if (!increment) { increment = 1; } + set(this, keyName, (get(this, keyName) || 0)+increment); + return get(this, keyName); + }, + + /** + Set the value of a property to the current value minus some amount. + + ```javascript + player.decrementProperty('lives'); + orc.decrementProperty('health', 5); + ``` + + @method decrementProperty + @param {String} keyName The name of the property to decrement + @param {Object} increment The amount to decrement by. Defaults to 1 + @return {Object} The new property value + */ + decrementProperty: function(keyName, increment) { + if (!increment) { increment = 1; } + set(this, keyName, (get(this, keyName) || 0)-increment); + return get(this, keyName); + }, + + /** + Set the value of a boolean property to the opposite of it's + current value. + + ```javascript + starship.toggleProperty('warpDriveEnaged'); + ``` + + @method toggleProperty + @param {String} keyName The name of the property to toggle + @return {Object} The new property value + */ + toggleProperty: function(keyName) { + set(this, keyName, !get(this, keyName)); + return get(this, keyName); + }, + + /** + Returns the cached value of a computed property, if it exists. + This allows you to inspect the value of a computed property + without accidentally invoking it if it is intended to be + generated lazily. + + @method cacheFor + @param {String} keyName + @return {Object} The cached value of the computed property, if any + */ + cacheFor: function(keyName) { + return Ember.cacheFor(this, keyName); + }, + + // intended for debugging purposes + observersForKey: function(keyName) { + return Ember.observersFor(this, keyName); + } +}); + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set; + +/** +@class TargetActionSupport +@namespace Ember +@extends Ember.Mixin +*/ +Ember.TargetActionSupport = Ember.Mixin.create({ + target: null, + action: null, + + targetObject: Ember.computed(function() { + var target = get(this, 'target'); + + if (Ember.typeOf(target) === "string") { + var value = get(this, target); + if (value === undefined) { value = get(Ember.lookup, target); } + return value; + } else { + return target; + } + }).property('target'), + + triggerAction: function() { + var action = get(this, 'action'), + target = get(this, 'targetObject'); + + if (target && action) { + var ret; + + if (typeof target.send === 'function') { + ret = target.send(action, this); + } else { + if (typeof action === 'string') { + action = target[action]; + } + ret = action.call(target, this); + } + if (ret !== false) ret = true; + + return ret; + } else { + return false; + } + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +/** + This mixin allows for Ember objects to subscribe to and emit events. + + ```javascript + App.Person = Ember.Object.extend(Ember.Evented, { + greet: function() { + // ... + this.trigger('greet'); + } + }); + + var person = App.Person.create(); + + person.on('greet', function() { + console.log('Our person has greeted'); + }); + + person.greet(); + + // outputs: 'Our person has greeted' + ``` + + @class Evented + @namespace Ember + @extends Ember.Mixin + */ +Ember.Evented = Ember.Mixin.create({ + + /** + Subscribes to a named event with given function. + + ```javascript + person.on('didLoad', function() { + // fired once the person has loaded + }); + ``` + + An optional target can be passed in as the 2nd argument that will + be set as the "this" for the callback. This is a good way to give your + function access to the object triggering the event. When the target + parameter is used the callback becomes the third argument. + + @method on + @param {String} name The name of the event + @param {Object} [target] The "this" binding for the callback + @param {Function} method The callback to execute + */ + on: function(name, target, method) { + Ember.addListener(this, name, target, method); + }, + + /** + Subscribes a function to a named event and then cancels the subscription + after the first time the event is triggered. It is good to use ``one`` when + you only care about the first time an event has taken place. + + This function takes an optional 2nd argument that will become the "this" + value for the callback. If this argument is passed then the 3rd argument + becomes the function. + + @method one + @param {String} name The name of the event + @param {Object} [target] The "this" binding for the callback + @param {Function} method The callback to execute + */ + one: function(name, target, method) { + if (!method) { + method = target; + target = null; + } + + Ember.addListener(this, name, target, method, true); + }, + + /** + Triggers a named event for the object. Any additional arguments + will be passed as parameters to the functions that are subscribed to the + event. + + ```javascript + person.on('didEat', food) { + console.log('person ate some ' + food); + }); + + person.trigger('didEat', 'broccoli'); + + // outputs: person ate some broccoli + ``` + @method trigger + @param {String} name The name of the event + @param {Object...} args Optional arguments to pass on + */ + trigger: function(name) { + var args = [], i, l; + for (i = 1, l = arguments.length; i < l; i++) { + args.push(arguments[i]); + } + Ember.sendEvent(this, name, args); + }, + + fire: function(name) { + + this.trigger.apply(this, arguments); + }, + + /** + Cancels subscription for give name, target, and method. + + @method off + @param {String} name The name of the event + @param {Object} target The target of the subscription + @param {Function} method The function of the subscription + */ + off: function(name, target, method) { + Ember.removeListener(this, name, target, method); + }, + + /** + Checks to see if object has any subscriptions for named event. + + @method has + @param {String} name The name of the event + @return {Boolean} does the object have a subscription for event + */ + has: function(name) { + return Ember.hasListeners(this, name); + } +}); + +})(); + + + +(function() { +var RSVP = requireModule("rsvp"); + +RSVP.async = function(callback, binding) { + Ember.run.schedule('actions', binding, callback); +}; + +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, + slice = Array.prototype.slice; + +/** + @class Deferred + @namespace Ember + @extends Ember.Mixin + */ +Ember.DeferredMixin = Ember.Mixin.create({ + /** + Add handlers to be called when the Deferred object is resolved or rejected. + + @method then + @param {Function} doneCallback a callback function to be called when done + @param {Function} failCallback a callback function to be called when failed + */ + then: function(doneCallback, failCallback) { + var promise = get(this, 'promise'); + return promise.then.apply(promise, arguments); + }, + + /** + Resolve a Deferred object and call any `doneCallbacks` with the given args. + + @method resolve + */ + resolve: function(value) { + get(this, 'promise').resolve(value); + }, + + /** + Reject a Deferred object and call any `failCallbacks` with the given args. + + @method reject + */ + reject: function(value) { + get(this, 'promise').reject(value); + }, + + promise: Ember.computed(function() { + return new RSVP.Promise(); + }) +}); + + +})(); + + + +(function() { + +})(); + + + +(function() { +Ember.Container = requireModule('container'); +Ember.Container.set = Ember.set; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + +// NOTE: this object should never be included directly. Instead use Ember. +// Ember.Object. We only define this separately so that Ember.Set can depend on it + + +var set = Ember.set, get = Ember.get, + o_create = Ember.create, + o_defineProperty = Ember.platform.defineProperty, + a_slice = Array.prototype.slice, + GUID_KEY = Ember.GUID_KEY, + guidFor = Ember.guidFor, + generateGuid = Ember.generateGuid, + meta = Ember.meta, + rewatch = Ember.rewatch, + finishChains = Ember.finishChains, + destroy = Ember.destroy, + schedule = Ember.run.schedule, + Mixin = Ember.Mixin, + applyMixin = Mixin._apply, + finishPartial = Mixin.finishPartial, + reopen = Mixin.prototype.reopen, + MANDATORY_SETTER = Ember.ENV.MANDATORY_SETTER, + indexOf = Ember.EnumerableUtils.indexOf; + +var undefinedDescriptor = { + configurable: true, + writable: true, + enumerable: false, + value: undefined +}; + +function makeCtor() { + + // Note: avoid accessing any properties on the object since it makes the + // method a lot faster. This is glue code so we want it to be as fast as + // possible. + + var wasApplied = false, initMixins, initProperties; + + var Class = function() { + if (!wasApplied) { + Class.proto(); // prepare prototype... + } + o_defineProperty(this, GUID_KEY, undefinedDescriptor); + o_defineProperty(this, '_super', undefinedDescriptor); + var m = meta(this); + m.proto = this; + if (initMixins) { + // capture locally so we can clear the closed over variable + var mixins = initMixins; + initMixins = null; + this.reopen.apply(this, mixins); + } + if (initProperties) { + // capture locally so we can clear the closed over variable + var props = initProperties; + initProperties = null; + + var concatenatedProperties = this.concatenatedProperties; + + for (var i = 0, l = props.length; i < l; i++) { + var properties = props[i]; + for (var keyName in properties) { + if (!properties.hasOwnProperty(keyName)) { continue; } + + var value = properties[keyName], + IS_BINDING = Ember.IS_BINDING; + + if (IS_BINDING.test(keyName)) { + var bindings = m.bindings; + if (!bindings) { + bindings = m.bindings = {}; + } else if (!m.hasOwnProperty('bindings')) { + bindings = m.bindings = o_create(m.bindings); + } + bindings[keyName] = value; + } + + var desc = m.descs[keyName]; + + + + if (concatenatedProperties && indexOf(concatenatedProperties, keyName) >= 0) { + var baseValue = this[keyName]; + + if (baseValue) { + if ('function' === typeof baseValue.concat) { + value = baseValue.concat(value); + } else { + value = Ember.makeArray(baseValue).concat(value); + } + } else { + value = Ember.makeArray(value); + } + } + + if (desc) { + desc.set(this, keyName, value); + } else { + if (typeof this.setUnknownProperty === 'function' && !(keyName in this)) { + this.setUnknownProperty(keyName, value); + } else if (MANDATORY_SETTER) { + Ember.defineProperty(this, keyName, null, value); // setup mandatory setter + } else { + this[keyName] = value; + } + } + } + } + } + finishPartial(this, m); + delete m.proto; + finishChains(this); + this.init.apply(this, arguments); + }; + + Class.toString = Mixin.prototype.toString; + Class.willReopen = function() { + if (wasApplied) { + Class.PrototypeMixin = Mixin.create(Class.PrototypeMixin); + } + + wasApplied = false; + }; + Class._initMixins = function(args) { initMixins = args; }; + Class._initProperties = function(args) { initProperties = args; }; + + Class.proto = function() { + var superclass = Class.superclass; + if (superclass) { superclass.proto(); } + + if (!wasApplied) { + wasApplied = true; + Class.PrototypeMixin.applyPartial(Class.prototype); + rewatch(Class.prototype); + } + + return this.prototype; + }; + + return Class; + +} + +var CoreObject = makeCtor(); +CoreObject.toString = function() { return "Ember.CoreObject"; }; + +CoreObject.PrototypeMixin = Mixin.create({ + reopen: function() { + applyMixin(this, arguments, true); + return this; + }, + + isInstance: true, + + init: function() {}, + + /** + Defines the properties that will be concatenated from the superclass + (instead of overridden). + + By default, when you extend an Ember class a property defined in + the subclass overrides a property with the same name that is defined + in the superclass. However, there are some cases where it is preferable + to build up a property's value by combining the superclass' property + value with the subclass' value. An example of this in use within Ember + is the `classNames` property of `Ember.View`. + + Here is some sample code showing the difference between a concatenated + property and a normal one: + + ```javascript + App.BarView = Ember.View.extend({ + someNonConcatenatedProperty: ['bar'], + classNames: ['bar'] + }); + + App.FooBarView = App.BarView.extend({ + someNonConcatenatedProperty: ['foo'], + classNames: ['foo'], + }); + + var fooBarView = App.FooBarView.create(); + fooBarView.get('someNonConcatenatedProperty'); // ['foo'] + fooBarView.get('classNames'); // ['ember-view', 'bar', 'foo'] + ``` + + This behavior extends to object creation as well. Continuing the + above example: + + ```javascript + var view = App.FooBarView.create({ + someNonConcatenatedProperty: ['baz'], + classNames: ['baz'] + }) + view.get('someNonConcatenatedProperty'); // ['baz'] + view.get('classNames'); // ['ember-view', 'bar', 'foo', 'baz'] + ``` + Adding a single property that is not an array will just add it in the array: + + ```javascript + var view = App.FooBarView.create({ + classNames: 'baz' + }) + view.get('classNames'); // ['ember-view', 'bar', 'foo', 'baz'] + ``` + + Using the `concatenatedProperties` property, we can tell to Ember that mix + the content of the properties. + + In `Ember.View` the `classNameBindings` and `attributeBindings` properties + are also concatenated, in addition to `classNames`. + + This feature is available for you to use throughout the Ember object model, + although typical app developers are likely to use it infrequently. + + @property concatenatedProperties + @type Array + @default null + */ + concatenatedProperties: null, + + /** + @property isDestroyed + @default false + */ + isDestroyed: false, + + /** + @property isDestroying + @default false + */ + isDestroying: false, + + /** + Destroys an object by setting the `isDestroyed` flag and removing its + metadata, which effectively destroys observers and bindings. + + If you try to set a property on a destroyed object, an exception will be + raised. + + Note that destruction is scheduled for the end of the run loop and does not + happen immediately. + + @method destroy + @return {Ember.Object} receiver + */ + destroy: function() { + if (this._didCallDestroy) { return; } + + this.isDestroying = true; + this._didCallDestroy = true; + + if (this.willDestroy) { this.willDestroy(); } + + schedule('destroy', this, this._scheduledDestroy); + return this; + }, + + /** + @private + + Invoked by the run loop to actually destroy the object. This is + scheduled for execution by the `destroy` method. + + @method _scheduledDestroy + */ + _scheduledDestroy: function() { + destroy(this); + set(this, 'isDestroyed', true); + + if (this.didDestroy) { this.didDestroy(); } + }, + + bind: function(to, from) { + if (!(from instanceof Ember.Binding)) { from = Ember.Binding.from(from); } + from.to(to).connect(this); + return from; + }, + + /** + Returns a string representation which attempts to provide more information + than Javascript's `toString` typically does, in a generic way for all Ember + objects. + + App.Person = Em.Object.extend() + person = App.Person.create() + person.toString() //=> "" + + If the object's class is not defined on an Ember namespace, it will + indicate it is a subclass of the registered superclass: + + Student = App.Person.extend() + student = Student.create() + student.toString() //=> "<(subclass of App.Person):ember1025>" + + If the method `toStringExtension` is defined, its return value will be + included in the output. + + App.Teacher = App.Person.extend({ + toStringExtension: function(){ + return this.get('fullName'); + } + }); + teacher = App.Teacher.create() + teacher.toString(); // #=> "" + + @method toString + @return {String} string representation + */ + toString: function toString() { + var hasToStringExtension = typeof this.toStringExtension === 'function', + extension = hasToStringExtension ? ":" + this.toStringExtension() : ''; + var ret = '<'+this.constructor.toString()+':'+guidFor(this)+extension+'>'; + this.toString = makeToString(ret); + return ret; + } +}); + +CoreObject.PrototypeMixin.ownerConstructor = CoreObject; + +function makeToString(ret) { + return function() { return ret; }; +} + +if (Ember.config.overridePrototypeMixin) { + Ember.config.overridePrototypeMixin(CoreObject.PrototypeMixin); +} + +CoreObject.__super__ = null; + +var ClassMixin = Mixin.create({ + + ClassMixin: Ember.required(), + + PrototypeMixin: Ember.required(), + + isClass: true, + + isMethod: false, + + extend: function() { + var Class = makeCtor(), proto; + Class.ClassMixin = Mixin.create(this.ClassMixin); + Class.PrototypeMixin = Mixin.create(this.PrototypeMixin); + + Class.ClassMixin.ownerConstructor = Class; + Class.PrototypeMixin.ownerConstructor = Class; + + reopen.apply(Class.PrototypeMixin, arguments); + + Class.superclass = this; + Class.__super__ = this.prototype; + + proto = Class.prototype = o_create(this.prototype); + proto.constructor = Class; + generateGuid(proto, 'ember'); + meta(proto).proto = proto; // this will disable observers on prototype + + Class.ClassMixin.apply(Class); + return Class; + }, + + createWithMixins: function() { + var C = this; + if (arguments.length>0) { this._initMixins(arguments); } + return new C(); + }, + + create: function() { + var C = this; + if (arguments.length>0) { this._initProperties(arguments); } + return new C(); + }, + + reopen: function() { + this.willReopen(); + reopen.apply(this.PrototypeMixin, arguments); + return this; + }, + + reopenClass: function() { + reopen.apply(this.ClassMixin, arguments); + applyMixin(this, arguments, false); + return this; + }, + + detect: function(obj) { + if ('function' !== typeof obj) { return false; } + while(obj) { + if (obj===this) { return true; } + obj = obj.superclass; + } + return false; + }, + + detectInstance: function(obj) { + return obj instanceof this; + }, + + /** + In some cases, you may want to annotate computed properties with additional + metadata about how they function or what values they operate on. For + example, computed property functions may close over variables that are then + no longer available for introspection. + + You can pass a hash of these values to a computed property like this: + + ```javascript + person: function() { + var personId = this.get('personId'); + return App.Person.create({ id: personId }); + }.property().meta({ type: App.Person }) + ``` + + Once you've done this, you can retrieve the values saved to the computed + property from your class like this: + + ```javascript + MyClass.metaForProperty('person'); + ``` + + This will return the original hash that was passed to `meta()`. + + @method metaForProperty + @param key {String} property name + */ + metaForProperty: function(key) { + var desc = meta(this.proto(), false).descs[key]; + + return desc._meta || {}; + }, + + /** + Iterate over each computed property for the class, passing its name + and any associated metadata (see `metaForProperty`) to the callback. + + @method eachComputedProperty + @param {Function} callback + @param {Object} binding + */ + eachComputedProperty: function(callback, binding) { + var proto = this.proto(), + descs = meta(proto).descs, + empty = {}, + property; + + for (var name in descs) { + property = descs[name]; + + if (property instanceof Ember.ComputedProperty) { + callback.call(binding || this, name, property._meta || empty); + } + } + } + +}); + +ClassMixin.ownerConstructor = CoreObject; + +if (Ember.config.overrideClassMixin) { + Ember.config.overrideClassMixin(ClassMixin); +} + +CoreObject.ClassMixin = ClassMixin; +ClassMixin.apply(CoreObject); + +/** + @class CoreObject + @namespace Ember +*/ +Ember.CoreObject = CoreObject; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set, guidFor = Ember.guidFor, none = Ember.isNone; + +/** + An unordered collection of objects. + + A Set works a bit like an array except that its items are not ordered. You + can create a set to efficiently test for membership for an object. You can + also iterate through a set just like an array, even accessing objects by + index, however there is no guarantee as to their order. + + All Sets are observable via the Enumerable Observer API - which works + on any enumerable object including both Sets and Arrays. + + ## Creating a Set + + You can create a set like you would most objects using + `new Ember.Set()`. Most new sets you create will be empty, but you can + also initialize the set with some content by passing an array or other + enumerable of objects to the constructor. + + Finally, you can pass in an existing set and the set will be copied. You + can also create a copy of a set by calling `Ember.Set#copy()`. + + ```javascript + // creates a new empty set + var foundNames = new Ember.Set(); + + // creates a set with four names in it. + var names = new Ember.Set(["Charles", "Tom", "Juan", "Alex"]); // :P + + // creates a copy of the names set. + var namesCopy = new Ember.Set(names); + + // same as above. + var anotherNamesCopy = names.copy(); + ``` + + ## Adding/Removing Objects + + You generally add or remove objects from a set using `add()` or + `remove()`. You can add any type of object including primitives such as + numbers, strings, and booleans. + + Unlike arrays, objects can only exist one time in a set. If you call `add()` + on a set with the same object multiple times, the object will only be added + once. Likewise, calling `remove()` with the same object multiple times will + remove the object the first time and have no effect on future calls until + you add the object to the set again. + + NOTE: You cannot add/remove `null` or `undefined` to a set. Any attempt to do + so will be ignored. + + In addition to add/remove you can also call `push()`/`pop()`. Push behaves + just like `add()` but `pop()`, unlike `remove()` will pick an arbitrary + object, remove it and return it. This is a good way to use a set as a job + queue when you don't care which order the jobs are executed in. + + ## Testing for an Object + + To test for an object's presence in a set you simply call + `Ember.Set#contains()`. + + ## Observing changes + + When using `Ember.Set`, you can observe the `"[]"` property to be + alerted whenever the content changes. You can also add an enumerable + observer to the set to be notified of specific objects that are added and + removed from the set. See `Ember.Enumerable` for more information on + enumerables. + + This is often unhelpful. If you are filtering sets of objects, for instance, + it is very inefficient to re-filter all of the items each time the set + changes. It would be better if you could just adjust the filtered set based + on what was changed on the original set. The same issue applies to merging + sets, as well. + + ## Other Methods + + `Ember.Set` primary implements other mixin APIs. For a complete reference + on the methods you will use with `Ember.Set`, please consult these mixins. + The most useful ones will be `Ember.Enumerable` and + `Ember.MutableEnumerable` which implement most of the common iterator + methods you are used to on Array. + + Note that you can also use the `Ember.Copyable` and `Ember.Freezable` + APIs on `Ember.Set` as well. Once a set is frozen it can no longer be + modified. The benefit of this is that when you call `frozenCopy()` on it, + Ember will avoid making copies of the set. This allows you to write + code that can know with certainty when the underlying set data will or + will not be modified. + + @class Set + @namespace Ember + @extends Ember.CoreObject + @uses Ember.MutableEnumerable + @uses Ember.Copyable + @uses Ember.Freezable + @since Ember 0.9 +*/ +Ember.Set = Ember.CoreObject.extend(Ember.MutableEnumerable, Ember.Copyable, Ember.Freezable, + /** @scope Ember.Set.prototype */ { + + // .......................................................... + // IMPLEMENT ENUMERABLE APIS + // + + /** + This property will change as the number of objects in the set changes. + + @property length + @type number + @default 0 + */ + length: 0, + + /** + Clears the set. This is useful if you want to reuse an existing set + without having to recreate it. + + ```javascript + var colors = new Ember.Set(["red", "green", "blue"]); + colors.length; // 3 + colors.clear(); + colors.length; // 0 + ``` + + @method clear + @return {Ember.Set} An empty Set + */ + clear: function() { + if (this.isFrozen) { throw new Error(Ember.FROZEN_ERROR); } + + var len = get(this, 'length'); + if (len === 0) { return this; } + + var guid; + + this.enumerableContentWillChange(len, 0); + Ember.propertyWillChange(this, 'firstObject'); + Ember.propertyWillChange(this, 'lastObject'); + + for (var i=0; i < len; i++){ + guid = guidFor(this[i]); + delete this[guid]; + delete this[i]; + } + + set(this, 'length', 0); + + Ember.propertyDidChange(this, 'firstObject'); + Ember.propertyDidChange(this, 'lastObject'); + this.enumerableContentDidChange(len, 0); + + return this; + }, + + /** + Returns true if the passed object is also an enumerable that contains the + same objects as the receiver. + + ```javascript + var colors = ["red", "green", "blue"], + same_colors = new Ember.Set(colors); + + same_colors.isEqual(colors); // true + same_colors.isEqual(["purple", "brown"]); // false + ``` + + @method isEqual + @param {Ember.Set} obj the other object. + @return {Boolean} + */ + isEqual: function(obj) { + // fail fast + if (!Ember.Enumerable.detect(obj)) return false; + + var loc = get(this, 'length'); + if (get(obj, 'length') !== loc) return false; + + while(--loc >= 0) { + if (!obj.contains(this[loc])) return false; + } + + return true; + }, + + /** + Adds an object to the set. Only non-`null` objects can be added to a set + and those can only be added once. If the object is already in the set or + the passed value is null this method will have no effect. + + This is an alias for `Ember.MutableEnumerable.addObject()`. + + ```javascript + var colors = new Ember.Set(); + colors.add("blue"); // ["blue"] + colors.add("blue"); // ["blue"] + colors.add("red"); // ["blue", "red"] + colors.add(null); // ["blue", "red"] + colors.add(undefined); // ["blue", "red"] + ``` + + @method add + @param {Object} obj The object to add. + @return {Ember.Set} The set itself. + */ + add: Ember.aliasMethod('addObject'), + + /** + Removes the object from the set if it is found. If you pass a `null` value + or an object that is already not in the set, this method will have no + effect. This is an alias for `Ember.MutableEnumerable.removeObject()`. + + ```javascript + var colors = new Ember.Set(["red", "green", "blue"]); + colors.remove("red"); // ["blue", "green"] + colors.remove("purple"); // ["blue", "green"] + colors.remove(null); // ["blue", "green"] + ``` + + @method remove + @param {Object} obj The object to remove + @return {Ember.Set} The set itself. + */ + remove: Ember.aliasMethod('removeObject'), + + /** + Removes the last element from the set and returns it, or `null` if it's empty. + + ```javascript + var colors = new Ember.Set(["green", "blue"]); + colors.pop(); // "blue" + colors.pop(); // "green" + colors.pop(); // null + ``` + + @method pop + @return {Object} The removed object from the set or null. + */ + pop: function() { + if (get(this, 'isFrozen')) throw new Error(Ember.FROZEN_ERROR); + var obj = this.length > 0 ? this[this.length-1] : null; + this.remove(obj); + return obj; + }, + + /** + Inserts the given object on to the end of the set. It returns + the set itself. + + This is an alias for `Ember.MutableEnumerable.addObject()`. + + ```javascript + var colors = new Ember.Set(); + colors.push("red"); // ["red"] + colors.push("green"); // ["red", "green"] + colors.push("blue"); // ["red", "green", "blue"] + ``` + + @method push + @return {Ember.Set} The set itself. + */ + push: Ember.aliasMethod('addObject'), + + /** + Removes the last element from the set and returns it, or `null` if it's empty. + + This is an alias for `Ember.Set.pop()`. + + ```javascript + var colors = new Ember.Set(["green", "blue"]); + colors.shift(); // "blue" + colors.shift(); // "green" + colors.shift(); // null + ``` + + @method shift + @return {Object} The removed object from the set or null. + */ + shift: Ember.aliasMethod('pop'), + + /** + Inserts the given object on to the end of the set. It returns + the set itself. + + This is an alias of `Ember.Set.push()` + + ```javascript + var colors = new Ember.Set(); + colors.unshift("red"); // ["red"] + colors.unshift("green"); // ["red", "green"] + colors.unshift("blue"); // ["red", "green", "blue"] + ``` + + @method unshift + @return {Ember.Set} The set itself. + */ + unshift: Ember.aliasMethod('push'), + + /** + Adds each object in the passed enumerable to the set. + + This is an alias of `Ember.MutableEnumerable.addObjects()` + + ```javascript + var colors = new Ember.Set(); + colors.addEach(["red", "green", "blue"]); // ["red", "green", "blue"] + ``` + + @method addEach + @param {Ember.Enumerable} objects the objects to add. + @return {Ember.Set} The set itself. + */ + addEach: Ember.aliasMethod('addObjects'), + + /** + Removes each object in the passed enumerable to the set. + + This is an alias of `Ember.MutableEnumerable.removeObjects()` + + ```javascript + var colors = new Ember.Set(["red", "green", "blue"]); + colors.removeEach(["red", "blue"]); // ["green"] + ``` + + @method removeEach + @param {Ember.Enumerable} objects the objects to remove. + @return {Ember.Set} The set itself. + */ + removeEach: Ember.aliasMethod('removeObjects'), + + // .......................................................... + // PRIVATE ENUMERABLE SUPPORT + // + + init: function(items) { + this._super(); + if (items) this.addObjects(items); + }, + + // implement Ember.Enumerable + nextObject: function(idx) { + return this[idx]; + }, + + // more optimized version + firstObject: Ember.computed(function() { + return this.length > 0 ? this[0] : undefined; + }), + + // more optimized version + lastObject: Ember.computed(function() { + return this.length > 0 ? this[this.length-1] : undefined; + }), + + // implements Ember.MutableEnumerable + addObject: function(obj) { + if (get(this, 'isFrozen')) throw new Error(Ember.FROZEN_ERROR); + if (none(obj)) return this; // nothing to do + + var guid = guidFor(obj), + idx = this[guid], + len = get(this, 'length'), + added ; + + if (idx>=0 && idx=0 && idx=0; + }, + + copy: function() { + var C = this.constructor, ret = new C(), loc = get(this, 'length'); + set(ret, 'length', loc); + while(--loc>=0) { + ret[loc] = this[loc]; + ret[guidFor(this[loc])] = loc; + } + return ret; + }, + + toString: function() { + var len = this.length, idx, array = []; + for(idx = 0; idx < len; idx++) { + array[idx] = this[idx]; + } + return "Ember.Set<%@>".fmt(array.join(',')); + } + +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +/** + `Ember.Object` is the main base class for all Ember objects. It is a subclass + of `Ember.CoreObject` with the `Ember.Observable` mixin applied. For details, + see the documentation for each of these. + + @class Object + @namespace Ember + @extends Ember.CoreObject + @uses Ember.Observable +*/ +Ember.Object = Ember.CoreObject.extend(Ember.Observable); +Ember.Object.toString = function() { return "Ember.Object"; }; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, indexOf = Ember.ArrayPolyfills.indexOf; + +/** + A Namespace is an object usually used to contain other objects or methods + such as an application or framework. Create a namespace anytime you want + to define one of these new containers. + + # Example Usage + + ```javascript + MyFramework = Ember.Namespace.create({ + VERSION: '1.0.0' + }); + ``` + + @class Namespace + @namespace Ember + @extends Ember.Object +*/ +var Namespace = Ember.Namespace = Ember.Object.extend({ + isNamespace: true, + + init: function() { + Ember.Namespace.NAMESPACES.push(this); + Ember.Namespace.PROCESSED = false; + }, + + toString: function() { + var name = get(this, 'name'); + if (name) { return name; } + + findNamespaces(); + return this[Ember.GUID_KEY+'_name']; + }, + + nameClasses: function() { + processNamespace([this.toString()], this, {}); + }, + + destroy: function() { + var namespaces = Ember.Namespace.NAMESPACES; + Ember.lookup[this.toString()] = undefined; + namespaces.splice(indexOf.call(namespaces, this), 1); + this._super(); + } +}); + +Namespace.reopenClass({ + NAMESPACES: [Ember], + PROCESSED: false, + processAll: processAllNamespaces +}); + +var hasOwnProp = ({}).hasOwnProperty, + guidFor = Ember.guidFor; + +function processNamespace(paths, root, seen) { + var idx = paths.length; + + // Loop over all of the keys in the namespace, looking for classes + for(var key in root) { + if (!hasOwnProp.call(root, key)) { continue; } + var obj = root[key]; + + // If we are processing the `Ember` namespace, for example, the + // `paths` will start with `["Ember"]`. Every iteration through + // the loop will update the **second** element of this list with + // the key, so processing `Ember.View` will make the Array + // `['Ember', 'View']`. + paths[idx] = key; + + // If we have found an unprocessed class + if (obj && obj.toString === classToString) { + // Replace the class' `toString` with the dot-separated path + // and set its `NAME_KEY` + obj.toString = makeToString(paths.join('.')); + obj[NAME_KEY] = paths.join('.'); + + // Support nested namespaces + } else if (obj && obj.isNamespace) { + // Skip aliased namespaces + if (seen[guidFor(obj)]) { continue; } + seen[guidFor(obj)] = true; + + // Process the child namespace + processNamespace(paths, obj, seen); + } + } + + paths.length = idx; // cut out last item +} + +function findNamespaces() { + var Namespace = Ember.Namespace, lookup = Ember.lookup, obj, isNamespace; + + if (Namespace.PROCESSED) { return; } + + for (var prop in lookup) { + // These don't raise exceptions but can cause warnings + if (prop === "parent" || prop === "top" || prop === "frameElement") { continue; } + + // get(window.globalStorage, 'isNamespace') would try to read the storage for domain isNamespace and cause exception in Firefox. + // globalStorage is a storage obsoleted by the WhatWG storage specification. See https://developer.mozilla.org/en/DOM/Storage#globalStorage + if (prop === "globalStorage" && lookup.StorageList && lookup.globalStorage instanceof lookup.StorageList) { continue; } + // Unfortunately, some versions of IE don't support window.hasOwnProperty + if (lookup.hasOwnProperty && !lookup.hasOwnProperty(prop)) { continue; } + + // At times we are not allowed to access certain properties for security reasons. + // There are also times where even if we can access them, we are not allowed to access their properties. + try { + obj = Ember.lookup[prop]; + isNamespace = obj && obj.isNamespace; + } catch (e) { + continue; + } + + if (isNamespace) { + + obj[NAME_KEY] = prop; + } + } +} + +var NAME_KEY = Ember.NAME_KEY = Ember.GUID_KEY + '_name'; + +function superClassString(mixin) { + var superclass = mixin.superclass; + if (superclass) { + if (superclass[NAME_KEY]) { return superclass[NAME_KEY]; } + else { return superClassString(superclass); } + } else { + return; + } +} + +function classToString() { + if (!Ember.BOOTED && !this[NAME_KEY]) { + processAllNamespaces(); + } + + var ret; + + if (this[NAME_KEY]) { + ret = this[NAME_KEY]; + } else { + var str = superClassString(this); + if (str) { + ret = "(subclass of " + str + ")"; + } else { + ret = "(unknown mixin)"; + } + this.toString = makeToString(ret); + } + + return ret; +} + +function processAllNamespaces() { + if (!Namespace.PROCESSED) { + findNamespaces(); + Namespace.PROCESSED = true; + } + + if (Ember.anyUnprocessedMixins) { + var namespaces = Namespace.NAMESPACES, namespace; + for (var i=0, l=namespaces.length; i=idx) { + var item = content.objectAt(loc); + if (item) { + Ember.addBeforeObserver(item, keyName, proxy, 'contentKeyWillChange'); + Ember.addObserver(item, keyName, proxy, 'contentKeyDidChange'); + + // keep track of the indicies each item was found at so we can map + // it back when the obj changes. + guid = guidFor(item); + if (!objects[guid]) objects[guid] = []; + objects[guid].push(loc); + } + } +} + +function removeObserverForContentKey(content, keyName, proxy, idx, loc) { + var objects = proxy._objects; + if (!objects) objects = proxy._objects = {}; + var indicies, guid; + + while(--loc>=idx) { + var item = content.objectAt(loc); + if (item) { + Ember.removeBeforeObserver(item, keyName, proxy, 'contentKeyWillChange'); + Ember.removeObserver(item, keyName, proxy, 'contentKeyDidChange'); + + guid = guidFor(item); + indicies = objects[guid]; + indicies[indicies.indexOf(loc)] = null; + } + } +} + +/** + This is the object instance returned when you get the `@each` property on an + array. It uses the unknownProperty handler to automatically create + EachArray instances for property names. + + @private + @class EachProxy + @namespace Ember + @extends Ember.Object +*/ +Ember.EachProxy = Ember.Object.extend({ + + init: function(content) { + this._super(); + this._content = content; + content.addArrayObserver(this); + + // in case someone is already observing some keys make sure they are + // added + forEach(Ember.watchedEvents(this), function(eventName) { + this.didAddListener(eventName); + }, this); + }, + + /** + You can directly access mapped properties by simply requesting them. + The `unknownProperty` handler will generate an EachArray of each item. + + @method unknownProperty + @param keyName {String} + @param value {anything} + */ + unknownProperty: function(keyName, value) { + var ret; + ret = new EachArray(this._content, keyName, this); + Ember.defineProperty(this, keyName, null, ret); + this.beginObservingContentKey(keyName); + return ret; + }, + + // .......................................................... + // ARRAY CHANGES + // Invokes whenever the content array itself changes. + + arrayWillChange: function(content, idx, removedCnt, addedCnt) { + var keys = this._keys, key, array, lim; + + lim = removedCnt>0 ? idx+removedCnt : -1; + Ember.beginPropertyChanges(this); + + for(key in keys) { + if (!keys.hasOwnProperty(key)) { continue; } + + if (lim>0) removeObserverForContentKey(content, key, this, idx, lim); + + Ember.propertyWillChange(this, key); + } + + Ember.propertyWillChange(this._content, '@each'); + Ember.endPropertyChanges(this); + }, + + arrayDidChange: function(content, idx, removedCnt, addedCnt) { + var keys = this._keys, key, array, lim; + + lim = addedCnt>0 ? idx+addedCnt : -1; + Ember.beginPropertyChanges(this); + + for(key in keys) { + if (!keys.hasOwnProperty(key)) { continue; } + + if (lim>0) addObserverForContentKey(content, key, this, idx, lim); + + Ember.propertyDidChange(this, key); + } + + Ember.propertyDidChange(this._content, '@each'); + Ember.endPropertyChanges(this); + }, + + // .......................................................... + // LISTEN FOR NEW OBSERVERS AND OTHER EVENT LISTENERS + // Start monitoring keys based on who is listening... + + didAddListener: function(eventName) { + if (IS_OBSERVER.test(eventName)) { + this.beginObservingContentKey(eventName.slice(0, -7)); + } + }, + + didRemoveListener: function(eventName) { + if (IS_OBSERVER.test(eventName)) { + this.stopObservingContentKey(eventName.slice(0, -7)); + } + }, + + // .......................................................... + // CONTENT KEY OBSERVING + // Actual watch keys on the source content. + + beginObservingContentKey: function(keyName) { + var keys = this._keys; + if (!keys) keys = this._keys = {}; + if (!keys[keyName]) { + keys[keyName] = 1; + var content = this._content, + len = get(content, 'length'); + addObserverForContentKey(content, keyName, this, 0, len); + } else { + keys[keyName]++; + } + }, + + stopObservingContentKey: function(keyName) { + var keys = this._keys; + if (keys && (keys[keyName]>0) && (--keys[keyName]<=0)) { + var content = this._content, + len = get(content, 'length'); + removeObserverForContentKey(content, keyName, this, 0, len); + } + }, + + contentKeyWillChange: function(obj, keyName) { + Ember.propertyWillChange(this, keyName); + }, + + contentKeyDidChange: function(obj, keyName) { + Ember.propertyDidChange(this, keyName); + } + +}); + + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + + +var get = Ember.get, set = Ember.set; + +// Add Ember.Array to Array.prototype. Remove methods with native +// implementations and supply some more optimized versions of generic methods +// because they are so common. +var NativeArray = Ember.Mixin.create(Ember.MutableArray, Ember.Observable, Ember.Copyable, { + + // because length is a built-in property we need to know to just get the + // original property. + get: function(key) { + if (key==='length') return this.length; + else if ('number' === typeof key) return this[key]; + else return this._super(key); + }, + + objectAt: function(idx) { + return this[idx]; + }, + + // primitive for array support. + replace: function(idx, amt, objects) { + + if (this.isFrozen) throw Ember.FROZEN_ERROR ; + + // if we replaced exactly the same number of items, then pass only the + // replaced range. Otherwise, pass the full remaining array length + // since everything has shifted + var len = objects ? get(objects, 'length') : 0; + this.arrayContentWillChange(idx, amt, len); + + if (!objects || objects.length === 0) { + this.splice(idx, amt) ; + } else { + var args = [idx, amt].concat(objects) ; + this.splice.apply(this,args) ; + } + + this.arrayContentDidChange(idx, amt, len); + return this ; + }, + + // If you ask for an unknown property, then try to collect the value + // from member items. + unknownProperty: function(key, value) { + var ret;// = this.reducedProperty(key, value) ; + if ((value !== undefined) && ret === undefined) { + ret = this[key] = value; + } + return ret ; + }, + + // If browser did not implement indexOf natively, then override with + // specialized version + indexOf: function(object, startAt) { + var idx, len = this.length; + + if (startAt === undefined) startAt = 0; + else startAt = (startAt < 0) ? Math.ceil(startAt) : Math.floor(startAt); + if (startAt < 0) startAt += len; + + for(idx=startAt;idx=0;idx--) { + if (this[idx] === object) return idx ; + } + return -1; + }, + + copy: function(deep) { + if (deep) { + return this.map(function(item){ return Ember.copy(item, true); }); + } + + return this.slice(); + } +}); + +// Remove any methods implemented natively so we don't override them +var ignore = ['length']; +Ember.EnumerableUtils.forEach(NativeArray.keys(), function(methodName) { + if (Array.prototype[methodName]) ignore.push(methodName); +}); + +if (ignore.length>0) { + NativeArray = NativeArray.without.apply(NativeArray, ignore); +} + +/** + The NativeArray mixin contains the properties needed to to make the native + Array support Ember.MutableArray and all of its dependent APIs. Unless you + have `Ember.EXTEND_PROTOTYPES or `Ember.EXTEND_PROTOTYPES.Array` set to + false, this will be applied automatically. Otherwise you can apply the mixin + at anytime by calling `Ember.NativeArray.activate`. + + @class NativeArray + @namespace Ember + @extends Ember.Mixin + @uses Ember.MutableArray + @uses Ember.MutableEnumerable + @uses Ember.Copyable + @uses Ember.Freezable +*/ +Ember.NativeArray = NativeArray; + +/** + Creates an `Ember.NativeArray` from an Array like object. + Does not modify the original object. + + @method A + @for Ember + @return {Ember.NativeArray} +*/ +Ember.A = function(arr){ + if (arr === undefined) { arr = []; } + return Ember.Array.detect(arr) ? arr : Ember.NativeArray.apply(arr); +}; + +/** + Activates the mixin on the Array.prototype if not already applied. Calling + this method more than once is safe. + + @method activate + @for Ember.NativeArray + @static + @return {void} +*/ +Ember.NativeArray.activate = function() { + NativeArray.apply(Array.prototype); + + Ember.A = function(arr) { return arr || []; }; +}; + +if (Ember.EXTEND_PROTOTYPES === true || Ember.EXTEND_PROTOTYPES.Array) { + Ember.NativeArray.activate(); +} + + +})(); + + + +(function() { +var DeferredMixin = Ember.DeferredMixin, // mixins/deferred + EmberObject = Ember.Object, // system/object + get = Ember.get; + +var Deferred = Ember.Object.extend(DeferredMixin); + +Deferred.reopenClass({ + promise: function(callback, binding) { + var deferred = Deferred.create(); + callback.call(binding, deferred); + return get(deferred, 'promise'); + } +}); + +Ember.Deferred = Deferred; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var loadHooks = Ember.ENV.EMBER_LOAD_HOOKS || {}; +var loaded = {}; + +/** +@method onLoad +@for Ember +@param name {String} name of hook +@param callback {Function} callback to be called +*/ +Ember.onLoad = function(name, callback) { + var object; + + loadHooks[name] = loadHooks[name] || Ember.A(); + loadHooks[name].pushObject(callback); + + if (object = loaded[name]) { + callback(object); + } +}; + +/** +@method runLoadHooks +@for Ember +@param name {String} name of hook +@param object {Object} object to pass to callbacks +*/ +Ember.runLoadHooks = function(name, object) { + var hooks; + + loaded[name] = object; + + if (hooks = loadHooks[name]) { + loadHooks[name].forEach(function(callback) { + callback(object); + }); + } +}; + +})(); + + + +(function() { + +})(); + + + +(function() { +var get = Ember.get; + +/** +@module ember +@submodule ember-runtime +*/ + +/** + `Ember.ControllerMixin` provides a standard interface for all classes that + compose Ember's controller layer: `Ember.Controller`, + `Ember.ArrayController`, and `Ember.ObjectController`. + + Within an `Ember.Router`-managed application single shared instaces of every + Controller object in your application's namespace will be added to the + application's `Ember.Router` instance. See `Ember.Application#initialize` + for additional information. + + ## Views + + By default a controller instance will be the rendering context + for its associated `Ember.View.` This connection is made during calls to + `Ember.ControllerMixin#connectOutlet`. + + Within the view's template, the `Ember.View` instance can be accessed + through the controller with `{{view}}`. + + ## Target Forwarding + + By default a controller will target your application's `Ember.Router` + instance. Calls to `{{action}}` within the template of a controller's view + are forwarded to the router. See `Ember.Handlebars.helpers.action` for + additional information. + + @class ControllerMixin + @namespace Ember + @extends Ember.Mixin +*/ +Ember.ControllerMixin = Ember.Mixin.create({ + /* ducktype as a controller */ + isController: true, + + /** + The object to which events from the view should be sent. + + For example, when a Handlebars template uses the `{{action}}` helper, + it will attempt to send the event to the view's controller's `target`. + + By default, a controller's `target` is set to the router after it is + instantiated by `Ember.Application#initialize`. + + @property target + @default null + */ + target: null, + + container: null, + + store: null, + + model: Ember.computed.alias('content'), + + send: function(actionName) { + var args = [].slice.call(arguments, 1), target; + + if (this[actionName]) { + + this[actionName].apply(this, args); + } else if(target = get(this, 'target')) { + + target.send.apply(target, arguments); + } + } +}); + +/** + @class Controller + @namespace Ember + @extends Ember.Object + @uses Ember.ControllerMixin +*/ +Ember.Controller = Ember.Object.extend(Ember.ControllerMixin); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set, forEach = Ember.EnumerableUtils.forEach; + +/** + `Ember.SortableMixin` provides a standard interface for array proxies + to specify a sort order and maintain this sorting when objects are added, + removed, or updated without changing the implicit order of their underlying + content array: + + ```javascript + songs = [ + {trackNumber: 4, title: 'Ob-La-Di, Ob-La-Da'}, + {trackNumber: 2, title: 'Back in the U.S.S.R.'}, + {trackNumber: 3, title: 'Glass Onion'}, + ]; + + songsController = Ember.ArrayController.create({ + content: songs, + sortProperties: ['trackNumber'], + sortAscending: true + }); + + songsController.get('firstObject'); // {trackNumber: 2, title: 'Back in the U.S.S.R.'} + + songsController.addObject({trackNumber: 1, title: 'Dear Prudence'}); + songsController.get('firstObject'); // {trackNumber: 1, title: 'Dear Prudence'} + ``` + + @class SortableMixin + @namespace Ember + @extends Ember.Mixin + @uses Ember.MutableEnumerable +*/ +Ember.SortableMixin = Ember.Mixin.create(Ember.MutableEnumerable, { + + /** + Specifies which properties dictate the arrangedContent's sort order. + + @property {Array} sortProperties + */ + sortProperties: null, + + /** + Specifies the arrangedContent's sort direction + + @property {Boolean} sortAscending + */ + sortAscending: true, + + orderBy: function(item1, item2) { + var result = 0, + sortProperties = get(this, 'sortProperties'), + sortAscending = get(this, 'sortAscending'); + + + forEach(sortProperties, function(propertyName) { + if (result === 0) { + result = Ember.compare(get(item1, propertyName), get(item2, propertyName)); + if ((result !== 0) && !sortAscending) { + result = (-1) * result; + } + } + }); + + return result; + }, + + destroy: function() { + var content = get(this, 'content'), + sortProperties = get(this, 'sortProperties'); + + if (content && sortProperties) { + forEach(content, function(item) { + forEach(sortProperties, function(sortProperty) { + Ember.removeObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + } + + return this._super(); + }, + + isSorted: Ember.computed.bool('sortProperties'), + + arrangedContent: Ember.computed('content', 'sortProperties.@each', function(key, value) { + var content = get(this, 'content'), + isSorted = get(this, 'isSorted'), + sortProperties = get(this, 'sortProperties'), + self = this; + + if (content && isSorted) { + content = content.slice(); + content.sort(function(item1, item2) { + return self.orderBy(item1, item2); + }); + forEach(content, function(item) { + forEach(sortProperties, function(sortProperty) { + Ember.addObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + return Ember.A(content); + } + + return content; + }), + + _contentWillChange: Ember.beforeObserver(function() { + var content = get(this, 'content'), + sortProperties = get(this, 'sortProperties'); + + if (content && sortProperties) { + forEach(content, function(item) { + forEach(sortProperties, function(sortProperty) { + Ember.removeObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + } + + this._super(); + }, 'content'), + + sortAscendingWillChange: Ember.beforeObserver(function() { + this._lastSortAscending = get(this, 'sortAscending'); + }, 'sortAscending'), + + sortAscendingDidChange: Ember.observer(function() { + if (get(this, 'sortAscending') !== this._lastSortAscending) { + var arrangedContent = get(this, 'arrangedContent'); + arrangedContent.reverseObjects(); + } + }, 'sortAscending'), + + contentArrayWillChange: function(array, idx, removedCount, addedCount) { + var isSorted = get(this, 'isSorted'); + + if (isSorted) { + var arrangedContent = get(this, 'arrangedContent'); + var removedObjects = array.slice(idx, idx+removedCount); + var sortProperties = get(this, 'sortProperties'); + + forEach(removedObjects, function(item) { + arrangedContent.removeObject(item); + + forEach(sortProperties, function(sortProperty) { + Ember.removeObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + } + + return this._super(array, idx, removedCount, addedCount); + }, + + contentArrayDidChange: function(array, idx, removedCount, addedCount) { + var isSorted = get(this, 'isSorted'), + sortProperties = get(this, 'sortProperties'); + + if (isSorted) { + var addedObjects = array.slice(idx, idx+addedCount); + var arrangedContent = get(this, 'arrangedContent'); + + forEach(addedObjects, function(item) { + this.insertItemSorted(item); + + forEach(sortProperties, function(sortProperty) { + Ember.addObserver(item, sortProperty, this, 'contentItemSortPropertyDidChange'); + }, this); + }, this); + } + + return this._super(array, idx, removedCount, addedCount); + }, + + insertItemSorted: function(item) { + var arrangedContent = get(this, 'arrangedContent'); + var length = get(arrangedContent, 'length'); + + var idx = this._binarySearch(item, 0, length); + arrangedContent.insertAt(idx, item); + }, + + contentItemSortPropertyDidChange: function(item) { + var arrangedContent = get(this, 'arrangedContent'), + oldIndex = arrangedContent.indexOf(item), + leftItem = arrangedContent.objectAt(oldIndex - 1), + rightItem = arrangedContent.objectAt(oldIndex + 1), + leftResult = leftItem && this.orderBy(item, leftItem), + rightResult = rightItem && this.orderBy(item, rightItem); + + if (leftResult < 0 || rightResult > 0) { + arrangedContent.removeObject(item); + this.insertItemSorted(item); + } + }, + + _binarySearch: function(item, low, high) { + var mid, midItem, res, arrangedContent; + + if (low === high) { + return low; + } + + arrangedContent = get(this, 'arrangedContent'); + + mid = low + Math.floor((high - low) / 2); + midItem = arrangedContent.objectAt(mid); + + res = this.orderBy(midItem, item); + + if (res < 0) { + return this._binarySearch(item, mid+1, high); + } else if (res > 0) { + return this._binarySearch(item, low, mid); + } + + return mid; + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +var get = Ember.get, set = Ember.set, isGlobalPath = Ember.isGlobalPath, + forEach = Ember.EnumerableUtils.forEach, replace = Ember.EnumerableUtils.replace; + +/** + `Ember.ArrayController` provides a way for you to publish a collection of + objects so that you can easily bind to the collection from a Handlebars + `#each` helper, an `Ember.CollectionView`, or other controllers. + + The advantage of using an `ArrayController` is that you only have to set up + your view bindings once; to change what's displayed, simply swap out the + `content` property on the controller. + + For example, imagine you wanted to display a list of items fetched via an XHR + request. Create an `Ember.ArrayController` and set its `content` property: + + ```javascript + MyApp.listController = Ember.ArrayController.create(); + + $.get('people.json', function(data) { + MyApp.listController.set('content', data); + }); + ``` + + Then, create a view that binds to your new controller: + + ```handlebars + {{#each MyApp.listController}} + {{firstName}} {{lastName}} + {{/each}} + ``` + + Although you are binding to the controller, the behavior of this controller + is to pass through any methods or properties to the underlying array. This + capability comes from `Ember.ArrayProxy`, which this class inherits from. + + Sometimes you want to display computed properties within the body of an + `#each` helper that depend on the underlying items in `content`, but are not + present on those items. To do this, set `itemController` to the name of a + controller (probably an `ObjectController`) that will wrap each individual item. + + For example: + + ```handlebars + {{#each post in controller}} +
          • {{title}} ({{titleLength}} characters)
          • + {{/each}} + ``` + + ```javascript + App.PostsController = Ember.ArrayController.extend({ + itemController: 'post' + }); + + App.PostController = Ember.ObjectController.extend({ + // the `title` property will be proxied to the underlying post. + + titleLength: function() { + return this.get('title').length; + }.property('title') + }); + ``` + + In some cases it is helpful to return a different `itemController` depending + on the particular item. Subclasses can do this by overriding + `lookupItemController`. + + For example: + + ```javascript + App.MyArrayController = Ember.ArrayController.extend({ + lookupItemController: function( object ) { + if (object.get('isSpecial')) { + return "special"; // use App.SpecialController + } else { + return "regular"; // use App.RegularController + } + } + }); + ``` + + @class ArrayController + @namespace Ember + @extends Ember.ArrayProxy + @uses Ember.SortableMixin + @uses Ember.ControllerMixin +*/ + +Ember.ArrayController = Ember.ArrayProxy.extend(Ember.ControllerMixin, + Ember.SortableMixin, { + + /** + The controller used to wrap items, if any. + + @property itemController + @type String + @default null + */ + itemController: null, + + /** + Return the name of the controller to wrap items, or `null` if items should + be returned directly. The default implementation simply returns the + `itemController` property, but subclasses can override this method to return + different controllers for different objects. + + For example: + + ```javascript + App.MyArrayController = Ember.ArrayController.extend({ + lookupItemController: function( object ) { + if (object.get('isSpecial')) { + return "special"; // use App.SpecialController + } else { + return "regular"; // use App.RegularController + } + } + }); + ``` + + @method + @type String + @default null + */ + lookupItemController: function(object) { + return get(this, 'itemController'); + }, + + objectAtContent: function(idx) { + var length = get(this, 'length'), + object = get(this,'arrangedContent').objectAt(idx), + controllerClass = this.lookupItemController(object); + + if (controllerClass && idx < length) { + return this.controllerAt(idx, object, controllerClass); + } else { + // When controllerClass is falsy we have not opted in to using item + // controllers, so return the object directly. However, when + // controllerClass is defined but the index is out of range, we want to + // return the "out of range" value, whatever that might be. Rather than + // make assumptions (e.g. guessing `null` or `undefined`) we defer this to + // `arrangedContent`. + return object; + } + }, + + arrangedContentDidChange: function() { + this._super(); + this._resetSubContainers(); + }, + + arrayContentDidChange: function(idx, removedCnt, addedCnt) { + var subContainers = get(this, 'subContainers'), + subContainersToRemove = subContainers.slice(idx, idx+removedCnt); + + forEach(subContainersToRemove, function(subContainer) { + if (subContainer) { subContainer.destroy(); } + }); + + replace(subContainers, idx, removedCnt, new Array(addedCnt)); + + // The shadow array of subcontainers must be updated before we trigger + // observers, otherwise observers will get the wrong subcontainer when + // calling `objectAt` + this._super(idx, removedCnt, addedCnt); + }, + + init: function() { + this._super(); + this._resetSubContainers(); + }, + + controllerAt: function(idx, object, controllerClass) { + var container = get(this, 'container'), + subContainers = get(this, 'subContainers'), + subContainer = subContainers[idx], + controller; + + if (!subContainer) { + subContainer = subContainers[idx] = container.child(); + } + + controller = subContainer.lookup("controller:" + controllerClass); + if (!controller) { + throw new Error('Could not resolve itemController: "' + controllerClass + '"'); + } + + controller.set('target', this); + controller.set('content', object); + + return controller; + }, + + subContainers: null, + + _resetSubContainers: function() { + var subContainers = get(this, 'subContainers'); + + if (subContainers) { + forEach(subContainers, function(subContainer) { + if (subContainer) { subContainer.destroy(); } + }); + } + + this.set('subContainers', Ember.A()); + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-runtime +*/ + +/** + `Ember.ObjectController` is part of Ember's Controller layer. A single shared + instance of each `Ember.ObjectController` subclass in your application's + namespace will be created at application initialization and be stored on your + application's `Ember.Router` instance. + + `Ember.ObjectController` derives its functionality from its superclass + `Ember.ObjectProxy` and the `Ember.ControllerMixin` mixin. + + @class ObjectController + @namespace Ember + @extends Ember.ObjectProxy + @uses Ember.ControllerMixin +**/ +Ember.ObjectController = Ember.ObjectProxy.extend(Ember.ControllerMixin); + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +Ember Runtime + +@module ember +@submodule ember-runtime +@requires ember-metal +*/ + +})(); + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var jQuery = Ember.imports.jQuery; + + +/** + Alias for jQuery + + @method $ + @for Ember +*/ +Ember.$ = jQuery; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +// http://www.whatwg.org/specs/web-apps/current-work/multipage/dnd.html#dndevents +var dragEvents = Ember.String.w('dragstart drag dragenter dragleave dragover drop dragend'); + +// Copies the `dataTransfer` property from a browser event object onto the +// jQuery event object for the specified events +Ember.EnumerableUtils.forEach(dragEvents, function(eventName) { + Ember.$.event.fixHooks[eventName] = { props: ['dataTransfer'] }; +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +/*** BEGIN METAMORPH HELPERS ***/ + +// Internet Explorer prior to 9 does not allow setting innerHTML if the first element +// is a "zero-scope" element. This problem can be worked around by making +// the first node an invisible text node. We, like Modernizr, use ­ +var needsShy = (function(){ + var testEl = document.createElement('div'); + testEl.innerHTML = "
            "; + testEl.firstChild.innerHTML = ""; + return testEl.firstChild.innerHTML === ''; +})(); + +// IE 8 (and likely earlier) likes to move whitespace preceeding +// a script tag to appear after it. This means that we can +// accidentally remove whitespace when updating a morph. +var movesWhitespace = (function() { + var testEl = document.createElement('div'); + testEl.innerHTML = "Test: Value"; + return testEl.childNodes[0].nodeValue === 'Test:' && + testEl.childNodes[2].nodeValue === ' Value'; +})(); + +// Use this to find children by ID instead of using jQuery +var findChildById = function(element, id) { + if (element.getAttribute('id') === id) { return element; } + + var len = element.childNodes.length, idx, node, found; + for (idx=0; idx 0) { + var len = matches.length, idx; + for (idx=0; idxTest'); + canSet = el.options.length === 1; + } + + innerHTMLTags[tagName] = canSet; + + return canSet; +}; + +var setInnerHTML = function(element, html) { + var tagName = element.tagName; + + if (canSetInnerHTML(tagName)) { + setInnerHTMLWithoutFix(element, html); + } else { + + + var startTag = element.outerHTML.match(new RegExp("<"+tagName+"([^>]*)>", 'i'))[0], + endTag = ''; + + var wrapper = document.createElement('div'); + setInnerHTMLWithoutFix(wrapper, startTag + html + endTag); + element = wrapper.firstChild; + while (element.tagName !== tagName) { + element = element.nextSibling; + } + } + + return element; +}; + +function isSimpleClick(event) { + var modifier = event.shiftKey || event.metaKey || event.altKey || event.ctrlKey, + secondaryClick = event.which > 1; // IE9 may return undefined + + return !modifier && !secondaryClick; +} + +Ember.ViewUtils = { + setInnerHTML: setInnerHTML, + isSimpleClick: isSimpleClick +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set; +var indexOf = Ember.ArrayPolyfills.indexOf; + + + + + +var ClassSet = function() { + this.seen = {}; + this.list = []; +}; + +ClassSet.prototype = { + add: function(string) { + if (string in this.seen) { return; } + this.seen[string] = true; + + this.list.push(string); + }, + + toDOM: function() { + return this.list.join(" "); + } +}; + +/** + `Ember.RenderBuffer` gathers information regarding the a view and generates the + final representation. `Ember.RenderBuffer` will generate HTML which can be pushed + to the DOM. + + @class RenderBuffer + @namespace Ember + @constructor +*/ +Ember.RenderBuffer = function(tagName) { + return new Ember._RenderBuffer(tagName); +}; + +Ember._RenderBuffer = function(tagName) { + this.tagNames = [tagName || null]; + this.buffer = []; +}; + +Ember._RenderBuffer.prototype = +/** @scope Ember.RenderBuffer.prototype */ { + + // The root view's element + _element: null, + + /** + @private + + An internal set used to de-dupe class names when `addClass()` is + used. After each call to `addClass()`, the `classes` property + will be updated. + + @property elementClasses + @type Array + @default [] + */ + elementClasses: null, + + /** + Array of class names which will be applied in the class attribute. + + You can use `setClasses()` to set this property directly. If you + use `addClass()`, it will be maintained for you. + + @property classes + @type Array + @default [] + */ + classes: null, + + /** + The id in of the element, to be applied in the id attribute. + + You should not set this property yourself, rather, you should use + the `id()` method of `Ember.RenderBuffer`. + + @property elementId + @type String + @default null + */ + elementId: null, + + /** + A hash keyed on the name of the attribute and whose value will be + applied to that attribute. For example, if you wanted to apply a + `data-view="Foo.bar"` property to an element, you would set the + elementAttributes hash to `{'data-view':'Foo.bar'}`. + + You should not maintain this hash yourself, rather, you should use + the `attr()` method of `Ember.RenderBuffer`. + + @property elementAttributes + @type Hash + @default {} + */ + elementAttributes: null, + + /** + The value for this attribute. Values cannot be set via attr after + jQuery 1.9, they need to be set with val() instead. + + You should not maintain this value yourself, rather, you should use + the `val()` method of `Ember.RenderBuffer`. + + @property elementValue + @type String + @default null + */ + elementValue: null, + + /** + The tagname of the element an instance of `Ember.RenderBuffer` represents. + + Usually, this gets set as the first parameter to `Ember.RenderBuffer`. For + example, if you wanted to create a `p` tag, then you would call + + ```javascript + Ember.RenderBuffer('p') + ``` + + @property elementTag + @type String + @default null + */ + elementTag: null, + + /** + A hash keyed on the name of the style attribute and whose value will + be applied to that attribute. For example, if you wanted to apply a + `background-color:black;` style to an element, you would set the + elementStyle hash to `{'background-color':'black'}`. + + You should not maintain this hash yourself, rather, you should use + the `style()` method of `Ember.RenderBuffer`. + + @property elementStyle + @type Hash + @default {} + */ + elementStyle: null, + + /** + Nested `RenderBuffers` will set this to their parent `RenderBuffer` + instance. + + @property parentBuffer + @type Ember._RenderBuffer + */ + parentBuffer: null, + + /** + Adds a string of HTML to the `RenderBuffer`. + + @method push + @param {String} string HTML to push into the buffer + @chainable + */ + push: function(string) { + this.buffer.push(string); + return this; + }, + + /** + Adds a class to the buffer, which will be rendered to the class attribute. + + @method addClass + @param {String} className Class name to add to the buffer + @chainable + */ + addClass: function(className) { + // lazily create elementClasses + var elementClasses = this.elementClasses = (this.elementClasses || new ClassSet()); + this.elementClasses.add(className); + this.classes = this.elementClasses.list; + + return this; + }, + + setClasses: function(classNames) { + this.classes = classNames; + }, + + /** + Sets the elementID to be used for the element. + + @method id + @param {String} id + @chainable + */ + id: function(id) { + this.elementId = id; + return this; + }, + + // duck type attribute functionality like jQuery so a render buffer + // can be used like a jQuery object in attribute binding scenarios. + + /** + Adds an attribute which will be rendered to the element. + + @method attr + @param {String} name The name of the attribute + @param {String} value The value to add to the attribute + @chainable + @return {Ember.RenderBuffer|String} this or the current attribute value + */ + attr: function(name, value) { + var attributes = this.elementAttributes = (this.elementAttributes || {}); + + if (arguments.length === 1) { + return attributes[name]; + } else { + attributes[name] = value; + } + + return this; + }, + + /** + Adds an value which will be rendered to the element. + + @method val + @param {String} value The value to set + @chainable + @return {Ember.RenderBuffer|String} this or the current value + */ + val: function(value) { + var elementValue = this.elementValue; + + if (arguments.length === 0) { + return elementValue; + } else { + this.elementValue = value; + } + + return this; + }, + + /** + Remove an attribute from the list of attributes to render. + + @method removeAttr + @param {String} name The name of the attribute + @chainable + */ + removeAttr: function(name) { + var attributes = this.elementAttributes; + if (attributes) { delete attributes[name]; } + + return this; + }, + + /** + Adds a style to the style attribute which will be rendered to the element. + + @method style + @param {String} name Name of the style + @param {String} value + @chainable + */ + style: function(name, value) { + var style = this.elementStyle = (this.elementStyle || {}); + + this.elementStyle[name] = value; + return this; + }, + + begin: function(tagName) { + this.tagNames.push(tagName || null); + return this; + }, + + pushOpeningTag: function() { + var tagName = this.currentTagName(); + if (!tagName) { return; } + + if (!this._element && this.buffer.length === 0) { + this._element = this.generateElement(); + return; + } + + var buffer = this.buffer, + id = this.elementId, + classes = this.classes, + attrs = this.elementAttributes, + value = this.elementValue, + style = this.elementStyle, + prop; + + buffer.push('<' + tagName); + + if (id) { + buffer.push(' id="' + this._escapeAttribute(id) + '"'); + this.elementId = null; + } + if (classes) { + buffer.push(' class="' + this._escapeAttribute(classes.join(' ')) + '"'); + this.classes = null; + } + + if (style) { + buffer.push(' style="'); + + for (prop in style) { + if (style.hasOwnProperty(prop)) { + buffer.push(prop + ':' + this._escapeAttribute(style[prop]) + ';'); + } + } + + buffer.push('"'); + + this.elementStyle = null; + } + + if (attrs) { + for (prop in attrs) { + if (attrs.hasOwnProperty(prop)) { + buffer.push(' ' + prop + '="' + this._escapeAttribute(attrs[prop]) + '"'); + } + } + + this.elementAttributes = null; + } + + if (value) { + buffer.push(' value="' + this._escapeAttribute(value) + '"'); + + this.elementValue = null; + } + + buffer.push('>'); + }, + + pushClosingTag: function() { + var tagName = this.tagNames.pop(); + if (tagName) { this.buffer.push(''); } + }, + + currentTagName: function() { + return this.tagNames[this.tagNames.length-1]; + }, + + generateElement: function() { + var tagName = this.tagNames.pop(), // pop since we don't need to close + element = document.createElement(tagName), + $element = Ember.$(element), + id = this.elementId, + classes = this.classes, + attrs = this.elementAttributes, + value = this.elementValue, + style = this.elementStyle, + styleBuffer = '', prop; + + if (id) { + $element.attr('id', id); + this.elementId = null; + } + if (classes) { + $element.attr('class', classes.join(' ')); + this.classes = null; + } + + if (style) { + for (prop in style) { + if (style.hasOwnProperty(prop)) { + styleBuffer += (prop + ':' + style[prop] + ';'); + } + } + + $element.attr('style', styleBuffer); + + this.elementStyle = null; + } + + if (attrs) { + for (prop in attrs) { + if (attrs.hasOwnProperty(prop)) { + $element.attr(prop, attrs[prop]); + } + } + + this.elementAttributes = null; + } + + if (value) { + $element.val(value); + + this.elementValue = null; + } + + return element; + }, + + /** + @method element + @return {DOMElement} The element corresponding to the generated HTML + of this buffer + */ + element: function() { + var html = this.innerString(); + + if (html) { + this._element = Ember.ViewUtils.setInnerHTML(this._element, html); + } + + return this._element; + }, + + /** + Generates the HTML content for this buffer. + + @method string + @return {String} The generated HTML + */ + string: function() { + if (this._element) { + return this.element().outerHTML; + } else { + return this.innerString(); + } + }, + + innerString: function() { + return this.buffer.join(''); + }, + + _escapeAttribute: function(value) { + // Stolen shamelessly from Handlebars + + var escape = { + "<": "<", + ">": ">", + '"': """, + "'": "'", + "`": "`" + }; + + var badChars = /&(?!\w+;)|[<>"'`]/g; + var possible = /[&<>"'`]/; + + var escapeChar = function(chr) { + return escape[chr] || "&"; + }; + + var string = value.toString(); + + if(!possible.test(string)) { return string; } + return string.replace(badChars, escapeChar); + } + +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, fmt = Ember.String.fmt; + +/** + `Ember.EventDispatcher` handles delegating browser events to their + corresponding `Ember.Views.` For example, when you click on a view, + `Ember.EventDispatcher` ensures that that view's `mouseDown` method gets + called. + + @class EventDispatcher + @namespace Ember + @private + @extends Ember.Object +*/ +Ember.EventDispatcher = Ember.Object.extend( +/** @scope Ember.EventDispatcher.prototype */{ + + /** + @private + + The root DOM element to which event listeners should be attached. Event + listeners will be attached to the document unless this is overridden. + + Can be specified as a DOMElement or a selector string. + + The default body is a string since this may be evaluated before document.body + exists in the DOM. + + @property rootElement + @type DOMElement + @default 'body' + */ + rootElement: 'body', + + /** + @private + + Sets up event listeners for standard browser events. + + This will be called after the browser sends a `DOMContentReady` event. By + default, it will set up all of the listeners on the document body. If you + would like to register the listeners on a different element, set the event + dispatcher's `root` property. + + @method setup + @param addedEvents {Hash} + */ + setup: function(addedEvents) { + var event, events = { + touchstart : 'touchStart', + touchmove : 'touchMove', + touchend : 'touchEnd', + touchcancel : 'touchCancel', + keydown : 'keyDown', + keyup : 'keyUp', + keypress : 'keyPress', + mousedown : 'mouseDown', + mouseup : 'mouseUp', + contextmenu : 'contextMenu', + click : 'click', + dblclick : 'doubleClick', + mousemove : 'mouseMove', + focusin : 'focusIn', + focusout : 'focusOut', + mouseenter : 'mouseEnter', + mouseleave : 'mouseLeave', + submit : 'submit', + input : 'input', + change : 'change', + dragstart : 'dragStart', + drag : 'drag', + dragenter : 'dragEnter', + dragleave : 'dragLeave', + dragover : 'dragOver', + drop : 'drop', + dragend : 'dragEnd' + }; + + Ember.$.extend(events, addedEvents || {}); + + var rootElement = Ember.$(get(this, 'rootElement')); + + + + + rootElement.addClass('ember-application'); + + + for (event in events) { + if (events.hasOwnProperty(event)) { + this.setupHandler(rootElement, event, events[event]); + } + } + }, + + /** + @private + + Registers an event listener on the document. If the given event is + triggered, the provided event handler will be triggered on the target view. + + If the target view does not implement the event handler, or if the handler + returns `false`, the parent view will be called. The event will continue to + bubble to each successive parent view until it reaches the top. + + For example, to have the `mouseDown` method called on the target view when + a `mousedown` event is received from the browser, do the following: + + ```javascript + setupHandler('mousedown', 'mouseDown'); + ``` + + @method setupHandler + @param {Element} rootElement + @param {String} event the browser-originated event to listen to + @param {String} eventName the name of the method to call on the view + */ + setupHandler: function(rootElement, event, eventName) { + var self = this; + + rootElement.delegate('.ember-view', event + '.ember', function(evt, triggeringManager) { + return Ember.handleErrors(function() { + var view = Ember.View.views[this.id], + result = true, manager = null; + + manager = self._findNearestEventManager(view,eventName); + + if (manager && manager !== triggeringManager) { + result = self._dispatchEvent(manager, evt, eventName, view); + } else if (view) { + result = self._bubbleEvent(view,evt,eventName); + } else { + evt.stopPropagation(); + } + + return result; + }, this); + }); + + rootElement.delegate('[data-ember-action]', event + '.ember', function(evt) { + return Ember.handleErrors(function() { + var actionId = Ember.$(evt.currentTarget).attr('data-ember-action'), + action = Ember.Handlebars.ActionHelper.registeredActions[actionId]; + + // We have to check for action here since in some cases, jQuery will trigger + // an event on `removeChild` (i.e. focusout) after we've already torn down the + // action handlers for the view. + if (action && action.eventName === eventName) { + return action.handler(evt); + } + }, this); + }); + }, + + _findNearestEventManager: function(view, eventName) { + var manager = null; + + while (view) { + manager = get(view, 'eventManager'); + if (manager && manager[eventName]) { break; } + + view = get(view, 'parentView'); + } + + return manager; + }, + + _dispatchEvent: function(object, evt, eventName, view) { + var result = true; + + var handler = object[eventName]; + if (Ember.typeOf(handler) === 'function') { + result = handler.call(object, evt, view); + // Do not preventDefault in eventManagers. + evt.stopPropagation(); + } + else { + result = this._bubbleEvent(view, evt, eventName); + } + + return result; + }, + + _bubbleEvent: function(view, evt, eventName) { + return Ember.run(function() { + return view.handleEvent(eventName, evt); + }); + }, + + destroy: function() { + var rootElement = get(this, 'rootElement'); + Ember.$(rootElement).undelegate('.ember').removeClass('ember-application'); + return this._super(); + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +// Add a new named queue for rendering views that happens +// after bindings have synced, and a queue for scheduling actions +// that that should occur after view rendering. +var queues = Ember.run.queues; +queues.splice(Ember.$.inArray('actions', queues)+1, 0, 'render', 'afterRender'); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set; + +// Original class declaration and documentation in runtime/lib/controllers/controller.js +// NOTE: It may be possible with YUIDoc to combine docs in two locations + +/** +Additional methods for the ControllerMixin + +@class ControllerMixin +@namespace Ember +*/ +Ember.ControllerMixin.reopen({ + target: null, + namespace: null, + view: null, + container: null, + _childContainers: null, + + init: function() { + this._super(); + set(this, '_childContainers', {}); + }, + + _modelDidChange: Ember.observer(function() { + var containers = get(this, '_childContainers'), + container; + + for (var prop in containers) { + if (!containers.hasOwnProperty(prop)) { continue; } + containers[prop].destroy(); + } + + set(this, '_childContainers', {}); + }, 'model') +}); + +})(); + + + +(function() { + +})(); + + + +(function() { +var states = {}; + +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, addObserver = Ember.addObserver, removeObserver = Ember.removeObserver; +var meta = Ember.meta, guidFor = Ember.guidFor, fmt = Ember.String.fmt; +var a_slice = [].slice; +var a_forEach = Ember.EnumerableUtils.forEach; +var a_addObject = Ember.EnumerableUtils.addObject; + +var childViewsProperty = Ember.computed(function() { + var childViews = this._childViews, ret = Ember.A(), view = this; + + a_forEach(childViews, function(view) { + if (view.isVirtual) { + ret.pushObjects(get(view, 'childViews')); + } else { + ret.push(view); + } + }); + + ret.replace = function (idx, removedCount, addedViews) { + if (view instanceof Ember.ContainerView) { + + return view.replace(idx, removedCount, addedViews); + } + throw new Error("childViews is immutable"); + }; + + return ret; +}); + + +/** + Global hash of shared templates. This will automatically be populated + by the build tools so that you can store your Handlebars templates in + separate files that get loaded into JavaScript at buildtime. + + @property TEMPLATES + @for Ember + @type Hash +*/ +Ember.TEMPLATES = {}; + +Ember.CoreView = Ember.Object.extend(Ember.Evented, { + isView: true, + + states: states, + + init: function() { + this._super(); + + // Register the view for event handling. This hash is used by + // Ember.EventDispatcher to dispatch incoming events. + if (!this.isVirtual) { + + Ember.View.views[this.elementId] = this; + } + + this.addBeforeObserver('elementId', function() { + throw new Error("Changing a view's elementId after creation is not allowed"); + }); + + this.transitionTo('preRender'); + }, + + /** + If the view is currently inserted into the DOM of a parent view, this + property will point to the parent of the view. + + @property parentView + @type Ember.View + @default null + */ + parentView: Ember.computed(function() { + var parent = this._parentView; + + if (parent && parent.isVirtual) { + return get(parent, 'parentView'); + } else { + return parent; + } + }).property('_parentView'), + + state: null, + + _parentView: null, + + // return the current view, not including virtual views + concreteView: Ember.computed(function() { + if (!this.isVirtual) { return this; } + else { return get(this, 'parentView'); } + }).property('parentView').volatile(), + + instrumentName: 'core_view', + + instrumentDetails: function(hash) { + hash.object = this.toString(); + }, + + /** + @private + + Invoked by the view system when this view needs to produce an HTML + representation. This method will create a new render buffer, if needed, + then apply any default attributes, such as class names and visibility. + Finally, the `render()` method is invoked, which is responsible for + doing the bulk of the rendering. + + You should not need to override this method; instead, implement the + `template` property, or if you need more control, override the `render` + method. + + @method renderToBuffer + @param {Ember.RenderBuffer} buffer the render buffer. If no buffer is + passed, a default buffer, using the current view's `tagName`, will + be used. + */ + renderToBuffer: function(parentBuffer, bufferOperation) { + var name = 'render.' + this.instrumentName, + details = {}; + + this.instrumentDetails(details); + + return Ember.instrument(name, details, function() { + return this._renderToBuffer(parentBuffer, bufferOperation); + }, this); + }, + + _renderToBuffer: function(parentBuffer, bufferOperation) { + Ember.run.sync(); + + // If this is the top-most view, start a new buffer. Otherwise, + // create a new buffer relative to the original using the + // provided buffer operation (for example, `insertAfter` will + // insert a new buffer after the "parent buffer"). + var tagName = this.tagName; + + if (tagName === null || tagName === undefined) { + tagName = 'div'; + } + + var buffer = this.buffer = parentBuffer && parentBuffer.begin(tagName) || Ember.RenderBuffer(tagName); + this.transitionTo('inBuffer', false); + + this.beforeRender(buffer); + this.render(buffer); + this.afterRender(buffer); + + return buffer; + }, + + /** + @private + + Override the default event firing from `Ember.Evented` to + also call methods with the given name. + + @method trigger + @param name {String} + */ + trigger: function(name) { + this._super.apply(this, arguments); + var method = this[name]; + if (method) { + var args = [], i, l; + for (i = 1, l = arguments.length; i < l; i++) { + args.push(arguments[i]); + } + return method.apply(this, args); + } + }, + + has: function(name) { + return Ember.typeOf(this[name]) === 'function' || this._super(name); + }, + + willDestroy: function() { + var parent = this._parentView; + + // destroy the element -- this will avoid each child view destroying + // the element over and over again... + if (!this.removedFromDOM) { this.destroyElement(); } + + // remove from parent if found. Don't call removeFromParent, + // as removeFromParent will try to remove the element from + // the DOM again. + if (parent) { parent.removeChild(this); } + + this.transitionTo('destroyed'); + + // next remove view from global hash + if (!this.isVirtual) delete Ember.View.views[this.elementId]; + }, + + clearRenderedChildren: Ember.K, + triggerRecursively: Ember.K, + invokeRecursively: Ember.K, + transitionTo: Ember.K, + destroyElement: Ember.K +}); + +/** + `Ember.View` is the class in Ember responsible for encapsulating templates of + HTML content, combining templates with data to render as sections of a page's + DOM, and registering and responding to user-initiated events. + + ## HTML Tag + + The default HTML tag name used for a view's DOM representation is `div`. This + can be customized by setting the `tagName` property. The following view +class: + + ```javascript + ParagraphView = Ember.View.extend({ + tagName: 'em' + }); + ``` + + Would result in instances with the following HTML: + + ```html + + ``` + + ## HTML `class` Attribute + + The HTML `class` attribute of a view's tag can be set by providing a + `classNames` property that is set to an array of strings: + + ```javascript + MyView = Ember.View.extend({ + classNames: ['my-class', 'my-other-class'] + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + `class` attribute values can also be set by providing a `classNameBindings` + property set to an array of properties names for the view. The return value + of these properties will be added as part of the value for the view's `class` + attribute. These properties can be computed properties: + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['propertyA', 'propertyB'], + propertyA: 'from-a', + propertyB: function(){ + if(someLogic){ return 'from-b'; } + }.property() + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + If the value of a class name binding returns a boolean the property name + itself will be used as the class name if the property is true. The class name + will not be added if the value is `false` or `undefined`. + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['hovered'], + hovered: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + When using boolean class name bindings you can supply a string value other + than the property name for use as the `class` HTML attribute by appending the + preferred value after a ":" character when defining the binding: + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['awesome:so-very-cool'], + awesome: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + Boolean value class name bindings whose property names are in a + camelCase-style format will be converted to a dasherized format: + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['isUrgent'], + isUrgent: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + Class name bindings can also refer to object values that are found by + traversing a path relative to the view itself: + + ```javascript + MyView = Ember.View.extend({ + classNameBindings: ['messages.empty'] + messages: Ember.Object.create({ + empty: true + }) + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + If you want to add a class name for a property which evaluates to true and + and a different class name if it evaluates to false, you can pass a binding + like this: + + ```javascript + // Applies 'enabled' class when isEnabled is true and 'disabled' when isEnabled is false + Ember.View.create({ + classNameBindings: ['isEnabled:enabled:disabled'] + isEnabled: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + When isEnabled is `false`, the resulting HTML reprensentation looks like + this: + + ```html +
            + ``` + + This syntax offers the convenience to add a class if a property is `false`: + + ```javascript + // Applies no class when isEnabled is true and class 'disabled' when isEnabled is false + Ember.View.create({ + classNameBindings: ['isEnabled::disabled'] + isEnabled: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            + ``` + + When the `isEnabled` property on the view is set to `false`, it will result + in view instances with an HTML representation of: + + ```html +
            + ``` + + Updates to the the value of a class name binding will result in automatic + update of the HTML `class` attribute in the view's rendered HTML + representation. If the value becomes `false` or `undefined` the class name + will be removed. + + Both `classNames` and `classNameBindings` are concatenated properties. See + `Ember.Object` documentation for more information about concatenated + properties. + + ## HTML Attributes + + The HTML attribute section of a view's tag can be set by providing an + `attributeBindings` property set to an array of property names on the view. + The return value of these properties will be used as the value of the view's + HTML associated attribute: + + ```javascript + AnchorView = Ember.View.extend({ + tagName: 'a', + attributeBindings: ['href'], + href: 'http://google.com' + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html + + ``` + + If the return value of an `attributeBindings` monitored property is a boolean + the property will follow HTML's pattern of repeating the attribute's name as + its value: + + ```javascript + MyTextInput = Ember.View.extend({ + tagName: 'input', + attributeBindings: ['disabled'], + disabled: true + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html + + ``` + + `attributeBindings` can refer to computed properties: + + ```javascript + MyTextInput = Ember.View.extend({ + tagName: 'input', + attributeBindings: ['disabled'], + disabled: function(){ + if (someLogic) { + return true; + } else { + return false; + } + }.property() + }); + ``` + + Updates to the the property of an attribute binding will result in automatic + update of the HTML attribute in the view's rendered HTML representation. + + `attributeBindings` is a concatenated property. See `Ember.Object` + documentation for more information about concatenated properties. + + ## Templates + + The HTML contents of a view's rendered representation are determined by its + template. Templates can be any function that accepts an optional context + parameter and returns a string of HTML that will be inserted within the + view's tag. Most typically in Ember this function will be a compiled + `Ember.Handlebars` template. + + ```javascript + AView = Ember.View.extend({ + template: Ember.Handlebars.compile('I am the template') + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            I am the template
            + ``` + + Within an Ember application is more common to define a Handlebars templates as + part of a page: + + ```html + + ``` + + And associate it by name using a view's `templateName` property: + + ```javascript + AView = Ember.View.extend({ + templateName: 'some-template' + }); + ``` + + Using a value for `templateName` that does not have a Handlebars template + with a matching `data-template-name` attribute will throw an error. + + Assigning a value to both `template` and `templateName` properties will throw + an error. + + For views classes that may have a template later defined (e.g. as the block + portion of a `{{view}}` Handlebars helper call in another template or in + a subclass), you can provide a `defaultTemplate` property set to compiled + template function. If a template is not later provided for the view instance + the `defaultTemplate` value will be used: + + ```javascript + AView = Ember.View.extend({ + defaultTemplate: Ember.Handlebars.compile('I was the default'), + template: null, + templateName: null + }); + ``` + + Will result in instances with an HTML representation of: + + ```html +
            I was the default
            + ``` + + If a `template` or `templateName` is provided it will take precedence over + `defaultTemplate`: + + ```javascript + AView = Ember.View.extend({ + defaultTemplate: Ember.Handlebars.compile('I was the default') + }); + + aView = AView.create({ + template: Ember.Handlebars.compile('I was the template, not default') + }); + ``` + + Will result in the following HTML representation when rendered: + + ```html +
            I was the template, not default
            + ``` + + ## View Context + + The default context of the compiled template is the view's controller: + + ```javascript + AView = Ember.View.extend({ + template: Ember.Handlebars.compile('Hello {{excitedGreeting}}') + }); + + aController = Ember.Object.create({ + firstName: 'Barry', + excitedGreeting: function(){ + return this.get("content.firstName") + "!!!" + }.property() + }); + + aView = AView.create({ + controller: aController, + }); + ``` + + Will result in an HTML representation of: + + ```html +
            Hello Barry!!!
            + ``` + + A context can also be explicitly supplied through the view's `context` + property. If the view has neither `context` nor `controller` properties, the + `parentView`'s context will be used. + + ## Layouts + + Views can have a secondary template that wraps their main template. Like + primary templates, layouts can be any function that accepts an optional + context parameter and returns a string of HTML that will be inserted inside + view's tag. Views whose HTML element is self closing (e.g. ``) + cannot have a layout and this property will be ignored. + + Most typically in Ember a layout will be a compiled `Ember.Handlebars` + template. + + A view's layout can be set directly with the `layout` property or reference + an existing Handlebars template by name with the `layoutName` property. + + A template used as a layout must contain a single use of the Handlebars + `{{yield}}` helper. The HTML contents of a view's rendered `template` will be + inserted at this location: + + ```javascript + AViewWithLayout = Ember.View.extend({ + layout: Ember.Handlebars.compile("
            {{yield}}
            ") + template: Ember.Handlebars.compile("I got wrapped"), + }); + ``` + + Will result in view instances with an HTML representation of: + + ```html +
            +
            + I got wrapped +
            +
            + ``` + + See `Handlebars.helpers.yield` for more information. + + ## Responding to Browser Events + + Views can respond to user-initiated events in one of three ways: method + implementation, through an event manager, and through `{{action}}` helper use + in their template or layout. + + ### Method Implementation + + Views can respond to user-initiated events by implementing a method that + matches the event name. A `jQuery.Event` object will be passed as the + argument to this method. + + ```javascript + AView = Ember.View.extend({ + click: function(event){ + // will be called when when an instance's + // rendered element is clicked + } + }); + ``` + + ### Event Managers + + Views can define an object as their `eventManager` property. This object can + then implement methods that match the desired event names. Matching events + that occur on the view's rendered HTML or the rendered HTML of any of its DOM + descendants will trigger this method. A `jQuery.Event` object will be passed + as the first argument to the method and an `Ember.View` object as the + second. The `Ember.View` will be the view whose rendered HTML was interacted + with. This may be the view with the `eventManager` property or one of its + descendent views. + + ```javascript + AView = Ember.View.extend({ + eventManager: Ember.Object.create({ + doubleClick: function(event, view){ + // will be called when when an instance's + // rendered element or any rendering + // of this views's descendent + // elements is clicked + } + }) + }); + ``` + + An event defined for an event manager takes precedence over events of the + same name handled through methods on the view. + + ```javascript + AView = Ember.View.extend({ + mouseEnter: function(event){ + // will never trigger. + }, + eventManager: Ember.Object.create({ + mouseEnter: function(event, view){ + // takes presedence over AView#mouseEnter + } + }) + }); + ``` + + Similarly a view's event manager will take precedence for events of any views + rendered as a descendent. A method name that matches an event name will not + be called if the view instance was rendered inside the HTML representation of + a view that has an `eventManager` property defined that handles events of the + name. Events not handled by the event manager will still trigger method calls + on the descendent. + + ```javascript + OuterView = Ember.View.extend({ + template: Ember.Handlebars.compile("outer {{#view InnerView}}inner{{/view}} outer"), + eventManager: Ember.Object.create({ + mouseEnter: function(event, view){ + // view might be instance of either + // OutsideView or InnerView depending on + // where on the page the user interaction occured + } + }) + }); + + InnerView = Ember.View.extend({ + click: function(event){ + // will be called if rendered inside + // an OuterView because OuterView's + // eventManager doesn't handle click events + }, + mouseEnter: function(event){ + // will never be called if rendered inside + // an OuterView. + } + }); + ``` + + ### Handlebars `{{action}}` Helper + + See `Handlebars.helpers.action`. + + ### Event Names + + Possible events names for any of the responding approaches described above + are: + + Touch events: + + * `touchStart` + * `touchMove` + * `touchEnd` + * `touchCancel` + + Keyboard events + + * `keyDown` + * `keyUp` + * `keyPress` + + Mouse events + + * `mouseDown` + * `mouseUp` + * `contextMenu` + * `click` + * `doubleClick` + * `mouseMove` + * `focusIn` + * `focusOut` + * `mouseEnter` + * `mouseLeave` + + Form events: + + * `submit` + * `change` + * `focusIn` + * `focusOut` + * `input` + + HTML5 drag and drop events: + + * `dragStart` + * `drag` + * `dragEnter` + * `dragLeave` + * `drop` + * `dragEnd` + + ## Handlebars `{{view}}` Helper + + Other `Ember.View` instances can be included as part of a view's template by + using the `{{view}}` Handlebars helper. See `Handlebars.helpers.view` for + additional information. + + @class View + @namespace Ember + @extends Ember.Object + @uses Ember.Evented +*/ +Ember.View = Ember.CoreView.extend( +/** @scope Ember.View.prototype */ { + + concatenatedProperties: ['classNames', 'classNameBindings', 'attributeBindings'], + + /** + @property isView + @type Boolean + @default true + @final + */ + isView: true, + + // .......................................................... + // TEMPLATE SUPPORT + // + + /** + The name of the template to lookup if no template is provided. + + `Ember.View` will look for a template with this name in this view's + `templates` object. By default, this will be a global object + shared in `Ember.TEMPLATES`. + + @property templateName + @type String + @default null + */ + templateName: null, + + /** + The name of the layout to lookup if no layout is provided. + + `Ember.View` will look for a template with this name in this view's + `templates` object. By default, this will be a global object + shared in `Ember.TEMPLATES`. + + @property layoutName + @type String + @default null + */ + layoutName: null, + + /** + The hash in which to look for `templateName`. + + @property templates + @type Ember.Object + @default Ember.TEMPLATES + */ + templates: Ember.TEMPLATES, + + /** + The template used to render the view. This should be a function that + accepts an optional context parameter and returns a string of HTML that + will be inserted into the DOM relative to its parent view. + + In general, you should set the `templateName` property instead of setting + the template yourself. + + @property template + @type Function + */ + template: Ember.computed(function(key, value) { + if (value !== undefined) { return value; } + + var templateName = get(this, 'templateName'), + template = this.templateForName(templateName, 'template'); + + + return template || get(this, 'defaultTemplate'); + }).property('templateName'), + + container: Ember.computed(function() { + var parentView = get(this, '_parentView'); + + if (parentView) { return get(parentView, 'container'); } + + return Ember.Container && Ember.Container.defaultContainer; + }), + + /** + The controller managing this view. If this property is set, it will be + made available for use by the template. + + @property controller + @type Object + */ + controller: Ember.computed(function(key) { + var parentView = get(this, '_parentView'); + return parentView ? get(parentView, 'controller') : null; + }).property('_parentView'), + + /** + A view may contain a layout. A layout is a regular template but + supersedes the `template` property during rendering. It is the + responsibility of the layout template to retrieve the `template` + property from the view (or alternatively, call `Handlebars.helpers.yield`, + `{{yield}}`) to render it in the correct location. + + This is useful for a view that has a shared wrapper, but which delegates + the rendering of the contents of the wrapper to the `template` property + on a subclass. + + @property layout + @type Function + */ + layout: Ember.computed(function(key) { + var layoutName = get(this, 'layoutName'), + layout = this.templateForName(layoutName, 'layout'); + + + return layout || get(this, 'defaultLayout'); + }).property('layoutName'), + + templateForName: function(name, type) { + if (!name) { return; } + + + var container = get(this, 'container'); + + if (container) { + return container.lookup('template:' + name); + } + }, + + /** + The object from which templates should access properties. + + This object will be passed to the template function each time the render + method is called, but it is up to the individual function to decide what + to do with it. + + By default, this will be the view's controller. + + @property context + @type Object + */ + context: Ember.computed(function(key, value) { + if (arguments.length === 2) { + set(this, '_context', value); + return value; + } else { + return get(this, '_context'); + } + }).volatile(), + + /** + @private + + Private copy of the view's template context. This can be set directly + by Handlebars without triggering the observer that causes the view + to be re-rendered. + + The context of a view is looked up as follows: + + 1. Supplied context (usually by Handlebars) + 2. Specified controller + 3. `parentView`'s context (for a child of a ContainerView) + + The code in Handlebars that overrides the `_context` property first + checks to see whether the view has a specified controller. This is + something of a hack and should be revisited. + + @property _context + */ + _context: Ember.computed(function(key) { + var parentView, controller; + + if (controller = get(this, 'controller')) { + return controller; + } + + parentView = this._parentView; + if (parentView) { + return get(parentView, '_context'); + } + + return null; + }), + + /** + @private + + If a value that affects template rendering changes, the view should be + re-rendered to reflect the new value. + + @method _displayPropertyDidChange + */ + _contextDidChange: Ember.observer(function() { + this.rerender(); + }, 'context'), + + /** + If `false`, the view will appear hidden in DOM. + + @property isVisible + @type Boolean + @default null + */ + isVisible: true, + + /** + @private + + Array of child views. You should never edit this array directly. + Instead, use `appendChild` and `removeFromParent`. + + @property childViews + @type Array + @default [] + */ + childViews: childViewsProperty, + + _childViews: [], + + // When it's a virtual view, we need to notify the parent that their + // childViews will change. + _childViewsWillChange: Ember.beforeObserver(function() { + if (this.isVirtual) { + var parentView = get(this, 'parentView'); + if (parentView) { Ember.propertyWillChange(parentView, 'childViews'); } + } + }, 'childViews'), + + // When it's a virtual view, we need to notify the parent that their + // childViews did change. + _childViewsDidChange: Ember.observer(function() { + if (this.isVirtual) { + var parentView = get(this, 'parentView'); + if (parentView) { Ember.propertyDidChange(parentView, 'childViews'); } + } + }, 'childViews'), + + /** + Return the nearest ancestor that is an instance of the provided + class. + + @property nearestInstanceOf + @param {Class} klass Subclass of Ember.View (or Ember.View itself) + @return Ember.View + @deprecated + */ + nearestInstanceOf: function(klass) { + + var view = get(this, 'parentView'); + + while (view) { + if(view instanceof klass) { return view; } + view = get(view, 'parentView'); + } + }, + + /** + Return the nearest ancestor that is an instance of the provided + class or mixin. + + @property nearestOfType + @param {Class,Mixin} klass Subclass of Ember.View (or Ember.View itself), + or an instance of Ember.Mixin. + @return Ember.View + */ + nearestOfType: function(klass) { + var view = get(this, 'parentView'), + isOfType = klass instanceof Ember.Mixin ? + function(view) { return klass.detect(view); } : + function(view) { return klass.detect(view.constructor); }; + + while (view) { + if( isOfType(view) ) { return view; } + view = get(view, 'parentView'); + } + }, + + /** + Return the nearest ancestor that has a given property. + + @property nearestWithProperty + @param {String} property A property name + @return Ember.View + */ + nearestWithProperty: function(property) { + var view = get(this, 'parentView'); + + while (view) { + if (property in view) { return view; } + view = get(view, 'parentView'); + } + }, + + /** + Return the nearest ancestor whose parent is an instance of + `klass`. + + @property nearestChildOf + @param {Class} klass Subclass of Ember.View (or Ember.View itself) + @return Ember.View + */ + nearestChildOf: function(klass) { + var view = get(this, 'parentView'); + + while (view) { + if(get(view, 'parentView') instanceof klass) { return view; } + view = get(view, 'parentView'); + } + }, + + /** + @private + + When the parent view changes, recursively invalidate `controller` + + @method _parentViewDidChange + */ + _parentViewDidChange: Ember.observer(function() { + if (this.isDestroying) { return; } + + if (get(this, 'parentView.controller') && !get(this, 'controller')) { + this.notifyPropertyChange('controller'); + } + }, '_parentView'), + + _controllerDidChange: Ember.observer(function() { + if (this.isDestroying) { return; } + + this.rerender(); + + this.forEachChildView(function(view) { + view.propertyDidChange('controller'); + }); + }, 'controller'), + + cloneKeywords: function() { + var templateData = get(this, 'templateData'); + + var keywords = templateData ? Ember.copy(templateData.keywords) : {}; + set(keywords, 'view', get(this, 'concreteView')); + set(keywords, '_view', this); + set(keywords, 'controller', get(this, 'controller')); + + return keywords; + }, + + /** + Called on your view when it should push strings of HTML into a + `Ember.RenderBuffer`. Most users will want to override the `template` + or `templateName` properties instead of this method. + + By default, `Ember.View` will look for a function in the `template` + property and invoke it with the value of `context`. The value of + `context` will be the view's controller unless you override it. + + @method render + @param {Ember.RenderBuffer} buffer The render buffer + */ + render: function(buffer) { + // If this view has a layout, it is the responsibility of the + // the layout to render the view's template. Otherwise, render the template + // directly. + var template = get(this, 'layout') || get(this, 'template'); + + if (template) { + var context = get(this, 'context'); + var keywords = this.cloneKeywords(); + var output; + + var data = { + view: this, + buffer: buffer, + isRenderData: true, + keywords: keywords, + insideGroup: get(this, 'templateData.insideGroup') + }; + + // Invoke the template with the provided template context, which + // is the view's controller by default. A hash of data is also passed that provides + // the template with access to the view and render buffer. + + // The template should write directly to the render buffer instead + // of returning a string. + output = template(context, { data: data }); + + // If the template returned a string instead of writing to the buffer, + // push the string onto the buffer. + if (output !== undefined) { buffer.push(output); } + } + }, + + /** + Renders the view again. This will work regardless of whether the + view is already in the DOM or not. If the view is in the DOM, the + rendering process will be deferred to give bindings a chance + to synchronize. + + If children were added during the rendering process using `appendChild`, + `rerender` will remove them, because they will be added again + if needed by the next `render`. + + In general, if the display of your view changes, you should modify + the DOM element directly instead of manually calling `rerender`, which can + be slow. + + @method rerender + */ + rerender: function() { + return this.currentState.rerender(this); + }, + + clearRenderedChildren: function() { + var lengthBefore = this.lengthBeforeRender, + lengthAfter = this.lengthAfterRender; + + // If there were child views created during the last call to render(), + // remove them under the assumption that they will be re-created when + // we re-render. + + // VIEW-TODO: Unit test this path. + var childViews = this._childViews; + for (var i=lengthAfter-1; i>=lengthBefore; i--) { + if (childViews[i]) { childViews[i].destroy(); } + } + }, + + /** + @private + + Iterates over the view's `classNameBindings` array, inserts the value + of the specified property into the `classNames` array, then creates an + observer to update the view's element if the bound property ever changes + in the future. + + @method _applyClassNameBindings + */ + _applyClassNameBindings: function(classBindings) { + var classNames = this.classNames, + elem, newClass, dasherizedClass; + + // Loop through all of the configured bindings. These will be either + // property names ('isUrgent') or property paths relative to the view + // ('content.isUrgent') + a_forEach(classBindings, function(binding) { + + // Variable in which the old class value is saved. The observer function + // closes over this variable, so it knows which string to remove when + // the property changes. + var oldClass; + // Extract just the property name from bindings like 'foo:bar' + var parsedPath = Ember.View._parsePropertyPath(binding); + + // Set up an observer on the context. If the property changes, toggle the + // class name. + var observer = function() { + // Get the current value of the property + newClass = this._classStringForProperty(binding); + elem = this.$(); + + // If we had previously added a class to the element, remove it. + if (oldClass) { + elem.removeClass(oldClass); + // Also remove from classNames so that if the view gets rerendered, + // the class doesn't get added back to the DOM. + classNames.removeObject(oldClass); + } + + // If necessary, add a new class. Make sure we keep track of it so + // it can be removed in the future. + if (newClass) { + elem.addClass(newClass); + oldClass = newClass; + } else { + oldClass = null; + } + }; + + // Get the class name for the property at its current value + dasherizedClass = this._classStringForProperty(binding); + + if (dasherizedClass) { + // Ensure that it gets into the classNames array + // so it is displayed when we render. + a_addObject(classNames, dasherizedClass); + + // Save a reference to the class name so we can remove it + // if the observer fires. Remember that this variable has + // been closed over by the observer. + oldClass = dasherizedClass; + } + + this.registerObserver(this, parsedPath.path, observer); + // Remove className so when the view is rerendered, + // the className is added based on binding reevaluation + this.one('willClearRender', function() { + if (oldClass) { + classNames.removeObject(oldClass); + oldClass = null; + } + }); + + }, this); + }, + + /** + @private + + Iterates through the view's attribute bindings, sets up observers for each, + then applies the current value of the attributes to the passed render buffer. + + @method _applyAttributeBindings + @param {Ember.RenderBuffer} buffer + */ + _applyAttributeBindings: function(buffer, attributeBindings) { + var attributeValue, elem, type; + + a_forEach(attributeBindings, function(binding) { + var split = binding.split(':'), + property = split[0], + attributeName = split[1] || property; + + // Create an observer to add/remove/change the attribute if the + // JavaScript property changes. + var observer = function() { + elem = this.$(); + if (!elem) { return; } + + attributeValue = get(this, property); + + Ember.View.applyAttributeBindings(elem, attributeName, attributeValue); + }; + + this.registerObserver(this, property, observer); + + // Determine the current value and add it to the render buffer + // if necessary. + attributeValue = get(this, property); + Ember.View.applyAttributeBindings(buffer, attributeName, attributeValue); + }, this); + }, + + /** + @private + + Given a property name, returns a dasherized version of that + property name if the property evaluates to a non-falsy value. + + For example, if the view has property `isUrgent` that evaluates to true, + passing `isUrgent` to this method will return `"is-urgent"`. + + @method _classStringForProperty + @param property + */ + _classStringForProperty: function(property) { + var parsedPath = Ember.View._parsePropertyPath(property); + var path = parsedPath.path; + + var val = get(this, path); + if (val === undefined && Ember.isGlobalPath(path)) { + val = get(Ember.lookup, path); + } + + return Ember.View._classStringForValue(path, val, parsedPath.className, parsedPath.falsyClassName); + }, + + // .......................................................... + // ELEMENT SUPPORT + // + + /** + Returns the current DOM element for the view. + + @property element + @type DOMElement + */ + element: Ember.computed(function(key, value) { + if (value !== undefined) { + return this.currentState.setElement(this, value); + } else { + return this.currentState.getElement(this); + } + }).property('_parentView'), + + /** + Returns a jQuery object for this view's element. If you pass in a selector + string, this method will return a jQuery object, using the current element + as its buffer. + + For example, calling `view.$('li')` will return a jQuery object containing + all of the `li` elements inside the DOM element of this view. + + @property $ + @param {String} [selector] a jQuery-compatible selector string + @return {jQuery} the CoreQuery object for the DOM node + */ + $: function(sel) { + return this.currentState.$(this, sel); + }, + + mutateChildViews: function(callback) { + var childViews = this._childViews, + idx = childViews.length, + view; + + while(--idx >= 0) { + view = childViews[idx]; + callback.call(this, view, idx); + } + + return this; + }, + + forEachChildView: function(callback) { + var childViews = this._childViews; + + if (!childViews) { return this; } + + var len = childViews.length, + view, idx; + + for(idx = 0; idx < len; idx++) { + view = childViews[idx]; + callback.call(this, view); + } + + return this; + }, + + /** + Appends the view's element to the specified parent element. + + If the view does not have an HTML representation yet, `createElement()` + will be called automatically. + + Note that this method just schedules the view to be appended; the DOM + element will not be appended to the given element until all bindings have + finished synchronizing. + + This is not typically a function that you will need to call directly when + building your application. You might consider using `Ember.ContainerView` + instead. If you do need to use `appendTo`, be sure that the target element + you are providing is associated with an `Ember.Application` and does not + have an ancestor element that is associated with an Ember view. + + @method appendTo + @param {String|DOMElement|jQuery} A selector, element, HTML string, or jQuery object + @return {Ember.View} receiver + */ + appendTo: function(target) { + // Schedule the DOM element to be created and appended to the given + // element after bindings have synchronized. + this._insertElementLater(function() { + + this.$().appendTo(target); + }); + + return this; + }, + + /** + Replaces the content of the specified parent element with this view's + element. If the view does not have an HTML representation yet, + `createElement()` will be called automatically. + + Note that this method just schedules the view to be appended; the DOM + element will not be appended to the given element until all bindings have + finished synchronizing + + @method replaceIn + @param {String|DOMElement|jQuery} A selector, element, HTML string, or jQuery object + @return {Ember.View} received + */ + replaceIn: function(target) { + + + this._insertElementLater(function() { + Ember.$(target).empty(); + this.$().appendTo(target); + }); + + return this; + }, + + /** + @private + + Schedules a DOM operation to occur during the next render phase. This + ensures that all bindings have finished synchronizing before the view is + rendered. + + To use, pass a function that performs a DOM operation. + + Before your function is called, this view and all child views will receive + the `willInsertElement` event. After your function is invoked, this view + and all of its child views will receive the `didInsertElement` event. + + ```javascript + view._insertElementLater(function() { + this.createElement(); + this.$().appendTo('body'); + }); + ``` + + @method _insertElementLater + @param {Function} fn the function that inserts the element into the DOM + */ + _insertElementLater: function(fn) { + this._scheduledInsert = Ember.run.scheduleOnce('render', this, '_insertElement', fn); + }, + + _insertElement: function (fn) { + this._scheduledInsert = null; + this.currentState.insertElement(this, fn); + }, + + /** + Appends the view's element to the document body. If the view does + not have an HTML representation yet, `createElement()` will be called + automatically. + + Note that this method just schedules the view to be appended; the DOM + element will not be appended to the document body until all bindings have + finished synchronizing. + + @method append + @return {Ember.View} receiver + */ + append: function() { + return this.appendTo(document.body); + }, + + /** + Removes the view's element from the element to which it is attached. + + @method remove + @return {Ember.View} receiver + */ + remove: function() { + // What we should really do here is wait until the end of the run loop + // to determine if the element has been re-appended to a different + // element. + // In the interim, we will just re-render if that happens. It is more + // important than elements get garbage collected. + if (!this.removedFromDOM) { this.destroyElement(); } + this.invokeRecursively(function(view) { + if (view.clearRenderedChildren) { view.clearRenderedChildren(); } + }); + }, + + elementId: null, + + /** + Attempts to discover the element in the parent element. The default + implementation looks for an element with an ID of `elementId` (or the + view's guid if `elementId` is null). You can override this method to + provide your own form of lookup. For example, if you want to discover your + element using a CSS class name instead of an ID. + + @method findElementInParentElement + @param {DOMElement} parentElement The parent's DOM element + @return {DOMElement} The discovered element + */ + findElementInParentElement: function(parentElem) { + var id = "#" + this.elementId; + return Ember.$(id)[0] || Ember.$(id, parentElem)[0]; + }, + + /** + Creates a DOM representation of the view and all of its + child views by recursively calling the `render()` method. + + After the element has been created, `didInsertElement` will + be called on this view and all of its child views. + + @method createElement + @return {Ember.View} receiver + */ + createElement: function() { + if (get(this, 'element')) { return this; } + + var buffer = this.renderToBuffer(); + set(this, 'element', buffer.element()); + + return this; + }, + + /** + Called when a view is going to insert an element into the DOM. + + @event willInsertElement + */ + willInsertElement: Ember.K, + + /** + Called when the element of the view has been inserted into the DOM. + Override this function to do any set up that requires an element in the + document body. + + @event didInsertElement + */ + didInsertElement: Ember.K, + + /** + Called when the view is about to rerender, but before anything has + been torn down. This is a good opportunity to tear down any manual + observers you have installed based on the DOM state + + @event willClearRender + */ + willClearRender: Ember.K, + + /** + @private + + Run this callback on the current view and recursively on child views. + + @method invokeRecursively + @param fn {Function} + */ + invokeRecursively: function(fn) { + var childViews = [this], currentViews, view; + + while (childViews.length) { + currentViews = childViews.slice(); + childViews = []; + + for (var i=0, l=currentViews.length; i` tag for views. + + @property tagName + @type String + @default null + */ + + // We leave this null by default so we can tell the difference between + // the default case and a user-specified tag. + tagName: null, + + /** + The WAI-ARIA role of the control represented by this view. For example, a + button may have a role of type 'button', or a pane may have a role of + type 'alertdialog'. This property is used by assistive software to help + visually challenged users navigate rich web applications. + + The full list of valid WAI-ARIA roles is available at: + http://www.w3.org/TR/wai-aria/roles#roles_categorization + + @property ariaRole + @type String + @default null + */ + ariaRole: null, + + /** + Standard CSS class names to apply to the view's outer element. This + property automatically inherits any class names defined by the view's + superclasses as well. + + @property classNames + @type Array + @default ['ember-view'] + */ + classNames: ['ember-view'], + + /** + A list of properties of the view to apply as class names. If the property + is a string value, the value of that string will be applied as a class + name. + + ```javascript + // Applies the 'high' class to the view element + Ember.View.create({ + classNameBindings: ['priority'] + priority: 'high' + }); + ``` + + If the value of the property is a Boolean, the name of that property is + added as a dasherized class name. + + ```javascript + // Applies the 'is-urgent' class to the view element + Ember.View.create({ + classNameBindings: ['isUrgent'] + isUrgent: true + }); + ``` + + If you would prefer to use a custom value instead of the dasherized + property name, you can pass a binding like this: + + ```javascript + // Applies the 'urgent' class to the view element + Ember.View.create({ + classNameBindings: ['isUrgent:urgent'] + isUrgent: true + }); + ``` + + This list of properties is inherited from the view's superclasses as well. + + @property classNameBindings + @type Array + @default [] + */ + classNameBindings: [], + + /** + A list of properties of the view to apply as attributes. If the property is + a string value, the value of that string will be applied as the attribute. + + ```javascript + // Applies the type attribute to the element + // with the value "button", like
            + Ember.View.create({ + attributeBindings: ['type'], + type: 'button' + }); + ``` + + If the value of the property is a Boolean, the name of that property is + added as an attribute. + + ```javascript + // Renders something like
            + Ember.View.create({ + attributeBindings: ['enabled'], + enabled: true + }); + ``` + + @property attributeBindings + */ + attributeBindings: [], + + // ....................................................... + // CORE DISPLAY METHODS + // + + /** + @private + + Setup a view, but do not finish waking it up. + - configure `childViews` + - register the view with the global views hash, which is used for event + dispatch + + @method init + */ + init: function() { + this.elementId = this.elementId || guidFor(this); + + this._super(); + + // setup child views. be sure to clone the child views array first + this._childViews = this._childViews.slice(); + + this.classNameBindings = Ember.A(this.classNameBindings.slice()); + + this.classNames = Ember.A(this.classNames.slice()); + + var viewController = get(this, 'viewController'); + if (viewController) { + viewController = get(viewController); + if (viewController) { + set(viewController, 'view', this); + } + } + }, + + appendChild: function(view, options) { + return this.currentState.appendChild(this, view, options); + }, + + /** + Removes the child view from the parent view. + + @method removeChild + @param {Ember.View} view + @return {Ember.View} receiver + */ + removeChild: function(view) { + // If we're destroying, the entire subtree will be + // freed, and the DOM will be handled separately, + // so no need to mess with childViews. + if (this.isDestroying) { return; } + + // update parent node + set(view, '_parentView', null); + + // remove view from childViews array. + var childViews = this._childViews; + + Ember.EnumerableUtils.removeObject(childViews, view); + + this.propertyDidChange('childViews'); // HUH?! what happened to will change? + + return this; + }, + + /** + Removes all children from the `parentView`. + + @method removeAllChildren + @return {Ember.View} receiver + */ + removeAllChildren: function() { + return this.mutateChildViews(function(view) { + this.removeChild(view); + }); + }, + + destroyAllChildren: function() { + return this.mutateChildViews(function(view) { + view.destroy(); + }); + }, + + /** + Removes the view from its `parentView`, if one is found. Otherwise + does nothing. + + @method removeFromParent + @return {Ember.View} receiver + */ + removeFromParent: function() { + var parent = this._parentView; + + // Remove DOM element from parent + this.remove(); + + if (parent) { parent.removeChild(this); } + return this; + }, + + /** + You must call `destroy` on a view to destroy the view (and all of its + child views). This will remove the view from any parent node, then make + sure that the DOM element managed by the view can be released by the + memory manager. + + @method willDestroy + */ + willDestroy: function() { + // calling this._super() will nuke computed properties and observers, + // so collect any information we need before calling super. + var childViews = this._childViews, + parent = this._parentView, + childLen, i; + + // destroy the element -- this will avoid each child view destroying + // the element over and over again... + if (!this.removedFromDOM) { this.destroyElement(); } + + childLen = childViews.length; + for (i=childLen-1; i>=0; i--) { + childViews[i].removedFromDOM = true; + } + + // remove from non-virtual parent view if viewName was specified + if (this.viewName) { + var nonVirtualParentView = get(this, 'parentView'); + if (nonVirtualParentView) { + set(nonVirtualParentView, this.viewName, null); + } + } + + // remove from parent if found. Don't call removeFromParent, + // as removeFromParent will try to remove the element from + // the DOM again. + if (parent) { parent.removeChild(this); } + + this.transitionTo('destroyed'); + + childLen = childViews.length; + for (i=childLen-1; i>=0; i--) { + childViews[i].destroy(); + } + + // next remove view from global hash + if (!this.isVirtual) delete Ember.View.views[get(this, 'elementId')]; + }, + + /** + Instantiates a view to be added to the childViews array during view + initialization. You generally will not call this method directly unless + you are overriding `createChildViews()`. Note that this method will + automatically configure the correct settings on the new view instance to + act as a child of the parent. + + @method createChildView + @param {Class} viewClass + @param {Hash} [attrs] Attributes to add + @return {Ember.View} new instance + */ + createChildView: function(view, attrs) { + if (view.isView && view._parentView === this) { return view; } + + if (Ember.CoreView.detect(view)) { + attrs = attrs || {}; + attrs._parentView = this; + attrs.templateData = attrs.templateData || get(this, 'templateData'); + + view = view.create(attrs); + + // don't set the property on a virtual view, as they are invisible to + // consumers of the view API + if (view.viewName) { set(get(this, 'concreteView'), view.viewName, view); } + } else { + + + if (attrs) { + view.setProperties(attrs); + } + + if (!get(view, 'templateData')) { + set(view, 'templateData', get(this, 'templateData')); + } + + set(view, '_parentView', this); + } + + return view; + }, + + becameVisible: Ember.K, + becameHidden: Ember.K, + + /** + @private + + When the view's `isVisible` property changes, toggle the visibility + element of the actual DOM element. + + @method _isVisibleDidChange + */ + _isVisibleDidChange: Ember.observer(function() { + var $el = this.$(); + if (!$el) { return; } + + var isVisible = get(this, 'isVisible'); + + $el.toggle(isVisible); + + if (this._isAncestorHidden()) { return; } + + if (isVisible) { + this._notifyBecameVisible(); + } else { + this._notifyBecameHidden(); + } + }, 'isVisible'), + + _notifyBecameVisible: function() { + this.trigger('becameVisible'); + + this.forEachChildView(function(view) { + var isVisible = get(view, 'isVisible'); + + if (isVisible || isVisible === null) { + view._notifyBecameVisible(); + } + }); + }, + + _notifyBecameHidden: function() { + this.trigger('becameHidden'); + this.forEachChildView(function(view) { + var isVisible = get(view, 'isVisible'); + + if (isVisible || isVisible === null) { + view._notifyBecameHidden(); + } + }); + }, + + _isAncestorHidden: function() { + var parent = get(this, 'parentView'); + + while (parent) { + if (get(parent, 'isVisible') === false) { return true; } + + parent = get(parent, 'parentView'); + } + + return false; + }, + + clearBuffer: function() { + this.invokeRecursively(function(view) { + view.buffer = null; + }); + }, + + transitionTo: function(state, children) { + this.currentState = this.states[state]; + this.state = state; + + if (children !== false) { + this.forEachChildView(function(view) { + view.transitionTo(state); + }); + } + }, + + // ....................................................... + // EVENT HANDLING + // + + /** + @private + + Handle events from `Ember.EventDispatcher` + + @method handleEvent + @param eventName {String} + @param evt {Event} + */ + handleEvent: function(eventName, evt) { + return this.currentState.handleEvent(this, eventName, evt); + }, + + registerObserver: function(root, path, target, observer) { + Ember.addObserver(root, path, target, observer); + + this.one('willClearRender', function() { + Ember.removeObserver(root, path, target, observer); + }); + } + +}); + +/* + Describe how the specified actions should behave in the various + states that a view can exist in. Possible states: + + * preRender: when a view is first instantiated, and after its + element was destroyed, it is in the preRender state + * inBuffer: once a view has been rendered, but before it has + been inserted into the DOM, it is in the inBuffer state + * inDOM: once a view has been inserted into the DOM it is in + the inDOM state. A view spends the vast majority of its + existence in this state. + * destroyed: once a view has been destroyed (using the destroy + method), it is in this state. No further actions can be invoked + on a destroyed view. +*/ + + // in the destroyed state, everything is illegal + + // before rendering has begun, all legal manipulations are noops. + + // inside the buffer, legal manipulations are done on the buffer + + // once the view has been inserted into the DOM, legal manipulations + // are done on the DOM element. + +var DOMManager = { + prepend: function(view, html) { + view.$().prepend(html); + }, + + after: function(view, html) { + view.$().after(html); + }, + + html: function(view, html) { + view.$().html(html); + }, + + replace: function(view) { + var element = get(view, 'element'); + + set(view, 'element', null); + + view._insertElementLater(function() { + Ember.$(element).replaceWith(get(view, 'element')); + }); + }, + + remove: function(view) { + view.$().remove(); + }, + + empty: function(view) { + view.$().empty(); + } +}; + +Ember.View.reopen({ + domManager: DOMManager +}); + +Ember.View.reopenClass({ + + /** + @private + + Parse a path and return an object which holds the parsed properties. + + For example a path like "content.isEnabled:enabled:disabled" wil return the + following object: + + ```javascript + { + path: "content.isEnabled", + className: "enabled", + falsyClassName: "disabled", + classNames: ":enabled:disabled" + } + ``` + + @method _parsePropertyPath + @static + */ + _parsePropertyPath: function(path) { + var split = path.split(':'), + propertyPath = split[0], + classNames = "", + className, + falsyClassName; + + // check if the property is defined as prop:class or prop:trueClass:falseClass + if (split.length > 1) { + className = split[1]; + if (split.length === 3) { falsyClassName = split[2]; } + + classNames = ':' + className; + if (falsyClassName) { classNames += ":" + falsyClassName; } + } + + return { + path: propertyPath, + classNames: classNames, + className: (className === '') ? undefined : className, + falsyClassName: falsyClassName + }; + }, + + /** + @private + + Get the class name for a given value, based on the path, optional + `className` and optional `falsyClassName`. + + - if a `className` or `falsyClassName` has been specified: + - if the value is truthy and `className` has been specified, + `className` is returned + - if the value is falsy and `falsyClassName` has been specified, + `falsyClassName` is returned + - otherwise `null` is returned + - if the value is `true`, the dasherized last part of the supplied path + is returned + - if the value is not `false`, `undefined` or `null`, the `value` + is returned + - if none of the above rules apply, `null` is returned + + @method _classStringForValue + @param path + @param val + @param className + @param falsyClassName + @static + */ + _classStringForValue: function(path, val, className, falsyClassName) { + // When using the colon syntax, evaluate the truthiness or falsiness + // of the value to determine which className to return + if (className || falsyClassName) { + if (className && !!val) { + return className; + + } else if (falsyClassName && !val) { + return falsyClassName; + + } else { + return null; + } + + // If value is a Boolean and true, return the dasherized property + // name. + } else if (val === true) { + // Normalize property path to be suitable for use + // as a class name. For exaple, content.foo.barBaz + // becomes bar-baz. + var parts = path.split('.'); + return Ember.String.dasherize(parts[parts.length-1]); + + // If the value is not false, undefined, or null, return the current + // value of the property. + } else if (val !== false && val !== undefined && val !== null) { + return val; + + // Nothing to display. Return null so that the old class is removed + // but no new class is added. + } else { + return null; + } + } +}); + +/** + Global views hash + + @property views + @static + @type Hash +*/ +Ember.View.views = {}; + +// If someone overrides the child views computed property when +// defining their class, we want to be able to process the user's +// supplied childViews and then restore the original computed property +// at view initialization time. This happens in Ember.ContainerView's init +// method. +Ember.View.childViewsProperty = childViewsProperty; + +Ember.View.applyAttributeBindings = function(elem, name, value) { + if (name === 'value') { + Ember.View.applyValueBinding(elem, value); + } else { + Ember.View.applyAttributeBinding(elem, name, value); + } +}; + +Ember.View.applyAttributeBinding = function(elem, name, value) { + var type = Ember.typeOf(value); + var currentValue = elem.attr(name); + + // if this changes, also change the logic in ember-handlebars/lib/helpers/binding.js + if ( + ( + ( type === 'string' ) || + ( type === 'number' && !isNaN(value) ) || + ( type === 'boolean' && value ) + ) && ( + value !== currentValue + ) + ) { + elem.attr(name, value); + } else if (!value) { + elem.removeAttr(name); + } +}; + +Ember.View.applyValueBinding = function(elem, value) { + var type = Ember.typeOf(value); + var currentValue = elem.val(); + + // if this changes, also change the logic in ember-handlebars/lib/helpers/binding.js + if ( + ( + ( type === 'string' ) || + ( type === 'number' && !isNaN(value) ) || + ( type === 'boolean' && value ) + ) && ( + value !== currentValue + ) + ) { + if (elem.caretPosition) { + var caretPosition = elem.caretPosition(); + elem.val(value); + elem.setCaretPosition(caretPosition); + } else { + elem.val(value); + } + } else if (!value) { + elem.val(''); + } +}; + +Ember.View.states = states; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set; + +Ember.View.states._default = { + // appendChild is only legal while rendering the buffer. + appendChild: function() { + throw "You can't use appendChild outside of the rendering process"; + }, + + $: function() { + return undefined; + }, + + getElement: function() { + return null; + }, + + // Handle events from `Ember.EventDispatcher` + handleEvent: function() { + return true; // continue event propagation + }, + + destroyElement: function(view) { + set(view, 'element', null); + if (view._scheduledInsert) { + Ember.run.cancel(view._scheduledInsert); + view._scheduledInsert = null; + } + return view; + }, + + renderToBufferIfNeeded: function () { + return false; + }, + + rerender: Ember.K +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var preRender = Ember.View.states.preRender = Ember.create(Ember.View.states._default); + +Ember.merge(preRender, { + // a view leaves the preRender state once its element has been + // created (createElement). + insertElement: function(view, fn) { + view.createElement(); + view.triggerRecursively('willInsertElement'); + // after createElement, the view will be in the hasElement state. + fn.call(view); + view.transitionTo('inDOM'); + view.triggerRecursively('didInsertElement'); + }, + + renderToBufferIfNeeded: function(view) { + return view.renderToBuffer(); + }, + + empty: Ember.K, + + setElement: function(view, value) { + if (value !== null) { + view.transitionTo('hasElement'); + } + return value; + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, meta = Ember.meta; + +var inBuffer = Ember.View.states.inBuffer = Ember.create(Ember.View.states._default); + +Ember.merge(inBuffer, { + $: function(view, sel) { + // if we don't have an element yet, someone calling this.$() is + // trying to update an element that isn't in the DOM. Instead, + // rerender the view to allow the render method to reflect the + // changes. + view.rerender(); + return Ember.$(); + }, + + // when a view is rendered in a buffer, rerendering it simply + // replaces the existing buffer with a new one + rerender: function(view) { + throw new Ember.Error("Something you did caused a view to re-render after it rendered but before it was inserted into the DOM."); + }, + + // when a view is rendered in a buffer, appending a child + // view will render that view and append the resulting + // buffer into its buffer. + appendChild: function(view, childView, options) { + var buffer = view.buffer; + + childView = view.createChildView(childView, options); + view._childViews.push(childView); + + childView.renderToBuffer(buffer); + + view.propertyDidChange('childViews'); + + return childView; + }, + + // when a view is rendered in a buffer, destroying the + // element will simply destroy the buffer and put the + // state back into the preRender state. + destroyElement: function(view) { + view.clearBuffer(); + view._notifyWillDestroyElement(); + view.transitionTo('preRender'); + + return view; + }, + + empty: function() { + + }, + + renderToBufferIfNeeded: function (view) { + return view.buffer; + }, + + // It should be impossible for a rendered view to be scheduled for + // insertion. + insertElement: function() { + throw "You can't insert an element that has already been rendered"; + }, + + setElement: function(view, value) { + if (value === null) { + view.transitionTo('preRender'); + } else { + view.clearBuffer(); + view.transitionTo('hasElement'); + } + + return value; + } +}); + + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, meta = Ember.meta; + +var hasElement = Ember.View.states.hasElement = Ember.create(Ember.View.states._default); + +Ember.merge(hasElement, { + $: function(view, sel) { + var elem = get(view, 'element'); + return sel ? Ember.$(sel, elem) : Ember.$(elem); + }, + + getElement: function(view) { + var parent = get(view, 'parentView'); + if (parent) { parent = get(parent, 'element'); } + if (parent) { return view.findElementInParentElement(parent); } + return Ember.$("#" + get(view, 'elementId'))[0]; + }, + + setElement: function(view, value) { + if (value === null) { + view.transitionTo('preRender'); + } else { + throw "You cannot set an element to a non-null value when the element is already in the DOM."; + } + + return value; + }, + + // once the view has been inserted into the DOM, rerendering is + // deferred to allow bindings to synchronize. + rerender: function(view) { + view.triggerRecursively('willClearRender'); + + view.clearRenderedChildren(); + + view.domManager.replace(view); + return view; + }, + + // once the view is already in the DOM, destroying it removes it + // from the DOM, nukes its element, and puts it back into the + // preRender state if inDOM. + + destroyElement: function(view) { + view._notifyWillDestroyElement(); + view.domManager.remove(view); + set(view, 'element', null); + if (view._scheduledInsert) { + Ember.run.cancel(view._scheduledInsert); + view._scheduledInsert = null; + } + return view; + }, + + empty: function(view) { + var _childViews = view._childViews, len, idx; + if (_childViews) { + len = _childViews.length; + for (idx = 0; idx < len; idx++) { + _childViews[idx]._notifyWillDestroyElement(); + } + } + view.domManager.empty(view); + }, + + // Handle events from `Ember.EventDispatcher` + handleEvent: function(view, eventName, evt) { + if (view.has(eventName)) { + // Handler should be able to re-dispatch events, so we don't + // preventDefault or stopPropagation. + return view.trigger(eventName, evt); + } else { + return true; // continue event propagation + } + } +}); + +var inDOM = Ember.View.states.inDOM = Ember.create(hasElement); + +Ember.merge(inDOM, { + insertElement: function(view, fn) { + throw "You can't insert an element into the DOM that has already been inserted"; + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-views +*/ + +var destroyedError = "You can't call %@ on a destroyed view", fmt = Ember.String.fmt; + +var destroyed = Ember.View.states.destroyed = Ember.create(Ember.View.states._default); + +Ember.merge(destroyed, { + appendChild: function() { + throw fmt(destroyedError, ['appendChild']); + }, + rerender: function() { + throw fmt(destroyedError, ['rerender']); + }, + destroyElement: function() { + throw fmt(destroyedError, ['destroyElement']); + }, + empty: function() { + throw fmt(destroyedError, ['empty']); + }, + + setElement: function() { + throw fmt(destroyedError, ["set('element', ...)"]); + }, + + renderToBufferIfNeeded: function() { + throw fmt(destroyedError, ["renderToBufferIfNeeded"]); + }, + + // Since element insertion is scheduled, don't do anything if + // the view has been destroyed between scheduling and execution + insertElement: Ember.K +}); + + +})(); + + + +(function() { +Ember.View.cloneStates = function(from) { + var into = {}; + + into._default = {}; + into.preRender = Ember.create(into._default); + into.destroyed = Ember.create(into._default); + into.inBuffer = Ember.create(into._default); + into.hasElement = Ember.create(into._default); + into.inDOM = Ember.create(into.hasElement); + + var viewState; + + for (var stateName in from) { + if (!from.hasOwnProperty(stateName)) { continue; } + Ember.merge(into[stateName], from[stateName]); + } + + return into; +}; + +})(); + + + +(function() { +var states = Ember.View.cloneStates(Ember.View.states); + +/** +@module ember +@submodule ember-views +*/ + +var get = Ember.get, set = Ember.set, meta = Ember.meta; +var forEach = Ember.EnumerableUtils.forEach; + +/** + A `ContainerView` is an `Ember.View` subclass that implements `Ember.MutableArray` + allowing programatic management of its child views. + + ## Setting Initial Child Views + + The initial array of child views can be set in one of two ways. You can + provide a `childViews` property at creation time that contains instance of + `Ember.View`: + + ```javascript + aContainer = Ember.ContainerView.create({ + childViews: [Ember.View.create(), Ember.View.create()] + }); + ``` + + You can also provide a list of property names whose values are instances of + `Ember.View`: + + ```javascript + aContainer = Ember.ContainerView.create({ + childViews: ['aView', 'bView', 'cView'], + aView: Ember.View.create(), + bView: Ember.View.create(), + cView: Ember.View.create() + }); + ``` + + The two strategies can be combined: + + ```javascript + aContainer = Ember.ContainerView.create({ + childViews: ['aView', Ember.View.create()], + aView: Ember.View.create() + }); + ``` + + Each child view's rendering will be inserted into the container's rendered + HTML in the same order as its position in the `childViews` property. + + ## Adding and Removing Child Views + + The container view implements `Ember.MutableArray` allowing programatic management of its child views. + + To remove a view, pass that view into a `removeObject` call on the container view. + + Given an empty `` the following code + + ```javascript + aContainer = Ember.ContainerView.create({ + classNames: ['the-container'], + childViews: ['aView', 'bView'], + aView: Ember.View.create({ + template: Ember.Handlebars.compile("A") + }), + bView: Ember.View.create({ + template: Ember.Handlebars.compile("B") + }) + }); + + aContainer.appendTo('body'); + ``` + + Results in the HTML + + ```html +
            +
            A
            +
            B
            +
            + ``` + + Removing a view + + ```javascript + aContainer.toArray(); // [aContainer.aView, aContainer.bView] + aContainer.removeObject(aContainer.get('bView')); + aContainer.toArray(); // [aContainer.aView] + ``` + + Will result in the following HTML + + ```html +
            +
            A
            +
            + ``` + + Similarly, adding a child view is accomplished by adding `Ember.View` instances to the + container view. + + Given an empty `` the following code + + ```javascript + aContainer = Ember.ContainerView.create({ + classNames: ['the-container'], + childViews: ['aView', 'bView'], + aView: Ember.View.create({ + template: Ember.Handlebars.compile("A") + }), + bView: Ember.View.create({ + template: Ember.Handlebars.compile("B") + }) + }); + + aContainer.appendTo('body'); + ``` + + Results in the HTML + + ```html +
            +
            A
            +
            B
            +
            + ``` + + Adding a view + + ```javascript + AnotherViewClass = Ember.View.extend({ + template: Ember.Handlebars.compile("Another view") + }); + + aContainer.toArray(); // [aContainer.aView, aContainer.bView] + aContainer.pushObject(AnotherViewClass.create()); + aContainer.toArray(); // [aContainer.aView, aContainer.bView, ] + ``` + + Will result in the following HTML + + ```html +
            +
            A
            +
            B
            +
            Another view
            +
            + ``` + + ## Templates and Layout + + A `template`, `templateName`, `defaultTemplate`, `layout`, `layoutName` or + `defaultLayout` property on a container view will not result in the template + or layout being rendered. The HTML contents of a `Ember.ContainerView`'s DOM + representation will only be the rendered HTML of its child views. + + ## Binding a View to Display + + If you would like to display a single view in your ContainerView, you can set + its `currentView` property. When the `currentView` property is set to a view + instance, it will be added to the ContainerView. If the `currentView` property + is later changed to a different view, the new view will replace the old view. + If `currentView` is set to `null`, the last `currentView` will be removed. + + This functionality is useful for cases where you want to bind the display of + a ContainerView to a controller or state manager. For example, you can bind + the `currentView` of a container to a controller like this: + + ```javascript + App.appController = Ember.Object.create({ + view: Ember.View.create({ + templateName: 'person_template' + }) + }); + ``` + + ```handlebars + {{view Ember.ContainerView currentViewBinding="App.appController.view"}} + ``` + + @class ContainerView + @namespace Ember + @extends Ember.View +*/ +Ember.ContainerView = Ember.View.extend(Ember.MutableArray, { + states: states, + + init: function() { + this._super(); + + var childViews = get(this, 'childViews'); + + // redefine view's childViews property that was obliterated + Ember.defineProperty(this, 'childViews', Ember.View.childViewsProperty); + + var _childViews = this._childViews; + + forEach(childViews, function(viewName, idx) { + var view; + + if ('string' === typeof viewName) { + view = get(this, viewName); + view = this.createChildView(view); + set(this, viewName, view); + } else { + view = this.createChildView(viewName); + } + + _childViews[idx] = view; + }, this); + + var currentView = get(this, 'currentView'); + if (currentView) { + _childViews.push(this.createChildView(currentView)); + } + }, + + replace: function(idx, removedCount, addedViews) { + var addedCount = addedViews ? get(addedViews, 'length') : 0; + + this.arrayContentWillChange(idx, removedCount, addedCount); + this.childViewsWillChange(this._childViews, idx, removedCount); + + if (addedCount === 0) { + this._childViews.splice(idx, removedCount) ; + } else { + var args = [idx, removedCount].concat(addedViews); + this._childViews.splice.apply(this._childViews, args); + } + + this.arrayContentDidChange(idx, removedCount, addedCount); + this.childViewsDidChange(this._childViews, idx, removedCount, addedCount); + + return this; + }, + + objectAt: function(idx) { + return this._childViews[idx]; + }, + + length: Ember.computed(function () { + return this._childViews.length; + }), + + /** + @private + + Instructs each child view to render to the passed render buffer. + + @method render + @param {Ember.RenderBuffer} buffer the buffer to render to + */ + render: function(buffer) { + this.forEachChildView(function(view) { + view.renderToBuffer(buffer); + }); + }, + + instrumentName: 'render.container', + + /** + @private + + When a child view is removed, destroy its element so that + it is removed from the DOM. + + The array observer that triggers this action is set up in the + `renderToBuffer` method. + + @method childViewsWillChange + @param {Ember.Array} views the child views array before mutation + @param {Number} start the start position of the mutation + @param {Number} removed the number of child views removed + **/ + childViewsWillChange: function(views, start, removed) { + this.propertyWillChange('childViews'); + + if (removed > 0) { + var changedViews = views.slice(start, start+removed); + // transition to preRender before clearing parentView + this.currentState.childViewsWillChange(this, views, start, removed); + this.initializeViews(changedViews, null, null); + } + }, + + removeChild: function(child) { + this.removeObject(child); + return this; + }, + + /** + @private + + When a child view is added, make sure the DOM gets updated appropriately. + + If the view has already rendered an element, we tell the child view to + create an element and insert it into the DOM. If the enclosing container + view has already written to a buffer, but not yet converted that buffer + into an element, we insert the string representation of the child into the + appropriate place in the buffer. + + @method childViewsDidChange + @param {Ember.Array} views the array of child views afte the mutation has occurred + @param {Number} start the start position of the mutation + @param {Number} removed the number of child views removed + @param {Number} the number of child views added + */ + childViewsDidChange: function(views, start, removed, added) { + if (added > 0) { + var changedViews = views.slice(start, start+added); + this.initializeViews(changedViews, this, get(this, 'templateData')); + this.currentState.childViewsDidChange(this, views, start, added); + } + this.propertyDidChange('childViews'); + }, + + initializeViews: function(views, parentView, templateData) { + forEach(views, function(view) { + set(view, '_parentView', parentView); + + if (!get(view, 'templateData')) { + set(view, 'templateData', templateData); + } + }); + }, + + currentView: null, + + _currentViewWillChange: Ember.beforeObserver(function() { + var currentView = get(this, 'currentView'); + if (currentView) { + currentView.destroy(); + } + }, 'currentView'), + + _currentViewDidChange: Ember.observer(function() { + var currentView = get(this, 'currentView'); + if (currentView) { + this.pushObject(currentView); + } + }, 'currentView'), + + _ensureChildrenAreInDOM: function () { + this.currentState.ensureChildrenAreInDOM(this); + } +}); + +Ember.merge(states._default, { + childViewsWillChange: Ember.K, + childViewsDidChange: Ember.K, + ensureChildrenAreInDOM: Ember.K +}); + +Ember.merge(states.inBuffer, { + childViewsDidChange: function(parentView, views, start, added) { + throw new Error('You cannot modify child views while in the inBuffer state'); + } +}); + +Ember.merge(states.hasElement, { + childViewsWillChange: function(view, views, start, removed) { + for (var i=start; i` and the following code: + + ```javascript + someItemsView = Ember.CollectionView.create({ + classNames: ['a-collection'], + content: ['A','B','C'], + itemViewClass: Ember.View.extend({ + template: Ember.Handlebars.compile("the letter: {{view.content}}") + }) + }); + + someItemsView.appendTo('body'); + ``` + + Will result in the following HTML structure + + ```html +
            +
            the letter: A
            +
            the letter: B
            +
            the letter: C
            +
            + ``` + + ## Automatic matching of parent/child tagNames + + Setting the `tagName` property of a `CollectionView` to any of + "ul", "ol", "table", "thead", "tbody", "tfoot", "tr", or "select" will result + in the item views receiving an appropriately matched `tagName` property. + + Given an empty `` and the following code: + + ```javascript + anUndorderedListView = Ember.CollectionView.create({ + tagName: 'ul', + content: ['A','B','C'], + itemViewClass: Ember.View.extend({ + template: Ember.Handlebars.compile("the letter: {{view.content}}") + }) + }); + + anUndorderedListView.appendTo('body'); + ``` + + Will result in the following HTML structure + + ```html +
              +
            • the letter: A
            • +
            • the letter: B
            • +
            • the letter: C
            • +
            + ``` + + Additional `tagName` pairs can be provided by adding to + `Ember.CollectionView.CONTAINER_MAP ` + + ```javascript + Ember.CollectionView.CONTAINER_MAP['article'] = 'section' + ``` + + ## Programatic creation of child views + + For cases where additional customization beyond the use of a single + `itemViewClass` or `tagName` matching is required CollectionView's + `createChildView` method can be overidden: + + ```javascript + CustomCollectionView = Ember.CollectionView.extend({ + createChildView: function(viewClass, attrs) { + if (attrs.content.kind == 'album') { + viewClass = App.AlbumView; + } else { + viewClass = App.SongView; + } + this._super(viewClass, attrs); + } + }); + ``` + + ## Empty View + + You can provide an `Ember.View` subclass to the `Ember.CollectionView` + instance as its `emptyView` property. If the `content` property of a + `CollectionView` is set to `null` or an empty array, an instance of this view + will be the `CollectionView`s only child. + + ```javascript + aListWithNothing = Ember.CollectionView.create({ + classNames: ['nothing'] + content: null, + emptyView: Ember.View.extend({ + template: Ember.Handlebars.compile("The collection is empty") + }) + }); + + aListWithNothing.appendTo('body'); + ``` + + Will result in the following HTML structure + + ```html +
            +
            + The collection is empty +
            +
            + ``` + + ## Adding and Removing items + + The `childViews` property of a `CollectionView` should not be directly + manipulated. Instead, add, remove, replace items from its `content` property. + This will trigger appropriate changes to its rendered HTML. + + ## Use in templates via the `{{collection}}` `Ember.Handlebars` helper + + `Ember.Handlebars` provides a helper specifically for adding + `CollectionView`s to templates. See `Ember.Handlebars.collection` for more + details + + @class CollectionView + @namespace Ember + @extends Ember.ContainerView + @since Ember 0.9 +*/ +Ember.CollectionView = Ember.ContainerView.extend( +/** @scope Ember.CollectionView.prototype */ { + + /** + A list of items to be displayed by the `Ember.CollectionView`. + + @property content + @type Ember.Array + @default null + */ + content: null, + + /** + @private + + This provides metadata about what kind of empty view class this + collection would like if it is being instantiated from another + system (like Handlebars) + + @property emptyViewClass + */ + emptyViewClass: Ember.View, + + /** + An optional view to display if content is set to an empty array. + + @property emptyView + @type Ember.View + @default null + */ + emptyView: null, + + /** + @property itemViewClass + @type Ember.View + @default Ember.View + */ + itemViewClass: Ember.View, + + init: function() { + var ret = this._super(); + this._contentDidChange(); + return ret; + }, + + _contentWillChange: Ember.beforeObserver(function() { + var content = this.get('content'); + + if (content) { content.removeArrayObserver(this); } + var len = content ? get(content, 'length') : 0; + this.arrayWillChange(content, 0, len); + }, 'content'), + + /** + @private + + Check to make sure that the content has changed, and if so, + update the children directly. This is always scheduled + asynchronously, to allow the element to be created before + bindings have synchronized and vice versa. + + @method _contentDidChange + */ + _contentDidChange: Ember.observer(function() { + var content = get(this, 'content'); + + if (content) { + + content.addArrayObserver(this); + } + + var len = content ? get(content, 'length') : 0; + this.arrayDidChange(content, 0, null, len); + }, 'content'), + + willDestroy: function() { + var content = get(this, 'content'); + if (content) { content.removeArrayObserver(this); } + + this._super(); + + if (this._createdEmptyView) { + this._createdEmptyView.destroy(); + } + }, + + arrayWillChange: function(content, start, removedCount) { + // If the contents were empty before and this template collection has an + // empty view remove it now. + var emptyView = get(this, 'emptyView'); + if (emptyView && emptyView instanceof Ember.View) { + emptyView.removeFromParent(); + } + + // Loop through child views that correspond with the removed items. + // Note that we loop from the end of the array to the beginning because + // we are mutating it as we go. + var childViews = this._childViews, childView, idx, len; + + len = this._childViews.length; + + var removingAll = removedCount === len; + + if (removingAll) { + this.currentState.empty(this); + } + + for (idx = start + removedCount - 1; idx >= start; idx--) { + childView = childViews[idx]; + if (removingAll) { childView.removedFromDOM = true; } + childView.destroy(); + } + }, + + /** + Called when a mutation to the underlying content array occurs. + + This method will replay that mutation against the views that compose the + `Ember.CollectionView`, ensuring that the view reflects the model. + + This array observer is added in `contentDidChange`. + + @method arrayDidChange + @param {Array} addedObjects the objects that were added to the content + @param {Array} removedObjects the objects that were removed from the content + @param {Number} changeIndex the index at which the changes occurred + */ + arrayDidChange: function(content, start, removed, added) { + var itemViewClass = get(this, 'itemViewClass'), + addedViews = [], view, item, idx, len, itemTagName; + + if ('string' === typeof itemViewClass) { + itemViewClass = get(itemViewClass); + } + + + len = content ? get(content, 'length') : 0; + if (len) { + for (idx = start; idx < start+added; idx++) { + item = content.objectAt(idx); + + view = this.createChildView(itemViewClass, { + content: item, + contentIndex: idx + }); + + addedViews.push(view); + } + } else { + var emptyView = get(this, 'emptyView'); + if (!emptyView) { return; } + + var isClass = Ember.CoreView.detect(emptyView); + + emptyView = this.createChildView(emptyView); + addedViews.push(emptyView); + set(this, 'emptyView', emptyView); + + if (isClass) { this._createdEmptyView = emptyView; } + } + this.replace(start, 0, addedViews); + }, + + createChildView: function(view, attrs) { + view = this._super(view, attrs); + + var itemTagName = get(view, 'tagName'); + var tagName = (itemTagName === null || itemTagName === undefined) ? Ember.CollectionView.CONTAINER_MAP[get(this, 'tagName')] : itemTagName; + + set(view, 'tagName', tagName); + + return view; + } +}); + +/** + A map of parent tags to their default child tags. You can add + additional parent tags if you want collection views that use + a particular parent tag to default to a child tag. + + @property CONTAINER_MAP + @type Hash + @static + @final +*/ +Ember.CollectionView.CONTAINER_MAP = { + ul: 'li', + ol: 'li', + table: 'tr', + thead: 'tr', + tbody: 'tr', + tfoot: 'tr', + tr: 'td', + select: 'option' +}; + +})(); + + + +(function() { + +})(); + + + +(function() { +/*globals jQuery*/ +/** +Ember Views + +@module ember +@submodule ember-views +@requires ember-runtime +@main ember-views +*/ + +})(); + +(function() { +define("metamorph", + [], + function() { + "use strict"; + // ========================================================================== + // Project: metamorph + // Copyright: ©2011 My Company Inc. All rights reserved. + // ========================================================================== + + var K = function(){}, + guid = 0, + document = window.document, + + // Feature-detect the W3C range API, the extended check is for IE9 which only partially supports ranges + supportsRange = ('createRange' in document) && (typeof Range !== 'undefined') && Range.prototype.createContextualFragment, + + // Internet Explorer prior to 9 does not allow setting innerHTML if the first element + // is a "zero-scope" element. This problem can be worked around by making + // the first node an invisible text node. We, like Modernizr, use ­ + needsShy = (function(){ + var testEl = document.createElement('div'); + testEl.innerHTML = "
            "; + testEl.firstChild.innerHTML = ""; + return testEl.firstChild.innerHTML === ''; + })(), + + + // IE 8 (and likely earlier) likes to move whitespace preceeding + // a script tag to appear after it. This means that we can + // accidentally remove whitespace when updating a morph. + movesWhitespace = (function() { + var testEl = document.createElement('div'); + testEl.innerHTML = "Test: Value"; + return testEl.childNodes[0].nodeValue === 'Test:' && + testEl.childNodes[2].nodeValue === ' Value'; + })(); + + // Constructor that supports either Metamorph('foo') or new + // Metamorph('foo'); + // + // Takes a string of HTML as the argument. + + var Metamorph = function(html) { + var self; + + if (this instanceof Metamorph) { + self = this; + } else { + self = new K(); + } + + self.innerHTML = html; + var myGuid = 'metamorph-'+(guid++); + self.start = myGuid + '-start'; + self.end = myGuid + '-end'; + + return self; + }; + + K.prototype = Metamorph.prototype; + + var rangeFor, htmlFunc, removeFunc, outerHTMLFunc, appendToFunc, afterFunc, prependFunc, startTagFunc, endTagFunc; + + outerHTMLFunc = function() { + return this.startTag() + this.innerHTML + this.endTag(); + }; + + startTagFunc = function() { + /* + * We replace chevron by its hex code in order to prevent escaping problems. + * Check this thread for more explaination: + * http://stackoverflow.com/questions/8231048/why-use-x3c-instead-of-when-generating-html-from-javascript + */ + return "
            hi
            "; + * div.firstChild.firstChild.tagName //=> "" + * + * If our script markers are inside such a node, we need to find that + * node and use *it* as the marker. + **/ + var realNode = function(start) { + while (start.parentNode.tagName === "") { + start = start.parentNode; + } + + return start; + }; + + /** + * When automatically adding a tbody, Internet Explorer inserts the + * tbody immediately before the first . Other browsers create it + * before the first node, no matter what. + * + * This means the the following code: + * + * div = document.createElement("div"); + * div.innerHTML = "
            hi
            + * + * Generates the following DOM in IE: + * + * + div + * + table + * - script id='first' + * + tbody + * + tr + * + td + * - "hi" + * - script id='last' + * + * Which means that the two script tags, even though they were + * inserted at the same point in the hierarchy in the original + * HTML, now have different parents. + * + * This code reparents the first script tag by making it the tbody's + * first child. + **/ + var fixParentage = function(start, end) { + if (start.parentNode !== end.parentNode) { + end.parentNode.insertBefore(start, end.parentNode.firstChild); + } + }; + + htmlFunc = function(html, outerToo) { + // get the real starting node. see realNode for details. + var start = realNode(document.getElementById(this.start)); + var end = document.getElementById(this.end); + var parentNode = end.parentNode; + var node, nextSibling, last; + + // make sure that the start and end nodes share the same + // parent. If not, fix it. + fixParentage(start, end); + + // remove all of the nodes after the starting placeholder and + // before the ending placeholder. + node = start.nextSibling; + while (node) { + nextSibling = node.nextSibling; + last = node === end; + + // if this is the last node, and we want to remove it as well, + // set the `end` node to the next sibling. This is because + // for the rest of the function, we insert the new nodes + // before the end (note that insertBefore(node, null) is + // the same as appendChild(node)). + // + // if we do not want to remove it, just break. + if (last) { + if (outerToo) { end = node.nextSibling; } else { break; } + } + + node.parentNode.removeChild(node); + + // if this is the last node and we didn't break before + // (because we wanted to remove the outer nodes), break + // now. + if (last) { break; } + + node = nextSibling; + } + + // get the first node for the HTML string, even in cases like + // tables and lists where a simple innerHTML on a div would + // swallow some of the content. + node = firstNodeFor(start.parentNode, html); + + // copy the nodes for the HTML between the starting and ending + // placeholder. + while (node) { + nextSibling = node.nextSibling; + parentNode.insertBefore(node, end); + node = nextSibling; + } + }; + + // remove the nodes in the DOM representing this metamorph. + // + // this includes the starting and ending placeholders. + removeFunc = function() { + var start = realNode(document.getElementById(this.start)); + var end = document.getElementById(this.end); + + this.html(''); + start.parentNode.removeChild(start); + end.parentNode.removeChild(end); + }; + + appendToFunc = function(parentNode) { + var node = firstNodeFor(parentNode, this.outerHTML()); + var nextSibling; + + while (node) { + nextSibling = node.nextSibling; + parentNode.appendChild(node); + node = nextSibling; + } + }; + + afterFunc = function(html) { + // get the real starting node. see realNode for details. + var end = document.getElementById(this.end); + var insertBefore = end.nextSibling; + var parentNode = end.parentNode; + var nextSibling; + var node; + + // get the first node for the HTML string, even in cases like + // tables and lists where a simple innerHTML on a div would + // swallow some of the content. + node = firstNodeFor(parentNode, html); + + // copy the nodes for the HTML between the starting and ending + // placeholder. + while (node) { + nextSibling = node.nextSibling; + parentNode.insertBefore(node, insertBefore); + node = nextSibling; + } + }; + + prependFunc = function(html) { + var start = document.getElementById(this.start); + var parentNode = start.parentNode; + var nextSibling; + var node; + + node = firstNodeFor(parentNode, html); + var insertBefore = start.nextSibling; + + while (node) { + nextSibling = node.nextSibling; + parentNode.insertBefore(node, insertBefore); + node = nextSibling; + } + }; + } + + Metamorph.prototype.html = function(html) { + this.checkRemoved(); + if (html === undefined) { return this.innerHTML; } + + htmlFunc.call(this, html); + + this.innerHTML = html; + }; + + Metamorph.prototype.replaceWith = function(html) { + this.checkRemoved(); + htmlFunc.call(this, html, true); + }; + + Metamorph.prototype.remove = removeFunc; + Metamorph.prototype.outerHTML = outerHTMLFunc; + Metamorph.prototype.appendTo = appendToFunc; + Metamorph.prototype.after = afterFunc; + Metamorph.prototype.prepend = prependFunc; + Metamorph.prototype.startTag = startTagFunc; + Metamorph.prototype.endTag = endTagFunc; + + Metamorph.prototype.isRemoved = function() { + var before = document.getElementById(this.start); + var after = document.getElementById(this.end); + + return !before || !after; + }; + + Metamorph.prototype.checkRemoved = function() { + if (this.isRemoved()) { + throw new Error("Cannot perform operations on a Metamorph that is not in the DOM."); + } + }; + + return Metamorph; + }); + +})(); + +(function() { +/** +@module ember +@submodule ember-handlebars +*/ + +// Eliminate dependency on any Ember to simplify precompilation workflow +var objectCreate = Object.create || function(parent) { + function F() {} + F.prototype = parent; + return new F(); +}; + +var Handlebars = this.Handlebars || Ember.imports.Handlebars; + + +/** + Prepares the Handlebars templating library for use inside Ember's view + system. + + The `Ember.Handlebars` object is the standard Handlebars library, extended to + use Ember's `get()` method instead of direct property access, which allows + computed properties to be used inside templates. + + To create an `Ember.Handlebars` template, call `Ember.Handlebars.compile()`. + This will return a function that can be used by `Ember.View` for rendering. + + @class Handlebars + @namespace Ember +*/ +Ember.Handlebars = objectCreate(Handlebars); + +/** +@class helpers +@namespace Ember.Handlebars +*/ +Ember.Handlebars.helpers = objectCreate(Handlebars.helpers); + +/** + Override the the opcode compiler and JavaScript compiler for Handlebars. + + @class Compiler + @namespace Ember.Handlebars + @private + @constructor +*/ +Ember.Handlebars.Compiler = function() {}; + +// Handlebars.Compiler doesn't exist in runtime-only +if (Handlebars.Compiler) { + Ember.Handlebars.Compiler.prototype = objectCreate(Handlebars.Compiler.prototype); +} + +Ember.Handlebars.Compiler.prototype.compiler = Ember.Handlebars.Compiler; + +/** + @class JavaScriptCompiler + @namespace Ember.Handlebars + @private + @constructor +*/ +Ember.Handlebars.JavaScriptCompiler = function() {}; + +// Handlebars.JavaScriptCompiler doesn't exist in runtime-only +if (Handlebars.JavaScriptCompiler) { + Ember.Handlebars.JavaScriptCompiler.prototype = objectCreate(Handlebars.JavaScriptCompiler.prototype); + Ember.Handlebars.JavaScriptCompiler.prototype.compiler = Ember.Handlebars.JavaScriptCompiler; +} + + +Ember.Handlebars.JavaScriptCompiler.prototype.namespace = "Ember.Handlebars"; + + +Ember.Handlebars.JavaScriptCompiler.prototype.initializeBuffer = function() { + return "''"; +}; + +/** + @private + + Override the default buffer for Ember Handlebars. By default, Handlebars + creates an empty String at the beginning of each invocation and appends to + it. Ember's Handlebars overrides this to append to a single shared buffer. + + @method appendToBuffer + @param string {String} +*/ +Ember.Handlebars.JavaScriptCompiler.prototype.appendToBuffer = function(string) { + return "data.buffer.push("+string+");"; +}; + +var prefix = "ember" + (+new Date()), incr = 1; + +/** + @private + + Rewrite simple mustaches from `{{foo}}` to `{{bind "foo"}}`. This means that + all simple mustaches in Ember's Handlebars will also set up an observer to + keep the DOM up to date when the underlying property changes. + + @method mustache + @for Ember.Handlebars.Compiler + @param mustache +*/ +Ember.Handlebars.Compiler.prototype.mustache = function(mustache) { + if (mustache.isHelper && mustache.id.string === 'control') { + mustache.hash = mustache.hash || new Handlebars.AST.HashNode([]); + mustache.hash.pairs.push(["controlID", new Handlebars.AST.StringNode(prefix + incr++)]); + } else if (mustache.params.length || mustache.hash) { + // no changes required + } else { + var id = new Handlebars.AST.IdNode(['_triageMustache']); + + // Update the mustache node to include a hash value indicating whether the original node + // was escaped. This will allow us to properly escape values when the underlying value + // changes and we need to re-render the value. + if(!mustache.escaped) { + mustache.hash = mustache.hash || new Handlebars.AST.HashNode([]); + mustache.hash.pairs.push(["unescaped", new Handlebars.AST.StringNode("true")]); + } + mustache = new Handlebars.AST.MustacheNode([id].concat([mustache.id]), mustache.hash, !mustache.escaped); + } + + return Handlebars.Compiler.prototype.mustache.call(this, mustache); +}; + +/** + Used for precompilation of Ember Handlebars templates. This will not be used + during normal app execution. + + @method precompile + @for Ember.Handlebars + @static + @param {String} string The template to precompile +*/ +Ember.Handlebars.precompile = function(string) { + var ast = Handlebars.parse(string); + + var options = { + knownHelpers: { + action: true, + unbound: true, + bindAttr: true, + template: true, + view: true, + _triageMustache: true + }, + data: true, + stringParams: true + }; + + var environment = new Ember.Handlebars.Compiler().compile(ast, options); + return new Ember.Handlebars.JavaScriptCompiler().compile(environment, options, undefined, true); +}; + +// We don't support this for Handlebars runtime-only +if (Handlebars.compile) { + /** + The entry point for Ember Handlebars. This replaces the default + `Handlebars.compile` and turns on template-local data and String + parameters. + + @method compile + @for Ember.Handlebars + @static + @param {String} string The template to compile + @return {Function} + */ + Ember.Handlebars.compile = function(string) { + var ast = Handlebars.parse(string); + var options = { data: true, stringParams: true }; + var environment = new Ember.Handlebars.Compiler().compile(ast, options); + var templateSpec = new Ember.Handlebars.JavaScriptCompiler().compile(environment, options, undefined, true); + + return Ember.Handlebars.template(templateSpec); + }; +} + + +})(); + +(function() { +var slice = Array.prototype.slice; + +/** + @private + + If a path starts with a reserved keyword, returns the root + that should be used. + + @method normalizePath + @for Ember + @param root {Object} + @param path {String} + @param data {Hash} +*/ +var normalizePath = Ember.Handlebars.normalizePath = function(root, path, data) { + var keywords = (data && data.keywords) || {}, + keyword, isKeyword; + + // Get the first segment of the path. For example, if the + // path is "foo.bar.baz", returns "foo". + keyword = path.split('.', 1)[0]; + + // Test to see if the first path is a keyword that has been + // passed along in the view's data hash. If so, we will treat + // that object as the new root. + if (keywords.hasOwnProperty(keyword)) { + // Look up the value in the template's data hash. + root = keywords[keyword]; + isKeyword = true; + + // Handle cases where the entire path is the reserved + // word. In that case, return the object itself. + if (path === keyword) { + path = ''; + } else { + // Strip the keyword from the path and look up + // the remainder from the newly found root. + path = path.substr(keyword.length+1); + } + } + + return { root: root, path: path, isKeyword: isKeyword }; +}; + + +/** + Lookup both on root and on window. If the path starts with + a keyword, the corresponding object will be looked up in the + template's data hash and used to resolve the path. + + @method get + @for Ember.Handlebars + @param {Object} root The object to look up the property on + @param {String} path The path to be lookedup + @param {Object} options The template's option hash +*/ +var handlebarsGet = Ember.Handlebars.get = function(root, path, options) { + var data = options && options.data, + normalizedPath = normalizePath(root, path, data), + value; + + // In cases where the path begins with a keyword, change the + // root to the value represented by that keyword, and ensure + // the path is relative to it. + root = normalizedPath.root; + path = normalizedPath.path; + + value = Ember.get(root, path); + + // If the path starts with a capital letter, look it up on Ember.lookup, + // which defaults to the `window` object in browsers. + if (value === undefined && root !== Ember.lookup && Ember.isGlobalPath(path)) { + value = Ember.get(Ember.lookup, path); + } + return value; +}; +Ember.Handlebars.getPath = Ember.deprecateFunc('`Ember.Handlebars.getPath` has been changed to `Ember.Handlebars.get` for consistency.', Ember.Handlebars.get); + +Ember.Handlebars.resolveParams = function(context, params, options) { + var resolvedParams = [], types = options.types, param, type; + + for (var i=0, l=params.length; i + ``` + + The above handlebars template will fill the ``'s `src` attribute will + the value of the property referenced with `"imageUrl"` and its `alt` + attribute with the value of the property referenced with `"imageTitle"`. + + If the rendering context of this template is the following object: + + ```javascript + { + imageUrl: 'http://lolcats.info/haz-a-funny', + imageTitle: 'A humorous image of a cat' + } + ``` + + The resulting HTML output will be: + + ```html + A humorous image of a cat + ``` + + `bindAttr` cannot redeclare existing DOM element attributes. The use of `src` + in the following `bindAttr` example will be ignored and the hard coded value + of `src="/failwhale.gif"` will take precedence: + + ```handlebars + imageTitle + ``` + + ### `bindAttr` and the `class` attribute + + `bindAttr` supports a special syntax for handling a number of cases unique + to the `class` DOM element attribute. The `class` attribute combines + multiple discreet values into a single attribute as a space-delimited + list of strings. Each string can be: + + * a string return value of an object's property. + * a boolean return value of an object's property + * a hard-coded value + + A string return value works identically to other uses of `bindAttr`. The + return value of the property will become the value of the attribute. For + example, the following view and template: + + ```javascript + AView = Ember.View.extend({ + someProperty: function(){ + return "aValue"; + }.property() + }) + ``` + + ```handlebars + + ``` + + A boolean return value will insert a specified class name if the property + returns `true` and remove the class name if the property returns `false`. + + A class name is provided via the syntax + `somePropertyName:class-name-if-true`. + + ```javascript + AView = Ember.View.extend({ + someBool: true + }) + ``` + + ```handlebars + + ``` + + Result in the following rendered output: + + ```html + + ``` + + An additional section of the binding can be provided if you want to + replace the existing class instead of removing it when the boolean + value changes: + + ```handlebars + + ``` + + A hard-coded value can be used by prepending `:` to the desired + class name: `:class-name-to-always-apply`. + + ```handlebars + + ``` + + Results in the following rendered output: + + ```html + + ``` + + All three strategies - string return value, boolean return value, and + hard-coded value – can be combined in a single declaration: + + ```handlebars + + ``` + + @method bindAttr + @for Ember.Handlebars.helpers + @param {Hash} options + @return {String} HTML string +*/ +EmberHandlebars.registerHelper('bindAttr', function(options) { + + var attrs = options.hash; + + + var view = options.data.view; + var ret = []; + var ctx = this; + + // Generate a unique id for this element. This will be added as a + // data attribute to the element so it can be looked up when + // the bound property changes. + var dataId = ++Ember.uuid; + + // Handle classes differently, as we can bind multiple classes + var classBindings = attrs['class']; + if (classBindings !== null && classBindings !== undefined) { + var classResults = EmberHandlebars.bindClasses(this, classBindings, view, dataId, options); + + ret.push('class="' + Handlebars.Utils.escapeExpression(classResults.join(' ')) + '"'); + delete attrs['class']; + } + + var attrKeys = Ember.keys(attrs); + + // For each attribute passed, create an observer and emit the + // current value of the property as an attribute. + forEach.call(attrKeys, function(attr) { + var path = attrs[attr], + pathRoot, normalized; + + + normalized = normalizePath(ctx, path, options.data); + + pathRoot = normalized.root; + path = normalized.path; + + var value = (path === 'this') ? pathRoot : handlebarsGet(pathRoot, path, options), + type = Ember.typeOf(value); + + + var observer, invoker; + + observer = function observer() { + var result = handlebarsGet(pathRoot, path, options); + + + var elem = view.$("[data-bindattr-" + dataId + "='" + dataId + "']"); + + // If we aren't able to find the element, it means the element + // to which we were bound has been removed from the view. + // In that case, we can assume the template has been re-rendered + // and we need to clean up the observer. + if (!elem || elem.length === 0) { + Ember.removeObserver(pathRoot, path, invoker); + return; + } + + Ember.View.applyAttributeBindings(elem, attr, result); + }; + + invoker = function() { + Ember.run.scheduleOnce('render', observer); + }; + + // Add an observer to the view for when the property changes. + // When the observer fires, find the element using the + // unique data id and update the attribute to the new value. + if (path !== 'this') { + view.registerObserver(pathRoot, path, invoker); + } + + // if this changes, also change the logic in ember-views/lib/views/view.js + if ((type === 'string' || (type === 'number' && !isNaN(value)))) { + ret.push(attr + '="' + Handlebars.Utils.escapeExpression(value) + '"'); + } else if (value && type === 'boolean') { + // The developer controls the attr name, so it should always be safe + ret.push(attr + '="' + attr + '"'); + } + }, this); + + // Add the unique identifier + // NOTE: We use all lower-case since Firefox has problems with mixed case in SVG + ret.push('data-bindattr-' + dataId + '="' + dataId + '"'); + return new EmberHandlebars.SafeString(ret.join(' ')); +}); + +/** + @private + + Helper that, given a space-separated string of property paths and a context, + returns an array of class names. Calling this method also has the side + effect of setting up observers at those property paths, such that if they + change, the correct class name will be reapplied to the DOM element. + + For example, if you pass the string "fooBar", it will first look up the + "fooBar" value of the context. If that value is true, it will add the + "foo-bar" class to the current element (i.e., the dasherized form of + "fooBar"). If the value is a string, it will add that string as the class. + Otherwise, it will not add any new class name. + + @method bindClasses + @for Ember.Handlebars + @param {Ember.Object} context The context from which to lookup properties + @param {String} classBindings A string, space-separated, of class bindings + to use + @param {Ember.View} view The view in which observers should look for the + element to update + @param {Srting} bindAttrId Optional bindAttr id used to lookup elements + @return {Array} An array of class names to add +*/ +EmberHandlebars.bindClasses = function(context, classBindings, view, bindAttrId, options) { + var ret = [], newClass, value, elem; + + // Helper method to retrieve the property from the context and + // determine which class string to return, based on whether it is + // a Boolean or not. + var classStringForPath = function(root, parsedPath, options) { + var val, + path = parsedPath.path; + + if (path === 'this') { + val = root; + } else if (path === '') { + val = true; + } else { + val = handlebarsGet(root, path, options); + } + + return Ember.View._classStringForValue(path, val, parsedPath.className, parsedPath.falsyClassName); + }; + + // For each property passed, loop through and setup + // an observer. + forEach.call(classBindings.split(' '), function(binding) { + + // Variable in which the old class value is saved. The observer function + // closes over this variable, so it knows which string to remove when + // the property changes. + var oldClass; + + var observer, invoker; + + var parsedPath = Ember.View._parsePropertyPath(binding), + path = parsedPath.path, + pathRoot = context, + normalized; + + if (path !== '' && path !== 'this') { + normalized = normalizePath(context, path, options.data); + + pathRoot = normalized.root; + path = normalized.path; + } + + // Set up an observer on the context. If the property changes, toggle the + // class name. + observer = function() { + // Get the current value of the property + newClass = classStringForPath(pathRoot, parsedPath, options); + elem = bindAttrId ? view.$("[data-bindattr-" + bindAttrId + "='" + bindAttrId + "']") : view.$(); + + // If we can't find the element anymore, a parent template has been + // re-rendered and we've been nuked. Remove the observer. + if (!elem || elem.length === 0) { + Ember.removeObserver(pathRoot, path, invoker); + } else { + // If we had previously added a class to the element, remove it. + if (oldClass) { + elem.removeClass(oldClass); + } + + // If necessary, add a new class. Make sure we keep track of it so + // it can be removed in the future. + if (newClass) { + elem.addClass(newClass); + oldClass = newClass; + } else { + oldClass = null; + } + } + }; + + invoker = function() { + Ember.run.scheduleOnce('render', observer); + }; + + if (path !== '' && path !== 'this') { + view.registerObserver(pathRoot, path, invoker); + } + + // We've already setup the observer; now we just need to figure out the + // correct behavior right now on the first pass through. + value = classStringForPath(pathRoot, parsedPath, options); + + if (value) { + ret.push(value); + + // Make sure we save the current value so that it can be removed if the + // observer fires. + oldClass = value; + } + }); + + return ret; +}; + + +})(); + + + +(function() { +/*globals Handlebars */ + +// TODO: Don't require the entire module +/** +@module ember +@submodule ember-handlebars +*/ + +var get = Ember.get, set = Ember.set; +var PARENT_VIEW_PATH = /^parentView\./; +var EmberHandlebars = Ember.Handlebars; + +EmberHandlebars.ViewHelper = Ember.Object.create({ + + propertiesFromHTMLOptions: function(options, thisContext) { + var hash = options.hash, data = options.data; + var extensions = {}, + classes = hash['class'], + dup = false; + + if (hash.id) { + extensions.elementId = hash.id; + dup = true; + } + + if (classes) { + classes = classes.split(' '); + extensions.classNames = classes; + dup = true; + } + + if (hash.classBinding) { + extensions.classNameBindings = hash.classBinding.split(' '); + dup = true; + } + + if (hash.classNameBindings) { + if (extensions.classNameBindings === undefined) extensions.classNameBindings = []; + extensions.classNameBindings = extensions.classNameBindings.concat(hash.classNameBindings.split(' ')); + dup = true; + } + + if (hash.attributeBindings) { + + extensions.attributeBindings = null; + dup = true; + } + + if (dup) { + hash = Ember.$.extend({}, hash); + delete hash.id; + delete hash['class']; + delete hash.classBinding; + } + + // Set the proper context for all bindings passed to the helper. This applies to regular attribute bindings + // as well as class name bindings. If the bindings are local, make them relative to the current context + // instead of the view. + var path; + + // Evaluate the context of regular attribute bindings: + for (var prop in hash) { + if (!hash.hasOwnProperty(prop)) { continue; } + + // Test if the property ends in "Binding" + if (Ember.IS_BINDING.test(prop) && typeof hash[prop] === 'string') { + path = this.contextualizeBindingPath(hash[prop], data); + if (path) { hash[prop] = path; } + } + } + + // Evaluate the context of class name bindings: + if (extensions.classNameBindings) { + for (var b in extensions.classNameBindings) { + var full = extensions.classNameBindings[b]; + if (typeof full === 'string') { + // Contextualize the path of classNameBinding so this: + // + // classNameBinding="isGreen:green" + // + // is converted to this: + // + // classNameBinding="_parentView.context.isGreen:green" + var parsedPath = Ember.View._parsePropertyPath(full); + path = this.contextualizeBindingPath(parsedPath.path, data); + if (path) { extensions.classNameBindings[b] = path + parsedPath.classNames; } + } + } + } + + return Ember.$.extend(hash, extensions); + }, + + // Transform bindings from the current context to a context that can be evaluated within the view. + // Returns null if the path shouldn't be changed. + // + // TODO: consider the addition of a prefix that would allow this method to return `path`. + contextualizeBindingPath: function(path, data) { + var normalized = Ember.Handlebars.normalizePath(null, path, data); + if (normalized.isKeyword) { + return 'templateData.keywords.' + path; + } else if (Ember.isGlobalPath(path)) { + return null; + } else if (path === 'this') { + return '_parentView.context'; + } else { + return '_parentView.context.' + path; + } + }, + + helper: function(thisContext, path, options) { + var inverse = options.inverse, + data = options.data, + view = data.view, + fn = options.fn, + hash = options.hash, + newView; + + if ('string' === typeof path) { + newView = EmberHandlebars.get(thisContext, path, options); + + } else { + newView = path; + } + + + var viewOptions = this.propertiesFromHTMLOptions(options, thisContext); + var currentView = data.view; + viewOptions.templateData = options.data; + var newViewProto = newView.proto ? newView.proto() : newView; + + if (fn) { + + viewOptions.template = fn; + } + + // We only want to override the `_context` computed property if there is + // no specified controller. See View#_context for more information. + if (!newViewProto.controller && !newViewProto.controllerBinding && !viewOptions.controller && !viewOptions.controllerBinding) { + viewOptions._context = thisContext; + } + + currentView.appendChild(newView, viewOptions); + } +}); + +/** + `{{view}}` inserts a new instance of `Ember.View` into a template passing its + options to the `Ember.View`'s `create` method and using the supplied block as + the view's own template. + + An empty `` and the following template: + + ```handlebars + A span: + {{#view tagName="span"}} + hello. + {{/view}} + ``` + + Will result in HTML structure: + + ```html + + + +
            + A span: + + Hello. + +
            + + ``` + + ### `parentView` setting + + The `parentView` property of the new `Ember.View` instance created through + `{{view}}` will be set to the `Ember.View` instance of the template where + `{{view}}` was called. + + ```javascript + aView = Ember.View.create({ + template: Ember.Handlebars.compile("{{#view}} my parent: {{parentView.elementId}} {{/view}}") + }); + + aView.appendTo('body'); + ``` + + Will result in HTML structure: + + ```html +
            +
            + my parent: ember1 +
            +
            + ``` + + ### Setting CSS id and class attributes + + The HTML `id` attribute can be set on the `{{view}}`'s resulting element with + the `id` option. This option will _not_ be passed to `Ember.View.create`. + + ```handlebars + {{#view tagName="span" id="a-custom-id"}} + hello. + {{/view}} + ``` + + Results in the following HTML structure: + + ```html +
            + + hello. + +
            + ``` + + The HTML `class` attribute can be set on the `{{view}}`'s resulting element + with the `class` or `classNameBindings` options. The `class` option will + directly set the CSS `class` attribute and will not be passed to + `Ember.View.create`. `classNameBindings` will be passed to `create` and use + `Ember.View`'s class name binding functionality: + + ```handlebars + {{#view tagName="span" class="a-custom-class"}} + hello. + {{/view}} + ``` + + Results in the following HTML structure: + + ```html +
            + + hello. + +
            + ``` + + ### Supplying a different view class + + `{{view}}` can take an optional first argument before its supplied options to + specify a path to a custom view class. + + ```handlebars + {{#view "MyApp.CustomView"}} + hello. + {{/view}} + ``` + + The first argument can also be a relative path. Ember will search for the + view class starting at the `Ember.View` of the template where `{{view}}` was + used as the root object: + + ```javascript + MyApp = Ember.Application.create({}); + MyApp.OuterView = Ember.View.extend({ + innerViewClass: Ember.View.extend({ + classNames: ['a-custom-view-class-as-property'] + }), + template: Ember.Handlebars.compile('{{#view "innerViewClass"}} hi {{/view}}') + }); + + MyApp.OuterView.create().appendTo('body'); + ``` + + Will result in the following HTML: + + ```html +
            +
            + hi +
            +
            + ``` + + ### Blockless use + + If you supply a custom `Ember.View` subclass that specifies its own template + or provide a `templateName` option to `{{view}}` it can be used without + supplying a block. Attempts to use both a `templateName` option and supply a + block will throw an error. + + ```handlebars + {{view "MyApp.ViewWithATemplateDefined"}} + ``` + + ### `viewName` property + + You can supply a `viewName` option to `{{view}}`. The `Ember.View` instance + will be referenced as a property of its parent view by this name. + + ```javascript + aView = Ember.View.create({ + template: Ember.Handlebars.compile('{{#view viewName="aChildByName"}} hi {{/view}}') + }); + + aView.appendTo('body'); + aView.get('aChildByName') // the instance of Ember.View created by {{view}} helper + ``` + + @method view + @for Ember.Handlebars.helpers + @param {String} path + @param {Hash} options + @return {String} HTML string +*/ +EmberHandlebars.registerHelper('view', function(path, options) { + + + // If no path is provided, treat path param as options. + if (path && path.data && path.data.isRenderData) { + options = path; + path = "Ember.View"; + } + + return EmberHandlebars.ViewHelper.helper(this, path, options); +}); + + +})(); + + + +(function() { +/*globals Handlebars */ + +// TODO: Don't require all of this module +/** +@module ember +@submodule ember-handlebars +*/ + +var get = Ember.get, handlebarsGet = Ember.Handlebars.get, fmt = Ember.String.fmt; + +/** + `{{collection}}` is a `Ember.Handlebars` helper for adding instances of + `Ember.CollectionView` to a template. See `Ember.CollectionView` for + additional information on how a `CollectionView` functions. + + `{{collection}}`'s primary use is as a block helper with a `contentBinding` + option pointing towards an `Ember.Array`-compatible object. An `Ember.View` + instance will be created for each item in its `content` property. Each view + will have its own `content` property set to the appropriate item in the + collection. + + The provided block will be applied as the template for each item's view. + + Given an empty `` the following template: + + ```handlebars + {{#collection contentBinding="App.items"}} + Hi {{view.content.name}} + {{/collection}} + ``` + + And the following application code + + ```javascript + App = Ember.Application.create() + App.items = [ + Ember.Object.create({name: 'Dave'}), + Ember.Object.create({name: 'Mary'}), + Ember.Object.create({name: 'Sara'}) + ] + ``` + + Will result in the HTML structure below + + ```html +
            +
            Hi Dave
            +
            Hi Mary
            +
            Hi Sara
            +
            + ``` + + ### Blockless Use + + If you provide an `itemViewClass` option that has its own `template` you can + omit the block. + + The following template: + + ```handlebars + {{collection contentBinding="App.items" itemViewClass="App.AnItemView"}} + ``` + + And application code + + ```javascript + App = Ember.Application.create(); + App.items = [ + Ember.Object.create({name: 'Dave'}), + Ember.Object.create({name: 'Mary'}), + Ember.Object.create({name: 'Sara'}) + ]; + + App.AnItemView = Ember.View.extend({ + template: Ember.Handlebars.compile("Greetings {{view.content.name}}") + }); + ``` + + Will result in the HTML structure below + + ```html +
            +
            Greetings Dave
            +
            Greetings Mary
            +
            Greetings Sara
            +
            + ``` + + ### Specifying a CollectionView subclass + + By default the `{{collection}}` helper will create an instance of + `Ember.CollectionView`. You can supply a `Ember.CollectionView` subclass to + the helper by passing it as the first argument: + + ```handlebars + {{#collection App.MyCustomCollectionClass contentBinding="App.items"}} + Hi {{view.content.name}} + {{/collection}} + ``` + + ### Forwarded `item.*`-named Options + + As with the `{{view}}`, helper options passed to the `{{collection}}` will be + set on the resulting `Ember.CollectionView` as properties. Additionally, + options prefixed with `item` will be applied to the views rendered for each + item (note the camelcasing): + + ```handlebars + {{#collection contentBinding="App.items" + itemTagName="p" + itemClassNames="greeting"}} + Howdy {{view.content.name}} + {{/collection}} + ``` + + Will result in the following HTML structure: + + ```html +
            +

            Howdy Dave

            +

            Howdy Mary

            +

            Howdy Sara

            +
            + ``` + + @method collection + @for Ember.Handlebars.helpers + @param {String} path + @param {Hash} options + @return {String} HTML string + @deprecated Use `{{each}}` helper instead. +*/ +Ember.Handlebars.registerHelper('collection', function(path, options) { + + + // If no path is provided, treat path param as options. + if (path && path.data && path.data.isRenderData) { + options = path; + path = undefined; + + } else { + + } + + var fn = options.fn; + var data = options.data; + var inverse = options.inverse; + var view = options.data.view; + + // If passed a path string, convert that into an object. + // Otherwise, just default to the standard class. + var collectionClass; + collectionClass = path ? handlebarsGet(this, path, options) : Ember.CollectionView; + + + var hash = options.hash, itemHash = {}, match; + + // Extract item view class if provided else default to the standard class + var itemViewClass, itemViewPath = hash.itemViewClass; + var collectionPrototype = collectionClass.proto(); + delete hash.itemViewClass; + itemViewClass = itemViewPath ? handlebarsGet(collectionPrototype, itemViewPath, options) : collectionPrototype.itemViewClass; + + + // Go through options passed to the {{collection}} helper and extract options + // that configure item views instead of the collection itself. + for (var prop in hash) { + if (hash.hasOwnProperty(prop)) { + match = prop.match(/^item(.)(.*)$/); + + if(match && prop !== 'itemController') { + // Convert itemShouldFoo -> shouldFoo + itemHash[match[1].toLowerCase() + match[2]] = hash[prop]; + // Delete from hash as this will end up getting passed to the + // {{view}} helper method. + delete hash[prop]; + } + } + } + + var tagName = hash.tagName || collectionPrototype.tagName; + + if (fn) { + itemHash.template = fn; + delete options.fn; + } + + var emptyViewClass; + if (inverse && inverse !== Handlebars.VM.noop) { + emptyViewClass = get(collectionPrototype, 'emptyViewClass'); + emptyViewClass = emptyViewClass.extend({ + template: inverse, + tagName: itemHash.tagName + }); + } else if (hash.emptyViewClass) { + emptyViewClass = handlebarsGet(this, hash.emptyViewClass, options); + } + if (emptyViewClass) { hash.emptyView = emptyViewClass; } + + if(!hash.keyword){ + itemHash._context = Ember.computed.alias('content'); + } + + var viewString = view.toString(); + + var viewOptions = Ember.Handlebars.ViewHelper.propertiesFromHTMLOptions({ data: data, hash: itemHash }, this); + hash.itemViewClass = itemViewClass.extend(viewOptions); + + return Ember.Handlebars.helpers.view.call(this, collectionClass, options); +}); + + +})(); + + + +(function() { +/*globals Handlebars */ +/** +@module ember +@submodule ember-handlebars +*/ + +var handlebarsGet = Ember.Handlebars.get; + +/** + `unbound` allows you to output a property without binding. *Important:* The + output will not be updated if the property changes. Use with caution. + + ```handlebars +
            {{unbound somePropertyThatDoesntChange}}
            + ``` + + `unbound` can also be used in conjunction with a bound helper to + render it in its unbound form: + + ```handlebars +
            {{unbound helperName somePropertyThatDoesntChange}}
            + ``` + + @method unbound + @for Ember.Handlebars.helpers + @param {String} property + @return {String} HTML string +*/ +Ember.Handlebars.registerHelper('unbound', function(property, fn) { + var options = arguments[arguments.length - 1], helper, context, out; + + if(arguments.length > 2) { + // Unbound helper call. + options.data.isUnbound = true; + helper = Ember.Handlebars.helpers[arguments[0]] || Ember.Handlebars.helperMissing; + out = helper.apply(this, Array.prototype.slice.call(arguments, 1)); + delete options.data.isUnbound; + return out; + } + + context = (fn.contexts && fn.contexts[0]) || this; + return handlebarsGet(context, property, fn); +}); + +})(); + + + +(function() { +/*jshint debug:true*/ +/** +@module ember +@submodule ember-handlebars +*/ + +var handlebarsGet = Ember.Handlebars.get, normalizePath = Ember.Handlebars.normalizePath; + +/** + `log` allows you to output the value of a value in the current rendering + context. + + ```handlebars + {{log myVariable}} + ``` + + @method log + @for Ember.Handlebars.helpers + @param {String} property +*/ +Ember.Handlebars.registerHelper('log', function(property, options) { + var context = (options.contexts && options.contexts[0]) || this, + normalized = normalizePath(context, property, options.data), + pathRoot = normalized.root, + path = normalized.path, + value = (path === 'this') ? pathRoot : handlebarsGet(pathRoot, path, options); + Ember.Logger.log(value); +}); + +/** + Execute the `debugger` statement in the current context. + + ```handlebars + {{debugger}} + ``` + + @method debugger + @for Ember.Handlebars.helpers + @param {String} property +*/ +Ember.Handlebars.registerHelper('debugger', function() { + debugger; +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-handlebars +*/ + +var get = Ember.get, set = Ember.set; + +Ember.Handlebars.EachView = Ember.CollectionView.extend(Ember._Metamorph, { + init: function() { + var itemController = get(this, 'itemController'); + var binding; + + if (itemController) { + var controller = Ember.ArrayController.create(); + set(controller, 'itemController', itemController); + set(controller, 'container', get(this, 'controller.container')); + set(controller, '_eachView', this); + this.disableContentObservers(function() { + set(this, 'content', controller); + binding = new Ember.Binding('content', '_eachView.dataSource').oneWay(); + binding.connect(controller); + }); + + set(this, '_arrayController', controller); + } else { + this.disableContentObservers(function() { + binding = new Ember.Binding('content', 'dataSource').oneWay(); + binding.connect(this); + }); + } + + return this._super(); + }, + + disableContentObservers: function(callback) { + Ember.removeBeforeObserver(this, 'content', null, '_contentWillChange'); + Ember.removeObserver(this, 'content', null, '_contentDidChange'); + + callback.apply(this); + + Ember.addBeforeObserver(this, 'content', null, '_contentWillChange'); + Ember.addObserver(this, 'content', null, '_contentDidChange'); + }, + + itemViewClass: Ember._MetamorphView, + emptyViewClass: Ember._MetamorphView, + + createChildView: function(view, attrs) { + view = this._super(view, attrs); + + // At the moment, if a container view subclass wants + // to insert keywords, it is responsible for cloning + // the keywords hash. This will be fixed momentarily. + var keyword = get(this, 'keyword'); + var content = get(view, 'content'); + + if (keyword) { + var data = get(view, 'templateData'); + + data = Ember.copy(data); + data.keywords = view.cloneKeywords(); + set(view, 'templateData', data); + + // In this case, we do not bind, because the `content` of + // a #each item cannot change. + data.keywords[keyword] = content; + } + + // If {{#each}} is looping over an array of controllers, + // point each child view at their respective controller. + if (content && get(content, 'isController')) { + set(view, 'controller', content); + } + + return view; + }, + + willDestroy: function() { + var arrayController = get(this, '_arrayController'); + + if (arrayController) { + arrayController.destroy(); + } + + return this._super(); + } +}); + +var GroupedEach = Ember.Handlebars.GroupedEach = function(context, path, options) { + var self = this, + normalized = Ember.Handlebars.normalizePath(context, path, options.data); + + this.context = context; + this.path = path; + this.options = options; + this.template = options.fn; + this.containingView = options.data.view; + this.normalizedRoot = normalized.root; + this.normalizedPath = normalized.path; + this.content = this.lookupContent(); + + this.addContentObservers(); + this.addArrayObservers(); + + this.containingView.on('willClearRender', function() { + self.destroy(); + }); +}; + +GroupedEach.prototype = { + contentWillChange: function() { + this.removeArrayObservers(); + }, + + contentDidChange: function() { + this.content = this.lookupContent(); + this.addArrayObservers(); + this.rerenderContainingView(); + }, + + contentArrayWillChange: Ember.K, + + contentArrayDidChange: function() { + this.rerenderContainingView(); + }, + + lookupContent: function() { + return Ember.Handlebars.get(this.normalizedRoot, this.normalizedPath, this.options); + }, + + addArrayObservers: function() { + this.content.addArrayObserver(this, { + willChange: 'contentArrayWillChange', + didChange: 'contentArrayDidChange' + }); + }, + + removeArrayObservers: function() { + this.content.removeArrayObserver(this, { + willChange: 'contentArrayWillChange', + didChange: 'contentArrayDidChange' + }); + }, + + addContentObservers: function() { + Ember.addBeforeObserver(this.normalizedRoot, this.normalizedPath, this, this.contentWillChange); + Ember.addObserver(this.normalizedRoot, this.normalizedPath, this, this.contentDidChange); + }, + + removeContentObservers: function() { + Ember.removeBeforeObserver(this.normalizedRoot, this.normalizedPath, this.contentWillChange); + Ember.removeObserver(this.normalizedRoot, this.normalizedPath, this.contentDidChange); + }, + + render: function() { + var content = this.content, + contentLength = get(content, 'length'), + data = this.options.data, + template = this.template; + + data.insideEach = true; + for (var i = 0; i < contentLength; i++) { + template(content.objectAt(i), { data: data }); + } + }, + + rerenderContainingView: function() { + Ember.run.scheduleOnce('render', this.containingView, 'rerender'); + }, + + destroy: function() { + this.removeContentObservers(); + this.removeArrayObservers(); + } +}; + +/** + The `{{#each}}` helper loops over elements in a collection, rendering its + block once for each item. It is an extension of the base Handlebars `{{#each}}` + helper: + + ```javascript + Developers = [{name: 'Yehuda'},{name: 'Tom'}, {name: 'Paul'}]; + ``` + + ```handlebars + {{#each Developers}} + {{name}} + {{/each}} + ``` + + `{{each}}` supports an alternative syntax with element naming: + + ```handlebars + {{#each person in Developers}} + {{person.name}} + {{/each}} + ``` + + When looping over objects that do not have properties, `{{this}}` can be used + to render the object: + + ```javascript + DeveloperNames = ['Yehuda', 'Tom', 'Paul'] + ``` + + ```handlebars + {{#each DeveloperNames}} + {{this}} + {{/each}} + ``` + ### {{else}} condition + `{{#each}}` can have a matching `{{else}}`. The contents of this block will render + if the collection is empty. + + ``` + {{#each person in Developers}} + {{person.name}} + {{else}} +

            Sorry, nobody is available for this task.

            + {{/each}} + ``` + ### Specifying a View class for items + If you provide an `itemViewClass` option that references a view class + with its own `template` you can omit the block. + + The following template: + + ```handlebars + {{#view App.MyView }} + {{each view.items itemViewClass="App.AnItemView"}} + {{/view}} + ``` + + And application code + + ```javascript + App = Ember.Application.create({ + MyView: Ember.View.extend({ + items: [ + Ember.Object.create({name: 'Dave'}), + Ember.Object.create({name: 'Mary'}), + Ember.Object.create({name: 'Sara'}) + ] + }) + }); + + App.AnItemView = Ember.View.extend({ + template: Ember.Handlebars.compile("Greetings {{name}}") + }); + ``` + + Will result in the HTML structure below + + ```html +
            +
            Greetings Dave
            +
            Greetings Mary
            +
            Greetings Sara
            +
            + ``` + + ### Representing each item with a Controller. + By default the controller lookup within an `{{#each}}` block will be + the controller of the template where the `{{#each}}` was used. If each + item needs to be presented by a custom controller you can provide a + `itemController` option which references a controller by lookup name. + Each item in the loop will be wrapped in an instance of this controller + and the item itself will be set to the `content` property of that controller. + + This is useful in cases where properties of model objects need transformation + or synthesis for display: + + ```javascript + App.DeveloperController = Ember.ObjectController.extend({ + isAvailableForHire: function(){ + return !this.get('content.isEmployed') && this.get('content.isSeekingWork'); + }.property('isEmployed', 'isSeekingWork') + }) + ``` + + ```handlebars + {{#each person in Developers itemController="developer"}} + {{person.name}} {{#if person.isAvailableForHire}}Hire me!{{/if}} + {{/each}} + ``` + + @method each + @for Ember.Handlebars.helpers + @param [name] {String} name for item (used with `in`) + @param path {String} path + @param [options] {Object} Handlebars key/value pairs of options + @param [options.itemViewClass] {String} a path to a view class used for each item + @param [options.itemController] {String} name of a controller to be created for each item +*/ +Ember.Handlebars.registerHelper('each', function(path, options) { + if (arguments.length === 4) { + + + var keywordName = arguments[0]; + + options = arguments[3]; + path = arguments[2]; + if (path === '') { path = "this"; } + + options.hash.keyword = keywordName; + } + + options.hash.dataSourceBinding = path; + // Set up emptyView as a metamorph with no tag + //options.hash.emptyViewClass = Ember._MetamorphView; + + if (options.data.insideGroup && !options.hash.groupedRows && !options.hash.itemViewClass) { + new Ember.Handlebars.GroupedEach(this, path, options).render(); + } else { + return Ember.Handlebars.helpers.collection.call(this, 'Ember.Handlebars.EachView', options); + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-handlebars +*/ + +/** + `template` allows you to render a template from inside another template. + This allows you to re-use the same template in multiple places. For example: + + ```html + + ``` + + ```html + + ``` + + This helper looks for templates in the global `Ember.TEMPLATES` hash. If you + add ` + ``` + + And application code + + ```javascript + AController = Ember.Controller.extend({ + anActionName: function() {} + }); + + AView = Ember.View.extend({ + controller: AController.create(), + templateName: 'a-template' + }); + + aView = AView.create(); + aView.appendTo('body'); + ``` + + Will results in the following rendered HTML + + ```html +
            +
            + click me +
            +
            + ``` + + Clicking "click me" will trigger the `anActionName` method of the + `AController`. In this case, no additional parameters will be passed. + + If you provide additional parameters to the helper: + + ```handlebars + + ``` + + Those parameters will be passed along as arguments to the JavaScript + function implementing the action. + + ### Event Propagation + + Events triggered through the action helper will automatically have + `.preventDefault()` called on them. You do not need to do so in your event + handlers. + + To also disable bubbling, pass `bubbles=false` to the helper: + + ```handlebars + + ``` + + If you need the default handler to trigger you should either register your + own event handler, or use event methods on your view class. See `Ember.View` + 'Responding to Browser Events' for more information. + + ### Specifying DOM event type + + By default the `{{action}}` helper registers for DOM `click` events. You can + supply an `on` option to the helper to specify a different DOM event name: + + ```handlebars + + ``` + + See `Ember.View` 'Responding to Browser Events' for a list of + acceptable DOM event names. + + NOTE: Because `{{action}}` depends on Ember's event dispatch system it will + only function if an `Ember.EventDispatcher` instance is available. An + `Ember.EventDispatcher` instance will be created when a new `Ember.Application` + is created. Having an instance of `Ember.Application` will satisfy this + requirement. + + ### Specifying a Target + + There are several possible target objects for `{{action}}` helpers: + + In a typical Ember application, where views are managed through use of the + `{{outlet}}` helper, actions will bubble to the current controller, then + to the current route, and then up the route hierarchy. + + Alternatively, a `target` option can be provided to the helper to change + which object will receive the method call. This option must be a path + path to an object, accessible in the current context: + + ```handlebars + + ``` + + Clicking "click me" in the rendered HTML of the above template will trigger + the `anActionName` method of the object at `MyApplication.someObject`. + + If an action's target does not implement a method that matches the supplied + action name an error will be thrown. + + ```handlebars + + ``` + + With the following application code + + ```javascript + AView = Ember.View.extend({ + templateName; 'a-template', + // note: no method 'aMethodNameThatIsMissing' + anActionName: function(event) {} + }); + + aView = AView.create(); + aView.appendTo('body'); + ``` + + Will throw `Uncaught TypeError: Cannot call method 'call' of undefined` when + "click me" is clicked. + + ### Additional Parameters + + You may specify additional parameters to the `{{action}}` helper. These + parameters are passed along as the arguments to the JavaScript function + implementing the action. + + ```handlebars + + ``` + + Clicking "click me" will trigger the `edit` method on the current view's + controller with the current person as a parameter. + + @method action + @for Ember.Handlebars.helpers + @param {String} actionName + @param {Object...} contexts + @param {Hash} options + */ + EmberHandlebars.registerHelper('action', function(actionName) { + var options = arguments[arguments.length - 1], + contexts = a_slice.call(arguments, 1, -1); + + var hash = options.hash, + view = options.data.view, + controller, link; + + // create a hash to pass along to registerAction + var action = { + eventName: hash.on || "click" + }; + + action.parameters = { + context: this, + options: options, + params: contexts + }; + + action.view = view = get(view, 'concreteView'); + + var root, target; + + if (hash.target) { + root = this; + target = hash.target; + } else if (controller = options.data.keywords.controller) { + root = controller; + } + + action.target = { root: root, target: target, options: options }; + action.bubbles = hash.bubbles; + + var actionId = ActionHelper.registerAction(actionName, action); + return new SafeString('data-ember-action="' + actionId + '"'); + }); + +}); + +})(); + + + +(function() { +var get = Ember.get, set = Ember.set; + +Ember.Handlebars.registerHelper('control', function(path, modelPath, options) { + if (arguments.length === 2) { + options = modelPath; + modelPath = undefined; + } + + var model; + + if (modelPath) { + model = Ember.Handlebars.get(this, modelPath, options); + } + + var controller = options.data.keywords.controller, + view = options.data.keywords.view, + children = get(controller, '_childContainers'), + controlID = options.hash.controlID, + container, subContainer; + + if (children.hasOwnProperty(controlID)) { + subContainer = children[controlID]; + } else { + container = get(controller, 'container'), + subContainer = container.child(); + children[controlID] = subContainer; + } + + var normalizedPath = path.replace(/\//g, '.'); + + var childView = subContainer.lookup('view:' + normalizedPath) || subContainer.lookup('view:default'), + childController = subContainer.lookup('controller:' + normalizedPath), + childTemplate = subContainer.lookup('template:' + path); + + + + set(childController, 'target', controller); + set(childController, 'model', model); + + options.hash.template = childTemplate; + options.hash.controller = childController; + + function observer() { + var model = Ember.Handlebars.get(this, modelPath, options); + set(childController, 'model', model); + childView.rerender(); + } + + Ember.addObserver(this, modelPath, observer); + childView.one('willDestroyElement', this, function() { + Ember.removeObserver(this, modelPath, observer); + }); + + Ember.Handlebars.helpers.view.call(this, childView, options); +}); + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +Ember.ControllerMixin.reopen({ + transitionToRoute: function() { + var target = get(this, 'target'); + + return target.transitionTo.apply(target, arguments); + }, + + // TODO: Deprecate this, see https://github.com/emberjs/ember.js/issues/1785 + transitionTo: function() { + return this.transitionToRoute.apply(this, arguments); + }, + + replaceRoute: function() { + var target = get(this, 'target'); + + return target.replaceWith.apply(target, arguments); + }, + + // TODO: Deprecate this, see https://github.com/emberjs/ember.js/issues/1785 + replaceWith: function() { + return this.replaceRoute.apply(this, arguments); + } +}); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +Ember.View.reopen({ + init: function() { + set(this, '_outlets', {}); + this._super(); + }, + + connectOutlet: function(outletName, view) { + var outlets = get(this, '_outlets'), + container = get(this, 'container'), + router = container && container.lookup('router:main'), + renderedName = get(view, 'renderedName'); + + set(outlets, outletName, view); + + if (router && renderedName) { + router._connectActiveView(renderedName, view); + } + }, + + disconnectOutlet: function(outletName) { + var outlets = get(this, '_outlets'); + + set(outlets, outletName, null); + } +}); + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +/* + This file implements the `location` API used by Ember's router. + + That API is: + + getURL: returns the current URL + setURL(path): sets the current URL + replaceURL(path): replace the current URL (optional) + onUpdateURL(callback): triggers the callback when the URL changes + formatURL(url): formats `url` to be placed into `href` attribute + + Calling setURL or replaceURL will not trigger onUpdateURL callbacks. + + TODO: This should perhaps be moved so that it's visible in the doc output. +*/ + +/** + Ember.Location returns an instance of the correct implementation of + the `location` API. + + You can pass it a `implementation` ('hash', 'history', 'none') to force a + particular implementation. + + @class Location + @namespace Ember + @static +*/ +Ember.Location = { + create: function(options) { + var implementation = options && options.implementation; + + + var implementationClass = this.implementations[implementation]; + + + return implementationClass.create.apply(implementationClass, arguments); + }, + + registerImplementation: function(name, implementation) { + this.implementations[name] = implementation; + }, + + implementations: {} +}; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +/** + Ember.NoneLocation does not interact with the browser. It is useful for + testing, or when you need to manage state with your Router, but temporarily + don't want it to muck with the URL (for example when you embed your + application in a larger page). + + @class NoneLocation + @namespace Ember + @extends Ember.Object +*/ +Ember.NoneLocation = Ember.Object.extend({ + path: '', + + getURL: function() { + return get(this, 'path'); + }, + + setURL: function(path) { + set(this, 'path', path); + }, + + onUpdateURL: function(callback) { + // We are not wired up to the browser, so we'll never trigger the callback. + }, + + formatURL: function(url) { + // The return value is not overly meaningful, but we do not want to throw + // errors when test code renders templates containing {{action href=true}} + // helpers. + return url; + } +}); + +Ember.Location.registerImplementation('none', Ember.NoneLocation); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; + +/** + Ember.HashLocation implements the location API using the browser's + hash. At present, it relies on a hashchange event existing in the + browser. + + @class HashLocation + @namespace Ember + @extends Ember.Object +*/ +Ember.HashLocation = Ember.Object.extend({ + + init: function() { + set(this, 'location', get(this, 'location') || window.location); + }, + + /** + @private + + Returns the current `location.hash`, minus the '#' at the front. + + @method getURL + */ + getURL: function() { + return get(this, 'location').hash.substr(1); + }, + + /** + @private + + Set the `location.hash` and remembers what was set. This prevents + `onUpdateURL` callbacks from triggering when the hash was set by + `HashLocation`. + + @method setURL + @param path {String} + */ + setURL: function(path) { + get(this, 'location').hash = path; + set(this, 'lastSetURL', path); + }, + + /** + @private + + Register a callback to be invoked when the hash changes. These + callbacks will execute when the user presses the back or forward + button, but not after `setURL` is invoked. + + @method onUpdateURL + @param callback {Function} + */ + onUpdateURL: function(callback) { + var self = this; + var guid = Ember.guidFor(this); + + Ember.$(window).bind('hashchange.ember-location-'+guid, function() { + var path = location.hash.substr(1); + if (get(self, 'lastSetURL') === path) { return; } + + set(self, 'lastSetURL', null); + + callback(location.hash.substr(1)); + }); + }, + + /** + @private + + Given a URL, formats it to be placed into the page as part + of an element's `href` attribute. + + This is used, for example, when using the {{action}} helper + to generate a URL based on an event. + + @method formatURL + @param url {String} + */ + formatURL: function(url) { + return '#'+url; + }, + + willDestroy: function() { + var guid = Ember.guidFor(this); + + Ember.$(window).unbind('hashchange.ember-location-'+guid); + } +}); + +Ember.Location.registerImplementation('hash', Ember.HashLocation); + +})(); + + + +(function() { +/** +@module ember +@submodule ember-routing +*/ + +var get = Ember.get, set = Ember.set; +var popstateReady = false; + +/** + Ember.HistoryLocation implements the location API using the browser's + history.pushState API. + + @class HistoryLocation + @namespace Ember + @extends Ember.Object +*/ +Ember.HistoryLocation = Ember.Object.extend({ + + init: function() { + set(this, 'location', get(this, 'location') || window.location); + this.initState(); + }, + + /** + @private + + Used to set state on first call to setURL + + @method initState + */ + initState: function() { + this.replaceState(this.formatURL(this.getURL())); + set(this, 'history', window.history); + }, + + /** + Will be pre-pended to path upon state change + + @property rootURL + @default '/' + */ + rootURL: '/', + + /** + @private + + Returns the current `location.pathname` without rootURL + + @method getURL + */ + getURL: function() { + var rootURL = get(this, 'rootURL'), + url = get(this, 'location').pathname; + + rootURL = rootURL.replace(/\/$/, ''); + url = url.replace(rootURL, ''); + + return url; + }, + + /** + @private + + Uses `history.pushState` to update the url without a page reload. + + @method setURL + @param path {String} + */ + setURL: function(path) { + path = this.formatURL(path); + + if (this.getState() && this.getState().path !== path) { + popstateReady = true; + this.pushState(path); + } + }, + + /** + @private + + Uses `history.replaceState` to update the url without a page reload + or history modification. + + @method replaceURL + @param path {String} + */ + replaceURL: function(path) { + path = this.formatURL(path); + + if (this.getState() && this.getState().path !== path) { + popstateReady = true; + this.replaceState(path); + } + }, + + /** + @private + + Get the current `history.state` + + @method getState + */ + getState: function() { + return get(this, 'history').state; + }, + + /** + @private + + Pushes a new state + + @method pushState + @param path {String} + */ + pushState: function(path) { + window.history.pushState({ path: path }, null, path); + }, + + /** + @private + + Replaces the current state + + @method replaceState + @param path {String} + */ + replaceState: function(path) { + window.history.replaceState({ path: path }, null, path); + }, + + /** + @private + + Register a callback to be invoked whenever the browser + history changes, including using forward and back buttons. + + @method onUpdateURL + @param callback {Function} + */ + onUpdateURL: function(callback) { + var guid = Ember.guidFor(this), + self = this; + + Ember.$(window).bind('popstate.ember-location-'+guid, function(e) { + if(!popstateReady) { + return; + } + callback(self.getURL()); + }); + }, + + /** + @private + + Used when using `{{action}}` helper. The url is always appended to the rootURL. + + @method formatURL + @param url {String} + */ + formatURL: function(url) { + var rootURL = get(this, 'rootURL'); + + if (url !== '') { + rootURL = rootURL.replace(/\/$/, ''); + } + + return rootURL + url; + }, + + willDestroy: function() { + var guid = Ember.guidFor(this); + + Ember.$(window).unbind('popstate.ember-location-'+guid); + } +}); + +Ember.Location.registerImplementation('history', Ember.HistoryLocation); + +})(); + + + +(function() { + +})(); + + + +(function() { +/** +Ember Routing + +@module ember +@submodule ember-routing +@requires ember-states +@requires ember-views +*/ + +})(); + +(function() { +function visit(vertex, fn, visited, path) { + var name = vertex.name, + vertices = vertex.incoming, + names = vertex.incomingNames, + len = names.length, + i; + if (!visited) { + visited = {}; + } + if (!path) { + path = []; + } + if (visited.hasOwnProperty(name)) { + return; + } + path.push(name); + visited[name] = true; + for (i = 0; i < len; i++) { + visit(vertices[names[i]], fn, visited, path); + } + fn(vertex, path); + path.pop(); +} + +function DAG() { + this.names = []; + this.vertices = {}; +} + +DAG.prototype.add = function(name) { + if (!name) { return; } + if (this.vertices.hasOwnProperty(name)) { + return this.vertices[name]; + } + var vertex = { + name: name, incoming: {}, incomingNames: [], hasOutgoing: false, value: null + }; + this.vertices[name] = vertex; + this.names.push(name); + return vertex; +}; + +DAG.prototype.map = function(name, value) { + this.add(name).value = value; +}; + +DAG.prototype.addEdge = function(fromName, toName) { + if (!fromName || !toName || fromName === toName) { + return; + } + var from = this.add(fromName), to = this.add(toName); + if (to.incoming.hasOwnProperty(fromName)) { + return; + } + function checkCycle(vertex, path) { + if (vertex.name === toName) { + throw new Error("cycle detected: " + toName + " <- " + path.join(" <- ")); + } + } + visit(from, checkCycle); + from.hasOutgoing = true; + to.incoming[fromName] = from; + to.incomingNames.push(fromName); +}; + +DAG.prototype.topsort = function(fn) { + var visited = {}, + vertices = this.vertices, + names = this.names, + len = names.length, + i, vertex; + for (i = 0; i < len; i++) { + vertex = vertices[names[i]]; + if (!vertex.hasOutgoing) { + visit(vertex, fn, visited); + } + } +}; + +DAG.prototype.addEdges = function(name, value, before, after) { + var i; + this.map(name, value); + if (before) { + if (typeof before === 'string') { + this.addEdge(name, before); + } else { + for (i = 0; i < before.length; i++) { + this.addEdge(name, before[i]); + } + } + } + if (after) { + if (typeof after === 'string') { + this.addEdge(after, name); + } else { + for (i = 0; i < after.length; i++) { + this.addEdge(after[i], name); + } + } + } +}; + +Ember.DAG = DAG; + +})(); + + + +(function() { +/** +@module ember +@submodule ember-application +*/ + +var get = Ember.get, set = Ember.set, + classify = Ember.String.classify, + decamelize = Ember.String.decamelize; + +/** + An instance of `Ember.Application` is the starting point for every Ember + application. It helps to instantiate, initialize and coordinate the many + objects that make up your app. + + Each Ember app has one and only one `Ember.Application` object. In fact, the + very first thing you should do in your application is create the instance: + + ```javascript + window.App = Ember.Application.create(); + ``` + + Typically, the application object is the only global variable. All other + classes in your app should be properties on the `Ember.Application` instance, + which highlights its first role: a global namespace. + + For example, if you define a view class, it might look like this: + + ```javascript + App.MyView = Ember.View.extend(); + ``` + + By default, calling `Ember.Application.create()` will automatically initialize + your application by calling the `Ember.Application.initialize()` method. If + you need to delay initialization, you can call your app's `deferReadiness()` + method. When you are ready for your app to be initialized, call its + `advanceReadiness()` method. + + Because `Ember.Application` inherits from `Ember.Namespace`, any classes + you create will have useful string representations when calling `toString()`. + See the `Ember.Namespace` documentation for more information. + + While you can think of your `Ember.Application` as a container that holds the + other classes in your application, there are several other responsibilities + going on under-the-hood that you may want to understand. + + ### Event Delegation + + Ember uses a technique called _event delegation_. This allows the framework + to set up a global, shared event listener instead of requiring each view to + do it manually. For example, instead of each view registering its own + `mousedown` listener on its associated element, Ember sets up a `mousedown` + listener on the `body`. + + If a `mousedown` event occurs, Ember will look at the target of the event and + start walking up the DOM node tree, finding corresponding views and invoking + their `mouseDown` method as it goes. + + `Ember.Application` has a number of default events that it listens for, as + well as a mapping from lowercase events to camel-cased view method names. For + example, the `keypress` event causes the `keyPress` method on the view to be + called, the `dblclick` event causes `doubleClick` to be called, and so on. + + If there is a browser event that Ember does not listen for by default, you + can specify custom events and their corresponding view method names by + setting the application's `customEvents` property: + + ```javascript + App = Ember.Application.create({ + customEvents: { + // add support for the loadedmetadata media + // player event + 'loadedmetadata': "loadedMetadata" + } + }); + ``` + + By default, the application sets up these event listeners on the document + body. However, in cases where you are embedding an Ember application inside + an existing page, you may want it to set up the listeners on an element + inside the body. + + For example, if only events inside a DOM element with the ID of `ember-app` + should be delegated, set your application's `rootElement` property: + + ```javascript + window.App = Ember.Application.create({ + rootElement: '#ember-app' + }); + ``` + + The `rootElement` can be either a DOM element or a jQuery-compatible selector + string. Note that *views appended to the DOM outside the root element will + not receive events.* If you specify a custom root element, make sure you only + append views inside it! + + To learn more about the advantages of event delegation and the Ember view + layer, and a list of the event listeners that are setup by default, visit the + [Ember View Layer guide](http://emberjs.com/guides/view_layer#toc_event-delegation). + + ### Initializers + + Libraries on top of Ember can register additional initializers, like so: + + ```javascript + Ember.Application.initializer({ + name: "store", + + initialize: function(container, application) { + container.register('store', 'main', application.Store); + } + }); + ``` + + ### Routing + + In addition to creating your application's router, `Ember.Application` is + also responsible for telling the router when to start routing. + + By default, the router will begin trying to translate the current URL into + application state once the browser emits the `DOMContentReady` event. If you + need to defer routing, you can call the application's `deferReadiness()` + method. Once routing can begin, call the `advanceReadiness()` method. + + If there is any setup required before routing begins, you can implement a + `ready()` method on your app that will be invoked immediately before routing + begins: + + ```javascript + window.App = Ember.Application.create({ + ready: function() { + this.set('router.enableLogging', true); + } + }); + + To begin routing, you must have at a minimum a top-level controller and view. + You define these as `App.ApplicationController` and `App.ApplicationView`, + respectively. Your application will not work if you do not define these two + mandatory classes. For example: + + ```javascript + App.ApplicationView = Ember.View.extend({ + templateName: 'application' + }); + App.ApplicationController = Ember.Controller.extend(); + ``` + + @class Application + @namespace Ember + @extends Ember.Namespace +*/ +var Application = Ember.Application = Ember.Namespace.extend( +/** @scope Ember.Application.prototype */{ + + /** + The root DOM element of the Application. This can be specified as an + element or a + [jQuery-compatible selector string](http://api.jquery.com/category/selectors/). + + This is the element that will be passed to the Application's, + `eventDispatcher`, which sets up the listeners for event delegation. Every + view in your application should be a child of the element you specify here. + + @property rootElement + @type DOMElement + @default 'body' + */ + rootElement: 'body', + + /** + The `Ember.EventDispatcher` responsible for delegating events to this + application's views. + + The event dispatcher is created by the application at initialization time + and sets up event listeners on the DOM element described by the + application's `rootElement` property. + + See the documentation for `Ember.EventDispatcher` for more information. + + @property eventDispatcher + @type Ember.EventDispatcher + @default null + */ + eventDispatcher: null, + + /** + The DOM events for which the event dispatcher should listen. + + By default, the application's `Ember.EventDispatcher` listens + for a set of standard DOM events, such as `mousedown` and + `keyup`, and delegates them to your application's `Ember.View` + instances. + + If you would like additional events to be delegated to your + views, set your `Ember.Application`'s `customEvents` property + to a hash containing the DOM event name as the key and the + corresponding view method name as the value. For example: + + ```javascript + App = Ember.Application.create({ + customEvents: { + // add support for the loadedmetadata media + // player event + 'loadedmetadata': "loadedMetadata" + } + }); + ``` + + @property customEvents + @type Object + @default null + */ + customEvents: null, + + isInitialized: false, + + // Start off the number of deferrals at 1. This will be + // decremented by the Application's own `initialize` method. + _readinessDeferrals: 1, + + init: function() { + if (!this.$) { this.$ = Ember.$; } + this.__container__ = this.buildContainer(); + + this.Router = this.Router || this.defaultRouter(); + if (this.Router) { this.Router.namespace = this; } + + this._super(); + + this.deferUntilDOMReady(); + this.scheduleInitialize(); + + + + + + }, + + /** + @private + + Build the container for the current application. + + Also register a default application view in case the application + itself does not. + + @method buildContainer + @return {Ember.Container} the configured container + */ + buildContainer: function() { + var container = this.__container__ = Application.buildContainer(this); + + return container; + }, + + /** + @private + + If the application has not opted out of routing and has not explicitly + defined a router, supply a default router for the application author + to configure. + + This allows application developers to do: + + ```javascript + App = Ember.Application.create(); + + App.Router.map(function(match) { + match("/").to("index"); + }); + ``` + + @method defaultRouter + @return {Ember.Router} the default router + */ + defaultRouter: function() { + // Create a default App.Router if one was not supplied to make + // it possible to do App.Router.map(...) without explicitly + // creating a router first. + if (this.router === undefined) { + return Ember.Router.extend(); + } + }, + + /** + @private + + Defer Ember readiness until DOM readiness. By default, Ember + will wait for both DOM readiness and application initialization, + as well as any deferrals registered by initializers. + + @method deferUntilDOMReady + */ + deferUntilDOMReady: function() { + this.deferReadiness(); + + var self = this; + this.$().ready(function() { + self.advanceReadiness(); + }); + }, + + /** + @private + + Automatically initialize the application once the DOM has + become ready. + + The initialization itself is deferred using Ember.run.once, + which ensures that application loading finishes before + booting. + + If you are asynchronously loading code, you should call + `deferReadiness()` to defer booting, and then call + `advanceReadiness()` once all of your code has finished + loading. + + @method scheduleInitialize + */ + scheduleInitialize: function() { + var self = this; + this.$().ready(function() { + if (self.isDestroyed || self.isInitialized) return; + Ember.run.once(self, 'initialize'); + }); + }, + + /** + Use this to defer readiness until some condition is true. + + Example: + + ```javascript + App = Ember.Application.create(); + App.deferReadiness(); + + jQuery.getJSON("/auth-token", function(token) { + App.token = token; + App.advanceReadiness(); + }); + ``` + + This allows you to perform asynchronous setup logic and defer + booting your application until the setup has finished. + + However, if the setup requires a loading UI, it might be better + to use the router for this purpose. + + @method deferReadiness + */ + deferReadiness: function() { + + this._readinessDeferrals++; + }, + + /** + @method advanceReadiness + @see {Ember.Application#deferReadiness} + */ + advanceReadiness: function() { + this._readinessDeferrals--; + + if (this._readinessDeferrals === 0) { + Ember.run.once(this, this.didBecomeReady); + } + }, + + /** + registers a factory for later injection + + Example: + + ```javascript + App = Ember.Application.create(); + + App.Person = Ember.Object.extend({}); + App.Orange = Ember.Object.extend({}); + App.Email = Ember.Object.extend({}); + + App.register('model:user', App.Person, {singleton: false }); + App.register('fruit:favorite', App.Orange); + App.register('communication:main', App.Email, {singleton: false}); + ``` + + @method register + @param type {String} + @param name {String} + @param factory {String} + @param options {String} (optional) + **/ + register: function() { + var container = this.__container__; + container.register.apply(container, arguments); + }, + /** + defines an injection or typeInjection + + Example: + + ```javascript + App.inject(, , ) + App.inject('model:user', 'email', 'model:email') + App.inject('model', 'source', 'source:main') + ``` + + @method inject + @param factoryNameOrType {String} + @param property {String} + @param injectionName {String} + **/ + inject: function(){ + var container = this.__container__; + container.injection.apply(container, arguments); + }, + + /** + @private + + Initialize the application. This happens automatically. + + Run any initializers and run the application load hook. These hooks may + choose to defer readiness. For example, an authentication hook might want + to defer readiness until the auth token has been retrieved. + + @method initialize + */ + initialize: function() { + + + this.isInitialized = true; + + // At this point, the App.Router must already be assigned + this.__container__.register('router', 'main', this.Router); + + this.runInitializers(); + Ember.runLoadHooks('application', this); + + // At this point, any initializers or load hooks that would have wanted + // to defer readiness have fired. In general, advancing readiness here + // will proceed to didBecomeReady. + this.advanceReadiness(); + + return this; + }, + + /** + @private + @method runInitializers + */ + runInitializers: function() { + var initializers = get(this.constructor, 'initializers'), + container = this.__container__, + graph = new Ember.DAG(), + namespace = this, + properties, i, initializer; + + for (i=0; i` state will be added to the list of enter and exit + // states because its context has changed. + + while (contexts.length > 0) { + if (stateIdx >= 0) { + state = this.enterStates[stateIdx--]; + } else { + if (this.enterStates.length) { + state = get(this.enterStates[0], 'parentState'); + if (!state) { throw "Cannot match all contexts to states"; } + } else { + // If re-entering the current state with a context, the resolve + // state will be the current state. + state = this.resolveState; + } + + this.enterStates.unshift(state); + this.exitStates.unshift(state); + } + + // in routers, only states with dynamic segments have a context + if (get(state, 'hasContext')) { + context = contexts.pop(); + } else { + context = null; + } + + matchedContexts.unshift(context); + } + + this.contexts = matchedContexts; + }, + + /** + Add any `initialState`s to the list of enter states. + + @method addInitialStates + */ + addInitialStates: function() { + var finalState = this.finalState, initialState; + + while(true) { + initialState = get(finalState, 'initialState') || 'start'; + finalState = get(finalState, 'states.' + initialState); + + if (!finalState) { break; } + + this.finalState = finalState; + this.enterStates.push(finalState); + this.contexts.push(undefined); + } + }, + + /** + Remove any states that were added because the number of contexts + exceeded the number of explicit enter states, but the context has + not changed since the last time the state was entered. + + @method removeUnchangedContexts + @param {Ember.StateManager} manager passed in to look up the last + context for a states + */ + removeUnchangedContexts: function(manager) { + // Start from the beginning of the enter states. If the state was added + // to the list during the context matching phase, make sure the context + // has actually changed since the last time the state was entered. + while (this.enterStates.length > 0) { + if (this.enterStates[0] !== this.exitStates[0]) { break; } + + if (this.enterStates.length === this.contexts.length) { + if (manager.getStateMeta(this.enterStates[0], 'context') !== this.contexts[0]) { break; } + this.contexts.shift(); + } + + this.resolveState = this.enterStates.shift(); + this.exitStates.shift(); + } + } +}; + +var sendRecursively = function(event, currentState, isUnhandledPass) { + var log = this.enableLogging, + eventName = isUnhandledPass ? 'unhandledEvent' : event, + action = currentState[eventName], + contexts, sendRecursiveArguments, actionArguments; + + contexts = [].slice.call(arguments, 3); + + // Test to see if the action is a method that + // can be invoked. Don't blindly check just for + // existence, because it is possible the state + // manager has a child state of the given name, + // and we should still raise an exception in that + // case. + if (typeof action === 'function') { + if (log) { + if (isUnhandledPass) { + Ember.Logger.log(fmt("STATEMANAGER: Unhandled event '%@' being sent to state %@.", [event, get(currentState, 'path')])); + } else { + Ember.Logger.log(fmt("STATEMANAGER: Sending event '%@' to state %@.", [event, get(currentState, 'path')])); + } + } + + actionArguments = contexts; + if (isUnhandledPass) { + actionArguments.unshift(event); + } + actionArguments.unshift(this); + + return action.apply(currentState, actionArguments); + } else { + var parentState = get(currentState, 'parentState'); + if (parentState) { + + sendRecursiveArguments = contexts; + sendRecursiveArguments.unshift(event, parentState, isUnhandledPass); + + return sendRecursively.apply(this, sendRecursiveArguments); + } else if (!isUnhandledPass) { + return sendEvent.call(this, event, contexts, true); + } + } +}; + +var sendEvent = function(eventName, sendRecursiveArguments, isUnhandledPass) { + sendRecursiveArguments.unshift(eventName, get(this, 'currentState'), isUnhandledPass); + return sendRecursively.apply(this, sendRecursiveArguments); +}; + +/** + StateManager is part of Ember's implementation of a finite state machine. A + StateManager instance manages a number of properties that are instances of + `Ember.State`, + tracks the current active state, and triggers callbacks when states have changed. + + ## Defining States + + The states of StateManager can be declared in one of two ways. First, you can + define a `states` property that contains all the states: + + ```javascript + managerA = Ember.StateManager.create({ + states: { + stateOne: Ember.State.create(), + stateTwo: Ember.State.create() + } + }) + + managerA.get('states') + // { + // stateOne: Ember.State.create(), + // stateTwo: Ember.State.create() + // } + ``` + + You can also add instances of `Ember.State` (or an `Ember.State` subclass) + directly as properties of a StateManager. These states will be collected into + the `states` property for you. + + ```javascript + managerA = Ember.StateManager.create({ + stateOne: Ember.State.create(), + stateTwo: Ember.State.create() + }) + + managerA.get('states') + // { + // stateOne: Ember.State.create(), + // stateTwo: Ember.State.create() + // } + ``` + + ## The Initial State + + When created a StateManager instance will immediately enter into the state + defined as its `start` property or the state referenced by name in its + `initialState` property: + + ```javascript + managerA = Ember.StateManager.create({ + start: Ember.State.create({}) + }) + + managerA.get('currentState.name') // 'start' + + managerB = Ember.StateManager.create({ + initialState: 'beginHere', + beginHere: Ember.State.create({}) + }) + + managerB.get('currentState.name') // 'beginHere' + ``` + + Because it is a property you may also provide a computed function if you wish + to derive an `initialState` programmatically: + + ```javascript + managerC = Ember.StateManager.create({ + initialState: function(){ + if (someLogic) { + return 'active'; + } else { + return 'passive'; + } + }.property(), + active: Ember.State.create({}), + passive: Ember.State.create({}) + }) + ``` + + ## Moving Between States + + A StateManager can have any number of `Ember.State` objects as properties + and can have a single one of these states as its current state. + + Calling `transitionTo` transitions between states: + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({}), + poweredUp: Ember.State.create({}) + }) + + robotManager.get('currentState.name') // 'poweredDown' + robotManager.transitionTo('poweredUp') + robotManager.get('currentState.name') // 'poweredUp' + ``` + + Before transitioning into a new state the existing `currentState` will have + its `exit` method called with the StateManager instance as its first argument + and an object representing the transition as its second argument. + + After transitioning into a new state the new `currentState` will have its + `enter` method called with the StateManager instance as its first argument + and an object representing the transition as its second argument. + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({ + exit: function(stateManager){ + console.log("exiting the poweredDown state") + } + }), + poweredUp: Ember.State.create({ + enter: function(stateManager){ + console.log("entering the poweredUp state. Destroy all humans.") + } + }) + }) + + robotManager.get('currentState.name') // 'poweredDown' + robotManager.transitionTo('poweredUp') + + // will log + // 'exiting the poweredDown state' + // 'entering the poweredUp state. Destroy all humans.' + ``` + + Once a StateManager is already in a state, subsequent attempts to enter that + state will not trigger enter or exit method calls. Attempts to transition + into a state that the manager does not have will result in no changes in the + StateManager's current state: + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({ + exit: function(stateManager){ + console.log("exiting the poweredDown state") + } + }), + poweredUp: Ember.State.create({ + enter: function(stateManager){ + console.log("entering the poweredUp state. Destroy all humans.") + } + }) + }) + + robotManager.get('currentState.name') // 'poweredDown' + robotManager.transitionTo('poweredUp') + // will log + // 'exiting the poweredDown state' + // 'entering the poweredUp state. Destroy all humans.' + robotManager.transitionTo('poweredUp') // no logging, no state change + + robotManager.transitionTo('someUnknownState') // silently fails + robotManager.get('currentState.name') // 'poweredUp' + ``` + + Each state property may itself contain properties that are instances of + `Ember.State`. The StateManager can transition to specific sub-states in a + series of transitionTo method calls or via a single transitionTo with the + full path to the specific state. The StateManager will also keep track of the + full path to its currentState + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({ + charging: Ember.State.create(), + charged: Ember.State.create() + }), + poweredUp: Ember.State.create({ + mobile: Ember.State.create(), + stationary: Ember.State.create() + }) + }) + + robotManager.get('currentState.name') // 'poweredDown' + + robotManager.transitionTo('poweredUp') + robotManager.get('currentState.name') // 'poweredUp' + + robotManager.transitionTo('mobile') + robotManager.get('currentState.name') // 'mobile' + + // transition via a state path + robotManager.transitionTo('poweredDown.charging') + robotManager.get('currentState.name') // 'charging' + + robotManager.get('currentState.path') // 'poweredDown.charging' + ``` + + Enter transition methods will be called for each state and nested child state + in their hierarchical order. Exit methods will be called for each state and + its nested states in reverse hierarchical order. + + Exit transitions for a parent state are not called when entering into one of + its child states, only when transitioning to a new section of possible states + in the hierarchy. + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown', + poweredDown: Ember.State.create({ + enter: function(){}, + exit: function(){ + console.log("exited poweredDown state") + }, + charging: Ember.State.create({ + enter: function(){}, + exit: function(){} + }), + charged: Ember.State.create({ + enter: function(){ + console.log("entered charged state") + }, + exit: function(){ + console.log("exited charged state") + } + }) + }), + poweredUp: Ember.State.create({ + enter: function(){ + console.log("entered poweredUp state") + }, + exit: function(){}, + mobile: Ember.State.create({ + enter: function(){ + console.log("entered mobile state") + }, + exit: function(){} + }), + stationary: Ember.State.create({ + enter: function(){}, + exit: function(){} + }) + }) + }) + + + robotManager.get('currentState.path') // 'poweredDown' + robotManager.transitionTo('charged') + // logs 'entered charged state' + // but does *not* log 'exited poweredDown state' + robotManager.get('currentState.name') // 'charged + + robotManager.transitionTo('poweredUp.mobile') + // logs + // 'exited charged state' + // 'exited poweredDown state' + // 'entered poweredUp state' + // 'entered mobile state' + ``` + + During development you can set a StateManager's `enableLogging` property to + `true` to receive console messages of state transitions. + + ```javascript + robotManager = Ember.StateManager.create({ + enableLogging: true + }) + ``` + + ## Managing currentState with Actions + + To control which transitions are possible for a given state, and + appropriately handle external events, the StateManager can receive and + route action messages to its states via the `send` method. Calling to + `send` with an action name will begin searching for a method with the same + name starting at the current state and moving up through the parent states + in a state hierarchy until an appropriate method is found or the StateManager + instance itself is reached. + + If an appropriately named method is found it will be called with the state + manager as the first argument and an optional `context` object as the second + argument. + + ```javascript + managerA = Ember.StateManager.create({ + initialState: 'stateOne.substateOne.subsubstateOne', + stateOne: Ember.State.create({ + substateOne: Ember.State.create({ + anAction: function(manager, context){ + console.log("an action was called") + }, + subsubstateOne: Ember.State.create({}) + }) + }) + }) + + managerA.get('currentState.name') // 'subsubstateOne' + managerA.send('anAction') + // 'stateOne.substateOne.subsubstateOne' has no anAction method + // so the 'anAction' method of 'stateOne.substateOne' is called + // and logs "an action was called" + // with managerA as the first argument + // and no second argument + + someObject = {} + managerA.send('anAction', someObject) + // the 'anAction' method of 'stateOne.substateOne' is called again + // with managerA as the first argument and + // someObject as the second argument. + ``` + + If the StateManager attempts to send an action but does not find an appropriately named + method in the current state or while moving upwards through the state hierarchy, it will + repeat the process looking for a `unhandledEvent` method. If an `unhandledEvent` method is + found, it will be called with the original event name as the second argument. If an + `unhandledEvent` method is not found, the StateManager will throw a new Ember.Error. + + ```javascript + managerB = Ember.StateManager.create({ + initialState: 'stateOne.substateOne.subsubstateOne', + stateOne: Ember.State.create({ + substateOne: Ember.State.create({ + subsubstateOne: Ember.State.create({}), + unhandledEvent: function(manager, eventName, context) { + console.log("got an unhandledEvent with name " + eventName); + } + }) + }) + }) + + managerB.get('currentState.name') // 'subsubstateOne' + managerB.send('anAction') + // neither `stateOne.substateOne.subsubstateOne` nor any of it's + // parent states have a handler for `anAction`. `subsubstateOne` + // also does not have a `unhandledEvent` method, but its parent + // state, `substateOne`, does, and it gets fired. It will log + // "got an unhandledEvent with name anAction" + ``` + + Action detection only moves upwards through the state hierarchy from the current state. + It does not search in other portions of the hierarchy. + + ```javascript + managerC = Ember.StateManager.create({ + initialState: 'stateOne.substateOne.subsubstateOne', + stateOne: Ember.State.create({ + substateOne: Ember.State.create({ + subsubstateOne: Ember.State.create({}) + }) + }), + stateTwo: Ember.State.create({ + anAction: function(manager, context){ + // will not be called below because it is + // not a parent of the current state + } + }) + }) + + managerC.get('currentState.name') // 'subsubstateOne' + managerC.send('anAction') + // Error: could not + // respond to event anAction in state stateOne.substateOne.subsubstateOne. + ``` + + Inside of an action method the given state should delegate `transitionTo` calls on its + StateManager. + + ```javascript + robotManager = Ember.StateManager.create({ + initialState: 'poweredDown.charging', + poweredDown: Ember.State.create({ + charging: Ember.State.create({ + chargeComplete: function(manager, context){ + manager.transitionTo('charged') + } + }), + charged: Ember.State.create({ + boot: function(manager, context){ + manager.transitionTo('poweredUp') + } + }) + }), + poweredUp: Ember.State.create({ + beginExtermination: function(manager, context){ + manager.transitionTo('rampaging') + }, + rampaging: Ember.State.create() + }) + }) + + robotManager.get('currentState.name') // 'charging' + robotManager.send('boot') // throws error, no boot action + // in current hierarchy + robotManager.get('currentState.name') // remains 'charging' + + robotManager.send('beginExtermination') // throws error, no beginExtermination + // action in current hierarchy + robotManager.get('currentState.name') // remains 'charging' + + robotManager.send('chargeComplete') + robotManager.get('currentState.name') // 'charged' + + robotManager.send('boot') + robotManager.get('currentState.name') // 'poweredUp' + + robotManager.send('beginExtermination', allHumans) + robotManager.get('currentState.name') // 'rampaging' + ``` + + Transition actions can also be created using the `transitionTo` method of the `Ember.State` class. The + following example StateManagers are equivalent: + + ```javascript + aManager = Ember.StateManager.create({ + stateOne: Ember.State.create({ + changeToStateTwo: Ember.State.transitionTo('stateTwo') + }), + stateTwo: Ember.State.create({}) + }) + + bManager = Ember.StateManager.create({ + stateOne: Ember.State.create({ + changeToStateTwo: function(manager, context){ + manager.transitionTo('stateTwo', context) + } + }), + stateTwo: Ember.State.create({}) + }) + ``` + + @class StateManager + @namespace Ember + @extends Ember.State +**/ +Ember.StateManager = Ember.State.extend({ + /** + @private + + When creating a new statemanager, look for a default state to transition + into. This state can either be named `start`, or can be specified using the + `initialState` property. + + @method init + */ + init: function() { + this._super(); + + set(this, 'stateMeta', Ember.Map.create()); + + var initialState = get(this, 'initialState'); + + if (!initialState && get(this, 'states.start')) { + initialState = 'start'; + } + + if (initialState) { + this.transitionTo(initialState); + + } + }, + + stateMetaFor: function(state) { + var meta = get(this, 'stateMeta'), + stateMeta = meta.get(state); + + if (!stateMeta) { + stateMeta = {}; + meta.set(state, stateMeta); + } + + return stateMeta; + }, + + setStateMeta: function(state, key, value) { + return set(this.stateMetaFor(state), key, value); + }, + + getStateMeta: function(state, key) { + return get(this.stateMetaFor(state), key); + }, + + /** + The current state from among the manager's possible states. This property should + not be set directly. Use `transitionTo` to move between states by name. + + @property currentState + @type Ember.State + */ + currentState: null, + + /** + The path of the current state. Returns a string representation of the current + state. + + @property currentPath + @type String + */ + currentPath: Ember.computed.alias('currentState.path'), + + /** + The name of transitionEvent that this stateManager will dispatch + + @property transitionEvent + @type String + @default 'setup' + */ + transitionEvent: 'setup', + + /** + If set to true, `errorOnUnhandledEvents` will cause an exception to be + raised if you attempt to send an event to a state manager that is not + handled by the current state or any of its parent states. + + @property errorOnUnhandledEvents + @type Boolean + @default true + */ + errorOnUnhandledEvent: true, + + send: function(event) { + var contexts = [].slice.call(arguments, 1); + + return sendEvent.call(this, event, contexts, false); + }, + unhandledEvent: function(manager, event) { + if (get(this, 'errorOnUnhandledEvent')) { + throw new Ember.Error(this.toString() + " could not respond to event " + event + " in state " + get(this, 'currentState.path') + "."); + } + }, + + /** + Finds a state by its state path. + + Example: + + ```javascript + manager = Ember.StateManager.create({ + root: Ember.State.create({ + dashboard: Ember.State.create() + }) + }); + + manager.getStateByPath(manager, "root.dashboard") + + // returns the dashboard state + ``` + + @method getStateByPath + @param {Ember.State} root the state to start searching from + @param {String} path the state path to follow + @return {Ember.State} the state at the end of the path + */ + getStateByPath: function(root, path) { + var parts = path.split('.'), + state = root; + + for (var i=0, len=parts.length; i`, an attempt to + // transition to `comments.show` will match ``. + // + // First, this code will look for root.posts.show.comments.show. + // Next, it will look for root.posts.comments.show. Finally, + // it will look for `root.comments.show`, and find the state. + // + // After this process, the following variables will exist: + // + // * resolveState: a common parent state between the current + // and target state. In the above example, `` is the + // `resolveState`. + // * enterStates: a list of all of the states represented + // by the path from the `resolveState`. For example, for + // the path `root.comments.show`, `enterStates` would have + // `[, ]` + // * exitStates: a list of all of the states from the + // `resolveState` to the `currentState`. In the above + // example, `exitStates` would have + // `[`, `]`. + while (resolveState && !enterStates) { + exitStates.unshift(resolveState); + + resolveState = get(resolveState, 'parentState'); + if (!resolveState) { + enterStates = this.getStatesInPath(this, path); + if (!enterStates) { + + return; + } + } + enterStates = this.getStatesInPath(resolveState, path); + } + + // If the path contains some states that are parents of both the + // current state and the target state, remove them. + // + // For example, in the following hierarchy: + // + // |- root + // | |- post + // | | |- index (* current) + // | | |- show + // + // If the `path` is `root.post.show`, the three variables will + // be: + // + // * resolveState: `` + // * enterStates: `[, , ]` + // * exitStates: `[, , ]` + // + // The goal of this code is to remove the common states, so we + // have: + // + // * resolveState: `` + // * enterStates: `[]` + // * exitStates: `[]` + // + // This avoid unnecessary calls to the enter and exit transitions. + while (enterStates.length > 0 && enterStates[0] === exitStates[0]) { + resolveState = enterStates.shift(); + exitStates.shift(); + } + + // Cache the enterStates, exitStates, and resolveState for the + // current state and the `path`. + var transitions = currentState.pathsCache[path] = { + exitStates: exitStates, + enterStates: enterStates, + resolveState: resolveState + }; + + return transitions; + }, + + triggerSetupContext: function(transitions) { + var contexts = transitions.contexts, + offset = transitions.enterStates.length - contexts.length, + enterStates = transitions.enterStates, + transitionEvent = get(this, 'transitionEvent'); + + + arrayForEach.call(enterStates, function(state, idx) { + state.trigger(transitionEvent, this, contexts[idx-offset]); + }, this); + }, + + getState: function(name) { + var state = get(this, name), + parentState = get(this, 'parentState'); + + if (state) { + return state; + } else if (parentState) { + return parentState.getState(name); + } + }, + + enterState: function(transition) { + var log = this.enableLogging; + + var exitStates = transition.exitStates.slice(0).reverse(); + arrayForEach.call(exitStates, function(state) { + state.trigger('exit', this); + }, this); + + arrayForEach.call(transition.enterStates, function(state) { + if (log) { Ember.Logger.log("STATEMANAGER: Entering " + get(state, 'path')); } + state.trigger('enter', this); + }, this); + + set(this, 'currentState', transition.finalState); + } +}); + +})(); + + + +(function() { +/** +Ember States + +@module ember +@submodule ember-states +@requires ember-runtime +*/ + +})(); + + +})(); + + +if (typeof location !== 'undefined' && (location.hostname === 'localhost' || location.hostname === '127.0.0.1')) { + console.warn("You are running a production build of Ember on localhost and won't receive detailed error messages. "+ + "If you want full error messages please use the non-minified build provided on the Ember website."); +} diff --git a/app/assets/javascripts/external_production/group-helper.js b/app/assets/javascripts/external_production/group-helper.js new file mode 100644 index 00000000000..1824ed3e9b9 --- /dev/null +++ b/app/assets/javascripts/external_production/group-helper.js @@ -0,0 +1,23 @@ +(function() { +var get = Ember.get, set = Ember.set, EmberHandlebars = Ember.Handlebars; + +EmberHandlebars.registerHelper('group', function(options) { + var data = options.data, + fn = options.fn, + view = data.view, + childView; + + childView = view.createChildView(Ember._MetamorphView, { + context: get(view, 'context'), + + template: function(context, options) { + options.data.insideGroup = true; + return fn(context, options); + } + }); + + view.appendChild(childView); +}); + +})(); + diff --git a/app/assets/javascripts/external_production/sugar-1.3.5.js b/app/assets/javascripts/external_production/sugar-1.3.5.js new file mode 100644 index 00000000000..576f7ab7583 --- /dev/null +++ b/app/assets/javascripts/external_production/sugar-1.3.5.js @@ -0,0 +1,116 @@ +/* + * Sugar Library v1.3.5 + * + * Freely distributable and licensed under the MIT-style license. + * Copyright (c) 2012 Andrew Plummer + * http://sugarjs.com/ + * + * ---------------------------- */ +(function(){var k=true,l=null,n=false;function aa(a){return function(){return a}}var p=Object,q=Array,r=RegExp,s=Date,t=String,u=Number,v=Math,ba=typeof global!=="undefined"?global:this,ca=p.defineProperty&&p.defineProperties,x="Array,Boolean,Date,Function,Number,String,RegExp".split(","),da=y(x[0]),fa=y(x[1]),ga=y(x[2]),z=y(x[3]),A=y(x[4]),C=y(x[5]),D=y(x[6]);function y(a){return function(b){return p.prototype.toString.call(b)==="[object "+a+"]"}} +function ha(a){if(!a.SugarMethods){ia(a,"SugarMethods",{});E(a,n,n,{restore:function(){var b=arguments.length===0,c=F(arguments);G(a.SugarMethods,function(d,e){if(b||c.indexOf(d)>-1)ia(e.wa?a.prototype:a,d,e.method)})},extend:function(b,c,d){E(a,d!==n,c,b)}})}}function E(a,b,c,d){var e=b?a.prototype:a,g;ha(a);G(d,function(f,i){g=e[f];if(typeof c==="function")i=ja(e[f],i,c);if(c!==n||!e[f])ia(e,f,i);a.SugarMethods[f]={wa:b,method:i,Da:g}})} +function H(a,b,c,d,e){var g={};d=C(d)?d.split(","):d;d.forEach(function(f,i){e(g,f,i)});E(a,b,c,g)}function ja(a,b,c){return function(){return a&&(c===k||!c.apply(this,arguments))?a.apply(this,arguments):b.apply(this,arguments)}}function ia(a,b,c){if(ca)p.defineProperty(a,b,{value:c,configurable:k,enumerable:n,writable:k});else a[b]=c}function F(a,b){var c=[],d;for(d=0;d=b;){e.push(a);c&&c.call(this,a);a+=d||1}return e} +function M(a,b,c){c=v[c||"round"];var d=v.pow(10,v.abs(b||0));if(b<0)d=1/d;return c(a*d)/d}function N(a,b){return M(a,b,"floor")}function P(a,b,c,d){d=v.abs(a).toString(d||10);d=pa(b-d.replace(/\.\d+/,"").length,"0")+d;if(c||a<0)d=(a<0?"-":"+")+d;return d}function qa(a){if(a>=11&&a<=13)return"th";else switch(a%10){case 1:return"st";case 2:return"nd";case 3:return"rd";default:return"th"}} +function ra(){return"\t\n\u000b\u000c\r \u00a0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u2028\u2029\u3000\ufeff"}function pa(a,b){return q(v.max(0,I(a)?a:1)+1).join(b||"")}function sa(a,b){var c=a.toString().match(/[^/]*$/)[0];if(b)c=(c+b).split("").sort().join("").replace(/([gimy])\1+/g,"$1");return c}function Q(a){C(a)||(a=t(a));return a.replace(/([\\/'*+?|()\[\]{}.^$])/g,"\\$1")} +function ta(a,b){var c=typeof a,d,e,g,f,i,j;if(c==="string")return a;g=p.prototype.toString.call(a);d=L(a);e=g==="[object Array]";if(a!=l&&d||e){b||(b=[]);if(b.length>1)for(j=b.length;j--;)if(b[j]===a)return"CYC";b.push(a);d=t(a.constructor);f=e?a:p.keys(a).sort();for(j=0;j>0);if(c<0)c=e+c;if(!g&&c<0||g&&c>=e)c=f;for(;g&&c>=0||!g&&c>>0==e&&e!=4294967295&&e>=c&&d.push(parseInt(e));d.sort().each(function(g){return b.call(a,a[g],g,a)});return a}function Ea(a,b,c,d,e){var g,f;T(a,function(i,j,h){if(Ba(i,b,h,[i,j,h])){g=i;f=j;return n}},c,d);return e?f:g} +function Fa(a,b){var c=[],d={},e;T(a,function(g,f){e=b?R(g,b,a,[g,f,a]):g;Ga(d,e)||c.push(g)});return c}function Ha(a,b,c){var d=[],e={};b.each(function(g){Ga(e,g)});a.each(function(g){var f=ta(g),i=!ua(g);if(Ia(e,f,g,i)!=c){var j=0;if(i)for(f=e[f];je||i&&h0)c=b}a=a.concat(c)});return a}}); +E(q,k,n,{find:function(a,b,c){return Ea(this,a,b,c)},findAll:function(a,b,c){var d=[];T(this,function(e,g,f){Ba(e,a,f,[e,g,f])&&d.push(e)},b,c);return d},findIndex:function(a,b,c){a=Ea(this,a,b,c,k);return K(a)?-1:a},count:function(a){if(K(a))return this.length;return this.findAll(a).length},removeAt:function(a,b){if(K(a))return this;if(K(b))b=a;for(var c=0;c<=b-a;c++)this.splice(a,1);return this},include:function(a,b){return this.clone().add(a,b)},exclude:function(){return q.prototype.remove.apply(this.clone(), +arguments)},clone:function(){return ma([],this)},unique:function(a){return Fa(this,a)},flatten:function(a){return Ja(this,a)},union:function(){return Fa(this.concat(Ka(arguments)))},intersect:function(){return Ha(this,Ka(arguments),n)},subtract:function(){return Ha(this,Ka(arguments),k)},at:function(){return va(this,arguments)},first:function(a){if(K(a))return this[0];if(a<0)a=0;return this.slice(0,a)},last:function(a){if(K(a))return this[this.length-1];return this.slice(this.length-a<0?0:this.length- +a)},from:function(a){return this.slice(a)},to:function(a){if(K(a))a=this.length;return this.slice(0,a)},min:function(a,b){return La(this,a,"min",b)},max:function(a,b){return La(this,a,"max",b)},least:function(a,b){return La(this.groupBy.apply(this,[a]),"length","min",b)},most:function(a,b){return La(this.groupBy.apply(this,[a]),"length","max",b)},sum:function(a){a=a?this.map(a):this;return a.length>0?a.reduce(function(b,c){return b+c}):0},average:function(a){a=a?this.map(a):this;return a.length>0? +a.sum()/a.length:0},inGroups:function(a,b){var c=arguments.length>1,d=this,e=[],g=M(this.length/a,void 0,"ceil");oa(0,a-1,function(f){f=f*g;var i=d.slice(f,f+g);c&&i.lengthf?1:0;return g*(b?-1:1)});return c},randomize:function(){for(var a=this.concat(),b,c,d=a.length;d;b=parseInt(v.random()* +d),c=a[--d],a[d]=a[b],a[b]=c);return a},zip:function(){var a=F(arguments);return this.map(function(b,c){return[b].concat(a.map(function(d){return c in d?d[c]:l}))})},sample:function(a){var b=[],c=this.clone(),d;if(K(a))a=1;for(;b.length0?b:b[0]},each:function(a,b,c){T(this,a,b,c);return this},add:function(a,b){if(!A(u(b))||isNaN(b))b=this.length;q.prototype.splice.apply(this,[b,0].concat(a)); +return this},remove:function(){var a,b=this;F(arguments,function(c){for(a=0;a0&&!z(a[0])},"map,every,all,some,any,none,filter",function(a,b){a[b]=function(c){return this[b](function(d,e){return b==="map"?R(d,c,this,[d,e,this]):Ba(d,c,this,[d,e,this])})}})})(); +(function(){q[Sa]="A\u00c1\u00c0\u00c2\u00c3\u0104BC\u0106\u010c\u00c7D\u010e\u00d0E\u00c9\u00c8\u011a\u00ca\u00cb\u0118FG\u011eH\u0131I\u00cd\u00cc\u0130\u00ce\u00cfJKL\u0141MN\u0143\u0147\u00d1O\u00d3\u00d2\u00d4PQR\u0158S\u015a\u0160\u015eT\u0164U\u00da\u00d9\u016e\u00db\u00dcVWXY\u00ddZ\u0179\u017b\u017d\u00de\u00c6\u0152\u00d8\u00d5\u00c5\u00c4\u00d6".split("").map(function(b){return b+b.toLowerCase()}).join("");var a={};T("A\u00c1\u00c0\u00c2\u00c3\u00c4,C\u00c7,E\u00c9\u00c8\u00ca\u00cb,I\u00cd\u00cc\u0130\u00ce\u00cf,O\u00d3\u00d2\u00d4\u00d5\u00d6,S\u00df,U\u00da\u00d9\u00db\u00dc".split(","), +function(b){var c=b.charAt(0);T(b.slice(1).split(""),function(d){a[d]=c;a[d.toLowerCase()]=c.toLowerCase()})});q[Na]=k;q[Qa]=a})();Ua("each,any,all,none,count,find,findAll,isEmpty");Ua("sum,average,min,max,least,most",k);wa("map,reduce,size",na); +var U,Va,Wa=["ampm","hour","minute","second","ampm","utc","offset_sign","offset_hours","offset_minutes","ampm"],Xa="({t})?\\s*(\\d{1,2}(?:[,.]\\d+)?)(?:{h}(\\d{1,2}(?:[,.]\\d+)?)?{m}(?::?(\\d{1,2}(?:[,.]\\d+)?){s})?\\s*(?:({t})|(Z)|(?:([+-])(\\d{2,2})(?::?(\\d{2,2}))?)?)?|\\s*({t}))",Ya={},Za,$a,ab,bb=[],cb=[{ba:"f{1,4}|ms|milliseconds",format:function(a){return V(a,"Milliseconds")}},{ba:"ss?|seconds",format:function(a){return V(a,"Seconds")}},{ba:"mm?|minutes",format:function(a){return V(a,"Minutes")}}, +{ba:"hh?|hours|12hr",format:function(a){a=V(a,"Hours");return a===0?12:a-N(a/13)*12}},{ba:"HH?|24hr",format:function(a){return V(a,"Hours")}},{ba:"dd?|date|day",format:function(a){return V(a,"Date")}},{ba:"dow|weekday",la:k,format:function(a,b,c){a=V(a,"Day");return b.weekdays[a+(c-1)*7]}},{ba:"MM?",format:function(a){return V(a,"Month")+1}},{ba:"mon|month",la:k,format:function(a,b,c){a=V(a,"Month");return b.months[a+(c-1)*12]}},{ba:"y{2,4}|year",format:function(a){return V(a,"FullYear")}},{ba:"[Tt]{1,2}", +format:function(a,b,c,d){if(b.ampm.length==0)return"";a=V(a,"Hours");b=b.ampm[N(a/12)];if(d.length===1)b=b.slice(0,1);if(d.slice(0,1)==="T")b=b.toUpperCase();return b}},{ba:"z{1,4}|tz|timezone",text:k,format:function(a,b,c,d){a=a.getUTCOffset();if(d=="z"||d=="zz")a=a.replace(/(\d{2})(\d{2})/,function(e,g){return P(g,d.length)});return a}},{ba:"iso(tz|timezone)",format:function(a){return a.getUTCOffset(k)}},{ba:"ord",format:function(a){a=V(a,"Date");return a+qa(a)}}],db=[{$:"year",method:"FullYear", +ja:k,da:function(a){return(365+(a?a.isLeapYear()?1:0:0.25))*24*60*60*1E3}},{$:"month",method:"Month",ja:k,da:function(a,b){var c=30.4375,d;if(a){d=a.daysInMonth();if(b<=d.days())c=d}return c*24*60*60*1E3}},{$:"week",method:"Week",da:aa(6048E5)},{$:"day",method:"Date",ja:k,da:aa(864E5)},{$:"hour",method:"Hours",da:aa(36E5)},{$:"minute",method:"Minutes",da:aa(6E4)},{$:"second",method:"Seconds",da:aa(1E3)},{$:"millisecond",method:"Milliseconds",da:aa(1)}],eb={}; +function fb(a){ma(this,a);this.ga=bb.concat()} +fb.prototype={getMonth:function(a){return A(a)?a-1:this.months.indexOf(a)%12},getWeekday:function(a){return this.weekdays.indexOf(a)%7},oa:function(a){var b;return A(a)?a:a&&(b=this.numbers.indexOf(a))!==-1?(b+1)%10:1},ta:function(a){var b=this;return a.replace(r(this.num,"g"),function(c){return b.oa(c)||""})},ra:function(a){return U.units[this.units.indexOf(a)%8]},ua:function(a){return this.na(a,a[2]>0?"future":"past")},qa:function(a){return this.na(gb(a),"duration")},va:function(a){a=a||this.code; +return a==="en"||a==="en-US"?k:this.variant},ya:function(a){return a===this.ampm[0]},za:function(a){return a&&a===this.ampm[1]},na:function(a,b){var c,d,e=a[0],g=a[1],f=a[2],i=this[b]||this.relative;if(z(i))return i.call(this,e,g,f,b);d=this.units[(this.plural&&e>1?1:0)*8+g]||this.units[g];if(this.capitalizeUnit)d=hb(d);c=this.modifiers.filter(function(j){return j.name=="sign"&&j.value==(f>0?1:-1)})[0];return i.replace(/\{(.*?)\}/g,function(j,h){switch(h){case "num":return e;case "unit":return d; +case "sign":return c.src}})},sa:function(){return this.ma?[this.ma].concat(this.ga):this.ga},addFormat:function(a,b,c,d,e){var g=c||[],f=this,i;a=a.replace(/\s+/g,"[-,. ]*");a=a.replace(/\{([^,]+?)\}/g,function(j,h){var m,o,w,B=h.match(/\?$/);w=h.match(/^(\d+)\??$/);var J=h.match(/(\d)(?:-(\d))?/),O=h.replace(/[^a-z]+$/,"");if(w)m=f.tokens[w[1]];else if(f[O])m=f[O];else if(f[O+"s"]){m=f[O+"s"];if(J){o=[];m.forEach(function(ea,Da){var S=Da%(f.units?8:m.length);if(S>=J[1]&&S<=(J[2]||J[1]))o.push(ea)}); +m=o}m=ib(m)}if(w)w="(?:"+m+")";else{c||g.push(O);w="("+m+")"}if(B)w+="?";return w});if(b){b=jb(Xa,f,e);e=["t","[\\s\\u3000]"].concat(f.timeMarker);i=a.match(/\\d\{\d,\d\}\)+\??$/);kb(f,"(?:"+b+")[,\\s\\u3000]+?"+a,Wa.concat(g),d);kb(f,a+"(?:[,\\s]*(?:"+e.join("|")+(i?"+":"*")+")"+b+")?",g.concat(Wa),d)}else kb(f,a,g,d)}};function lb(a,b){var c;C(a)||(a="");c=eb[a]||eb[a.slice(0,2)];if(b===n&&!c)throw Error("Invalid locale.");return c||Va} +function mb(a,b){function c(j){var h=f[j];if(C(h))f[j]=h.split(",");else h||(f[j]=[])}function d(j,h){j=j.split("+").map(function(m){return m.replace(/(.+):(.+)$/,function(o,w,B){return B.split("|").map(function(J){return w+J}).join("|")})}).join("|");return j.split("|").forEach(h)}function e(j,h,m){var o=[];f[j].forEach(function(w,B){if(h)w+="+"+w.slice(0,3);d(w,function(J,O){o[O*m+B]=J.toLowerCase()})});f[j]=o}function g(j,h,m){j="\\d{"+j+","+h+"}";if(m)j+="|(?:"+ib(f.numbers)+")+";return j}var f, +i;f=new fb(b);c("modifiers");"months,weekdays,units,numbers,articles,tokens,timeMarker,ampm,timeSuffixes,dateParse,timeParse".split(",").forEach(c);i=!f.monthSuffix;e("months",i,12);e("weekdays",i,7);e("units",n,8);e("numbers",n,10);f.code=a;f.date=g(1,2,f.digitDate);f.year=g(4,4);f.num=function(){var j=["\\d+"].concat(f.articles);if(f.numbers)j=j.concat(f.numbers);return ib(j)}();(function(){var j=[];f.ha={};f.modifiers.forEach(function(h){var m=h.name;d(h.src,function(o){var w=f[m];f.ha[o]=h;j.push({name:m, +src:o,value:h.value});f[m]=w?w+"|"+o:o})});f.day+="|"+ib(f.weekdays);f.modifiers=j})();if(f.monthSuffix){f.month=g(1,2);f.months=oa(1,12).map(function(j){return j+f.monthSuffix})}f.full_month=g(1,2)+"|"+ib(f.months);f.timeSuffixes.length>0&&f.addFormat(jb(Xa,f),n,Wa);f.addFormat("{day}",k);f.addFormat("{month}"+(f.monthSuffix||""));f.addFormat("{year}"+(f.yearSuffix||""));f.timeParse.forEach(function(j){f.addFormat(j,k)});f.dateParse.forEach(function(j){f.addFormat(j)});return eb[a]=f} +function kb(a,b,c,d){a.ga.unshift({Ba:d,xa:a,Aa:r("^"+b+"$","i"),to:c})}function hb(a){return a.slice(0,1).toUpperCase()+a.slice(1)}function ib(a){return a.filter(function(b){return!!b}).join("|")}function nb(a,b){var c;if(L(a[0]))return a;else if(A(a[0])&&!A(a[1]))return[a[0]];else if(C(a[0])&&b)return[ob(a[0]),a[1]];c={};$a.forEach(function(d,e){c[d.$]=a[e]});return[c]} +function ob(a,b){var c={};if(match=a.match(/^(\d+)?\s?(\w+?)s?$/i)){if(K(b))b=parseInt(match[1])||1;c[match[2].toLowerCase()]=b}return c}function pb(a,b){var c={},d,e;b.forEach(function(g,f){d=a[f+1];if(!(K(d)||d==="")){if(g==="year")c.Ca=d;e=parseFloat(d.replace(/,/,"."));c[g]=!isNaN(e)?e:d.toLowerCase()}});return c}function qb(a){a=a.trim().replace(/^(just )?now|\.+$/i,"");return rb(a)} +function rb(a){return a.replace(Za,function(b,c,d){var e=0,g=1,f,i;if(c)return b;d.split("").reverse().forEach(function(j){j=Ya[j];var h=j>9;if(h){if(f)e+=g;g*=j/(i||1);i=j}else{if(f===n)g*=10;e+=g*j}f=h});if(f)e+=g;return e})} +function sb(a,b,c,d){var e=new s,g=n,f,i,j,h,m,o,w,B,J;e.utc(d);if(ga(a))e=new s(a.getTime());else if(A(a))e=new s(a);else if(L(a)){e.set(a,k);h=a}else if(C(a)){f=lb(b);a=qb(a);f&&G(f.sa(),function(O,ea){var Da=a.match(ea.Aa);if(Da){j=ea;i=j.xa;h=pb(Da,j.to,i);h.utc&&e.utc();i.ma=j;if(h.timestamp){h=h.timestamp;return n}if(j.Ba&&!C(h.month)&&(C(h.date)||f.va(b))){B=h.month;h.month=h.date;h.date=B}if(h.year&&h.Ca.length===2)h.year=M(V(new s,"FullYear")/100)*100-M(h.year/100)*100+h.year;if(h.month){h.month= +i.getMonth(h.month);if(h.shift&&!h.unit)h.unit=i.units[7]}if(h.weekday&&h.date)delete h.weekday;else if(h.weekday){h.weekday=i.getWeekday(h.weekday);if(h.shift&&!h.unit)h.unit=i.units[5]}if(h.day&&(B=i.ha[h.day])){h.day=B.value;e.reset();g=k}else if(h.day&&(o=i.getWeekday(h.day))>-1){delete h.day;if(h.num&&h.month){J=function(){var S=e.getWeekday();e.setWeekday(7*(h.num-1)+(S>o?o+7:o))};h.day=1}else h.weekday=o}if(h.date&&!A(h.date))h.date=i.ta(h.date);if(i.za(h.ampm)&&h.hour<12)h.hour+=12;else if(i.ya(h.ampm)&& +h.hour===12)h.hour=0;if("offset_hours"in h||"offset_minutes"in h){e.utc();h.offset_minutes=h.offset_minutes||0;h.offset_minutes+=h.offset_hours*60;if(h.offset_sign==="-")h.offset_minutes*=-1;h.minute-=h.offset_minutes}if(h.unit){g=k;w=i.oa(h.num);m=i.ra(h.unit);if(h.shift||h.edge){w*=(B=i.ha[h.shift])?B.value:0;if(m==="month"&&I(h.date)){e.set({day:h.date},k);delete h.date}if(m==="year"&&I(h.month)){e.set({month:h.month,day:h.date},k);delete h.month;delete h.date}}if(h.sign&&(B=i.ha[h.sign]))w*=B.value; +if(I(h.weekday)){e.set({weekday:h.weekday},k);delete h.weekday}h[m]=(h[m]||0)+w}if(h.year_sign==="-")h.year*=-1;ab.slice(1,4).forEach(function(S,Ub){var zb=h[S.$],Ab=zb%1;if(Ab){h[ab[Ub].$]=M(Ab*(S.$==="second"?1E3:60));h[S.$]=N(zb)}});return n}});if(j)if(g)e.advance(h);else{e._utc&&e.reset();tb(e,h,k,n,c)}else e=a?new s(a):new s;if(h&&h.edge){B=i.ha[h.edge];G(ab.slice(4),function(O,ea){if(I(h[ea.$])){m=ea.$;return n}});if(m==="year")h.fa="month";else if(m==="month"||m==="week")h.fa="day";e[(B.value< +0?"endOf":"beginningOf")+hb(m)]();B.value===-2&&e.reset()}J&&J()}e.utc(n);return{ea:e,set:h}}function gb(a){var b,c=v.abs(a),d=c,e=0;ab.slice(1).forEach(function(g,f){b=N(M(c/g.da()*10)/10);if(b>=1){d=b;e=f+1}});return[d,e,a]} +function ub(a,b,c,d){var e,g=lb(d),f=r(/^[A-Z]/);if(a.isValid())if(Date[b])b=Date[b];else{if(z(b)){e=gb(a.millisecondsFromNow());b=b.apply(a,e.concat(g))}}else return"Invalid Date";if(!b&&c){e=e||gb(a.millisecondsFromNow());if(e[1]===0){e[1]=1;e[0]=1}return g.ua(e)}b=b||"long";b=g[b]||b;cb.forEach(function(i){b=b.replace(r("\\{("+i.ba+")(\\d)?\\}",i.la?"i":""),function(j,h,m){j=i.format(a,g,m||1,h);m=h.length;var o=h.match(/^(.)\1+$/);if(i.la){if(m===3)j=j.slice(0,3);if(o||h.match(f))j=hb(j)}else if(o&& +!i.text)j=(A(j)?P(j,m):j.toString()).slice(-m);return j})});return b} +function vb(a,b,c,d){var e=sb(b,l,l,d),g=0;d=b=0;var f;if(c>0){b=d=c;f=k}if(!e.ea.isValid())return n;if(e.set&&e.set.fa){db.forEach(function(j){if(j.$===e.set.fa)g=j.da(e.ea,a-e.ea)-1});c=hb(e.set.fa);if(e.set.edge||e.set.shift)e.ea["beginningOf"+c]();if(e.set.fa==="month")i=e.ea.clone()["endOf"+c]().getTime();if(!f&&e.set.sign&&e.set.fa!="millisecond"){b=50;d=-50}}f=a.getTime();c=e.ea.getTime();var i=i||c+g;return f>=c-b&&f<=i+d} +function tb(a,b,c,d,e){function g(h){return I(b[h])?b[h]:b[h+"s"]}function f(h){return I(g(h))}var i,j;if(A(b)&&d)b={milliseconds:b};else if(A(b)){a.setTime(b);return a}if(b.date)b.day=b.date;G(ab,function(h,m){var o=m.$==="day";if(f(m.$)||o&&f("weekday")){b.fa=m.$;j=+h;return n}else if(c&&m.$!=="week"&&(!o||!f("week")))W(a,m.method,o?1:0)});db.forEach(function(h){var m=h.$;h=h.method;var o;o=g(m);if(!K(o)){if(d){if(m==="week"){o=(b.day||0)+o*7;h="Date"}o=o*d+V(a,h)}else m==="month"&&f("day")&&W(a, +"Date",15);W(a,h,o);if(d&&m==="month"){m=o;if(m<0)m+=12;m%12!=V(a,"Month")&&W(a,"Date",0)}}});if(!d&&!f("day")&&f("weekday")){i=g("weekday");a.setWeekday(i)}(function(){var h=new s;return e===-1&&a>h||e===1&&as.create(a).getTime()-(b||0)},isBefore:function(a,b){return this.getTime()d},isLeapYear:function(){var a=V(this,"FullYear");return a%4===0&&a%100!==0||a%400===0},daysInMonth:function(){return 32-V(new s(V(this,"FullYear"),V(this,"Month"),32),"Date")},format:function(a,b){return ub(this,a,n,b)},relative:function(a,b){if(C(a)){b=a;a=l}return ub(this,a,k,b)},is:function(a,b,c){var d,e;if(this.isValid()){if(C(a)){a=a.trim().toLowerCase(); +e=this.clone().utc(c);switch(k){case a==="future":return this.getTime()>(new s).getTime();case a==="past":return this.getTime()<(new s).getTime();case a==="weekday":return V(e,"Day")>0&&V(e,"Day")<6;case a==="weekend":return V(e,"Day")===0||V(e,"Day")===6;case (d=U.weekdays.indexOf(a)%7)>-1:return V(e,"Day")===d;case (d=U.months.indexOf(a)%12)>-1:return V(e,"Month")===d}}return vb(this,a,b,c)}},reset:function(a){var b={},c;a=a||"hours";if(a==="date")a="days";c=db.some(function(d){return a===d.$|| +a===d.$+"s"});b[a]=a.match(/^days?/)?1:0;return c?this.set(b,k):this},clone:function(){var a=new s(this.getTime());a._utc=this._utc;return a}});s.extend({iso:function(){return this.toISOString()},getWeekday:s.prototype.getDay,getUTCWeekday:s.prototype.getUTCDay}); +function wb(a,b){function c(){return M(this*b)}function d(){return X(arguments)[a.ia](this)}function e(){return X(arguments)[a.ia](-this)}var g=a.$,f={};f[g]=c;f[g+"s"]=c;f[g+"Before"]=e;f[g+"sBefore"]=e;f[g+"Ago"]=e;f[g+"sAgo"]=e;f[g+"After"]=d;f[g+"sAfter"]=d;f[g+"FromNow"]=d;f[g+"sFromNow"]=d;u.extend(f)}u.extend({duration:function(a){return lb(a).qa(this)}}); +U=Va=s.addLocale("en",{plural:k,timeMarker:"at",ampm:"am,pm",months:"January,February,March,April,May,June,July,August,September,October,November,December",weekdays:"Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday",units:"millisecond:|s,second:|s,minute:|s,hour:|s,day:|s,week:|s,month:|s,year:|s",numbers:"one,two,three,four,five,six,seven,eight,nine,ten",articles:"a,an,the",tokens:"the,st|nd|rd|th,of","short":"{Month} {d}, {yyyy}","long":"{Month} {d}, {yyyy} {h}:{mm}{tt}",full:"{Weekday} {Month} {d}, {yyyy} {h}:{mm}:{ss}{tt}", +past:"{num} {unit} {sign}",future:"{num} {unit} {sign}",duration:"{num} {unit}",modifiers:[{name:"day",src:"yesterday",value:-1},{name:"day",src:"today",value:0},{name:"day",src:"tomorrow",value:1},{name:"sign",src:"ago|before",value:-1},{name:"sign",src:"from now|after|from|in|later",value:1},{name:"edge",src:"last day",value:-2},{name:"edge",src:"end",value:-1},{name:"edge",src:"first day|beginning",value:1},{name:"shift",src:"last",value:-1},{name:"shift",src:"the|this",value:0},{name:"shift", +src:"next",value:1}],dateParse:["{num} {unit} {sign}","{sign} {num} {unit}","{month} {year}","{shift} {unit=5-7}","{0?} {date}{1}","{0?} {edge} of {shift?} {unit=4-7?}{month?}{year?}"],timeParse:["{0} {num}{1} {day} of {month} {year?}","{weekday?} {month} {date}{1?} {year?}","{date} {month} {year}","{shift} {weekday}","{shift} week {weekday}","{weekday} {2?} {shift} week","{num} {unit=4-5} {sign} {day}","{0?} {date}{1} of {month}","{0?}{month?} {date?}{1?} of {shift} {unit=6-7}"]});ab=db.concat().reverse(); +$a=db.concat();$a.splice(2,1); +H(s,k,n,db,function(a,b,c){var d=b.$,e=hb(d),g=b.da(),f,i;b.ia="add"+e+"s";f=function(j,h){return M((this.getTime()-s.create(j,h).getTime())/g)};i=function(j,h){return M((s.create(j,h).getTime()-this.getTime())/g)};a[d+"sAgo"]=i;a[d+"sUntil"]=i;a[d+"sSince"]=f;a[d+"sFromNow"]=f;a[b.ia]=function(j,h){var m={};m[d]=j;return this.advance(m,h)};wb(b,g);c<3&&["Last","This","Next"].forEach(function(j){a["is"+j+e]=function(){return this.is(j+" "+d)}});if(c<4){a["beginningOf"+e]=function(){var j={};switch(d){case "year":j.year= +V(this,"FullYear");break;case "month":j.month=V(this,"Month");break;case "day":j.day=V(this,"Date");break;case "week":j.weekday=0}return this.set(j,k)};a["endOf"+e]=function(){var j={hours:23,minutes:59,seconds:59,milliseconds:999};switch(d){case "year":j.month=11;j.day=31;break;case "month":j.day=this.daysInMonth();break;case "week":j.weekday=6}return this.set(j,k)}}});U.addFormat("([+-])?(\\d{4,4})[-.]?{full_month}[-.]?(\\d{1,2})?",k,["year_sign","year","month","date"],n,k); +U.addFormat("(\\d{1,2})[-.\\/]{full_month}(?:[-.\\/](\\d{2,4}))?",k,["date","month","year"],k);U.addFormat("{full_month}[-.](\\d{4,4})",n,["month","year"]);U.addFormat("\\/Date\\((\\d+(?:\\+\\d{4,4})?)\\)\\/",n,["timestamp"]);U.addFormat(jb(Xa,U),n,Wa);bb=U.ga.slice(0,7).reverse();U.ga=U.ga.slice(7).concat(bb);H(s,k,n,"short,long,full",function(a,b){a[b]=function(c){return ub(this,b,n,c)}}); +"\u3007\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07".split("").forEach(function(a,b){if(b>9)b=v.pow(10,b-9);Ya[a]=b});"\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19".split("").forEach(function(a,b){Ya[a]=b});Za=r("([\u671f\u9031\u5468])?([\u3007\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19]+)(?!\u6628)","g"); +(function(){var a="today,yesterday,tomorrow,weekday,weekend,future,past".split(","),b=U.weekdays.slice(0,7),c=U.months.slice(0,12);H(s,k,n,a.concat(b).concat(c),function(d,e){d["is"+hb(e)]=function(g){return this.is(e,0,g)}})})();(function(){s.extend({utc:{create:function(){return X(arguments,0,k)},past:function(){return X(arguments,-1,k)},future:function(){return X(arguments,1,k)}}},n,n)})(); +s.extend({RFC1123:"{Dow}, {dd} {Mon} {yyyy} {HH}:{mm}:{ss} {tz}",RFC1036:"{Weekday}, {dd}-{Mon}-{yy} {HH}:{mm}:{ss} {tz}",ISO8601_DATE:"{yyyy}-{MM}-{dd}",ISO8601_DATETIME:"{yyyy}-{MM}-{dd}T{HH}:{mm}:{ss}.{fff}{isotz}"},n,n); +DateRange=function(a,b){this.start=s.create(a);this.end=s.create(b)};DateRange.prototype.toString=function(){return this.isValid()?this.start.full()+".."+this.end.full():"Invalid DateRange"}; +E(DateRange,k,n,{isValid:function(){return this.start=b.start&&c<=b.end})},every:function(a,b){var c=this.start.clone(),d=[],e=0,g,f;if(C(a)){c.advance(ob(a,0),k);g=ob(a);f=a.toLowerCase()==="day"}else g={milliseconds:a};for(;c<=this.end;){d.push(c);b&&b(c,e);if(f&&V(c,"Hours")===23){c=c.clone();W(c, +"Hours",48)}else c=c.clone().advance(g,k);e++}return d},union:function(a){return new DateRange(this.starta.end?this.end:a.end)},intersect:function(a){return new DateRange(this.start>a.start?this.start:a.start,this.endb-2)){e.push([this,arguments]);f()}}var d=this,e=[],g=n,f,i,j;a=a||1;b=b||Infinity;i=M(a,void 0,"ceil");j=M(i/a);f=function(){if(!(g||e.length==0)){for(var h=v.max(e.length-j,0);e.length>h;)Function.prototype.apply.apply(d,e.shift());xb(c,i,function(){g=n;f()});g=k}};return c},delay:function(a){var b=F(arguments).slice(1);xb(this,a,this,this,b);return this},throttle:function(a){return this.lazy(a,1)},debounce:function(a){function b(){b.cancel(); +xb(b,a,c,this,arguments)}var c=this;return b},cancel:function(){if(da(this.timers))for(;this.timers.length>0;)clearTimeout(this.timers.shift());return this},after:function(a){var b=this,c=0,d=[];if(A(a)){if(a===0){b.call();return b}}else a=1;return function(){var e;d.push(F(arguments));c++;if(c==a){e=b.call(this,d);c=0;d=[];return e}}},once:function(){var a=this;return function(){return la(a,"memo")?a.memo:a.memo=a.apply(this,arguments)}},fill:function(){var a=this,b=F(arguments);return function(){var c= +F(arguments);b.forEach(function(d,e){if(d!=l||e>=c.length)c.splice(e,0,d)});return a.apply(this,c)}}}); +function yb(a,b,c,d,e,g){var f=a.toFixed(20),i=f.search(/\./);f=f.search(/[1-9]/);i=i-f;if(i>0)i-=1;e=v.max(v.min((i/3).floor(),e===n?c.length:e),-d);d=c.charAt(e+d-1);if(i<-9){e=-3;b=i.abs()-9;d=c.slice(0,1)}return(a/(g?(2).pow(10*e):(10).pow(e*3))).round(b||0).format()+d.trim()} +E(u,n,n,{random:function(a,b){var c,d;if(arguments.length==1){b=a;a=0}c=v.min(a||0,K(b)?1:b);d=v.max(a||0,K(b)?1:b)+1;return N(v.random()*(d-c)+c)}}); +E(u,k,n,{log:function(a){return v.log(this)/(a?v.log(a):1)},abbr:function(a){return yb(this,a,"kmbt",0,4)},metric:function(a,b){return yb(this,a,"n\u03bcm kMGTPE",4,K(b)?1:b)},bytes:function(a,b){return yb(this,a,"kMGTPE",0,K(b)?4:b,k)+"B"},isInteger:function(){return this%1==0},isOdd:function(){return!this.isMultipleOf(2)},isEven:function(){return this.isMultipleOf(2)},isMultipleOf:function(a){return this%a===0},format:function(a,b,c){var d,e,g=/(\d+)(\d{3})/;if(t(b).match(/\d/))throw new TypeError("Thousands separator cannot contain numbers."); +d=A(a)?M(this,a||0).toFixed(v.max(a,0)):this.toString();b=b||",";c=c||".";e=d.split(".");d=e[0];for(e=e[1]||"";d.match(g);)d=d.replace(g,"$1"+b+"$2");if(e.length>0)d+=c+pa((a||0)-e.length,"0")+e;return d},hex:function(a){return this.pad(a||1,n,16)},upto:function(a,b,c){return oa(this,a,b,c||1)},downto:function(a,b,c){return oa(this,a,b,-(c||1))},times:function(a){if(a)for(var b=0;b/g,">")},unescapeHTML:function(){return this.replace(/</g,"<").replace(/>/g,">").replace(/&/g,"&")},encodeBase64:function(){return Eb(this)},decodeBase64:function(){return Fb(this)},each:function(a,b){var c, +d;if(z(a)){b=a;a=/[\s\S]/g}else if(a)if(C(a))a=r(Q(a),"gi");else{if(D(a))a=r(a.source,sa(a,"g"))}else a=/[\s\S]/g;c=this.match(a)||[];if(b)for(d=0;d0?"_":"")+a.toLowerCase()}).replace(/([A-Z\d]+)([A-Z][a-z])/g,"$1_$2").replace(/([a-z\d])([A-Z])/g,"$1_$2").toLowerCase()},camelize:function(a){return this.underscore().replace(/(^|_)([^_]+)/g,function(b,c,d,e){b=d;b=(c=t.Inflector)&&c.acronyms[b];b=C(b)?b:void 0;e=a!==n||e>0;if(b)return e?b:b.toLowerCase();return e?d.capitalize():d})},spacify:function(){return this.underscore().replace(/_/g, +" ")},stripTags:function(){var a=this;F(arguments.length>0?arguments:[""],function(b){a=a.replace(r("]*>","gi"),"")});return a},removeTags:function(){var a=this;F(arguments.length>0?arguments:["\\S+"],function(b){b=r("<("+b+")[^<>]*(?:\\/>|>.*?<\\/\\1>)","gi");a=a.replace(b,"")});return a},truncate:function(a,b,c,d){var e="",g="",f=this.toString(),i="["+ra()+"]+",j="[^"+ra()+"]*",h=r(i+j+"$");d=K(d)?"...":t(d);if(f.length<=a)return f;switch(c){case "left":a=f.length-a;e=d;f=f.slice(a); +h=r("^"+j+i);break;case "middle":a=N(a/2);g=d+f.slice(f.length-a).trimLeft();f=f.slice(0,a);break;default:a=a;g=d;f=f.slice(0,a)}if(b===n&&this.slice(a,a+1).match(/\S/))f=f.remove(h);return e+f+g},pad:function(a,b){return pa(b,a)+this+pa(b,a)},padLeft:function(a,b){return pa(b,a)+this},padRight:function(a,b){return this+pa(b,a)},first:function(a){if(K(a))a=1;return this.substr(0,a)},last:function(a){if(K(a))a=1;return this.substr(this.length-a<0?0:this.length-a)},repeat:function(a){var b="",c=0;if(A(a)&& +a>0)for(;c>2;e=(e&3)<<4|g>>4;j=(g&15)<<2|f>>6;h=f&63;if(isNaN(g))j=h=64;else if(isNaN(f))h=64;d=d+a.charAt(i)+a.charAt(e)+a.charAt(j)+a.charAt(h)}while(m>4;g=(g&15)<<4|i>>2;f=(i&3)<<6|j;d+=t.fromCharCode(e);if(i!=64)d+=t.fromCharCode(g);if(j!=64)d+=t.fromCharCode(f)}while(h + # AWFUL but I can't figure out how to call a controller method from outside + # my app? + Discourse.__container__.lookup('controller:composer').importQuote() + ] diff --git a/app/assets/javascripts/preload_store.js.coffee b/app/assets/javascripts/preload_store.js.coffee new file mode 100644 index 00000000000..bbc5380edbc --- /dev/null +++ b/app/assets/javascripts/preload_store.js.coffee @@ -0,0 +1,47 @@ +# +# We can insert data into the PreloadStore when the document is loaded. +# The data can be accessed once by a key, after which it is removed. +# +window.PreloadStore = + + data: {} + + store: (key, value) -> + @data[key] = value + + # To retrieve a key, you provide the key you want, plus a finder to + # load it if the key cannot be found. Once the key is used once, it is + # removed from the store. So, for example, you can't load a preloaded topic + # more than once. + get: (key, finder) -> + promise = new RSVP.Promise + + if @data[key] + promise.resolve(@data[key]) + delete @data[key] + else + if finder + result = finder() + + # If the finder returns a promise, we support that too + if result.then + result.then (result) -> + promise.resolve(result) + , (result) -> promise.reject(result) + else + promise.resolve(result) + else + promise.resolve(undefined) + + promise + + # Does the store contain a particular key? Does not delete, just returns + # true or false. + contains: (key) -> @data[key] isnt undefined + + # If we are sure it's preloaded, we don't have to supply a finder. Just + # returns undefined if it's not in the store. + getStatic: (key) -> + result = @data[key] + delete @data[key] + result diff --git a/app/assets/stylesheets/admin.css b/app/assets/stylesheets/admin.css new file mode 100644 index 00000000000..4dd250ef271 --- /dev/null +++ b/app/assets/stylesheets/admin.css @@ -0,0 +1,6 @@ +// Manifest +// +//= require_tree ./admin + + + diff --git a/app/assets/stylesheets/admin/admin_base.scss b/app/assets/stylesheets/admin/admin_base.scss new file mode 100644 index 00000000000..d1cc13009f0 --- /dev/null +++ b/app/assets/stylesheets/admin/admin_base.scss @@ -0,0 +1,247 @@ +// these are the styles associated with the Discourse admin section +@import "foundation/variables"; +@import "foundation/mixins"; + +.admin-content { + margin-bottom: 50px; + .admin-contents { + padding: 8px; + } +} + +.admin-loading { + width: 100px; + margin: 0 auto 30px auto; + background-color: $black; + @include border-radius-all(10px); + padding: 10px 10px 10px 30px; + font-size: 15px; + line-height: 23px; + text-align: center; + color: $white; + background: { + image: image-url("spinner_96_w.gif"); + repeat: no-repeat; + position: 10px 8px; + size: 25px; + }; +} + +.admin-controls { + @include border-radius-all(5px); + background-color: darken($white, 5%); + border: 1px solid darken($white, 10%); + padding: 5px 10px 3px 0px; + margin-bottom: 20px; + height: 35px; + .nav.nav-pills { + li.active { + a { + background-color: $dark_gray; + &:hover { + background-color: $dark_gray; + } + } + } + } + h1 { + font-size: 20px; + line-height: 25px; + color: $darkish_gray; + } + .controls { + padding-top: 3px; + } + button { + float: left; + margin-right: 5px; + } + .result-message { + display: inline-block; + padding-left: 10px; + padding-top: 5px; + } + .username { + input[type=text] { + width: 240px; + } + } + .search { + label { + margin-top: 5px; + float: right; + } + input[type=text] { + float: right; + } + } +} + +.settings { + .setting { + padding-bottom: 20px; + .overridden { + input[type=text] { + background-color: lighten($yellow, 40%); + } + } + .desc { + padding-top: 3px; + color: darken($white, 40%); + } + } +} + +section.details { + h1 { + font-size: 15px; + background-color: $gray; + box-shadow: 1px 1px 3px $darkish_gray; + color: $darkish_gray; + padding: 0 10px; + line-height: 25px; + margin: 0px 0 5px 0; + } +} + +#selected-controls { + background-color: lighten($blue, 50%); + padding: 8px; + min-height: 27px; + position: fixed; + bottom: 0; + width: 1075px; + border: 1px solid lighten($blue, 45%); +} + +table { + tr.selected { + background-color: lighten($blue, 58%); + } +} + +.display-row { + padding: 5px; + &:nth-of-type(1) { + border-top: 0; + } + border-top: 1px solid #dddddd; + &:before, &:after { + display: table; + content: ""; + } + &:after { + clear: both; + } + .field { + height: 30px; + line-height: 30px; + font-weight: bold; + width: 196px; + float: left; + margin-left: 12px; + } + .value { + width: 250px; + height: 30px; + line-height: 30px; + float: left; + margin-left: 12px; + } + .controls { + .btn { + margin-right: 5px; + } + } +} + +// Customise area +.customize { + .nav.nav-pills { + margin-left: 10px; + } + .well { + min-height: 20px; + padding: 4px; + margin-bottom: 20px; + background-color: whitesmoke; + border: 1px solid #e3e3e3; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); + -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); + height: 638px; + margin-bottom: 10px; + } + a { + cursor: pointer; + } + .list, .current-style { + float: left; + } + .list { + width: 20%; + margin-right: 20px; + margin-top: 65px; + } + .current-style { + .delete-link { + margin-left: 15px; + margin-top: 5px; + } + .preview-link { + margin-left: 30px; + } + width: 75%; + .style-name { + width: 600px; + height: 25px; + font-size: 20px; + } + .ace-wrapper { + position: relative; + height: 600px; + width: 100%; + } + .ace_editor { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + } + .status-actions { + float: right; + span { + margin-right: 20px; + } + } + .buttons { + float: left; + button, a { + float: left; + } + } + } +} + + +.admin-flags { + tr.hidden-post td.excerpt { opacity: 0.4; } + td.message { + padding: 4px 0; + background-color: #f8f8e0; + } + td { vertical-align: middle; } + th { text-align: left; } + .user { width: 40px; } + .excerpt { + width: 600px; padding: 0 10px 10px 0; + .icon,h3 { display: inline-block; } + + } + .flaggers { padding: 0 10px; } + .last-flagged { padding: 0 10px; } +} diff --git a/app/assets/stylesheets/application.css.erb b/app/assets/stylesheets/application.css.erb new file mode 100644 index 00000000000..bfc253b1de7 --- /dev/null +++ b/app/assets/stylesheets/application.css.erb @@ -0,0 +1,15 @@ +// Manifest +// +//= require ./vendor/normalize +//= require ./vendor/bootstrap +//= require ./foundation/base +//= require ./vendor/font-awesome +//= require ./vendor/chosen +//= require_tree ./components +//= require_tree ./application +//= require ./foundation/helpers +<% + DiscoursePluginRegistry.stylesheets.each do |css| + require_asset(css) + end +%> diff --git a/app/assets/stylesheets/application/activation.css.scss b/app/assets/stylesheets/application/activation.css.scss new file mode 100644 index 00000000000..09fd5a604d9 --- /dev/null +++ b/app/assets/stylesheets/application/activation.css.scss @@ -0,0 +1,13 @@ +// Styles used before the user is logged into discourse. For example, activating their +// account or changing their email. + +@import "foundation/variables"; +@import "foundation/mixins"; + +#simple-container { + @include border-radius-all(10px); + background-color: white; + padding: 20px; + width: 550px; + margin: 0 auto; +} diff --git a/app/assets/stylesheets/application/bbcode.css.scss b/app/assets/stylesheets/application/bbcode.css.scss new file mode 100644 index 00000000000..2d7de236c1e --- /dev/null +++ b/app/assets/stylesheets/application/bbcode.css.scss @@ -0,0 +1,128 @@ +// Support for BBCode styles like colors and font sizes + +span { + &.bbcode-b { + font-weight: bold; + } + &.bbcode-i { + font-style: italic; + } + &.bbcode-u { + text-decoration: underline; + } + &.bbcode-s { + text-decoration: line-through; + } + // Font sizes + &.bbcode-size-4 { + font-size: 4px; + } + &.bbcode-size-5 { + font-size: 5px; + } + &.bbcode-size-6 { + font-size: 6px; + } + &.bbcode-size-7 { + font-size: 7px; + } + &.bbcode-size-8 { + font-size: 8px; + } + &.bbcode-size-9 { + font-size: 9px; + } + &.bbcode-size-10 { + font-size: 10px; + } + &.bbcode-size-11 { + font-size: 11px; + } + &.bbcode-size-12 { + font-size: 12px; + } + &.bbcode-size-13 { + font-size: 13px; + } + &.bbcode-size-14 { + font-size: 14px; + } + &.bbcode-size-15 { + font-size: 15px; + } + &.bbcode-size-16 { + font-size: 16px; + } + &.bbcode-size-17 { + font-size: 17px; + } + &.bbcode-size-18 { + font-size: 18px; + } + &.bbcode-size-19 { + font-size: 19px; + } + &.bbcode-size-20 { + font-size: 20px; + } + &.bbcode-size-21 { + font-size: 21px; + } + &.bbcode-size-22 { + font-size: 22px; + } + &.bbcode-size-23 { + font-size: 23px; + } + &.bbcode-size-24 { + font-size: 24px; + } + &.bbcode-size-25 { + font-size: 25px; + } + &.bbcode-size-26 { + font-size: 26px; + } + &.bbcode-size-27 { + font-size: 27px; + } + &.bbcode-size-28 { + font-size: 28px; + } + &.bbcode-size-29 { + font-size: 29px; + } + &.bbcode-size-30 { + font-size: 30px; + } + &.bbcode-size-31 { + font-size: 31px; + } + &.bbcode-size-32 { + font-size: 32px; + } + &.bbcode-size-33 { + font-size: 33px; + } + &.bbcode-size-34 { + font-size: 34px; + } + &.bbcode-size-35 { + font-size: 35px; + } + &.bbcode-size-36 { + font-size: 36px; + } + &.bbcode-size-37 { + font-size: 37px; + } + &.bbcode-size-38 { + font-size: 38px; + } + &.bbcode-size-39 { + font-size: 39px; + } + &.bbcode-size-40 { + font-size: 40px; + } +} diff --git a/app/assets/stylesheets/application/code_highlighting.css.scss b/app/assets/stylesheets/application/code_highlighting.css.scss new file mode 100644 index 00000000000..ec910a95cb0 --- /dev/null +++ b/app/assets/stylesheets/application/code_highlighting.css.scss @@ -0,0 +1,84 @@ +// github.com style (c) Vasily Polovnyov + +pre { + code { + display: block; + padding: 0.5em; + color: #333333; + background: ghostwhite; + font-size: 12px; + overflow: auto; + } + .comment, .template_comment, .diff .header, .javadoc { + color: #999988; + font-style: italic; + } + .keyword, .css .keyword, .winutils, .javascript .title, .nginx .title, .subst, .request, .status { + color: #333333; + font-weight: bold; + } + .number, .hexcolor, .ruby .constant { + color: #009999; + } + .string, .tag .value, .phpdoc, .tex .formula { + color: #dd1144; + } + .title, .id { + color: #990000; + font-weight: bold; + } + .javascript .title, .lisp .title, .clojure .title, .subst { + font-weight: normal; + } + .class .title, .haskell .type, .vhdl .literal, .tex .command { + color: #445588; + font-weight: bold; + } + .tag { + color: navy; + font-weight: normal; + .title { + color: navy; + font-weight: normal; + } + } + .rules .property, .django .keyword { + color: navy; + font-weight: normal; + } + .attribute, .variable, .lisp .body { + color: teal; + } + .regexp { + color: #009926; + } + .class { + color: #445588; + font-weight: bold; + } + .symbol, .ruby .string, .lisp .keyword, .tex .special, .input_number { + color: #990073; + } + .built_in, .lisp .title, .clojure .built_in { + color: #0086b3; + } + .preprocessor, .pi, .doctype, .shebang, .cdata { + color: #999999; + font-weight: bold; + } + .deletion { + background: #ffdddd; + } + .addition { + background: #ddffdd; + } + .diff .change { + background: #0086b3; + } + .chunk { + color: #aaaaaa; + } + .tex .formula { + opacity: 0.5; + } +} diff --git a/app/assets/stylesheets/application/compose.css.scss b/app/assets/stylesheets/application/compose.css.scss new file mode 100644 index 00000000000..ab044e448a0 --- /dev/null +++ b/app/assets/stylesheets/application/compose.css.scss @@ -0,0 +1,371 @@ +// styles that apply to the reply pane that slides up to compose replies + +@import "foundation/variables"; +@import "foundation/mixins"; + +#reply-control { + .toggle-preview, .saving-draft { + position: absolute; + bottom: -31px; + margin-top: 0px; + } + .toggle-preview { + right: 5px; + text-decoration: underline; + } + .saving-draft { + right: 51%; + color: lighten($black, 60); + } + @include transition(height 0.4s ease); + width: 100%; + z-index: 1039; + height: 0px; + background-color: rgba($composer_background, 0.96); + bottom: 0px; + font-size: 14px; + position: fixed; + .toggler { + display: block; + width: 13px; + height: 13px; + right: 13px; + position: absolute; + font-size: 15px; + color: $darkish_gray; + text-decoration: none; + &:before { + font-family: "FontAwesome"; + content: "\f078"; + } + } + a.cancel { + text-decoration: underline; + padding-left: 7px; + } + .control-row { + margin: 0 0 0 5px; + } + .saving-text { + display: none; + } + .draft-text { + display: none; + } + .grippie { + display: none; + } + // The various states + &.open { + height: 300px; + .grippie { + display: block; + } + } + &.closed { + height: 0px !important; + } + &.draft { + height: 40px !important; + cursor: pointer; + border-top: 1px solid $gray; + .draft-text { + display: block; + } + .toggler { + &:before { + font-family: "FontAwesome"; + content: "\f077"; + } + } + } + &.saving { + height: 40px !important; + border-top: 1px solid $gray; + .saving-text { + display: block; + } + .toggler { + &:before { + font-family: "FontAwesome"; + content: "\f00d"; + } + } + } + .spinner { + position: absolute; + @include fades-in(0.25s); + @include border-radius-all(10px); + left: 250px; + top: 95px; + height: 100px; + width: 70px; + height: 70px; + text-indent: -9999em; + background: { + color: $black; + image: image-url("spinner_96_w.gif"); + repeat: no-repeat; + size: 35px; + position: 17px 17px; + }; + } + &.loading { + .spinner { + z-index: 1000; + @include visible; + } + } + .reply-area { + max-width: 1500px; + margin-left: auto; + margin-right: auto; + float: none; + } + .autocomplete { + z-index: 999; + position: absolute; + width: 200px; + background-color: $white; + border: 1px solid $gray; + ul { + list-style: none; + padding: 0; + margin: 0; + li { + border-bottom: 1px solid $light_gray; + a[href] { + padding: 5px; + display: block; + span.username { + color: lighten($black, 20); + } + span.name { + font-size: 11px; + } + &.selected { + background-color: $light_gray; + } + @include hover { + background-color: $light_gray; + text-decoration: none; + } + } + } + } + } + // When the post is new (new topic) the sizings are different + &.edit-title { + &.open { + height: 400px; + } + .contents { + input[type=text] { + padding: 7px 10px; + margin: 6px 0 3px 0; + } + .wmd-controls { + top: 110px; + } + } + } + .contents { + padding: 10px; + min-width: 1280px; + .form-element { + .chzn-container { + margin-top: 6px; + a { + padding-top: 4px; + height: 28px; + } + b { + margin-top: 4px; + } + } + } + #reply-title { + margin-right: 10px; + float: left; + &:disabled { + background-color: $light_gray; + } + } + #wmd-input:disabled { + background-color: $light_gray; + } + #wmd-input, #wmd-preview { + color: $black; + img { + // Otherwise we get the wrong size in JS + max-width: none; + } + } + #wmd-preview { + border: 1px dashed $gray; + overflow: auto; + visibility: visible; + p { + margin-top: 0; + } + &.hidden { + width: 0; + visibility: hidden; + } + } + #wmd-input { + bottom: 35px; + } + .submit-panel { + position: absolute; + display: block; + bottom: 8px; + #image-uploading { + display: inline-block; + margin-left: 330px; + font-size: 12px; + color: darken($gray, 40); + } + } + } +} + +.reply-to { + margin-bottom: 10px; + margin-left: 5px; +} + +#main #reply-control { + div.ac-wrap { + background-color: $white; + border: 1px solid #cccccc; + padding: 4px 10px; + @include border-radius-all(3px); + div.item { + float: left; + margin-right: 10px; + span { + padding-left: 5px; + height: 22px; + display: inline-block; + line-height: 22px; + vertical-align: bottom; + } + a { + margin-left: 4px; + font-size: 10px; + line-height: 10px; + padding: 2px 1px 1px 3px; + border-radius: 10px; + width: 10px; + display: inline-block; + border: 1px solid rgba(255, 255, 255, 0); + &:hover { + background-color: lighten($red, 45); + border: 1px solid lighten($red, 20); + text-decoration: none; + color: $white; + } + } + } + input[type="text"] { + float: left; + margin-top: 5px; + border: 0; + padding: 0; + margin: 4px 0 0; + box-shadow: none; + } + } +} + +#reply-control.edit-title.private-message { + .wmd-controls { + top: 140px; + } +} + +#reply-control { + &.hide-preview { + .wmd-controls { + #wmd-input { + width: 100%; + } + .preview-wrapper { + display: none; + } + .textarea-wrapper { + width: 100%; + } + } + } + .wmd-controls { + left: 30px; + right: 30px; + position: absolute; + top: 60px; + bottom: 48px; + #wmd-input, #wmd-preview { + box-sizing: border-box; + -moz-box-sizing: border-box; + width: 100%; + height: 100%; + min-height: 100%; + padding: 7px; + margin: 0; + background-color: $white; + } + #wmd-input { + position: absolute; + left: 0; + top: 0; + height: 100%; + min-height: 100%; + box-sizing: border-box; + border: 0; + border-top: 36px solid transparent; + @include border-radius-all(0); + transition: none; + } + .textarea-wrapper, .preview-wrapper { + position: relative; + box-sizing: border-box; + -moz-box-sizing: border-box; + height: 100%; + min-height: 100%; + margin: 0; + padding: 0; + width: 50%; + } + .textarea-wrapper { + padding-right: 5px; + float: left; + } + .preview-wrapper { + padding-left: 5px; + float: right; + } + } + #wmd-button-bar { + top: 0; + position: absolute; + border-bottom: 1px solid $inner_line; + background-color: $white; + z-index: 100; + } +} + +.control-row.reply-area { + padding-left: 20px; + padding-right: 20px; +} + +@media screen and (min-width: 1550px) { + #reply-control { + .wmd-controls { + width: 1450px; + left: auto; + right: auto; + } + } +} diff --git a/app/assets/stylesheets/application/discourse.css.scss b/app/assets/stylesheets/application/discourse.css.scss new file mode 100644 index 00000000000..4ceb5c82de6 --- /dev/null +++ b/app/assets/stylesheets/application/discourse.css.scss @@ -0,0 +1,310 @@ +// global styles that apply to the Discourse application specifically +// BEWARE: changing these styles implies they take effect anywhere they are seen +// throughout the Discourse application + +@import "foundation/variables"; +@import "foundation/mixins"; +@import "foundation/helpers"; + +body { + min-width: $large-width; +} + +.container { + @extend .clearfix; + width: $large-width; + margin-right: auto; + margin-left: auto; +} + +.full-width { + width: $large-width; + margin-left: 12px; +} + +@include medium-width { + body { + min-width: $medium-width; + } + .container, + .full-width { + width: $medium-width; + } +} + +@include small-width { + body { + min-width: $small-width; + } + .container, + .full-width { + width: $small-width; + } +} + +a.no-href { + cursor: pointer; +} + +header { + margin-bottom: 15px; +} + +body { + .nav-pills .active .dropdown-toggle .caret { + border-top-color: white; + border-bottom-color: white; + } + + .caret { + opacity: 0.9; + filter: alpha(opacity = 90); + } + .dropdown .caret { + margin-left: 6px; + } + button.ok { + @include linear-gradient(lighten($green, 5%), $green); + color: $white; + @include hover { + @include linear-gradient(lighten($green, 10%), $green); + color: $white; + } + } + button.cancel { + @include linear-gradient(lighten($red, 5%), $red); + color: $white; + @include hover { + @include linear-gradient(lighten($red, 10%), $red); + color: $white; + } + } + .coldmap-high { + color: lighten($blue, 20%) !important; + } + .coldmap-med { + color: lighten($blue, 10%) !important; + } + .coldmap-low { + color: $blue !important; + } + .heatmap-high { + color: lighten($red, 50%) !important; + } + .heatmap-med { + color: lighten($red, 10%) !important; + } + .heatmap-low { + color: $red !important; + } + #loading-message { + position: absolute; + font-size: 30px; + text-align: center; + top: 120px; + left: 500px; + color: $darkish_gray; + } + .top-space { + margin-top: 10px; + } + ul.breadcrumb { + margin: 0 10px 0px 10px; + } + .boxed { + height: 100%; + @include border-radius-all(5px); + .contents { + padding: 10px 20px 20px 20px; + } + &.white { + background-color: $white; + } + } + #main { + .icon-star.starred { + color: #e8d81f; + } + a.star { + display: inline-block; + font-size: 15px; + line-height: 1; + color: #cacaca; + &:before { + font-family: "FontAwesome"; + content: "\f005"; + } + &.starred { + color: #e8d81f; + @include hover { + opacity: 1; + &:before { + content: "\f005"; + } + } + } + @include hover { + opacity: 0.6; + } + + &:active { + opacity: 1; + } + } + img.avatar { + &.header { + width: 45px; + height: 45px; + } + &.medium { + width: 32px; + height: 32px; + } + &.small { + width: 25px; + height: 25px; + } + &.tiny { + width: 20px; + height: 20px; + } + } + .user-list { + .user { + padding-bottom: 5px; + } + } + } + .message { + @include border-radius-all(8px); + background-color: $white; + padding: 14px; + h2 { + margin-bottom: 20px; + } + p { + font-size: 20px; + } + } + #footer { + background-color: $eggplant; + .container { + height: 50px; + .contents { + padding-top: 10px; + a[href] { + color: $white; + } + } + } + } + .clear-transitions { + @include transition(none !important); + } + .grippie { + width: 100%; + border: 1px solid #dddddd; + border-width: 1px 0px; + cursor: row-resize; + height: 11px; + overflow: hidden; + background-color: #eeeeee; + display:block {} + background: image-url("grippie.png") #eeeeee no-repeat center 3px; + } +} + +form { + .tip { + display: inline-block; + &.good { + color: $green; + } + &.bad { + color: $red; + } + } +} + +blockquote { + padding: 10px 8px 1px 15px; + background-color: #f1f1f1; + border-left: 5px solid #dddddd; + p { + margin: 0 0 10px 0; + } +} + +.topic-statuses { + display: inline-block; + margin: 0; + padding: 0; + .topic-status { + padding: 5px 2px 0 0; + margin: 0; + display: inline-block; + i { + font-size: 15px; + color: darken($white, 60%); + } + } +} + +#wmd-input { + resize: none; +} + +#pagedown-editor { + width: 540px; + background-color: $white; + padding: 0 10px 13px 10px; + border: 1px solid $gray; + .preview { + margin-top: 8px; + border: 1px dashed $gray; + padding: 8px 8px 0 8px; + p { + margin: 0 0 10px 0; + } + } + .preview.hidden { + display: none; + } +} + +.spinner { + width: 100px; + margin: 0 auto 30px auto; + background-color: $black; + @include border-radius-all(10px); + padding: 10px 10px 10px 30px; + font-size: 15px; + line-height: 23px; + text-align: center; + color: $white; + background: { + image: image-url("spinner_96_w.gif"); + repeat: no-repeat; + position: 10px 8px; + size: 25px; + }; +} + +.avatar { + @include border-radius-all(2px); +} + +.avatar-wrapper { + background-color: white; + display: inline-block; + border: 1px solid #82786b; + @include border-radius-all(5px); + img { + @include border-radius-all(4px); + } +} + +.profiler-results.profiler-left { + top: 60px !important; +} + diff --git a/app/assets/stylesheets/application/faqs.css.scss b/app/assets/stylesheets/application/faqs.css.scss new file mode 100755 index 00000000000..634bd2cf11f --- /dev/null +++ b/app/assets/stylesheets/application/faqs.css.scss @@ -0,0 +1,208 @@ +@import "foundation/variables"; +@import "foundation/mixins"; +@import "foundation/helpers"; + +// -------------------------------------------------- +// FAQs +// -------------------------------------------------- + +// Base +// -------------------------------------------------- + +.body-page { + + color: #1d1f20; + font: 14px/20px $base-font-family; + background: $base-background-color; + + // Consistent vertical spacing + + address, + blockquote, + h1, + h2, + h3, + h4, + h5, + h6, + hgroup, + hr, + p, + pre, + ul, + ol, + dl, + figure, + fieldset, + table { + margin: 0 0 20px; + } + + // Links + + a { + color: $link-color; + text-decoration: none; + cursor: pointer; + &:visited { + color: $link-color-visited; + } + &:hover { + color: $link-color-hover; + } + &:focus { + outline: 0; + } + &:active { + color: $link-color-active; + } + } + + // Typography + + h1 { + font-size: 14px; + } + + // Lists + + ul, + ol, + dd { + margin-left: 40px; + padding: 0; + } + + li { + > ul, + > ol { + margin-bottom: 0; + } + } + + // Embedded content + + img { + vertical-align: middle; + } + +} + +// Content wrapper +// -------------------------------------------------- + +.body-page { + + .container { + @extend .clearfix; + width: 960px; + margin: 0 auto; + padding: 20px 10px; + } + +} + +// Navigation +// -------------------------------------------------- + +.body-page { + + nav { + width: 280px; + overflow: hidden; + position: fixed; + float: left; + border: 1px solid #b9b9b9; + background-color: #fafafa; + @include border-radius-all(4px); + @include box-shadow(0 1px 0 #fff); + > a { + display: block; + border-top: 1px solid #e6e6e6; + padding: 13px; + font-weight: bold; + font-size: 16px; + line-height: 20px; + text-shadow: 0 1px 0 rgba($white, 0.5); + &:first-child { + border-top: 0; + } + &:hover { + background-color: #eee; + } + &.active { + color: #f15b22; + background-color: #f9e7e0; + cursor: default; + } + } + } + +} + +// Excerpts & Questions +// -------------------------------------------------- + +.body-page { + + #excerpt, + #questions { + float: right; + width: 658px; + } + + // Excerpts + + #excerpt { + @extend .clearfix; + display: none; + } + + // Questions + + #questions { + > article { + @extend .clearfix; + } + h1 { + margin-top: 20px; + } + > article:first-child h1 { + margin-top: 0; + } + } + +} + +// Buttons +// -------------------------------------------------- + +.body-page { + + .btn { + float: right; + margin-left: 10px; + &:last-child { + margin-left: 0; + } + } + + .collapse { + height: auto; + } + + .read-more { + float: right; + } + + input + { + height: 28px; + border-radius: 4px; + border: 1px solid #DDD; + padding-left: 10px; + box-shadow: 0px 0px 2px 1px gold; + float: right; + margin-left: 10px; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application/header.css.scss b/app/assets/stylesheets/application/header.css.scss new file mode 100644 index 00000000000..85d60d56326 --- /dev/null +++ b/app/assets/stylesheets/application/header.css.scss @@ -0,0 +1,234 @@ +@import "foundation/variables"; +@import "foundation/mixins"; + +// -------------------------------------------------- +// Discourse header +// -------------------------------------------------- + +.d-header { + min-width: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 1000; + border-bottom: 1px solid #9baab2; + background-color: $white; + @include box-shadow((0 1px 3px rgba($black, 0.12), inset 0 -4px 4px -4px rgba($black, 0.3))); + .docked & { + position: fixed; + } + .contents { + margin: 10px 0; + } + .title { + display: table; + float: left; + height: 40px; + > a { + display: table-cell; + vertical-align: middle; + } + } + #site-logo { + width: 122px; + } + .icon-home { + font-size: 20px; + line-height: 40px; + } + .panel { + float: right; + position: relative; + } + .current-username { + float: left; + a { + color: $black; + font-size: 14px; + line-height: 40px; + } + button { + margin-top: 8px; + } + } + .icons { + float: left; + margin: 0 0 0 15px; + list-style: none; + > li { + float: left; + &:first-child .icon { + @include border-radius-all(4px 0 0 4px); + } + &:first-child.active .icon { + @include border-radius-all(4px 0 0 0); + } + &:last-child .icon { + border-right: 1px solid #ccc; + @include border-radius-all(0 4px 4px 0); + } + } + .icon { + display: block; + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; + border-left: 1px solid #d6d6d6; + padding: 3px; + color: $nav-button-color; + text-decoration: none; + cursor: pointer; + @include box-shadow(inset 0 -4px 4px -4px rgba($black, 0.14)); + &:hover { + color: $nav-button-color-hover; + background-color: $nav-button-background-color-hover; + } + &:active { + color: $nav-button-color-active; + background-color: $nav-button-background-color-active; + } + } + .active .icon { + position: relative; + color: #7b7b7b; + background-color: $white; + cursor: default; + @include box-shadow((6px 0 6px -6px rgba($black, 0.2), -6px 0 6px -6px rgba($black, 0.2), inset 0 13px 13px -13px rgba($black, 0.1))); + &:after { + display: block; + position: absolute; + top: 100%; + left: 0; + z-index: 1101; + width: 100%; + height: 0; + content: ""; + border-top: 1px solid $white; + } + } + [class^="icon-"] { + width: 32px; + height: 32px; + font-size: 20px; + line-height: 32px; + } + .notifications { + position: relative; + } + .badge-notification { + position: absolute; + top: -9px; + z-index: 1; + margin-left: 0; + } + .unread-notifications { + right: -4px; + background-color: #0088CC; + } + .unread-private-messages { + left: -4px; + background-color: #00953A; + } + .flagged-posts { + background-color: #E53B2E; + } + } +} + +#main { + position: relative; +} + +#main-outlet { + padding-top: 75px; +} + +// Dropdowns +// -------------------------------------------------- + +.d-dropdown { + display: none; + overflow: hidden; + width: 320px; + position: absolute; + top: 100%; + right: 0; + z-index: 1100; + margin-top: -1px; + border: 1px solid #ccc; + background-color: $white; + @include border-radius-all(4px); + @include box-shadow(0 3px 3px rgba($black, 0.2)); + + // Common + + ul { + margin: 0; + list-style: none; + } + li { + padding: 5px; + font-size: 13px; + line-height: 16px; + } + .heading { + border-top: 1px solid #c5c5c5; + border-bottom: 1px solid #c5c5c5; + color: #2d3234; + font-weight: bold; + font-size: 12px; + line-height: 15px; + text-shadow: 0 1px 0 $white; + background-color: #f5f6f7; + @include box-shadow(inset 0 1px 0 rgba($white, 0.8)); + } + .selected { + background-color: $header-item-highlight; + } + + // Notifications + + &#notifications-dropdown { + li { + background-color: $header-item-highlight; + } + .read { + background-color: $white; + } + .none { + padding: 5px; + } + } + + // Search + + input[type='text'] { + width: 298px; + height: 22px; + margin: 5px; + padding: 5px; + &:focus { + @include box-shadow((inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 4px rgba(82, 168, 236, 0.6))); + } + } + .searching { + display: block; + position: absolute; + top: 13px; + right: 13px; + color: #777; + font-size: 18px; + } + .no-results { + padding: 0 5px 5px; + } + .filter { + float: right; + } + + // Categories + + .category { + float: left; + background-color: transparent; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application/history.css.scss b/app/assets/stylesheets/application/history.css.scss new file mode 100644 index 00000000000..bb3243ebc74 --- /dev/null +++ b/app/assets/stylesheets/application/history.css.scss @@ -0,0 +1,26 @@ +// styles that apply to the popup that appears when you show the edit history +// of a post + +@import "foundation/variables"; +@import "foundation/mixins"; + +.modal.history-modal { + width: 960px; + margin-left: -460px; + min-height: 500px; + .modal-header { + height: 42px; + } + .history-loading { + margin: 25px 0; + width: 120px; + font-size: 20px; + padding: 8px 0 30px 30px; + background: { + image: image-url("spinner_96.gif"); + repeat: no-repeat; + size: 25px 25px; + position: 0 4px; + }; + } +} diff --git a/app/assets/stylesheets/application/image-upload.scss b/app/assets/stylesheets/application/image-upload.scss new file mode 100644 index 00000000000..38ece6e3769 --- /dev/null +++ b/app/assets/stylesheets/application/image-upload.scss @@ -0,0 +1,29 @@ +// base styles for every modal popup used in Discourse + +@import "foundation/variables"; +@import "foundation/mixins"; + +.add-picture .icon-plus { + font-size: 10px; + position: relative; + left: -5px; + bottom: -5px; + text-shadow: -1px -1px 0 $btn-primary-background-color, + 1px 1px 0 $btn-primary-background-color, + 1px -1px 0 $btn-primary-background-color, + -1px 1px 0 $btn-primary-background-color; +} + +// we should refactor this into something more general +.image-selector { + input[type="text"]{ + width: 520px; + } + input[type="file"] { + font-size: 14px; + line-height: 18px; + } + .description { + color: #9a9ea0; + } +} diff --git a/app/assets/stylesheets/application/login.css.scss b/app/assets/stylesheets/application/login.css.scss new file mode 100644 index 00000000000..3c8f6cd2008 --- /dev/null +++ b/app/assets/stylesheets/application/login.css.scss @@ -0,0 +1,25 @@ +// style that apply to the login popup + +@import "foundation/variables"; +@import "foundation/mixins"; + +#login-buttons { + button { + margin: 0 5px 5px 0; + } + margin-top: 10px; + margin-bottom: 20px; +} + +#login-form { + a { + color: $dark_gray; + cursor: pointer; + } +} + +// Create account + +#new-account-link { + cursor: pointer; +} \ No newline at end of file diff --git a/app/assets/stylesheets/application/modal.css.scss b/app/assets/stylesheets/application/modal.css.scss new file mode 100644 index 00000000000..2f628966200 --- /dev/null +++ b/app/assets/stylesheets/application/modal.css.scss @@ -0,0 +1,164 @@ +// base styles for every modal popup used in Discourse + +@import "foundation/variables"; +@import "foundation/mixins"; + +.modal-open { + .dropdown-menu { + z-index: 2050; + } + .dropdown.open { + *z-index: 2050; + } + .popover { + z-index: 2060; + } + .tooltip { + z-index: 2070; + } + +} + +.modal-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1040; + background-color: #000000; + &.fade { + opacity: 0; + } +} + +.modal-backdrop, +.modal-backdrop.fade.in { + opacity: 0.8; + filter: alpha(opacity=80); +} + +.modal { + position: fixed; + top: 50%; + left: 50%; + z-index: 1050; + overflow: auto; + width: 610px; + margin: -250px 0 0 -305px; + background-color: #ffffff; + border: 1px solid #999; + border: 1px solid rgba(0, 0, 0, 0.3); + *border: 1px solid #999; + /* IE6-7 */ + + @include border-radius-all (6px); + -webkit-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); + -moz-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); + box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); + -webkit-background-clip: padding-box; + -moz-background-clip: padding-box; + background-clip: padding-box; +} +.modal.fade { + -webkit-transition: opacity .3s linear, top .3s ease-out; + -moz-transition: opacity .3s linear, top .3s ease-out; + -ms-transition: opacity .3s linear, top .3s ease-out; + -o-transition: opacity .3s linear, top .3s ease-out; + transition: opacity .3s linear, top .3s ease-out; + top: -25%; +} +.modal.fade.in { + top: 50%; +} +.modal-body { + overflow-y: auto; + max-height: 400px; + padding: 15px; +} +.modal-form { + margin-bottom: 0; +} +.modal-footer { + margin: 0 15px; + padding: 14px 0 15px; + border-top: 1px solid #ddd; + @include border-radius-all(0 0 6px 6px); + @include box-shadow (inset 0 1px 0 #ffffff); + *zoom: 1; +} +.modal-footer:before, +.modal-footer:after { + display: table; + content: ""; +} +.modal-footer:after { + clear: both; +} +.modal-footer .btn + .btn { + margin-left: 5px; + margin-bottom: 0; +} +.modal-footer .btn-group .btn + .btn { + margin-left: -1px; +} +.modal-header { + border-bottom: 1px solid #9baab2; + @include box-shadow((0 1px 3px rgba($black, 0.12), inset 0 -4px 4px -4px rgba($black, 0.3))); + h3 { + color: $nav-pills-background-color-active; + font-size: 20px; + padding: 10px 15px 7px; + } + .close { + float: right; + font-size: 20px; + margin: 10px 10px 0px; + text-decoration: none; + color: $modal-close-button-color; + cursor: pointer; + &:hover { + color: darken($modal-close-button-color,20); + } + } +} + +.modal { + .nav { + padding: 10px 30px; + background-color: #e6e6e6; + li > a { + font-size: 14px; + } + border-bottom: 1px solid #bbb; + + } + &.hidden { + display: none; + } + .modal-body { + textarea { + width: 99%; + height: 80px; + } + label { + color: $darkish_gray; + } + p { + color: $black; + font-size: 13px; + } + .archetype-option { + margin-bottom: 20px; + } + } +} + +#move-selected { + form { + margin-top: 20px; + input[type=text] { + width: 500px; + } + } +} diff --git a/app/assets/stylesheets/application/onebox.scss b/app/assets/stylesheets/application/onebox.scss new file mode 100644 index 00000000000..29e2d3e5a8d --- /dev/null +++ b/app/assets/stylesheets/application/onebox.scss @@ -0,0 +1,66 @@ +@import "foundation/variables"; +@import "foundation/mixins"; + +a.loading-onebox { + background: { + image: image-url("spinner_96.gif"); + position: 0; + size: 20px; + height: 25px; + repeat: no-repeat; + }; + padding-left: 25px; +} + +.onebox-result { + @include border-radius-all(8px); + font-size: 14px; + > .source { + margin-bottom: 10px; + margin-right: 10px; + padding: 10px 0; + display: block; + color: $black; + position: relative; + border-bottom: 1px solid $inner_border; + .info { + a { + color: black; + text-decoration: none; + } + background-color: $white; + padding: 0 10px; + position: absolute; + font-size: 14px; + img.favicon { + margin-right: 3px; + } + } + } + .onebox-result-body { + padding: 5px; + font-family: Georgia, Times, "Times New Roman", serif; + img.thumbnail { + width: 25%; + height: auto; + } + code { + max-height: 400px; + } + img { + max-width: 30%; + max-height: 80%; + float: left; + margin-right: 10px; + } + .metrics { + clear: both; + padding-bottom: 25px; + .metric { + display: inline-block; + padding-left: 33px; + float: left; + } + } + } +} diff --git a/app/assets/stylesheets/application/pagedown.css.scss b/app/assets/stylesheets/application/pagedown.css.scss new file mode 100644 index 00000000000..be8669a88d4 --- /dev/null +++ b/app/assets/stylesheets/application/pagedown.css.scss @@ -0,0 +1,141 @@ +// styles that apply to the PageDown editor +// http://code.google.com/p/pagedown/ + +@import "foundation/mixins"; + +.wmd-panel { + margin-left: 25%; + margin-right: 25%; + width: 50%; + min-width: 500px; +} + +.wmd-button-bar { + width: 100%; +} + +.wmd-button-row { + margin-left: 5px; + margin-right: 5px; + margin-bottom: 5px; + margin-top: 10px; + padding: 0px; + height: 20px; +} + +.wmd-spacer { + width: 1px; + height: 20px; + margin-right: 8px; + margin-left: 5px; + background-color: silver; + display: inline-block; + float: left; +} + +.wmd-button { + width: 20px; + height: 20px; + padding-left: 2px; + padding-right: 3px; + margin-right: 5px; + background-image: image-url("wmd-buttons.png"); + background-repeat: no-repeat; + background-position: 0px 0px; + border: 0px; + width: 20px; + height: 20px; + position: relative; + border: 0; + float: left; +} + +@mixin wmd-button($offset: 0px) { + background-position: $offset 0; + @include hover { + background-position: $offset -40px; + } + + &:disabled { + background-position: $offset -20px; + } +} + +#wmd-bold-button { + @include wmd-button(0px); +} + +#wmd-italic-button { + @include wmd-button(-20px); +} + +#wmd-link-button { + @include wmd-button(-40px); +} + +#wmd-quote-button { + @include wmd-button(-60px); +} + +#wmd-code-button { + @include wmd-button(-80px); +} + +#wmd-image-button { + @include wmd-button(-100px); +} + +#wmd-olist-button { + @include wmd-button(-120px); +} + +#wmd-ulist-button { + @include wmd-button(-140px); +} + +#wmd-heading-button { + @include wmd-button(-160px); +} + +#wmd-hr-button { + @include wmd-button(-180px); +} + +#wmd-undo-button { + @include wmd-button(-200px); +} + +#wmd-redo-button { + @include wmd-button(-220px); +} + +#wmd-quote-post { + background-image: image-url("wmd-quote-post.gif"); + @include wmd-button(0px); +} + +.wmd-prompt-background { + background-color: black; +} + +.wmd-prompt-dialog { + border: 1px solid #999999; + background-color: whitesmoke; +} + +.wmd-prompt-dialog > div { + font-size: 0.8em; + font-family: arial, helvetica, sans-serif; +} + +.wmd-prompt-dialog > form > input[type="text"] { + border: 1px solid #999999; + color: black; +} + +.wmd-prompt-dialog > form > input[type="button"] { + border: 1px solid #888888; + font-family: trebuchet MS, helvetica, sans-serif; + font-size: 0.8em; + font-weight: bold; +} diff --git a/app/assets/stylesheets/application/request_access.css.scss b/app/assets/stylesheets/application/request_access.css.scss new file mode 100644 index 00000000000..b67de033444 --- /dev/null +++ b/app/assets/stylesheets/application/request_access.css.scss @@ -0,0 +1,13 @@ +#request-access { + width: 325px; + margin: 0 auto; + input[type=text] { + width: 320px; + height: 30px; + font-size: 22px; + } + input[type=submit] { + font-size: 22px; + padding: 10px; + } +} diff --git a/app/assets/stylesheets/application/share_link.css.scss b/app/assets/stylesheets/application/share_link.css.scss new file mode 100644 index 00000000000..c70e2241c77 --- /dev/null +++ b/app/assets/stylesheets/application/share_link.css.scss @@ -0,0 +1,30 @@ +// styles that apply to the "share" popup when sharing a link to a post or topic + +@import "foundation/variables"; +@import "foundation/mixins"; + +#share-link { + position: absolute; + left: 20px; + z-index: 990; + @include border-radius-all(3px); + @include box-shadow(1px 1px 5px $darkish_gray); + background-color: $light_gray; + padding: 3px 7px 3px 7px; + width: 300px; + @include fades-in(0.25s); + &.visible { + @include visible; + } + input[type=text] { + width: 96%; + } + h3 { + font-size: 13px; + } + .link { + display: block; + margin-right: 2px; + text-align: right; + } +} diff --git a/app/assets/stylesheets/application/topic-admin-menu.css.scss b/app/assets/stylesheets/application/topic-admin-menu.css.scss new file mode 100644 index 00000000000..427d65efb2e --- /dev/null +++ b/app/assets/stylesheets/application/topic-admin-menu.css.scss @@ -0,0 +1,37 @@ +// Styles for the topic admin menu + +@import "foundation/variables"; +@import "foundation/mixins"; + +#show-topic-admin { + position: fixed; + top: 70px; + right: 10px; + z-index: 1000; + + i { + margin: 0px; + line-height: 10px; + } +} + +.topic-admin-menu { + background-color: $white; + width: 205px; + padding: 10px; + border: 1px solid $gray; + position: fixed; + top: 70px; + right: 10px; + z-index: 1000; + + ul { + list-style: none; + margin: 10px 0 0 0; + } + + button { + width: 200px; + margin-bottom: 5px; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application/topic-list.css.scss b/app/assets/stylesheets/application/topic-list.css.scss new file mode 100755 index 00000000000..ad5e8c1c55b --- /dev/null +++ b/app/assets/stylesheets/application/topic-list.css.scss @@ -0,0 +1,295 @@ +@import "foundation/variables"; +@import "foundation/mixins"; + +// -------------------------------------------------- +// Topic lists +// -------------------------------------------------- + +// List controls +// -------------------------------------------------- + +#list-controls { + .nav { + float: left; + margin-bottom: 15px; + } + .btn { + float: right; + } +} + +#category-filter { + .has-icon span:before { + margin-right: 4px; + font: 15px/0.9 "FontAwesome"; + } + .has-icon .favorited:before { + content: "\f005"; + } + .has-icon .unread:before { + content: "\f02e"; + } +} + +// Base list +// -------------------------------------------------- + +#topic-list { + width: 100%; + border-collapse: separate; + border-spacing: 0; + border: 1px solid $topic-list-border-color; + @include border-radius-all(4px 4px 0 0); + @include box-shadow(0 1px 3px rgba($black, 0.22)); + tbody tr { + background-color: $white; + &:nth-child(even) { + background-color: darken($white, 2%); + } + &.archived { + opacity: 0.6; + } + } + th, + td { + padding: 7px 5px; + line-height: 1.25; + text-align: left; + vertical-align: middle; + &:first-of-type { + padding-left: 10px; + } + &:last-of-type { + padding-right: 10px; + } + } + th { + border-bottom: 1px solid $topic-list-th-border-color; + color: $topic-list-th-color; + font-weight: bold; + font-size: 13px; + text-shadow: 0 1px 0 $white; + background-color: $topic-list-th-background-color; + @include box-shadow(inset 0 1px 0 $white); + &:first-of-type { + @include border-radius-all(4px 0 0 0); + } + &:last-of-type { + @include border-radius-all(0 4px 0 0); + } + } + td { + //border-top: 1px solid $topic-list-td-border-color; + color: $topic-list-td-color; + font-size: 14px; + } + .star { + width: 20px; + padding-right: 0; + .icon-star { + position: relative; + top: 1px; + } + + .main-link { + padding-left: 0; + } + } + .main-link { + width: 515px; + font-size: 16px; + } + @include medium-width { + .main-link { + width: 400px; + } + } + @include small-width { + .main-link { + width: 355px; + } + } + .topic-statuses:empty { + display: none; + } + .topic-status { + margin-right: 4px; + padding: 0; + &:last-of-type { + margin-right: 0; + } + } + .badge-notification { + position: relative; + top: -1px; + } + .category { + width: 140px; + } + .posters { + width: 141px; + > a { + float: left; + margin-right: 4px; + &:last-of-type { + margin-right: 0; + } + } + } + .avatar { + &.latest { + @include box-shadow(0 0 5px lighten($link_color, 10%)); + } + } + .num { + text-align: center; + a:not(.badge-posts) { + color: inherit; + } + } +} + +// Category list +// -------------------------------------------------- + +.category-column { + float: left; + width: 550px; + &.first { + margin-right: 10px; + } +} + +@include medium-width { + .category-column { + width: 493px; + &.first { + margin-right: 9px; + } + } +} + +@include small-width { + .category-column { + width: 470px; + } +} + +.category-list-item { + margin-bottom: 10px; + #topic-list tbody tr:nth-child(even) { + background-color: $white; + } + .badge-category { + float: left; + margin: 3px 4px 0 0; + } + .posters { + float: left; + } + > footer { + border: 1px solid $topic-list-border-color; + border-top: 0; + padding: 7px 10px; + background-color: lighten($topic-list-th-background-color, 2%); + @include border-radius-all(0 0 4px 4px); + @include box-shadow(0 1px 3px rgba($black, 0.22)); + figure { + float: left; + margin: 3px 7px 0 0; + color: lighten($topic-list-th-color, 5%); + font-weight: bold; + font-size: 12px; + text-shadow: 0 1px 0 $white; + } + figcaption { + display: inline; + font-weight: normal; + } + .btn { + float: right; + margin-left: 7px; + } + } +} + +// Loading +// -------------------------------------------------- + +// List + +.loading #topic-list { + border: 0; + @include box-shadow(none); + tr { + background-color: transparent; + } +} + +// Topics + +#topic-list-bottom { + padding: 20px; + .topics-loading { + width: 200px; + margin: 0 auto; + padding: 10px 0 10px 43px; + color: $white; + font-size: 18px; + line-height: 25px; + background: { + color: $black; + image: image-url("spinner_96_w.gif"); + repeat: no-repeat; + position: 10px 50%; + size: 25px; + }; + @include border-radius-all(12px); + } +} + +// Misc. stuff +// -------------------------------------------------- + +#main { + #list-controls { + .badge-category { + display: inline-block; + background-color: yellow; + margin: 8px 0 0 8px; + float: left; + } + clear: both; + } + #list-area { + margin-bottom: 300px; + .empty-topic-list { + padding: 10px; + } + .unseen { + background-color: transparent; + padding: 0; + border: 0; + color: lighten($red, 10%); + font-size: 13px; + cursor: default; + } + } + #topic-list { + .alert { + margin-bottom: 0; + font-size: 14px; + } + .spinner { + margin-top: 40px; + } + } + span.posted { + display: inline-block; + text-indent: -9999em; + width: 15px; + height: 15px; + background: { + image: image-url("posted.png"); + }; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application/topic-post.css.scss b/app/assets/stylesheets/application/topic-post.css.scss new file mode 100644 index 00000000000..6c774072b17 --- /dev/null +++ b/app/assets/stylesheets/application/topic-post.css.scss @@ -0,0 +1,729 @@ +@import "foundation/variables"; +@import "foundation/mixins"; + +@include small-width { + #selected-posts { + display: none; + } + .topic-body { + width: 670px; + } +} + + +@include medium-width { + #selected-posts { + margin-left: 330px; + width: 12%; + p { + font-size: 13px; + } + } +} + + +@include large-width { + #selected-posts { + margin-left: 275px; + width: 20%; + } +} + + +#selected-posts { + position: fixed; + z-index: 1000; + left: 50%; + @include border-radius-all(4px); + background-color: lighten($blue, 52%); + border: 1px solid lighten($blue, 40%); + padding: 5px; + margin-bottom: 5px; + &.hidden { + display: none; + } + .controls { + margin-top: 10px; + } + p { + margin: 0 0 10px 0; + } + p.cancel { + margin: 10px 0 0 0; + } + h3 { + font-size: 25px; + color: $black; + margin-bottom: 5px; + i { + margin-right: 7px; + } + } +} + +@include small-width { + .topic-post { + .gutter { + width: 100px; + } + article.boxed { + .read-icon { + left: 750px !important; + } + } + } +} + + +@include medium-width { + .topic-post { + .gutter { + width: 160px; + } + } +} + + +.topic-post { + margin-bottom: 8px; + pre code { + max-height: 690px; + } + @include hover { + .gutter { + .reply-new, + .track-link { + visibility: visible; + } + } + } + + .gutter { + .reply-new {visibility: hidden;} + .reply-new, + .track-link { + display: inline-block; + color: #808080; + font-size: 13px; + text-shadow: 0 1px 0 $white; + .discourse-touch & { + visibility: visible; + } + &:hover { + color: #2eaee5; + } + &:active { + color: #1d92c5; + } + i { + position: relative; + width: 19px; + margin-right: 6px; + border: 1px solid #b9b9b9; + font-size: 14px; + line-height: 19px; + text-align: center; + background-color: #fafafa; + @include border-radius-all(20px); + &:after { + display: block; + position: absolute; + top: 9px; + left: -13px; + width: 12px; + height: 0; + content: ""; + border-top: 1px solid #ccc; + } + } + } + .reply-new i { + padding-top: 2px; + line-height: 17px; + color: $attention_fg; + background-color: $attention_bg; + } + .post-links { + margin: 0; + list-style: none; + > li { + margin-bottom: 10px; + } + .incoming .icon-arrow-right:before { + content: "\f060"; + } + } + } + + // Moderator post colours + &.moderator { + article.boxed { + .topic-body { + .contents { + background-color: #eef0ff; + &:after { + display: none; + } + } + &:after{ + border-right-color: #eef0ff; + } + } + } + } + img[alt=hidden] { + display: none; + } + ul { + li { + font-size: 14px; + } + } + .multi-select & { + section.post-menu-area { + display: none !important; + } + } + @include hover { + .read-icon { + i { + &:before { + font-size: 25px; + } + } + } + section.post-menu-area { + display: block; + } + } + + section.post-menu-area { + .discourse-no-touch & { + opacity: 0.2; + } + } + + &:hover section.post-menu-area { + opacity: 1; + } + + button.show-replies { + + span.badge-posts { + margin-right: 6px; + } + + i.icon { + margin-left: 6px; + font-size: 10px; + } + + color: $controls; + border: none; + background: none; + padding: 8px 10px; + border-right: 1px solid $inner_border; + } + + .contents:not(.bottom-round) .show-replies { + position: absolute; + padding-bottom: 9px; + border-right-color: #b9b9b9; + background-color: #e9e6e6; + } + + section.post-menu-area { + background-color: $post_footer; + border-top: 1px solid $inner_border; + overflow: hidden; + @include box-shadow(inset 0 -4px 4px -4px rgba($black, 0.14)); + nav.post-controls { + float: right; + padding: 0px; + button { + border: none; + color: $nav-button-color; + background: $post_footer; + font-size: 15px; + padding: 8px 10px; + border-left: 1px solid $inner_border; + vertical-align: top; + @include box-shadow(inset 0 -4px 4px -4px rgba($black, 0.14)); + &:hover { + color: $nav-button-color-hover; + background-color: $nav-button-background-color-hover; + } + &:active { + color: $nav-button-color-active; + background-color: $nav-button-background-color-active; + } + &.hidden { + display: none; + } + } + button.create { + color: $attention_fg; + font-weight:bold; + background-color: $attention_bg; + &:hover { + color: darken($attention_fg, 4%); + background-color: darken($attention_bg, 4%); + } + &:active { + color: darken($attention_fg, 8%); + background-color: darken($attention_bg, 8%); + } + i { + font-size: 13px; + margin-right: 4px; + } + } + button.delete { + } + button.like { + &:hover { + color: $nav-like-button-color-hover; + background-color: $nav-like-button-background-color-hover; + } + &:active { + color: $nav-like-button-color-active; + background-color: $nav-like-button-background-color-active; + } + } + &.toggled { + display: block; + } + .post-number { + // display: inline-block; + display: none; + line-height: 8px; + margin-left: 3px; + font-size: 14px; + .discourse-touch & { + margin-right: 29px; + } + } + } + } + + .bottom-round > .post-menu-area { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } + + .topic-meta-data { + .contents { + text-align: center; + padding: 0 10px 0 0; + h3 { + margin: 3px 0 0; + font-size: 14px; + line-height: 18px; + } + div { + display: block; + } + .score { + font-size: 12px; + } + } + .post-info { + margin-top: -1px; + line-height: 12px; + a { + color: rgba(#323232, 0.9); + font-size: 10px; + } + .bar { + color: $muted-link-color; + font-size: 10px; + } + } + } + .reply-to-tab { + z-index: 999; + font-size: 12px; + color: $darkish_gray; + display: block; + padding: 5px 5px 5px 8px; + @include border-radius-bottom(4px); + text-decoration: none; + position: absolute; + left: 120px; + background-color: #fafafa; + border: 1px solid #DDD; + margin-top: -1px; + @include box-shadow(1px 1px 2px rgba($black, 0.07)); + img { + margin-left: 6px; + } + @include hover { + background-color: mix($gray, $light_gray, 5%); + } + } + .embedded-posts.bottom { + @include border-radius-bottom(4px); + border-bottom: 1px solid #b9b9b9; + display: none; + overflow: hidden; + } + .embedded-posts.top { + @include border-radius-top(4px); + border-top: 1px solid #b9b9b9; + overflow: hidden; + } + + .embedded-posts { + background-color: #e9e6e6; + + div.reply { + border-left: 1px solid #b9b9b9; + border-right: 1px solid #b9b9b9; + padding: 10px; + margin: 0; + background-clip: padding-box; + .topic-meta-data { + .contents { + border: 0; + color: rgba(#323232, 0.9); + font-size: 10px; + line-height: 12px; + background-color: transparent; + @include box-shadow(none); + h5 { + margin-top: 1px; + font-size: 11px; + line-height: 13px; + } + } + } + .topic-body { + z-index: 10; + border: 1px solid #b9b9b9; + padding: 0 8px; + color: black; + background-color: $white; + background-clip: padding-box; + @include border-radius-all(4px); + @include box-shadow(0 1px 2px rgba($black, 0.07)); + &:before { + left: -10px; + } + &:after { + left: -9px; + } + } + .about { + .contents { + padding: 0; + } + } + } + } + &.selected { + article.boxed { + .post-select { + background-color: $blue; + color: $white; + } + .topic-body { + .contents { + @include box-shadow(0px 0px 7px $blue); + } + .contents:after { + display: none; + } + } + } + } + article.boxed { + position: relative; + font-size: 16px; + line-height: 20px; + @include small-width { + .read-icon { + right: 365px !important; + } + } + .post-select { + @include border-radius-all(4px); + background-color: $light_gray; + border-top: 1px solid $white; + border-left: 1px solid $white; + border-bottom: 1px solid $gray; + border-right: 1px solid $gray; + color: $darkish_gray; + top: 4px; + position: absolute; + right: 316px; + font-size: 12px; + padding: 2px 5px; + z-index: 490; + } + .read-icon { + @include transition(opacity 1s); + opacity: 0; + position: absolute; + top: -3px; + left: 800px; + width: 16px; + height: 22px; + z-index: 490; + font-size: 20px; + cursor: pointer; + &:before { + font-family: "FontAwesome"; + content: "\f02e"; + } + &.seen { + color: $gray; + opacity: 1; + } + &.last-read { + color: $red; + opacity: 1; + } + &.bookmarked { + &:before { + color: $bookmarkColor; + } + opacity: 1; + } + } + img { + max-width: 100%; + } + .topic-body { + position: relative; + .contents { + .cooked { + padding: 12px 10px 0; + } + position: relative; + border: 1px solid #b9b9b9; + padding: 0; + background-color: $white; + word-wrap: break-word; + @include border-radius-top(4px); + p:nth-of-type(1) { + margin-top: 0; + } + .calc { + background-color: $highlight; + margin-bottom: 10px; + padding: 4px 8px; + display: none; + p { + font-size: 12px; + margin: 0 0 5px 0; + } + } + } + .contents.bottom-round { + overflow: hidden; + background-clip: padding-box; + @include border-radius-bottom(4px); + @include box-shadow(0 1px 2px rgba($black, 0.07)); + } + .contents.avoid-tab { + padding-top: 30px; + } + .post-actions { + padding: 10px 0 0px 15px; + font-size: 12px; + img { + margin-right: 2px; + } + } + footer { + padding-left: 35px; + } + } + section { + font-size: 14px; + color: $dark_gray; + } + } + &.replies-above .boxed .topic-body .contents { + @include border-radius-top(0); + } +} + +.topic-body { + position: relative; + &:before, + &:after { + position: absolute; + width: 0; + height: 0; + content: ""; + border-style: solid; + border-color: transparent; + pointer-events: none; + } + &:before { + top: 12px; + left: -9px; + border-width: 10px 10px 10px 0; + border-right-color: #b9b9b9; + } + &:after { + top: 13px; + left: -8px; + border-width: 9px 9px 9px 0; + border-right-color: $white; + } +} + +.topic-post.hidden { + display: block; + opacity: 0.4; +} + +.topic-post.deleted { + opacity: 0.8; + article.boxed { + .topic-body .contents { + background-color: #ffcece; + } + .topic-body::after { + border-right-color: #ffcece; + } + } +} + +// Topic summary +// -------------------------------------------------- + +.topic-summary { + margin-top: 8px; + border: 1px solid #b9b9b9; + background-color: darken($white, 3%); + @include border-radius-all(4px); + @include box-shadow(0 1px 2px rgba($black, 0.07)); + h3 { + margin-bottom: 4px; + color: #323232; + line-height: 23px; + } + h4 { + margin-bottom: 3px; + color: #6c7376; + font-weight: normal; + font-size: 12px; + line-height: 15px; + } + p, + .participants { + margin: 0 0 7px; + } + ul { + margin: 0; + list-style: none; + } + .avatars { + > div { + float: left; + position: relative; + margin: 3px 0; + } + .post-count { + position: absolute; + top: 2px; + right: 6px; + padding: 0 4px; + color: $white; + font-weight: normal; + font-size: 11px; + line-height: 14px; + background-color: rgba($black, 0.5); + @include border-radius-all(2px); + } + } + .avatar { + float: left; + margin-right: 4px; + } + .poster.toggled .avatar { + @include box-shadow(0 0 5px lighten($link_color, 10%)); + } + .summary { + border-bottom: 1px solid #d1d1d2; + &.collapsed { + border-bottom: 0; + } + li { + float: left; + border-right: 1px solid #e6e6e6; + padding: 7px 14px; + &:last-of-type { + border-right: 0; + } + } + a, + .number { + font-weight: bold; + line-height: 20px; + } + .number { + color: #445a62; + } + .avatar + a { + float: left; + } + } + .avatars, + .links, + .information { + padding: 7px 14px; + color: #000; + } + .information { + border-top: 1px solid #d1d1d2; + } + .topic-links { + .badge-notification { + margin: 0 0 4px; + } + } + .buttons { + float: right; + .btn { + border: 0; + border-left: 1px solid #e6e6e6; + padding: 0 23px; + color: #4c666f; + background: #eaf2f5; + text-shadow: 0 1px 0 rgba($white, 0.28); + @include border-radius-all(0 2px 0 0); + @include box-shadow(none); + &:hover { + background: darken(#eaf2f5, 5%); + } + &.collapsed { + @include border-radius-all(0 2px 2px 0); + } + .icon { + margin: 0; + font-size: 18px; + line-height: 52px; + } + } + } +} + +// Private messages +// -------------------------------------------------- + +.private_message .gutter { + position: relative; + &:before { + display: block; + position: absolute; + top: 0; + left: 0; + color: rgba($black, 0.05); + font: 90px/1 FontAwesome; + content: "\f023"; + } +} diff --git a/app/assets/stylesheets/application/topic.css.scss b/app/assets/stylesheets/application/topic.css.scss new file mode 100644 index 00000000000..585121ce83b --- /dev/null +++ b/app/assets/stylesheets/application/topic.css.scss @@ -0,0 +1,469 @@ +/* styles that apply when viewing a topic/topic */ + +@import "foundation/variables"; +@import "foundation/mixins"; + +// Topic title +// -------------------------------------------------- + +#topic-title, +.extra-info-wrapper { + h1 { + display: inline-block; + max-width: 850px; + margin-top: 5px; + font-size: 22px; + line-height: 28px; + } + @include medium-width { + h1 { + max-width: 735px; + } + } + @include small-width { + h1 { + max-width: 690px; + } + } + .star { + height: 40px; + font-size: 20px !important; + line-height: 40px !important; + vertical-align: top; + } + .topic-statuses { + &:empty { + display: none; + } + } + .topic-status { + margin: 0 4px 0 0; + padding: 0; + &:last-of-type { + margin-right: 0; + } + > .icon { + display: inline-block; + margin-top: 8px; + vertical-align: top; + } + } + .badge-category { + margin-top: 5px; + vertical-align: top; + } + .edit-topic { + i { + font-size: 12px; + line-height: 28px; + vertical-align: top; + } + .discourse-no-touch & { + visibility: hidden; + } + } + @include hover { + .edit-topic { + visibility: visible; + } + } + #edit-title, + .chzn-container { + margin: 6px 0; + } + button { + margin: 8px 0; + vertical-align: top; + } + .btn-primary { + margin-left: 5px; + } +} + +// Page header + +#topic-title { + margin-bottom: 14px; +} + +// App header + +.extra-info-wrapper { + display: none; + float: left; + margin-left: 10px; + &.show-extra-info { + display: block; + } +} + +#multi-select-options { + button { + margin-bottom: 5px; + width: 150px; + } +} + +#topic-filter { + background-color: $highlight_light; + padding: 8px; + bottom: 0; + position: fixed; + width: 100%; + font-size: 15px; +} + +#main { + // used in topic onebox + .topic-info { + margin-top: 20px; + .info-line { + float: left; + color: lighten($black, 40%); + } + .posters { + margin-right: 10px; + float: right; + } + } + section.divider { + background-color: $dark_gray; + height: 3px; + margin: 10px 0 20px 0; + opacity: 1; + @include transition(opacity 3s); + &.fade { + opacity: 0; + } + } + a.mention, .ac-wrap .item span { + @include border-radius-all(5px); + @include linear-gradient(lighten($light_gray, 10%), $light_gray); + border: 1px solid $gray; + color: $darkish_gray; + padding: 1px 2px; + } + a.mention { + cursor: pointer; + .clicks { + display:none; + } + } + span.spoiler { + background-color: $black; + color: $black; + @include hover { + color: $white; + } + } + + // When we are quoting something + aside.quote { + margin-top: 14px; + border: 1px solid #eee; + font-size: 14px; + line-height: 20px; + overflow: hidden; + @include border-radius-all(4px); + &:nth-of-type(1) { + margin: 0; + } + .title { + padding: 8px; + background-color: #f1f1f1; + a, + .avatar { + margin-right: 4px; + vertical-align: top; + } + } + .quote-controls { + float: right; + color: #323232; + @include fades-in(0.25s); + a { + margin: 0; + } + .back:before, + .quote-other-topic:before { + display: inline-block; + margin-left: 8px; + color: #323232; + font-family: "FontAwesome"; + } + .back:before { + content: "\f062"; + } + .quote-other-topic:before { + content: "\f061"; + } + } + .discourse-touch & { + .quote-controls { + @include visible; + i, + a { + opacity: 0.5; + } + } + } + @include hover { + .quote-controls { + @include visible; + } + } + blockquote { + margin: 0; + border: 0; + padding: 8px; + color: #333; + background-color: $white; + *:last-child { + margin-bottom: 0; + } + .highlighted { + display: inline; + padding: 3px 5px 3px 8px; + background-color: $highlight; + } + } + } + .quote-button { + background-color: $blue; + z-index: 500; + color: $white; + font-weight: bold; + font-size: 14px; + padding: 2px 9px 5px 9px; + text-decoration: none; + border: 5px solid rgba(0, 0, 0, 0.75); + top: 10px; + left: 10px; + z-index: 99999; + position: absolute; + display: none; + @include border-radius-all(4px); + @include hover { + cursor: pointer; + background-color: lighten($blue, 10%); + } + &.visible { + display: block; + } + } + #topic-bottom { + height: 20px; + } + + + #suggested-topics { + margin: 40px 0 40px 20px; + width: 1110px; + @include medium-width { width: 970px; } + @include small-width { width: 870px; } + h3 { + font-size: 15px; + margin-left: 6px; + color: darken($darkish_gray, 20%); + margin-bottom: 10px; + } + } + + #topic-footer-buttons { + margin: 20px 0 0 103px; + + width: 1110px; + @include medium-width { width: 970px; } + @include small-width { width: 870px; } + + .btn-group { + margin-top: 20px; + p { + line-height: 32px; + float: left; + margin: 0 10px; + color: #7f7f7f; + } + h4.title { + line-height: 32px; + font-weight: normal; + float: left; + display: inline; + margin-right: 10px; + } + } + ul.dropdown-menu { + top: auto; + bottom: 115%; + left: 0px; + } + ul li a { + span { + display: block; + font-size: 11px; + } + span.title { + font-weight: bold; + font-size: 14px; + } + } + button { + margin-right: 5px; + span.caret { + margin-left: 10px; + } + i { + margin-right: 5px; + } + } + } +} + +kbd { + white-space: nowrap; + border-style: solid; + border-color: #cccccc #aaaaaa #888888 #bbbbbb; + padding: 0px 6px; + @include box-shadow(0 1px 0 rgba(0, 0, 0, 0.2) 0 0 0 1px white inset); + border-radius: 4px; + background-color: #fafafa; + border-width: 1px 1px 2px; + color: #444444; + font-family: "Helvetica Neue", Helvetica, Arial, Sans-serif; + font-size: 11px; + font-weight: bold; + white-space: nowrap; + display: inline-block; + margin-bottom: 5px; +} + +.post-text img { + max-width: 640px; +} + +#topic-progress-wrapper { + position: fixed; + width: 0; + bottom: 0px; + right: 50%; + z-index: 1; + outline: 1px solid transparent; +} + +.posts .spinner { + width: 100px; + margin: 0 auto 30px 375px; +} + +.posts-wrapper { + position: relative; +} + +#topic-progress-wrapper.docked { + #topic-progress { + border-bottom: 1px solid $gray; + } + position: absolute; +} + +#topic-progress { + position: relative; + left: 275px; + &.hidden { + display: none; + } + border-left: 1px solid $gray; + border-right: 1px solid $gray; + border-top: 1px solid $gray; + background-color: $white; + color: $darkish_gray; + width: 130px; + height: 34px; + .nums { + position: relative; + top: 9px; + width: 100%; + text-align: center; + z-index: 1; + } + button { + padding: 0; + cursor: pointer; + z-index: 1000; + position: absolute; + top: 8px; + left: 4px; + border: 0; + background: none; + color: $darkish_gray; + i { + font-size: 18px; + } + &:nth-of-type(2) { + right: 4px; + left: auto; + } + @include hover { + color: darken($darkish_gray, 10%); + &:disabled { + cursor: default; + color: lighten($darkish_gray, 30%); + } + } + + &:disabled { + cursor: default; + color: lighten($darkish_gray, 30%); + } + } + h4 { + display: inline; + font-size: 18px; + line-height: 15px; + } + .bg { + position: absolute; + top: 0px; + bottom: 0px; + width: 0px; + border-right: 1px solid $white; + background-color: #e6f7ef; + } +} + +#edit-title { + vertical-align: top; +} + +.private_message .participants .user { + float: left; + display: block; + margin-right: 15px; +} + +.posts-wrapper .spinner { + margin-left: 390px; +} + + +@include medium-width { + .posts-wrapper .spinner { + margin-left: 380px; + } + #topic-progress { + left: 325px; + } +} + + +@include small-width { + .posts-wrapper .spinner { + margin-left: 360px; + } + #topic-progress { + left: 0; + } + #topic-progress-wrapper { + right: 180px; + } +} diff --git a/app/assets/stylesheets/application/user.css.scss b/app/assets/stylesheets/application/user.css.scss new file mode 100644 index 00000000000..dc8ed5a4ee1 --- /dev/null +++ b/app/assets/stylesheets/application/user.css.scss @@ -0,0 +1,283 @@ +// styles that apply to the user page +@import "foundation/variables"; +@import "foundation/mixins"; + +.user-preferences { + textarea { + width: 530px; + height: 100px; + } + .static { + color: $black; + margin-top: 5px; + margin-left: 5px; + display: inline-block; + } + .instructions { + color: $dark_gray; + margin-left: 165px; + margin-top: 5px; + } + .avatar { + margin-left: 3px; + } + .instructions a[href] { + color: $darkish_gray; + } + .warning { + @include border-radius-all(6px); + background-color: lighten($red, 10%); + padding: 5px 8px; + color: $white; + width: 520px; + } +} + +#user-menu { + .btn { + float: right; + margin: 5px 0 0 10px; + } +} + +#about-me { + padding: 4px; + margin: -4px; + display: block; + width: 220px; + min-height: 200px; + background-color: #f8f8f8; + border-radius: 5px; + color: #444; + word-wrap: break-word; +} + +#user-info { + width: 240px; + margin-right: 30px; + float: left; + .summary { + height: 50px; + } + .avatar { + float: left; + width: 45px; + } + nav.buttons { + width: 180px; + padding: 0; + .btn { + width: 100%; + margin-bottom: 5px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + } + } + h2 { + a { + font-size: 14px; + color: #999999; + cursor: pointer; + } + } + .show { + dl { + width: 100%; + overflow: hidden; + dt { + margin: 0; + padding: 0; + width: 80px; + font-size: 12px; + color: #555555; + float: left; + clear: left; + } + dd { + margin: 0; + padding: 0; + width: 100px; + float: left; + color: #444444; + } + } + } + .avatar { + vertical-align: bottom; + a { + display: inline-block; + } + } + form { + .bio { + width: 220px; + height: 150px; + } + } + .side-nav { + margin-top: 5px; + } +} + +#no-invites { + padding: 10px; +} + +#invited-users { + h2 { + color: $darkish_gray; + font-size: 20px; + } + .invites { + margin-bottom: 20px; + tr { + height: 45px; + } + } +} + +#user-menu h1 { + color: #2d3234; + float: left; + padding-left: 150px; + span { + font-size: 18px; + color: #676b6c; + margin-left: 15px; + font-weight: lighter; + position: relative; + top: -4px; + } +} + +#user-menu { + margin: 10px 0 0; +} + +.user-heading { + border-bottom: 1px solid #bcbcbc; + background-color: #e6e6e6; + margin-top: -15px; + margin-bottom: 10px; + position: relative; + .nav { + float: right; + margin: 5px 0 14px 5px; + } +} + +.user-info { + margin-bottom: 10px; + .about-me { + position: relative; + border: 1px solid #b9b9b9; + padding: 6px; + @include border-radius-all(4px); + float: left; + width: 946px; + height: 57px; + margin-left: 150px; + background-color: white; + @include box-shadow((0 1px 2px rgba($black, 0.07), inset 0 -4px 4px -4px rgba($black, 0.14))); + &:before, + &:after { + position: absolute; + width: 0; + height: 0; + content: ""; + border-style: solid; + border-color: transparent; + pointer-events: none; + } + &:before { + top: 12px; + left: -10px; + border-width: 10px 10px 10px 0; + border-right-color: #b9b9b9; + } + &:after { + top: 13px; + left: -9px; + border-width: 9px 9px 9px 0; + border-right-color: $white; + } + .missing-profile { + color: lighten(#000, 70%); + } + } + @include medium-width { + .about-me { + width: 831px; + } + } + @include small-width { + .about-me { + width: 786px; + } + } +} + +.user-heading { + .avatar-wrapper { + position: absolute; + display: block; + width: 120px; + } +} + +#user-stream-bottom { + margin-bottom: 50px; + clear: both; +} + +#user-stream { + width: 840px; + float: left; + .excerpt { + margin: 5px 0px; + font-size: 13px; + color: lighten($black, 30%); + } + .item { + .post-number { + color: lighten($black, 40%); + margin-right: 4px; + } + padding: 10px 8px; + background-color: white; + border: 1px solid #b9b9b9; + margin-bottom: 10px; + @include border-radius-all(4px); + @include box-shadow((0 1px 2px rgba($black, 0.07), inset 0 -4px 4px -4px rgba($black, 0.14))); + } + .type { + color: lighten($black, 40%); + } + .time { + display: block; + float: right; + color: silver; + margin-right: 8px; + font-size: 11px; + } + .avatar-link { + float: left; + margin-right: 10px; + } + .name { + display: inline-block; + margin-bottom: 4px; + font-size: 14px; + } +} +@include medium-width { + #user-stream { + width: 725px; + } +} +@include small-width { + #user-stream { + width: 680px; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application/username_tagsinput.css b/app/assets/stylesheets/application/username_tagsinput.css new file mode 100644 index 00000000000..5f2b7a74a06 --- /dev/null +++ b/app/assets/stylesheets/application/username_tagsinput.css @@ -0,0 +1,33 @@ +div.tagsinput { + border:1px solid #CCC; + background: #FFF; + padding:5px 5px 0px; + width:584px; + height:100px; + overflow-y: auto; + border-radius: 4px; +} + +div.tagsinput span.tag { + border: 1px solid #a5d24a; + -moz-border-radius:2px; + -webkit-border-radius:2px; + border-radius: 2px; + display: block; + float: left; + padding: 1px 5px; + text-decoration:none; + background: #cde69c; + color: #638421; + margin-right: 5px; + margin-bottom:5px; + font-family: helvetica; + font-size:13px; +} + +div.tagsinput span.tag a { font-weight: bold; color: #82ad2b; text-decoration:none; font-size: 11px; } +div.tagsinput input { width:80px; margin:0px; font-family: helvetica; font-size: 13px; border:1px solid transparent; +padding:2px 5px; background: transparent; color: #000; outline:0px; margin-right:5px; margin-bottom:5px; } +div.tagsinput div { display:block; float: left; } +.tags_clear { clear: both; width: 100%; height: 0px; } +.not_valid {background: #FBD8DB !important; color: #90111A !important;} diff --git a/app/assets/stylesheets/components/badges.css.scss b/app/assets/stylesheets/components/badges.css.scss new file mode 100755 index 00000000000..67fd02409cb --- /dev/null +++ b/app/assets/stylesheets/components/badges.css.scss @@ -0,0 +1,78 @@ +@import "foundation/variables"; +@import "foundation/mixins"; + +// -------------------------------------------------- +// Badges +// -------------------------------------------------- + +// Base +// -------------------------------------------------- + +%badge { + display: inline-block; + border: 1px solid rgba($black, 0.4); + font-weight: bold; + line-height: 1; + white-space: nowrap; + @include border-radius-all(4px); +} + +// Category badge +// -------------------------------------------------- + +.badge-category { + @extend %badge; + padding: 3px 8px; + color: $white; + font-size: 12px; + text-shadow: 0 1px 0 rgba($black, 0.3); + @include box-shadow(inset 0 1px 0 rgba($white, 0.22)); + &[href] { + color: $white; + } +} + +// Notification badge +// -------------------------------------------------- + +.badge-notification { + @extend %badge; + margin-left: 4px; + padding: 2px 4px; + color: $white; + font-size: 11px; + text-shadow: 0 1px 0 rgba($black, 0.2); + background-color: $badge-notification-background-color; + @include box-shadow(inset 0 1px 0 rgba($white, 0.26)); + &[href] { + color: $white; + } + + // New posts + + &.new-posts { + background-color: $blue; + } + + // Click count + + &.clicks { + border: 0; + font-weight: normal; + text-shadow: none; + background-color: rgba($black, 0.15) + } +} + +// Posts badge +// -------------------------------------------------- + +.badge-posts { + color: $badge-posts-color; + font-weight: bold; + font-size: 14px; + line-height: 1; + &[href] { + color: $badge-posts-color; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/components/buttons.css.scss b/app/assets/stylesheets/components/buttons.css.scss new file mode 100755 index 00000000000..74e25ad4a51 --- /dev/null +++ b/app/assets/stylesheets/components/buttons.css.scss @@ -0,0 +1,149 @@ +@import "foundation/variables"; +@import "foundation/mixins"; + +// -------------------------------------------------- +// Buttons +// -------------------------------------------------- + +// Base +// -------------------------------------------------- + +.btn { + display: inline-block; + outline: 0; + margin: 0; + padding: 6px 12px; + font-weight: 500; + font-size: 14px; + line-height: 18px; + text-align: center; + cursor: pointer; + @include border-radius-all(4px); + &:active { + text-shadow: none; + } + &[disabled] { + cursor: default; + opacity: 0.4; + } + .icon { + margin-right: 7px; + } +} + +// Default button +// -------------------------------------------------- + +.btn { + border: 1px solid rgba($black, 0.3); + color: $btn-default-color; + text-shadow: 0 1px 0 $white; + @include linear-gradient($white, $btn-default-background-color); + @include box-shadow(inset 0 -1px 2px rgba($black, 0.2)); + &[href] { + color: $btn-default-color; + } + &:hover, + &:focus { + @include linear-gradient($white, $btn-default-background-color-hover); + @include box-shadow(none); + } + &:active { + @include linear-gradient($btn-default-background-color, $white); + @include box-shadow(inset 0 1px 3px rgba($black, 0.2)); + } + &[disabled] { + text-shadow: 0 1px 0 $white; + @include linear-gradient($white, $btn-default-background-color); + @include box-shadow(inset 0 -1px 2px rgba($black, 0.2)); + } +} + +// Primary button +// -------------------------------------------------- + +.btn-primary { + border: 1px solid $btn-primary-border-color; + color: $white; + text-shadow: 0 1px 0 rgba($black, 0.2); + font-weight: bold; + @include linear-gradient($btn-primary-background-color, $btn-primary-background-color-dark); + @include box-shadow((inset 0 1px 0 rgba($white, 0.33), inset 0 -1px 2px rgba($black, 0.2))); + &[href] { + color: $white; + } + &:hover, + &:focus { + @include linear-gradient($btn-primary-background-color, $btn-primary-background-color-light); + @include box-shadow(inset 0 1px 0 rgba($white, 0.33)); + } + &:active { + @include linear-gradient($btn-primary-background-color-dark, $btn-primary-background-color); + @include box-shadow(inset 0 1px 3px rgba($black, 0.2)); + } + &[disabled] { + text-shadow: 0 1px 0 rgba($black, 0.2); + @include linear-gradient($btn-primary-background-color, $btn-primary-background-color-dark); + @include box-shadow((inset 0 1px 0 rgba($white, 0.33), inset 0 -1px 2px rgba($black, 0.2))); + } +} + +// Social buttons +// -------------------------------------------------- + +.btn-social { + color: $white; + text-shadow: 0 1px 0 rgba($black, 0.2); + @include box-shadow(inset 0 1px 0 rgba($white, 0.1)); + &[href] { + color: $white; + } + &:before { + margin-right: 7px; + font-family: zocial; + line-height: 0.9; + } + &.google { + background: $google; + &:before { + content: "G"; + } + } + &.facebook { + background: $facebook; + &:before { + content: "f"; + } + } + &.twitter { + background: $twitter; + &:before { + content: "T"; + } + } + &.yahoo { + background: $yahoo; + &:before { + content: "Y"; + } + } +} + +// Button Sizes +// -------------------------------------------------- + +// Small + +.btn-small { + padding: 3px 6px; + font-size: 12px; + line-height: 16px; +} + +// Large + +.btn-large { + padding: 9px 18px; + font-size: 16px; + line-height: 20px; +} \ No newline at end of file diff --git a/app/assets/stylesheets/components/navs.css.scss b/app/assets/stylesheets/components/navs.css.scss new file mode 100755 index 00000000000..d49b2e36988 --- /dev/null +++ b/app/assets/stylesheets/components/navs.css.scss @@ -0,0 +1,96 @@ +@import "foundation/variables"; +@import "foundation/mixins"; +@import "foundation/helpers"; + +// -------------------------------------------------- +// Navigation menus +// -------------------------------------------------- + +// Base +// -------------------------------------------------- + +%nav { + margin-left: 0; + list-style: none; + > li > a { + display: block; + text-decoration: none; + } +} + +// Pill nav +// -------------------------------------------------- + +.nav-pills { + @extend %nav; + @extend .clearfix; + > li { + float: left; + margin-right: 5px; + > a { + border: 1px solid transparent; + padding: 5px 12px; + color: $nav-pills-color; + font-size: 16px; + line-height: 20px; + text-shadow: 0 1px 0 rgba($white, 0.6); + @include border-radius-all(4px); + &:hover { + border-color: $nav-pills-border-color-hover; + color: $nav-pills-color-hover; + background-color: $nav-pills-background-color-hover; + } + } + &.active > a, > a.active { + border-color: $nav-pills-border-color-active; + color: $nav-pills-color-active; + text-shadow: 0 1px 0 rgba($black, 0.24); + background-color: $nav-pills-background-color-active; + @include box-shadow((0 1px 3px rgba($black, 0.16), inset 0 1px 0 rgba($white, 0.22))); + } + } +} + +// Stacked nav +// -------------------------------------------------- + +.nav-stacked { + @extend %nav; + border: 1px solid $nav-stacked-border-color; + padding: 0; + overflow: hidden; + background-color: $nav-stacked-background-color; + @include border-radius-all(4px); + @include box-shadow(0 1px 0 $white); + > li { + border-bottom: 1px solid $nav-stacked-divider-color; + &:last-of-type { + border-bottom: 0; + } + > a { + margin: 0; + padding: 13px; + font-weight: bold; + font-size: 16px; + line-height: 20px; + cursor: pointer; + } + } + .active > a, + .active .icon-chevron-right { + color: $nav-stacked-border-color-active; + text-shadow: 0 1px 0 rgba($white, 0.5); + background-color: $nav-stacked-background-color-active; + } + .count { + font-size: 12px; + line-height: 16px; + } + .icon-chevron-right { + float: right; + margin: 0; + color: $nav-stacked-chevron-color; + font-size: 14px; + line-height: 20px; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/components/tooltips.css.scss b/app/assets/stylesheets/components/tooltips.css.scss new file mode 100644 index 00000000000..093e22739d4 --- /dev/null +++ b/app/assets/stylesheets/components/tooltips.css.scss @@ -0,0 +1,144 @@ +@import "foundation/variables"; +@import "foundation/mixins"; + +// -------------------------------------------------- +// Excerpts +// -------------------------------------------------- + +.excerpt-view { + display: none; + position: fixed; + z-index: 2000; + width: 500px; + border: 1px solid #b9b9b9; + padding: 10px; + background-color: $white; + @include border-radius-all(4px); + .contents { + padding: 0 15px 0 55px; + color: #262525; + font-size: 14px; + } + h1 { + font-size: 20px; + line-height: 1; + margin-bottom: 5px; + } + .image { + float: left; + overflow: hidden; + @include border-radius-all(4px); + } + .info { + color: #7f7f7f; + font-size: 11px; + } + .description { + margin: 5px 0; + } + .figs { + figure { + display: inline; + margin: 0 10px 0 0; + padding: 0; + font-weight: bold; + } + figcaption { + display: inline; + font-weight: normal; + } + } + .button-row { + margin-top: 5px; + text-align: right; + } + .close { + position: absolute; + top: 3px; + right: 5px; + color: #7f7f7f; + font-size: 12px; + line-height: 8px; + } + &.medium { + width: 430px; + .contents { + padding-left: 0; + } + } + &.small { + width: 300px; + } + &:before, + &:after { + position: absolute; + width: 0; + height: 0; + content: ""; + border-style: solid; + border-color: transparent; + pointer-events: none; + } + &:before { + border-width: 10px; + } + &:after { + border-width: 9px; + } + &.top:before { + top: 100%; + left: 5%; + margin-left: -10px; + border-bottom-width: 0; + border-top-color: #b9b9b9; + } + &.top:after { + top: 100%; + left: 5%; + margin-left: -9px; + border-bottom-width: 0; + border-top-color: $white; + } + &.right:before { + right: 100%; + top: 20%; + margin-top: -10px; + border-left-width: 0; + border-right-color: #b9b9b9; + } + &.right:after { + right: 100%; + top: 20%; + margin-top: -9px; + border-left-width: 0; + border-right-color: $white; + } + &.bottom:before { + bottom: 100%; + left: 5%; + margin-left: -10px; + border-top-width: 0; + border-bottom-color: #b9b9b9; + } + &.bottom:after { + bottom: 100%; + left: 5%; + margin-left: -9px; + border-top-width: 0; + border-bottom-color: $white; + } + &.left:before { + left: 100%; + top: 20%; + margin-top: -10px; + border-right-width: 0; + border-left-color: #b9b9b9; + } + &.left:after { + left: 100%; + top: 20%; + margin-top: -9px; + border-right-width: 0; + border-left-color: $white; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/foundation/base.css.scss b/app/assets/stylesheets/foundation/base.css.scss new file mode 100755 index 00000000000..1d7b3f6f828 --- /dev/null +++ b/app/assets/stylesheets/foundation/base.css.scss @@ -0,0 +1,79 @@ +@import "variables"; +@import "mixins"; + +// -------------------------------------------------- +// Base styles for HTML elements +// -------------------------------------------------- + +html { + color: $black; + font: #{$base-font-size}/#{$base-line-height} $base-font-family; + background-color: $base-background-color; + overflow-y: scroll; + -webkit-font-smoothing: subpixel-antialiased; +} + +// Links +// -------------------------------------------------- + +a { + color: $link-color; + text-decoration: none; + &:visited { + color: $link-color-visited; + } + &:hover { + color: $link-color-hover; + } + &:focus { + outline: 0; + } + &:active { + color: $link-color-active; + } +} + +// Typography +// -------------------------------------------------- + +hr { + display: block; + height: 1px; + margin: 1em 0; + border: 0; + border-top: 1px solid $hr-border-color; + padding: 0; +} + +// Lists +// -------------------------------------------------- + +ul, +ol, +dd { + margin: 0 0 9px 25px; + padding: 0; +} + +li { + > ul, + > ol { + margin-bottom: 0; + } +} + +// Embedded content +// -------------------------------------------------- + +img { + vertical-align: middle; +} + +// Forms +// -------------------------------------------------- + +fieldset { + margin: 0; + border: 0; + padding: 0; +} \ No newline at end of file diff --git a/app/assets/stylesheets/foundation/helpers.css.scss b/app/assets/stylesheets/foundation/helpers.css.scss new file mode 100755 index 00000000000..de63992d4f0 --- /dev/null +++ b/app/assets/stylesheets/foundation/helpers.css.scss @@ -0,0 +1,66 @@ +// -------------------------------------------------- +// Generic helper classes +// -------------------------------------------------- + +// Floats +// -------------------------------------------------- + +.pull-left { + float: left; +} + +.pull-right { + float: right; +} + +// Element visibility +// -------------------------------------------------- + +.show { + display: block; +} + +.hide, +.hidden { + display: none; +} + +.invisible { + visibility: hidden; +} + +// Affix +// -------------------------------------------------- + +.affix { + position: fixed; +} + +// Contain floats +// -------------------------------------------------- + +.clearfix { + &:before, + &:after { + display: table; + content: " "; + } + &:after { + clear: both; + } +} + +// Image replacement +// -------------------------------------------------- + +.hide-text { + border: 0; + background-color: transparent; + overflow: hidden; + &:before { + display: block; + width: 0; + height: 150%; + content: ""; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/foundation/mixins.scss b/app/assets/stylesheets/foundation/mixins.scss new file mode 100644 index 00000000000..b4c4193f9b8 --- /dev/null +++ b/app/assets/stylesheets/foundation/mixins.scss @@ -0,0 +1,182 @@ +// -------------------------------------------------- +// Mixins used throughout the theme +// -------------------------------------------------- + +// Media queries +// -------------------------------------------------- + +@mixin small-height { + @media screen and (max-height: 444px) { + @content; + } +} + +@mixin regular-height { + @media screen and (min-height: 445px) { + @content; + } +} + +@mixin not-small-width { + @media screen and (min-width: 967px) { + @content; + } +} + +@mixin small-width { + @media screen and (max-width: 966px) { + @content; + } +} + +@mixin medium-width { + @media screen and (min-width: 967px) and (max-width: 1139px) { + @content; + } +} + +@mixin large-width { + @media screen and (min-width: 1140px) { + @content; + } +} + +// CSS3 properties +// -------------------------------------------------- + +// Box sizing + +@mixin box-sizing($sizing) { + -webkit-box-sizing: $sizing; + -moz-box-sizing: $sizing; + box-sizing: $sizing; +} + +// Border radius + +@mixin border-radius-all($radius) { + -webkit-border-radius: $radius; + border-radius: $radius; +} + +@mixin border-radius-top($radius) { + -webkit-border-top-right-radius: $radius; + border-top-right-radius: $radius; + -webkit-border-top-left-radius: $radius; + border-top-left-radius: $radius; +} + +@mixin border-radius-bottom($radius) { + -webkit-border-bottom-right-radius: $radius; + border-bottom-right-radius: $radius; + -webkit-border-bottom-left-radius: $radius; + border-bottom-left-radius: $radius; +} + +// Box shadow + +@mixin box-shadow($shadow) { + -webkit-box-shadow: $shadow; + box-shadow: $shadow; +} + +// Linear gradient + +@mixin linear-gradient($start-color, $end-color) { + background-color: $start-color; + background-image: -webkit-gradient(linear, left top, left bottom, from($start-color), to($end-color)); + background-image: -webkit-linear-gradient(top, $start-color, $end-color); + background-image: -moz-linear-gradient(top, $start-color, $end-color); + background-image: -o-linear-gradient(top, $start-color, $end-color); + background-image: linear-gradient(to bottom, $start-color, $end-color); +} + +// Background size + +@mixin background-size($size) { + -webkit-background-size: $size; + background-size: $size; +} + +// Background clip + +@mixin background-clip($clip) { + -webkit-background-clip: $clip; + background-clip: $clip; +} + +// Rotate + +@mixin rotate($degrees) { + -webkit-transform: rotate($degrees); + -moz-transform: rotate($degrees); + -ms-transform: rotate($degrees); + -o-transform: rotate($degrees); + transform: rotate($degrees); +} + +// Scale + +@mixin scale($ratio) { + -webkit-transform: scale($ratio); + -moz-transform: scale($ratio); + -ms-transform: scale($ratio); + -o-transform: scale($ratio); + transform: scale($ratio); +} + +// Transition + +@mixin transition($transition) { + .discourse-no-touch & { + -webkit-transition: #{$transition}; + -moz-transition: #{$transition}; + -ms-transition: #{$transition}; + -o-transition: #{$transition}; + transition: #{$transition}; + } +} + +// Visibility +// -------------------------------------------------- + +@mixin hover { + .discourse-no-touch & { + &:hover { + @content; + } + } +} + +@mixin fades-in($time: 0.5s) { + opacity: 0; + visibility: hidden; + .discourse-no-touch & { + -webkit-transition: visibility 0s linear $time, opacity $time linear; + -moz-transition: visibility 0s linear $time, opacity $time linear; + -ms-transition: visibility 0s linear $time, opacity $time linear; + -o-transition: visibility 0s linear $time, opacity $time linear; + transition: visibility 0s linear $time, opacity $time linear; + } +} + +@mixin visible { + opacity: 1; + visibility: visible; + -webkit-transition-delay: 0s; + -moz-transition-delay: 0s; + -ms-transition-delay: 0s; + -o-transition-delay: 0s; + transition-delay: 0s; +} + +// Decorations +// -------------------------------------------------- + +// Glow + +@mixin glow($color) { + border: 1px solid $color; + -webkit-box-shadow: 0 0 5px $color; + box-shadow: 0 0 5px $color; +} \ No newline at end of file diff --git a/app/assets/stylesheets/foundation/variables.scss b/app/assets/stylesheets/foundation/variables.scss new file mode 100644 index 00000000000..7458b94caf6 --- /dev/null +++ b/app/assets/stylesheets/foundation/variables.scss @@ -0,0 +1,169 @@ +// -------------------------------------------------- +// Variables used throughout the theme +// -------------------------------------------------- + +// Base +// -------------------------------------------------- + +$base-font-size: 13px !default; +$base-line-height: 18px !default; +$base-font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !default; +$base-background-color: #eee !default; + +// Links + +$link-color: #006e97 !default; +$link-color-visited: #4a6b82 !default; +$link-color-hover: #0081b0 !default; +$link-color-active: #005c7d !default; + +// Typography + +$hr-border-color: #cfcfcf !default; + +// Badges +// -------------------------------------------------- + +// Notification badge + +$badge-notification-background-color: #999 !default; + +// Posts badge + +$badge-posts-color: #808080 !default; + +// Buttons +// -------------------------------------------------- + +// Default button + +$btn-default-color: #565656 !default; +$btn-default-background-color: #eee !default; +$btn-default-background-color-hover: #fafafa !default; + +// Primary button + +$btn-primary-border-color: #16617d !default; +$btn-primary-background-color: #00aeef !default; +$btn-primary-background-color-dark: #009dd8 !default; +$btn-primary-background-color-light: #00b0f0 !default; + + +// Navigation menus +// -------------------------------------------------- + +// Pill nav + +$nav-pills-color: #534d4b !default; +$nav-pills-color-hover: #e45735 !default; +$nav-pills-border-color-hover: #f6d5cd !default; +$nav-pills-background-color-hover: #fff0ed !default; +$nav-pills-color-active: #ffeeea !default; +$nav-pills-border-color-active: #ac3d22 !default; +$nav-pills-background-color-active: #e45735 !default; + +// Stacked nav + +$nav-stacked-border-color: #b9b9b9 !default; +$nav-stacked-background-color: #fafafa !default; +$nav-stacked-divider-color: #e6e6e6 !default; +$nav-stacked-chevron-color: #ccc !default; +$nav-stacked-border-color-active: #f15b22 !default; +$nav-stacked-background-color-active: #f9e7e0 !default; + +// Button nav + +$nav-button-color: #7b7b7b !default; +$nav-button-color-hover: #616161 !default; +$nav-button-background-color-hover: #f2f2f2 !default; +$nav-button-color-active: #474747 !default; +$nav-button-background-color-active: #e0e0e0 !default; + +$nav-like-button-color-hover: #fa6c8d !default; +$nav-like-button-background-color-hover: #fae9ed !default; +$nav-like-button-color-active: #bd6377 !default; +$nav-like-button-background-color-active: #fae9ed !default; + +// Modals +// -------------------------------------------------- + +// Header + +$modal-header-color: #e45735 !default; +$modal-header-border-color: #b3b3b3 !default; +$modal-close-button-color: #b4b4b4; + +// Topic list +// -------------------------------------------------- + +$topic-list-border-color: #bebebe !default; +$topic-list-th-color: #534d4b !default; +$topic-list-th-border-color: #b5b5b5 !default; +$topic-list-th-background-color: #f6f6f6 !default; +$topic-list-td-color: #808080 !default; +$topic-list-td-border-color: #ededed !default; +$topic-list-star-color: #eee !default; +$topic-list-starred-color: #f7cb1d !default; + +// Generic +// -------------------------------------------------- + +// Colors + +$black: #000 !default; +$white: #fff !default; +$google: #5b76f7 !default; +$facebook: #3b5998 !default; +$twitter: #00bced !default; +$yahoo: #810293 !default; + +// Layout dimensions + +$small-width: 950px !default; +$medium-width: 995px !default; +$large-width: 1110px !default; + +// Misc. +// -------------------------------------------------- + +// Basic colors + +$gray: #cccccc; +$light_gray: #eeeeee; +$darkish_gray: #666666; +$dark_gray: #999999; +$highlight: #ffff99; +$highlight_light: #ffffdd; +$red: #770000; +$green: #007700; +$blue: #0088cc; +$bright_blue: #9999ff; +$yellow: yellow; +$eggplant: #5f5b65; + +$link_color: darken($blue, 10%); +$muted-link-color: #999; +$muted-important-link-color: #5d5d5d; + +// Colors based on basics + +$topicMenuColor: darken($white, 80%); +$bookmarkColor: #b5b500; + +$tag_color: #e1ecf9; + +$composer_background: #dcdfe0; + +$post_footer: #fafafa; +$inner_border: #e4e4e4; +$inner_line: #d4d4d4; + +$controls: #808080; +$controls_hover: #2eaee5; +$controls_active: #1d92c5; +$heart: #fa6c8d; + +$attention_bg: #e4f2f8; +$attention_fg: #1aaae4; + +$header-item-highlight: #ecf8f6; \ No newline at end of file diff --git a/app/assets/stylesheets/vendor/bootstrap.css.scss b/app/assets/stylesheets/vendor/bootstrap.css.scss new file mode 100644 index 00000000000..813d5da2c03 --- /dev/null +++ b/app/assets/stylesheets/vendor/bootstrap.css.scss @@ -0,0 +1,2037 @@ +/*! + * Bootstrap v2.0.4 + * + * Copyright 2012 Twitter, Inc + * Licensed under the Apache License v2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Designed and built with all the love in the world @twitter by @mdo and @fat. + * + * NOTE: This is not a clean version of Bootstrap. As we work toward removing the + * default theme's dependency on Bootstrap, we are deleting code from here that is + * no longer required. + */ + + @import "foundation/mixins"; + +.input-block-level { + display: block; + width: 100%; + min-height: 28px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; +} +a:focus { + outline: thin dotted #333; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +img { + max-width: 100%; +} +#map_canvas img { + max-width: none; +} +.row { + margin-left: -12px; + *zoom: 1; +} +.row:before, +.row:after { + display: table; + content: ""; +} +.row:after { + clear: both; +} +[class*="span"] { + float: left; + margin-left: 12px; +} +.span24 { + width: 1236px; +} +.span23 { + width: 1184px; +} +.span22 { + width: 1132px; +} +.span21 { + width: 1080px; +} +.span20 { + width: 1028px; +} +.span19 { + width: 976px; +} +.span18 { + width: 924px; +} +.span17 { + width: 872px; +} +.span16 { + width: 820px; +} +.span15 { + width: 768px; +} +.span14 { + width: 716px; +} +.span13 { + width: 664px; +} +.span12 { + width: 612px; +} +.span11 { + width: 560px; +} +.span10 { + width: 508px; +} +.span9 { + width: 456px; +} +.span8 { + width: 404px; +} +.span7 { + width: 352px; +} +.span6 { + width: 300px; +} +.span5 { + width: 248px; +} +.span4 { + width: 196px; +} +.span3 { + width: 144px; +} +.span2 { + width: 92px; +} +.span1 { + width: 40px; +} +.offset24 { + margin-left: 1260px; +} +.offset23 { + margin-left: 1208px; +} +.offset22 { + margin-left: 1156px; +} +.offset21 { + margin-left: 1104px; +} +.offset20 { + margin-left: 1052px; +} +.offset19 { + margin-left: 1000px; +} +.offset18 { + margin-left: 948px; +} +.offset17 { + margin-left: 896px; +} +.offset16 { + margin-left: 844px; +} +.offset15 { + margin-left: 792px; +} +.offset14 { + margin-left: 740px; +} +.offset13 { + margin-left: 688px; +} +.offset12 { + margin-left: 636px; +} +.offset11 { + margin-left: 584px; +} +.offset10 { + margin-left: 532px; +} +.offset9 { + margin-left: 480px; +} +.offset8 { + margin-left: 428px; +} +.offset7 { + margin-left: 376px; +} +.offset6 { + margin-left: 324px; +} +.offset5 { + margin-left: 272px; +} +.offset4 { + margin-left: 220px; +} +.offset3 { + margin-left: 168px; +} +.offset2 { + margin-left: 116px; +} +.offset1 { + margin-left: 64px; +} +.label { + font-size: 10.998px; + font-weight: bold; + line-height: 14px; + color: #ffffff; + vertical-align: baseline; + white-space: nowrap; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); + background-color: #999999; +} +.label { + padding: 1px 4px 2px; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} +a.label:hover { + color: #ffffff; + text-decoration: none; + cursor: pointer; +} +.label-important { + background-color: #b94a48; +} +.label-important[href] { + background-color: #953b39; +} +.label-warning { + background-color: #f89406; +} +.label-warning[href] { + background-color: #c67605; +} +.label-success { + background-color: #468847; +} +.label-success[href] { + background-color: #356635; +} +.label-info { + background-color: #3a87ad; +} +.label-info[href] { + background-color: #2d6987; +} +.label-inverse, { + background-color: #333333; +} +.label-inverse[href] { + background-color: #1a1a1a; +} +button.btn::-moz-focus-inner, +input[type="submit"].btn::-moz-focus-inner { + padding: 0; + border: 0; +} +.btn-group { + position: relative; +} +.btn-group:before, +.btn-group:after { + display: table; + content: " "; +} +.btn-group:after { + clear: both; +} +.btn-group + .btn-group { + margin-left: 5px; +} +.btn-toolbar { + margin-top: 9px; + margin-bottom: 9px; +} +.btn-toolbar .btn-group { + display: inline-block; +} +.btn-group > .btn { + position: relative; + float: left; +} +.btn-group > .btn:hover, +.btn-group > .btn:focus, +.btn-group > .btn:active, +.btn-group > .btn.active { + z-index: 2; +} +.btn-group .dropdown-toggle:active, +.btn-group.open .dropdown-toggle { + outline: 0; +} +.btn .caret { + margin-top: 7px; + margin-left: 0; + border-top-color: #565656; +} +.btn:hover .caret, +.open.btn-group .caret { + opacity: 1; + filter: alpha(opacity=100); +} +.btn-small .caret { + margin-top: 6px; +} +.btn-large .caret { + margin-top: 6px; + border-left-width: 5px; + border-right-width: 5px; + border-top-width: 5px; +} +.dropup .btn-large .caret { + border-bottom: 5px solid #565656; + border-top: 0; +} +.nav .nav-header { + display: block; + padding: 3px 15px; + font-size: 11px; + font-weight: bold; + line-height: 18px; + color: #999999; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-transform: uppercase; +} +.nav li + .nav-header { + margin-top: 9px; +} +.nav-list { + padding-left: 15px; + padding-right: 15px; + margin-bottom: 0; +} +.nav-list > li > a, +.nav-list .nav-header { + margin-left: -15px; + margin-right: -15px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); +} +.nav-list > li > a { + padding: 3px 15px; +} +.nav-list > .active > a, +.nav-list > .active > a:hover { + color: #ffffff; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2); + background-color: #0088cc; +} +.nav-list [class^="icon-"] { + margin-right: 2px; +} +.nav-list .divider { + *width: 100%; + height: 1px; + margin: 8px 1px; + *margin: -5px 0 5px; + overflow: hidden; + background-color: #e5e5e5; + border-bottom: 1px solid #ffffff; +} +.nav-tabs { + *zoom: 1; +} +.nav-tabs:before, +.nav-tabs:after { + display: table; + content: ""; +} +.nav-tabs:after { + clear: both; +} +.nav-tabs > li { + float: left; +} +.nav-tabs > li > a { + padding-right: 12px; + padding-left: 12px; + margin-right: 2px; + line-height: 14px; +} +.nav-tabs { + border-bottom: 1px solid #ddd; +} +.nav-tabs > li { + margin-bottom: -1px; +} +.nav-tabs > li > a { + padding-top: 8px; + padding-bottom: 8px; + line-height: 18px; + border: 1px solid transparent; + -webkit-border-radius: 4px 4px 0 0; + -moz-border-radius: 4px 4px 0 0; + border-radius: 4px 4px 0 0; +} +.nav-tabs > li > a:hover { + border-color: #eeeeee #eeeeee #dddddd; +} +.nav-tabs > .active > a, +.nav-tabs > .active > a:hover { + color: #555555; + background-color: #ffffff; + border: 1px solid #ddd; + border-bottom-color: transparent; + cursor: default; +} +.nav-stacked > li { + float: none; +} +.nav-stacked > li > a { + margin-right: 0; +} +.nav-tabs.nav-stacked { + border-bottom: 0; +} +.nav-tabs.nav-stacked > li > a { + border: 1px solid #ddd; + -webkit-border-radius: 0; + -moz-border-radius: 0; + border-radius: 0; +} +.nav-tabs.nav-stacked > li:first-child > a { + -webkit-border-radius: 4px 4px 0 0; + -moz-border-radius: 4px 4px 0 0; + border-radius: 4px 4px 0 0; +} +.nav-tabs.nav-stacked > li:last-child > a { + -webkit-border-radius: 0 0 4px 4px; + -moz-border-radius: 0 0 4px 4px; + border-radius: 0 0 4px 4px; +} +.nav-tabs.nav-stacked > li > a:hover { + border-color: #ddd; + z-index: 2; +} +.nav-pills.nav-stacked > li > a { + margin-bottom: 3px; +} +.nav-pills.nav-stacked > li:last-child > a { + margin-bottom: 1px; +} +.nav-tabs .dropdown-menu { + -webkit-border-radius: 0 0 5px 5px; + -moz-border-radius: 0 0 5px 5px; + border-radius: 0 0 5px 5px; +} +.nav-pills .dropdown-menu { + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +.nav-tabs .dropdown-toggle .caret, +.nav-pills .dropdown-toggle .caret { + border-top-color: #0088cc; + border-bottom-color: #0088cc; + margin-top: 6px; +} +.nav-tabs .dropdown-toggle:hover .caret, +.nav-pills .dropdown-toggle:hover .caret { + border-top-color: #005580; + border-bottom-color: #005580; +} +.nav-tabs .active .dropdown-toggle .caret, +.nav-pills .active .dropdown-toggle .caret { + border-top-color: #333333; + border-bottom-color: #333333; +} +.nav > .dropdown.active > a:hover { + color: #000000; + cursor: pointer; +} +.nav-tabs .open .dropdown-toggle, +.nav-pills .open .dropdown-toggle, +.nav > li.dropdown.open.active > a:hover { + color: #ffffff; + background-color: #999999; + border-color: #999999; +} +.nav li.dropdown.open .caret, +.nav li.dropdown.open.active .caret, +.nav li.dropdown.open a:hover .caret { + border-top-color: #ffffff; + border-bottom-color: #ffffff; + opacity: 1; + filter: alpha(opacity=100); +} +.tabs-stacked .open > a:hover { + border-color: #999999; +} +.tabbable { + *zoom: 1; +} +.tabbable:before, +.tabbable:after { + display: table; + content: ""; +} +.tabbable:after { + clear: both; +} +.tab-content { + overflow: auto; +} +.tabs-below > .nav-tabs, +.tabs-right > .nav-tabs, +.tabs-left > .nav-tabs { + border-bottom: 0; +} +.tab-content > .tab-pane, +.pill-content > .pill-pane { + display: none; +} +.tab-content > .active, +.pill-content > .active { + display: block; +} +.tabs-below > .nav-tabs { + border-top: 1px solid #ddd; +} +.tabs-below > .nav-tabs > li { + margin-top: -1px; + margin-bottom: 0; +} +.tabs-below > .nav-tabs > li > a { + -webkit-border-radius: 0 0 4px 4px; + -moz-border-radius: 0 0 4px 4px; + border-radius: 0 0 4px 4px; +} +.tabs-below > .nav-tabs > li > a:hover { + border-bottom-color: transparent; + border-top-color: #ddd; +} +.tabs-below > .nav-tabs > .active > a, +.tabs-below > .nav-tabs > .active > a:hover { + border-color: transparent #ddd #ddd #ddd; +} +.tabs-left > .nav-tabs > li, +.tabs-right > .nav-tabs > li { + float: none; +} +.tabs-left > .nav-tabs > li > a, +.tabs-right > .nav-tabs > li > a { + min-width: 74px; + margin-right: 0; + margin-bottom: 3px; +} +.tabs-left > .nav-tabs { + float: left; + margin-right: 19px; + border-right: 1px solid #ddd; +} +.tabs-left > .nav-tabs > li > a { + margin-right: -1px; + -webkit-border-radius: 4px 0 0 4px; + -moz-border-radius: 4px 0 0 4px; + border-radius: 4px 0 0 4px; +} +.tabs-left > .nav-tabs > li > a:hover { + border-color: #eeeeee #dddddd #eeeeee #eeeeee; +} +.tabs-left > .nav-tabs .active > a, +.tabs-left > .nav-tabs .active > a:hover { + border-color: #ddd transparent #ddd #ddd; + *border-right-color: #ffffff; +} +.tabs-right > .nav-tabs { + float: right; + margin-left: 19px; + border-left: 1px solid #ddd; +} +.tabs-right > .nav-tabs > li > a { + margin-left: -1px; + -webkit-border-radius: 0 4px 4px 0; + -moz-border-radius: 0 4px 4px 0; + border-radius: 0 4px 4px 0; +} +.tabs-right > .nav-tabs > li > a:hover { + border-color: #eeeeee #eeeeee #eeeeee #dddddd; +} +.tabs-right > .nav-tabs .active > a, +.tabs-right > .nav-tabs .active > a:hover { + border-color: #ddd #ddd #ddd transparent; + *border-left-color: #ffffff; +} +.thumbnails { + margin-left: -12px; + list-style: none; + *zoom: 1; +} +.thumbnails:before, +.thumbnails:after { + display: table; + content: ""; +} +.thumbnails:after { + clear: both; +} +.row-fluid .thumbnails { + margin-left: 0; +} +.thumbnails > li { + float: left; + margin-bottom: 18px; + margin-left: 12px; +} +.thumbnail { + display: block; + padding: 0; + line-height: 1; + border: 1px solid #ddd; + -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075); + -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075); +} +a.thumbnail:hover { + border-color: #0088cc; + -webkit-box-shadow: 0 1px 4px rgba(0, 105, 214, 0.25); + -moz-box-shadow: 0 1px 4px rgba(0, 105, 214, 0.25); + box-shadow: 0 1px 4px rgba(0, 105, 214, 0.25); +} +.thumbnail > img { + display: block; + max-width: 100%; + margin-left: auto; + margin-right: auto; +} +.thumbnail .caption { + padding: 9px; +} +.tooltip { + position: absolute; + z-index: 1020; + display: block; + visibility: visible; + padding: 5px; + font-size: 11px; + opacity: 0; + filter: alpha(opacity=0); +} +.tooltip.in { + opacity: 0.8; + filter: alpha(opacity=80); +} +.tooltip.top { + margin-top: -2px; +} +.tooltip.right { + margin-left: 2px; +} +.tooltip.bottom { + margin-top: 2px; +} +.tooltip.left { + margin-left: -2px; +} +.tooltip.top .tooltip-arrow { + bottom: 0; + left: 50%; + margin-left: -5px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid #000000; +} +.tooltip.left .tooltip-arrow { + top: 50%; + right: 0; + margin-top: -5px; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 5px solid #000000; +} +.tooltip.bottom .tooltip-arrow { + top: 0; + left: 50%; + margin-left: -5px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 5px solid #000000; +} +.tooltip.right .tooltip-arrow { + top: 50%; + left: 0; + margin-top: -5px; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-right: 5px solid #000000; +} +.tooltip-inner { + max-width: 200px; + padding: 3px 8px; + color: #ffffff; + text-align: center; + text-decoration: none; + background-color: #000000; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +.tooltip-arrow { + position: absolute; + width: 0; + height: 0; +} +.popover { + position: absolute; + top: 0; + left: 0; + z-index: 1010; + display: none; + padding: 5px; +} +.popover.top { + margin-top: -5px; +} +.popover.right { + margin-left: 5px; +} +.popover.bottom { + margin-top: 5px; +} +.popover.left { + margin-left: -5px; +} +.popover.top .arrow { + bottom: 0; + left: 50%; + margin-left: -5px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid #000000; +} +.popover.right .arrow { + top: 50%; + left: 0; + margin-top: -5px; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-right: 5px solid #000000; +} +.popover.bottom .arrow { + top: 0; + left: 50%; + margin-left: -5px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 5px solid #000000; +} +.popover.left .arrow { + top: 50%; + right: 0; + margin-top: -5px; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 5px solid #000000; +} +.popover .arrow { + position: absolute; + width: 0; + height: 0; +} +.popover-inner { + padding: 3px; + width: 280px; + overflow: hidden; + background: #000000; + background: rgba(0, 0, 0, 0.8); + -webkit-border-radius: 6px; + -moz-border-radius: 6px; + border-radius: 6px; + -webkit-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); + -moz-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); + box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); +} +.popover-title { + padding: 9px 15px; + line-height: 1; + background-color: #f5f5f5; + border-bottom: 1px solid #eee; + -webkit-border-radius: 3px 3px 0 0; + -moz-border-radius: 3px 3px 0 0; + border-radius: 3px 3px 0 0; +} +.popover-content { + padding: 14px; + background-color: #ffffff; + -webkit-border-radius: 0 0 3px 3px; + -moz-border-radius: 0 0 3px 3px; + border-radius: 0 0 3px 3px; + -webkit-background-clip: padding-box; + -moz-background-clip: padding-box; + background-clip: padding-box; +} +.popover-content p, +.popover-content ul, +.popover-content ol { + margin-bottom: 0; +} +.dropup, +.dropdown { + position: relative; +} +.dropdown-toggle { + *margin-bottom: -3px; +} +.dropdown-toggle:active, +.open .dropdown-toggle { + outline: 0; +} +.caret { + display: inline-block; + width: 0; + height: 0; + vertical-align: top; + border-top: 4px solid #000000; + border-right: 4px solid transparent; + border-left: 4px solid transparent; + content: ""; + opacity: 0.3; + filter: alpha(opacity=30); +} +.dropdown .caret { + margin-top: 8px; + margin-left: 2px; +} +.dropdown:hover .caret, +.open .caret { + opacity: 1; + filter: alpha(opacity=100); +} +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 160px; + padding: 4px 0; + margin: 1px 0 0; + list-style: none; + background-color: #ffffff; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, 0.2); + *border-right-width: 2px; + *border-bottom-width: 2px; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); + -webkit-background-clip: padding-box; + -moz-background-clip: padding; + background-clip: padding-box; +} +.dropdown-menu.pull-right { + right: 0; + left: auto; +} +.dropdown-menu .divider { + *width: 100%; + height: 1px; + margin: 8px 1px; + *margin: -5px 0 5px; + overflow: hidden; + background-color: #e5e5e5; + border-bottom: 1px solid #ffffff; +} +.dropdown-menu a { + display: block; + padding: 3px 15px; + clear: both; + font-weight: normal; + line-height: 18px; + color: #333333; + white-space: nowrap; +} +.dropdown-menu li > a:hover, +.dropdown-menu .active > a, +.dropdown-menu .active > a:hover { + color: #ffffff; + text-decoration: none; + background-color: #0088cc; +} +.open { + *z-index: 1000; +} +.open > .dropdown-menu { + display: block; +} +.pull-right > .dropdown-menu { + right: 0; + left: auto; +} +.dropup .caret, +.navbar-fixed-bottom .dropdown .caret { + border-top: 0; + border-bottom: 4px solid #000000; + content: "\2191"; +} +.dropup .dropdown-menu, +.navbar-fixed-bottom .dropdown .dropdown-menu { + top: auto; + bottom: 100%; + margin-bottom: 1px; +} +.typeahead { + margin-top: 2px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +.accordion { + margin-bottom: 18px; +} +.accordion-group { + margin-bottom: 2px; + border: 1px solid #e5e5e5; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +.accordion-heading { + border-bottom: 0; +} +.accordion-heading .accordion-toggle { + display: block; + padding: 8px 15px; +} +.accordion-toggle { + cursor: pointer; +} +.accordion-inner { + padding: 9px 15px; + border-top: 1px solid #e5e5e5; +} +.fade { + opacity: 0; + -webkit-transition: opacity 0.15s linear; + -moz-transition: opacity 0.15s linear; + -ms-transition: opacity 0.15s linear; + -o-transition: opacity 0.15s linear; + transition: opacity 0.15s linear; +} +.fade.in { + opacity: 1; +} +.collapse { + position: relative; + height: 0; + overflow: hidden; + -webkit-transition: height 0.35s ease; + -moz-transition: height 0.35s ease; + -ms-transition: height 0.35s ease; + -o-transition: height 0.35s ease; + transition: height 0.35s ease; +} +.collapse.in { + height: auto; +} + +body { + input, textarea, select, .uneditable-input { + color: #222222; + } + p, pre, li, ul { + font-size: 14px; + } + p { + line-height: 20px; + } + code, pre { + font-family: Consolas, Menlo, Monaco, "Lucida Console", "Liberation Mono", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Courier New", monospace, serif; + } + h1, h2, h3, h4, h5, h6 { + margin: 0; + font-family: inherit; + font-weight: bold; + color: inherit; + text-rendering: optimizelegibility; + } + h1 small, h2 small, h3 small, h4 small, h5 small, h6 small { + font-weight: normal; + color: #999999; + } + h1 { + font-size: 30px; + line-height: 36px; + small { + font-size: 18px; + } + } + h2 { + font-size: 24px; + line-height: 36px; + small { + font-size: 18px; + } + } + h3 { + font-size: 18px; + line-height: 27px; + small { + font-size: 14px; + } + } + h4, h5, h6 { + line-height: 18px; + } + h4 { + font-size: 14px; + small { + font-size: 12px; + } + } + h5 { + font-size: 12px; + } + h6 { + font-size: 11px; + color: #999999; + text-transform: uppercase; + } + blockquote { + margin: 15px 0 8px; + } + li { + line-height: 18px; + } + form { + margin: 0 0 18px; + } + legend { + display: block; + width: 100%; + padding: 0; + margin-bottom: 27px; + font-size: 19.5px; + line-height: 36px; + color: #333333; + border: 0; + border-bottom: 1px solid #e5e5e5; + small { + font-size: 13.5px; + color: #999999; + } + } + label, input, button, select, textarea { + font-size: 13px; + font-weight: normal; + line-height: 18px; + } + input, button, select, textarea { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + } + label { + display: block; + margin-bottom: 5px; + } + select, textarea { + display: inline-block; + height: 18px; + padding: 4px; + margin-bottom: 9px; + font-size: 13px; + line-height: 18px; + color: #555555; + } + input { + &[type="text"], &[type="password"], &[type="datetime"], &[type="datetime-local"], &[type="date"], &[type="month"], &[type="time"], &[type="week"], &[type="number"], &[type="email"], &[type="url"], &[type="search"], &[type="tel"], &[type="color"] { + display: inline-block; + height: 18px; + padding: 4px; + margin-bottom: 9px; + font-size: 13px; + line-height: 18px; + color: #555555; + } + } + .uneditable-input { + display: inline-block; + height: 18px; + padding: 4px; + margin-bottom: 9px; + font-size: 13px; + line-height: 18px; + color: #555555; + } + input { + width: 210px; + } + textarea { + width: 210px; + height: auto; + background-color: white; + border: 1px solid #cccccc; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + -webkit-transition: border linear 0.2s, box-shadow linear 0.2s; + -moz-transition: border linear 0.2s, box-shadow linear 0.2s; + -ms-transition: border linear 0.2s, box-shadow linear 0.2s; + -o-transition: border linear 0.2s, box-shadow linear 0.2s; + transition: border linear 0.2s, box-shadow linear 0.2s; + } + input { + &[type="text"], &[type="password"], &[type="datetime"], &[type="datetime-local"], &[type="date"], &[type="month"], &[type="time"], &[type="week"], &[type="number"], &[type="email"], &[type="url"], &[type="search"], &[type="tel"], &[type="color"] { + background-color: white; + border: 1px solid #cccccc; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + -webkit-transition: border linear 0.2s, box-shadow linear 0.2s; + -moz-transition: border linear 0.2s, box-shadow linear 0.2s; + -ms-transition: border linear 0.2s, box-shadow linear 0.2s; + -o-transition: border linear 0.2s, box-shadow linear 0.2s; + transition: border linear 0.2s, box-shadow linear 0.2s; + } + } + .uneditable-input { + background-color: white; + border: 1px solid #cccccc; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + -webkit-transition: border linear 0.2s, box-shadow linear 0.2s; + -moz-transition: border linear 0.2s, box-shadow linear 0.2s; + -ms-transition: border linear 0.2s, box-shadow linear 0.2s; + -o-transition: border linear 0.2s, box-shadow linear 0.2s; + transition: border linear 0.2s, box-shadow linear 0.2s; + } + textarea:focus { + border-color: rgba(82, 168, 236, 0.8); + outline: 0; + outline: thin dotted \9; + /* IE6-9 */ + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + } + input { + &[type="text"]:focus, &[type="password"]:focus, &[type="datetime"]:focus, &[type="datetime-local"]:focus, &[type="date"]:focus, &[type="month"]:focus, &[type="time"]:focus, &[type="week"]:focus, &[type="number"]:focus, &[type="email"]:focus, &[type="url"]:focus, &[type="search"]:focus, &[type="tel"]:focus, &[type="color"]:focus { + border-color: rgba(82, 168, 236, 0.8); + outline: 0; + outline: thin dotted \9; + /* IE6-9 */ + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + } + } + .uneditable-input:focus { + border-color: rgba(82, 168, 236, 0.8); + outline: 0; + outline: thin dotted \9; + /* IE6-9 */ + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); + } + input { + &[type="radio"], &[type="checkbox"] { + margin: 3px 0; + *margin-top: 0; + /* IE7 */ + line-height: normal; + cursor: pointer; + } + &[type="submit"], &[type="reset"], &[type="button"], &[type="radio"], &[type="checkbox"] { + width: auto; + } + } + .uneditable-textarea { + width: auto; + height: auto; + } + select, input[type="file"] { + height: 28px; + /* In IE7, the height of the select element cannot be changed by height, only font-size */ + *margin-top: 4px; + /* For IE7, add top margin to align select with labels */ + line-height: 28px; + } + + select { + width: 220px; + border: 1px solid #bbbbbb; + &[multiple], &[size] { + height: auto; + } + &:focus { + outline: thin dotted #333333; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; + } + } + input { + &[type="file"]:focus, &[type="radio"]:focus, &[type="checkbox"]:focus { + outline: thin dotted #333333; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; + } + } + .radio, .checkbox { + min-height: 18px; + padding-left: 18px; + } + .radio input[type="radio"], .checkbox input[type="checkbox"] { + float: left; + margin-left: -18px; + } + .controls > { + .radio:first-child, .checkbox:first-child { + padding-top: 5px; + } + } + .radio.inline, .checkbox.inline { + display: inline-block; + padding-top: 5px; + margin-bottom: 0; + vertical-align: middle; + } + .radio.inline .radio.inline, .checkbox.inline .checkbox.inline { + margin-left: 10px; + } + .input-mini { + width: 60px; + } + .input-small { + width: 90px; + } + .input-medium { + width: 150px; + } + .input-large { + width: 210px; + } + .input-xlarge { + width: 270px; + } + .input-xxlarge { + width: 530px; + } + input[class*="span"], select[class*="span"], textarea[class*="span"], .uneditable-input[class*="span"] { + float: none; + margin-left: 0; + } + .row-fluid { + input[class*="span"], select[class*="span"], textarea[class*="span"], .uneditable-input[class*="span"] { + float: none; + margin-left: 0; + } + } + .input-append { + input[class*="span"], .uneditable-input[class*="span"] { + display: inline-block; + } + } + .input-prepend { + input[class*="span"], .uneditable-input[class*="span"] { + display: inline-block; + } + } + .row-fluid { + .input-prepend [class*="span"], .input-append [class*="span"] { + display: inline-block; + } + } + input, textarea, .uneditable-input { + margin-left: 0; + } + input.span12, textarea.span12, .uneditable-input.span12 { + width: 930px; + } + input.span11, textarea.span11, .uneditable-input.span11 { + width: 850px; + } + input.span10, textarea.span10, .uneditable-input.span10 { + width: 770px; + } + input.span9, textarea.span9, .uneditable-input.span9 { + width: 690px; + } + input.span8, textarea.span8, .uneditable-input.span8 { + width: 610px; + } + input.span7, textarea.span7, .uneditable-input.span7 { + width: 530px; + } + input.span6, textarea.span6, .uneditable-input.span6 { + width: 450px; + } + input.span5, textarea.span5, .uneditable-input.span5 { + width: 370px; + } + input.span4, textarea.span4, .uneditable-input.span4 { + width: 290px; + } + input.span3, textarea.span3, .uneditable-input.span3 { + width: 210px; + } + input.span2, textarea.span2, .uneditable-input.span2 { + width: 130px; + } + input.span1, textarea.span1, .uneditable-input.span1 { + width: 50px; + } + input[disabled], select[disabled], textarea[disabled], input[readonly], select[readonly], textarea[readonly] { + cursor: not-allowed; + background-color: #eeeeee; + border-color: #dddddd; + } + input { + &[type="radio"][disabled], &[type="checkbox"][disabled], &[type="radio"][readonly], &[type="checkbox"][readonly] { + background-color: transparent; + } + } + .control-group { + &.warning { + > label, .help-block, .help-inline { + color: #c09853; + } + .checkbox, .radio, input, select, textarea { + color: #c09853; + border-color: #c09853; + } + .checkbox:focus, .radio:focus, input:focus, select:focus, textarea:focus { + border-color: #a47e3c; + -webkit-box-shadow: 0 0 6px #dbc59e; + -moz-box-shadow: 0 0 6px #dbc59e; + box-shadow: 0 0 6px #dbc59e; + } + .input-prepend .add-on, .input-append .add-on { + color: #c09853; + background-color: #fcf8e3; + border-color: #c09853; + } + } + &.error { + > label, .help-block, .help-inline { + color: #b94a48; + } + .checkbox, .radio, input, select, textarea { + color: #b94a48; + border-color: #b94a48; + } + .checkbox:focus, .radio:focus, input:focus, select:focus, textarea:focus { + border-color: #953b39; + -webkit-box-shadow: 0 0 6px #d59392; + -moz-box-shadow: 0 0 6px #d59392; + box-shadow: 0 0 6px #d59392; + } + .input-prepend .add-on, .input-append .add-on { + color: #b94a48; + background-color: #f2dede; + border-color: #b94a48; + } + } + &.success { + > label, .help-block, .help-inline { + color: #468847; + } + .checkbox, .radio, input, select, textarea { + color: #468847; + border-color: #468847; + } + .checkbox:focus, .radio:focus, input:focus, select:focus, textarea:focus { + border-color: #356635; + -webkit-box-shadow: 0 0 6px #7aba7b; + -moz-box-shadow: 0 0 6px #7aba7b; + box-shadow: 0 0 6px #7aba7b; + } + .input-prepend .add-on, .input-append .add-on { + color: #468847; + background-color: #dff0d8; + border-color: #468847; + } + } + } + input:focus:required:invalid, textarea:focus:required:invalid, select:focus:required:invalid { + color: #b94a48; + border-color: #ee5f5b; + } + input:focus:required:invalid:focus, textarea:focus:required:invalid:focus, select:focus:required:invalid:focus { + border-color: #e9322d; + -webkit-box-shadow: 0 0 6px #f8b9b7; + -moz-box-shadow: 0 0 6px #f8b9b7; + box-shadow: 0 0 6px #f8b9b7; + } + .form-actions { + padding: 17px 20px 18px; + margin-top: 18px; + margin-bottom: 18px; + background-color: whitesmoke; + border-top: 1px solid #e5e5e5; + *zoom: 1; + &:before { + display: table; + content: ""; + } + &:after { + display: table; + content: ""; + clear: both; + } + } + .uneditable-input { + overflow: hidden; + white-space: nowrap; + cursor: not-allowed; + background-color: white; + border-color: #eeeeee; + -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); + -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); + } + :-moz-placeholder, :-ms-input-placeholder, ::-webkit-input-placeholder { + color: #999999; + } + .help-block, .help-inline { + color: #555555; + } + .help-block { + display: block; + margin-bottom: 9px; + } + .help-inline { + display: inline-block; + *display: inline; + /* IE7 inline-block hack */ + *zoom: 1; + vertical-align: middle; + padding-left: 5px; + } + .input-prepend, .input-append { + margin-bottom: 5px; + } + .input-prepend input, .input-append input, .input-prepend select, .input-append select, .input-prepend .uneditable-input, .input-append .uneditable-input { + position: relative; + margin-bottom: 0; + *margin-left: 0; + vertical-align: middle; + -webkit-border-radius: 0 3px 3px 0; + -moz-border-radius: 0 3px 3px 0; + border-radius: 0 3px 3px 0; + } + .input-prepend input:focus, .input-append input:focus, .input-prepend select:focus, .input-append select:focus, .input-prepend .uneditable-input:focus, .input-append .uneditable-input:focus { + z-index: 2; + } + .input-prepend .uneditable-input, .input-append .uneditable-input { + border-left-color: #cccccc; + } + .input-prepend .add-on, .input-append .add-on { + display: inline-block; + width: auto; + height: 18px; + min-width: 16px; + padding: 4px 5px; + font-weight: normal; + line-height: 18px; + text-align: center; + text-shadow: 0 1px 0 white; + vertical-align: middle; + background-color: #eeeeee; + border: 1px solid #cccccc; + } + .input-prepend .add-on, .input-append .add-on, .input-prepend .btn, .input-append .btn { + margin-left: -1px; + -webkit-border-radius: 0; + -moz-border-radius: 0; + border-radius: 0; + } + .input-prepend .active, .input-append .active { + background-color: #a9dba9; + border-color: #46a546; + } + .input-prepend { + .add-on, .btn { + margin-right: -1px; + } + .add-on:first-child, .btn:first-child { + -webkit-border-radius: 3px 0 0 3px; + -moz-border-radius: 3px 0 0 3px; + border-radius: 3px 0 0 3px; + } + } + .input-append { + input, select { + -webkit-border-radius: 3px 0 0 3px; + -moz-border-radius: 3px 0 0 3px; + border-radius: 3px 0 0 3px; + } + .uneditable-input { + -webkit-border-radius: 3px 0 0 3px; + -moz-border-radius: 3px 0 0 3px; + border-radius: 3px 0 0 3px; + border-right-color: #cccccc; + border-left-color: #eeeeee; + } + .add-on:last-child, .btn:last-child { + -webkit-border-radius: 0 3px 3px 0; + -moz-border-radius: 0 3px 3px 0; + border-radius: 0 3px 3px 0; + } + } + .input-prepend.input-append { + input, select, .uneditable-input { + -webkit-border-radius: 0; + -moz-border-radius: 0; + border-radius: 0; + } + .add-on:first-child, .btn:first-child { + margin-right: -1px; + -webkit-border-radius: 3px 0 0 3px; + -moz-border-radius: 3px 0 0 3px; + border-radius: 3px 0 0 3px; + } + .add-on:last-child, .btn:last-child { + margin-left: -1px; + -webkit-border-radius: 0 3px 3px 0; + -moz-border-radius: 0 3px 3px 0; + border-radius: 0 3px 3px 0; + } + } + .search-query { + padding-right: 14px; + padding-right: 4px \9; + padding-left: 14px; + padding-left: 4px \9; + /* IE7-8 doesn't have border-radius, so don't indent the padding */ + margin-bottom: 0; + -webkit-border-radius: 14px; + -moz-border-radius: 14px; + border-radius: 14px; + } + .form-search input, .form-inline input, .form-horizontal input, .form-search textarea, .form-inline textarea, .form-horizontal textarea, .form-search select, .form-inline select, .form-horizontal select, .form-search .help-inline, .form-inline .help-inline, .form-horizontal .help-inline, .form-search .uneditable-input, .form-inline .uneditable-input, .form-horizontal .uneditable-input, .form-search .input-prepend, .form-inline .input-prepend, .form-horizontal .input-prepend, .form-search .input-append, .form-inline .input-append, .form-horizontal .input-append { + display: inline-block; + *display: inline; + /* IE7 inline-block hack */ + *zoom: 1; + margin-bottom: 0; + } + .form-search .hide, .form-inline .hide, .form-horizontal .hide { + display: none; + } + .form-search label, .form-inline label { + display: inline-block; + } + .form-search .input-append, .form-inline .input-append, .form-search .input-prepend, .form-inline .input-prepend { + margin-bottom: 0; + } + .form-search { + .radio, .checkbox { + padding-left: 0; + margin-bottom: 0; + vertical-align: middle; + } + } + .form-inline { + .radio, .checkbox { + padding-left: 0; + margin-bottom: 0; + vertical-align: middle; + } + } + .form-search { + .radio input[type="radio"], .checkbox input[type="checkbox"] { + float: left; + margin-right: 3px; + margin-left: 0; + } + } + .form-inline { + .radio input[type="radio"], .checkbox input[type="checkbox"] { + float: left; + margin-right: 3px; + margin-left: 0; + } + } + .control-group { + margin-bottom: 9px; + } + legend .control-group { + margin-top: 18px; + -webkit-margin-top-collapse: separate; + } + .form-horizontal { + .control-group { + margin-bottom: 18px; + *zoom: 1; + &:before { + display: table; + content: ""; + } + &:after { + display: table; + content: ""; + clear: both; + } + } + .control-indent { + margin-left: 20px; + margin-bottom: 10px; + } + .control-label { + float: left; + width: 140px; + padding-top: 5px; + text-align: right; + } + .controls { + *display: inline-block; + *padding-left: 20px; + margin-left: 160px; + *margin-left: 0; + &:first-child { + *padding-left: 160px; + } + } + .help-block { + margin-top: 9px; + margin-bottom: 0; + } + .form-actions { + padding-left: 160px; + } + } + .hide-text { + font: 0 / 0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; + } + .input-block-level { + display: block; + width: 100%; + min-height: 28px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + } + .alert { + padding: 8px 35px 8px 14px; + margin-bottom: 18px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + background-color: #fcf8e3; + border: 1px solid #fbeed5; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + color: #c09853; + } + .alert-heading { + color: inherit; + } + .alert .close { + position: relative; + top: -2px; + right: -21px; + line-height: 18px; + } + .alert-success { + background-color: #dff0d8; + border-color: #d6e9c6; + color: #468847; + } + .alert-danger, .alert-error { + background-color: #f2dede; + border-color: #eed3d7; + color: #b94a48; + } + .alert-info { + background-color: #d9edf7; + border-color: #bce8f1; + color: #3a87ad; + } + .alert-block { + padding-top: 14px; + padding-bottom: 14px; + > { + p, ul { + margin-bottom: 0; + } + } + p p { + margin-top: 5px; + } + } + .alert { + .close { + float: right; + font-size: 20px; + font-weight: bold; + line-height: 18px; + color: black; + text-shadow: 0 1px 0 white; + opacity: 0.2; + filter: alpha(opacity = 20); + @include hover { + color: black; + text-decoration: none; + cursor: pointer; + opacity: 0.4; + filter: alpha(opacity = 40); + } + } + button.close { + padding: 0; + cursor: pointer; + background: transparent; + border: 0; + -webkit-appearance: none; + } + } + .bootbox.modal { + .modal-footer { + a.btn-primary { + color: white; + } + } + } +} + +table { + max-width: 100%; + background-color: transparent; +} + +.table { + width: 100%; + margin-bottom: 20px; + th, td { + padding: 8px; + line-height: 20px; + text-align: left; + vertical-align: top; + border-top: 1px solid #dddddd; + } + th { + font-weight: bold; + } + thead th { + vertical-align: bottom; + } + caption tr:first-child { + th, td { + border-top: 0; + } + } + colgroup tr:first-child { + th, td { + border-top: 0; + } + } + thead:first-child tr:first-child { + th, td { + border-top: 0; + } + } + tbody tbody { + border-top: 2px solid #dddddd; + } +} + +.table-condensed { + th, td { + padding: 4px 5px; + } +} + +.table-bordered { + border: 1px solid #dddddd; + border-collapse: separate; + *border-collapse: collapse; + border-left: 0; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + th, td { + border-left: 1px solid #dddddd; + } + caption + { + thead th { + border-top: 0; + } + tbody tr:first-child { + th, td { + border-top: 0; + } + } + } + colgroup + { + thead th { + border-top: 0; + } + tbody tr:first-child { + th, td { + border-top: 0; + } + } + } + thead:first-child th { + border-top: 0; + } + tbody:first-child tr:first-child { + th, td { + border-top: 0; + } + } + thead:first-child th:first-child, tbody:first-child td:first-child { + -webkit-border-top-left-radius: 4px; + border-top-left-radius: 4px; + -moz-border-radius-topleft: 4px; + } + thead:first-child th:last-child, tbody:first-child td:last-child { + -webkit-border-top-right-radius: 4px; + border-top-right-radius: 4px; + -moz-border-radius-topright: 4px; + } + thead:last-child th:first-child, tbody:last-child td:first-child, tfoot:last-child td:first-child { + -webkit-border-radius: 0 0 0 4px; + -moz-border-radius: 0 0 0 4px; + border-radius: 0 0 0 4px; + -webkit-border-bottom-left-radius: 4px; + border-bottom-left-radius: 4px; + -moz-border-radius-bottomleft: 4px; + } + thead:last-child th:last-child, tbody:last-child td:last-child, tfoot:last-child td:last-child { + -webkit-border-bottom-right-radius: 4px; + border-bottom-right-radius: 4px; + -moz-border-radius-bottomright: 4px; + } + caption + { + thead th:first-child, tbody td:first-child { + -webkit-border-top-left-radius: 4px; + border-top-left-radius: 4px; + -moz-border-radius-topleft: 4px; + } + } + colgroup + { + thead th:first-child, tbody td:first-child { + -webkit-border-top-left-radius: 4px; + border-top-left-radius: 4px; + -moz-border-radius-topleft: 4px; + } + } + caption + { + thead th:last-child, tbody td:last-child { + -webkit-border-top-right-radius: 4px; + border-top-right-radius: 4px; + -moz-border-radius-topleft: 4px; + } + } + colgroup + { + thead th:last-child, tbody td:last-child { + -webkit-border-top-right-radius: 4px; + border-top-right-radius: 4px; + -moz-border-radius-topleft: 4px; + } + } +} + +.table-striped tr:nth-child(odd) { + td, th { + background-color: #f9f9f9; + } +} + +.table-hover tr:hover { + td, th { + background-color: whitesmoke; + } +} + +table [class*=span], .row-fluid [class*=span] { + display: table-cell; + float: none; + margin-left: 0; +} + +.table { + .span1 { + float: none; + width: 44px; + margin-left: 0; + } + .span2 { + float: none; + width: 124px; + margin-left: 0; + } + .span3 { + float: none; + width: 204px; + margin-left: 0; + } + .span4 { + float: none; + width: 284px; + margin-left: 0; + } + .span5 { + float: none; + width: 364px; + margin-left: 0; + } + .span6 { + float: none; + width: 444px; + margin-left: 0; + } + .span7 { + float: none; + width: 524px; + margin-left: 0; + } + .span8 { + float: none; + width: 604px; + margin-left: 0; + } + .span9 { + float: none; + width: 684px; + margin-left: 0; + } + .span10 { + float: none; + width: 764px; + margin-left: 0; + } + .span11 { + float: none; + width: 844px; + margin-left: 0; + } + .span12 { + float: none; + width: 924px; + margin-left: 0; + } + .span13 { + float: none; + width: 1004px; + margin-left: 0; + } + .span14 { + float: none; + width: 1084px; + margin-left: 0; + } + .span15 { + float: none; + width: 1164px; + margin-left: 0; + } + .span16 { + float: none; + width: 1244px; + margin-left: 0; + } + .span17 { + float: none; + width: 1324px; + margin-left: 0; + } + .span18 { + float: none; + width: 1404px; + margin-left: 0; + } + .span19 { + float: none; + width: 1484px; + margin-left: 0; + } + .span20 { + float: none; + width: 1564px; + margin-left: 0; + } + .span21 { + float: none; + width: 1644px; + margin-left: 0; + } + .span22 { + float: none; + width: 1724px; + margin-left: 0; + } + .span23 { + float: none; + width: 1804px; + margin-left: 0; + } + .span24 { + float: none; + width: 1884px; + margin-left: 0; + } + tbody tr { + &.success td { + background-color: #dff0d8; + } + &.error td { + background-color: #f2dede; + } + &.warning td { + background-color: #fcf8e3; + } + &.info td { + background-color: #d9edf7; + } + } +} + +.table-hover tr { + &.success:hover td { + background-color: #d0e9c6; + } + &.error:hover td { + background-color: #ebcccc; + } + &.warning:hover td { + background-color: #faf2cc; + } + &.info:hover td { + background-color: #c4e3f3; + } +} diff --git a/app/assets/stylesheets/vendor/chosen.css.erb b/app/assets/stylesheets/vendor/chosen.css.erb new file mode 100644 index 00000000000..3249fd62e77 --- /dev/null +++ b/app/assets/stylesheets/vendor/chosen.css.erb @@ -0,0 +1,395 @@ +/* for the Chosen plugin http://harvesthq.github.com/chosen/ */ + +/* @group Base */ +.chzn-container { + font-size: 13px; + position: relative; + display: inline-block; + zoom: 1; + *display: inline; +} +.chzn-container .chzn-drop { + background: #fff; + border: 1px solid #aaa; + border-top: 0; + position: absolute; + top: 29px; + left: 0; + -webkit-box-shadow: 0 4px 5px rgba(0,0,0,.15); + -moz-box-shadow : 0 4px 5px rgba(0,0,0,.15); + -o-box-shadow : 0 4px 5px rgba(0,0,0,.15); + box-shadow : 0 4px 5px rgba(0,0,0,.15); + z-index: 1010; +} +/* @end */ + +/* @group Single Chosen */ +.chzn-container-single .chzn-single { + background-color: #ffffff; + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#eeeeee', GradientType=0 ); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #ffffff), color-stop(50%, #f6f6f6), color-stop(52%, #eeeeee), color-stop(100%, #f4f4f4)); + background-image: -webkit-linear-gradient(top, #ffffff 20%, #f6f6f6 50%, #eeeeee 52%, #f4f4f4 100%); + background-image: -moz-linear-gradient(top, #ffffff 20%, #f6f6f6 50%, #eeeeee 52%, #f4f4f4 100%); + background-image: -o-linear-gradient(top, #ffffff 20%, #f6f6f6 50%, #eeeeee 52%, #f4f4f4 100%); + background-image: -ms-linear-gradient(top, #ffffff 20%, #f6f6f6 50%, #eeeeee 52%, #f4f4f4 100%); + background-image: linear-gradient(top, #ffffff 20%, #f6f6f6 50%, #eeeeee 52%, #f4f4f4 100%); + -webkit-border-radius: 5px; + -moz-border-radius : 5px; + border-radius : 5px; + -moz-background-clip : padding; + -webkit-background-clip: padding-box; + background-clip : padding-box; + border: 1px solid #aaaaaa; + -webkit-box-shadow: 0 0 3px #ffffff inset, 0 1px 1px rgba(0,0,0,0.1); + -moz-box-shadow : 0 0 3px #ffffff inset, 0 1px 1px rgba(0,0,0,0.1); + box-shadow : 0 0 3px #ffffff inset, 0 1px 1px rgba(0,0,0,0.1); + display: block; + overflow: hidden; + white-space: nowrap; + position: relative; + height: 26px; + line-height: 26px; + padding: 0 0 0 8px; + color: #444444; + text-decoration: none; +} +.chzn-container-single .chzn-default { + color: #999; +} +.chzn-container-single .chzn-single span { + margin-right: 26px; + display: block; + overflow: hidden; + white-space: nowrap; + -o-text-overflow: ellipsis; + -ms-text-overflow: ellipsis; + text-overflow: ellipsis; +} +.chzn-container-single .chzn-single abbr { + display: block; + position: absolute; + right: 26px; + top: 6px; + width: 12px; + height: 13px; + font-size: 1px; + background: url(<%=asset_path "chosen-sprite.png"%>) right top no-repeat; +} +.chzn-container-single .chzn-single abbr:hover { + background-position: right -11px; +} +.chzn-container-single .chzn-single div { + position: absolute; + right: 0; + top: 0; + display: block; + height: 100%; + width: 18px; +} +.chzn-container-single .chzn-single div b { + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat 0 0; + display: block; + width: 100%; + height: 100%; +} +.chzn-container-single .chzn-search { + padding: 3px 4px; + position: relative; + margin: 0; + white-space: nowrap; + z-index: 1010; +} +.chzn-container-single .chzn-search input { + background: #fff url(<%=asset_path "chosen-sprite.png"%>) no-repeat 100% -22px; + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat 100% -22px, -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eeeeee), color-stop(15%, #ffffff)); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat 100% -22px, -webkit-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat 100% -22px, -moz-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat 100% -22px, -o-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat 100% -22px, -ms-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat 100% -22px, linear-gradient(top, #eeeeee 1%, #ffffff 15%); + margin: 1px 0; + padding: 4px 20px 4px 5px; + outline: 0; + border: 1px solid #aaa; + font-family: sans-serif; + font-size: 1em; +} +.chzn-container-single .chzn-drop { + -webkit-border-radius: 0 0 4px 4px; + -moz-border-radius : 0 0 4px 4px; + border-radius : 0 0 4px 4px; + -moz-background-clip : padding; + -webkit-background-clip: padding-box; + background-clip : padding-box; +} +/* @end */ + +.chzn-container-single-nosearch .chzn-search input { + position: absolute; + left: -9000px; +} + +/* @group Multi Chosen */ +.chzn-container-multi .chzn-choices { + background-color: #fff; + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eeeeee), color-stop(15%, #ffffff)); + background-image: -webkit-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: -moz-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: -o-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: -ms-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background-image: linear-gradient(top, #eeeeee 1%, #ffffff 15%); + border: 1px solid #aaa; + margin: 0; + padding: 0; + cursor: text; + overflow: hidden; + height: auto !important; + height: 1%; + position: relative; +} +.chzn-container-multi .chzn-choices li { + float: left; + list-style: none; +} +.chzn-container-multi .chzn-choices .search-field { + white-space: nowrap; + margin: 0; + padding: 0; +} +.chzn-container-multi .chzn-choices .search-field input { + color: #666; + background: transparent !important; + border: 0 !important; + font-family: sans-serif; + font-size: 100%; + height: 15px; + padding: 5px; + margin: 1px 0; + outline: 0; + -webkit-box-shadow: none; + -moz-box-shadow : none; + -o-box-shadow : none; + box-shadow : none; +} +.chzn-container-multi .chzn-choices .search-field .default { + color: #999; +} +.chzn-container-multi .chzn-choices .search-choice { + -webkit-border-radius: 3px; + -moz-border-radius : 3px; + border-radius : 3px; + -moz-background-clip : padding; + -webkit-background-clip: padding-box; + background-clip : padding-box; + background-color: #e4e4e4; + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#f4f4f4', endColorstr='#eeeeee', GradientType=0 ); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eeeeee)); + background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: -o-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: -ms-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + background-image: linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eeeeee 100%); + -webkit-box-shadow: 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + -moz-box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + box-shadow : 0 0 2px #ffffff inset, 0 1px 0 rgba(0,0,0,0.05); + color: #333; + border: 1px solid #aaaaaa; + line-height: 13px; + padding: 3px 20px 3px 5px; + margin: 3px 0 3px 5px; + position: relative; + cursor: default; +} +.chzn-container-multi .chzn-choices .search-choice-focus { + background: #d4d4d4; +} +.chzn-container-multi .chzn-choices .search-choice .search-choice-close { + display: block; + position: absolute; + right: 3px; + top: 4px; + width: 12px; + height: 13px; + font-size: 1px; + background: url(<%=asset_path "chosen-sprite.png"%>) right top no-repeat; +} +.chzn-container-multi .chzn-choices .search-choice .search-choice-close:hover { + background-position: right -11px; +} +.chzn-container-multi .chzn-choices .search-choice-focus .search-choice-close { + background-position: right -11px; +} +/* @end */ + +/* @group Results */ +.chzn-container .chzn-results { + margin: 0 4px 4px 0; + max-height: 240px; + padding: 0 0 0 4px; + position: relative; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} +.chzn-container-multi .chzn-results { + margin: -1px 0 0; + padding: 0; +} +.chzn-container .chzn-results li { + display: none; + line-height: 15px; + padding: 5px 6px; + margin: 0; + list-style: none; +} +.chzn-container .chzn-results .active-result { + cursor: pointer; + display: list-item; +} +.chzn-container .chzn-results .highlighted { + background-color: #3875d7; + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3875d7', endColorstr='#2a62bc', GradientType=0 ); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #3875d7), color-stop(90%, #2a62bc)); + background-image: -webkit-linear-gradient(top, #3875d7 20%, #2a62bc 90%); + background-image: -moz-linear-gradient(top, #3875d7 20%, #2a62bc 90%); + background-image: -o-linear-gradient(top, #3875d7 20%, #2a62bc 90%); + background-image: -ms-linear-gradient(top, #3875d7 20%, #2a62bc 90%); + background-image: linear-gradient(top, #3875d7 20%, #2a62bc 90%); + color: #fff; +} +.chzn-container .chzn-results li em { + background: #feffde; + font-style: normal; +} +.chzn-container .chzn-results .highlighted em { + background: transparent; +} +.chzn-container .chzn-results .no-results { + background: #f4f4f4; + display: list-item; +} +.chzn-container .chzn-results .group-result { + cursor: default; + color: #999; + font-weight: bold; +} +.chzn-container .chzn-results .group-option { + padding-left: 15px; +} +.chzn-container-multi .chzn-drop .result-selected { + display: none; +} +.chzn-container .chzn-results-scroll { + background: white; + margin: 0 4px; + position: absolute; + text-align: center; + width: 321px; /* This should by dynamic with js */ + z-index: 1; +} +.chzn-container .chzn-results-scroll span { + display: inline-block; + height: 17px; + text-indent: -5000px; + width: 9px; +} +.chzn-container .chzn-results-scroll-down { + bottom: 0; +} +.chzn-container .chzn-results-scroll-down span { + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat -4px -3px; +} +.chzn-container .chzn-results-scroll-up span { + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat -22px -3px; +} +/* @end */ + +/* @group Active */ +.chzn-container-active .chzn-single { + -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3); + -moz-box-shadow : 0 0 5px rgba(0,0,0,.3); + -o-box-shadow : 0 0 5px rgba(0,0,0,.3); + box-shadow : 0 0 5px rgba(0,0,0,.3); + border: 1px solid #5897fb; +} +.chzn-container-active .chzn-single-with-drop { + border: 1px solid #aaa; + -webkit-box-shadow: 0 1px 0 #fff inset; + -moz-box-shadow : 0 1px 0 #fff inset; + -o-box-shadow : 0 1px 0 #fff inset; + box-shadow : 0 1px 0 #fff inset; + background-color: #eee; + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0 ); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #eeeeee), color-stop(80%, #ffffff)); + background-image: -webkit-linear-gradient(top, #eeeeee 20%, #ffffff 80%); + background-image: -moz-linear-gradient(top, #eeeeee 20%, #ffffff 80%); + background-image: -o-linear-gradient(top, #eeeeee 20%, #ffffff 80%); + background-image: -ms-linear-gradient(top, #eeeeee 20%, #ffffff 80%); + background-image: linear-gradient(top, #eeeeee 20%, #ffffff 80%); + -webkit-border-bottom-left-radius : 0; + -webkit-border-bottom-right-radius: 0; + -moz-border-radius-bottomleft : 0; + -moz-border-radius-bottomright: 0; + border-bottom-left-radius : 0; + border-bottom-right-radius: 0; +} +.chzn-container-active .chzn-single-with-drop div { + background: transparent; + border-left: none; +} +.chzn-container-active .chzn-single-with-drop div b { + background-position: -18px 1px; +} +.chzn-container-active .chzn-choices { + -webkit-box-shadow: 0 0 5px rgba(0,0,0,.3); + -moz-box-shadow : 0 0 5px rgba(0,0,0,.3); + -o-box-shadow : 0 0 5px rgba(0,0,0,.3); + box-shadow : 0 0 5px rgba(0,0,0,.3); + border: 1px solid #5897fb; +} +.chzn-container-active .chzn-choices .search-field input { + color: #111 !important; +} +/* @end */ + +/* @group Disabled Support */ +.chzn-disabled { + cursor: default; + opacity:0.5 !important; +} +.chzn-disabled .chzn-single { + cursor: default; +} +.chzn-disabled .chzn-choices .search-choice .search-choice-close { + cursor: default; +} + +/* @group Right to Left */ +.chzn-rtl { text-align: right; } +.chzn-rtl .chzn-single { padding: 0 8px 0 0; overflow: visible; } +.chzn-rtl .chzn-single span { margin-left: 26px; margin-right: 0; direction: rtl; } + +.chzn-rtl .chzn-single div { left: 3px; right: auto; } +.chzn-rtl .chzn-single abbr { + left: 26px; + right: auto; +} +.chzn-rtl .chzn-choices .search-field input { direction: rtl; } +.chzn-rtl .chzn-choices li { float: right; } +.chzn-rtl .chzn-choices .search-choice { padding: 3px 5px 3px 19px; margin: 3px 5px 3px 0; } +.chzn-rtl .chzn-choices .search-choice .search-choice-close { left: 4px; right: auto; background-position: right top;} +.chzn-rtl.chzn-container-single .chzn-results { margin: 0 0 4px 4px; padding: 0 4px 0 0; } +.chzn-rtl .chzn-results .group-option { padding-left: 0; padding-right: 15px; } +.chzn-rtl.chzn-container-active .chzn-single-with-drop div { border-right: none; } +.chzn-rtl .chzn-search input { + background: #fff url(<%=asset_path "chosen-sprite.png"%>) no-repeat -38px -22px; + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat -38px -22px, -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eeeeee), color-stop(15%, #ffffff)); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat -38px -22px, -webkit-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat -38px -22px, -moz-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat -38px -22px, -o-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat -38px -22px, -ms-linear-gradient(top, #eeeeee 1%, #ffffff 15%); + background: url(<%=asset_path "chosen-sprite.png"%>) no-repeat -38px -22px, linear-gradient(top, #eeeeee 1%, #ffffff 15%); + padding: 4px 5px 4px 20px; + direction: rtl; +} +/* @end */ \ No newline at end of file diff --git a/app/assets/stylesheets/vendor/font-awesome.css.erb b/app/assets/stylesheets/vendor/font-awesome.css.erb new file mode 100755 index 00000000000..a7ce7e436c7 --- /dev/null +++ b/app/assets/stylesheets/vendor/font-awesome.css.erb @@ -0,0 +1,462 @@ +/* Font Awesome + the iconic font designed for use with Twitter Bootstrap + ------------------------------------------------------- + The full suite of pictographic icons, examples, and documentation + can be found at: http://fortawesome.github.com/Font-Awesome/ + + License + ------------------------------------------------------- + The Font Awesome webfont, CSS, and LESS files are licensed under CC BY 3.0: + http://creativecommons.org/licenses/by/3.0/ A mention of + 'Font Awesome - http://fortawesome.github.com/Font-Awesome' in human-readable + source code is considered acceptable attribution (most common on the web). + If human readable source code is not available to the end user, a mention in + an 'About' or 'Credits' screen is considered acceptable (most common in desktop + or mobile software). + + Contact + ------------------------------------------------------- + Email: dave@davegandy.com + Twitter: http://twitter.com/fortaweso_me + Work: http://lemonwi.se co-founder + +*/ + +/* Font Awesome styles + ------------------------------------------------------- */ +/* includes sprites.less reset */ +[class^="icon-"], +[class*=" icon-"] { + font-family: FontAwesome; + font-weight: normal; + font-style: normal; + text-decoration: inherit; + display: inline; + width: auto; + height: auto; + line-height: normal; + vertical-align: baseline; + background-image: none !important; + background-position: 0% 0%; + background-repeat: repeat; +} +[class^="icon-"]:before, +[class*=" icon-"]:before { + text-decoration: inherit; + display: inline-block; + speak: none; +} +/* makes sure icons active on rollover in links */ +a [class^="icon-"], +a [class*=" icon-"] { + display: inline-block; +} +/* makes the font 33% larger relative to the icon container */ +.icon-large:before { + vertical-align: -10%; + font-size: 1.3333333333333333em; +} +.btn [class^="icon-"], +.nav [class^="icon-"], +.btn [class*=" icon-"], +.nav [class*=" icon-"] { + display: inline; + /* keeps button heights with and without icons the same */ + + line-height: .6em; +} +.btn [class^="icon-"].icon-spin, +.nav [class^="icon-"].icon-spin, +.btn [class*=" icon-"].icon-spin, +.nav [class*=" icon-"].icon-spin { + display: inline-block; +} +li [class^="icon-"], +li [class*=" icon-"] { + display: inline-block; + width: 1.25em; + text-align: center; +} +li [class^="icon-"].icon-large, +li [class*=" icon-"].icon-large { + /* increased font size for icon-large */ + + width: 1.5625em; +} +ul.icons { + list-style-type: none; +} +ul.icons li [class^="icon-"], +ul.icons li [class*=" icon-"] { + width: .75em; +} +.icon-muted { + color: #eeeeee; +} +.icon-border { + border: solid 1px #eeeeee; + padding: .2em .25em .15em; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} +.icon-2x { + font-size: 2em; +} +.icon-2x.icon-border { + border-width: 2px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +.icon-3x { + font-size: 3em; +} +.icon-3x.icon-border { + border-width: 3px; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; +} +.icon-4x { + font-size: 4em; +} +.icon-4x.icon-border { + border-width: 4px; + -webkit-border-radius: 6px; + -moz-border-radius: 6px; + border-radius: 6px; +} +.pull-right { + float: right; +} +.pull-left { + float: left; +} +[class^="icon-"].pull-left, +[class*=" icon-"].pull-left { + margin-right: .35em; +} +[class^="icon-"].pull-right, +[class*=" icon-"].pull-right { + margin-left: .35em; +} +.btn [class^="icon-"].pull-left.icon-2x, +.btn [class*=" icon-"].pull-left.icon-2x, +.btn [class^="icon-"].pull-right.icon-2x, +.btn [class*=" icon-"].pull-right.icon-2x { + margin-top: .35em; +} +.btn [class^="icon-"].icon-spin.icon-large, +.btn [class*=" icon-"].icon-spin.icon-large { + height: .75em; +} +.btn.btn-small [class^="icon-"].pull-left.icon-2x, +.btn.btn-small [class*=" icon-"].pull-left.icon-2x, +.btn.btn-small [class^="icon-"].pull-right.icon-2x, +.btn.btn-small [class*=" icon-"].pull-right.icon-2x { + margin-top: .45em; +} +.btn.btn-large [class^="icon-"].pull-left.icon-2x, +.btn.btn-large [class*=" icon-"].pull-left.icon-2x, +.btn.btn-large [class^="icon-"].pull-right.icon-2x, +.btn.btn-large [class*=" icon-"].pull-right.icon-2x { + margin-top: .2em; +} +.icon-spin { + display: inline-block; + -moz-animation: spin 2s infinite linear; + -o-animation: spin 2s infinite linear; + -webkit-animation: spin 2s infinite linear; + animation: spin 2s infinite linear; +} +@-moz-keyframes spin { + 0% { -moz-transform: rotate(0deg); } + 100% { -moz-transform: rotate(359deg); } +} +@-webkit-keyframes spin { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(359deg); } +} +@-o-keyframes spin { + 0% { -o-transform: rotate(0deg); } + 100% { -o-transform: rotate(359deg); } +} +@-ms-keyframes spin { + 0% { -ms-transform: rotate(0deg); } + 100% { -ms-transform: rotate(359deg); } +} +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(359deg); } +} +/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen + readers do not read off random characters that represent icons */ +.icon-glass:before { content: "\f000"; } +.icon-music:before { content: "\f001"; } +.icon-search:before { content: "\f002"; } +.icon-envelope:before { content: "\f003"; } +.icon-heart:before { content: "\f004"; } +.icon-star:before { content: "\f005"; } +.icon-star-empty:before { content: "\f006"; } +.icon-user:before { content: "\f007"; } +.icon-film:before { content: "\f008"; } +.icon-th-large:before { content: "\f009"; } +.icon-th:before { content: "\f00a"; } +.icon-th-list:before { content: "\f00b"; } +.icon-ok:before { content: "\f00c"; } +.icon-remove:before { content: "\f00d"; } +.icon-zoom-in:before { content: "\f00e"; } + +.icon-zoom-out:before { content: "\f010"; } +.icon-off:before { content: "\f011"; } +.icon-signal:before { content: "\f012"; } +.icon-cog:before { content: "\f013"; } +.icon-trash:before { content: "\f014"; } +.icon-home:before { content: "\f015"; } +.icon-file:before { content: "\f016"; } +.icon-time:before { content: "\f017"; } +.icon-road:before { content: "\f018"; } +.icon-download-alt:before { content: "\f019"; } +.icon-download:before { content: "\f01a"; } +.icon-upload:before { content: "\f01b"; } +.icon-inbox:before { content: "\f01c"; } +.icon-play-circle:before { content: "\f01d"; } +.icon-repeat:before { content: "\f01e"; } + +/* \f020 doesn't work in Safari. all shifted one down */ +.icon-refresh:before { content: "\f021"; } +.icon-list-alt:before { content: "\f022"; } +.icon-lock:before { content: "\f023"; } +.icon-flag:before { content: "\f024"; } +.icon-headphones:before { content: "\f025"; } +.icon-volume-off:before { content: "\f026"; } +.icon-volume-down:before { content: "\f027"; } +.icon-volume-up:before { content: "\f028"; } +.icon-qrcode:before { content: "\f029"; } +.icon-barcode:before { content: "\f02a"; } +.icon-tag:before { content: "\f02b"; } +.icon-tags:before { content: "\f02c"; } +.icon-book:before { content: "\f02d"; } +.icon-bookmark:before { content: "\f02e"; } +.icon-print:before { content: "\f02f"; } + +.icon-camera:before { content: "\f030"; } +.icon-font:before { content: "\f031"; } +.icon-bold:before { content: "\f032"; } +.icon-italic:before { content: "\f033"; } +.icon-text-height:before { content: "\f034"; } +.icon-text-width:before { content: "\f035"; } +.icon-align-left:before { content: "\f036"; } +.icon-align-center:before { content: "\f037"; } +.icon-align-right:before { content: "\f038"; } +.icon-align-justify:before { content: "\f039"; } +.icon-list:before { content: "\f03a"; } +.icon-indent-left:before { content: "\f03b"; } +.icon-indent-right:before { content: "\f03c"; } +.icon-facetime-video:before { content: "\f03d"; } +.icon-picture:before { content: "\f03e"; } + +.icon-pencil:before { content: "\f040"; } +.icon-map-marker:before { content: "\f041"; } +.icon-adjust:before { content: "\f042"; } +.icon-tint:before { content: "\f043"; } +.icon-edit:before { content: "\f044"; } +.icon-share:before { content: "\f045"; } +.icon-check:before { content: "\f046"; } +.icon-move:before { content: "\f047"; } +.icon-step-backward:before { content: "\f048"; } +.icon-fast-backward:before { content: "\f049"; } +.icon-backward:before { content: "\f04a"; } +.icon-play:before { content: "\f04b"; } +.icon-pause:before { content: "\f04c"; } +.icon-stop:before { content: "\f04d"; } +.icon-forward:before { content: "\f04e"; } + +.icon-fast-forward:before { content: "\f050"; } +.icon-step-forward:before { content: "\f051"; } +.icon-eject:before { content: "\f052"; } +.icon-chevron-left:before { content: "\f053"; } +.icon-chevron-right:before { content: "\f054"; } +.icon-plus-sign:before { content: "\f055"; } +.icon-minus-sign:before { content: "\f056"; } +.icon-remove-sign:before { content: "\f057"; } +.icon-ok-sign:before { content: "\f058"; } +.icon-question-sign:before { content: "\f059"; } +.icon-info-sign:before { content: "\f05a"; } +.icon-screenshot:before { content: "\f05b"; } +.icon-remove-circle:before { content: "\f05c"; } +.icon-ok-circle:before { content: "\f05d"; } +.icon-ban-circle:before { content: "\f05e"; } + +.icon-arrow-left:before { content: "\f060"; } +.icon-arrow-right:before { content: "\f061"; } +.icon-arrow-up:before { content: "\f062"; } +.icon-arrow-down:before { content: "\f063"; } +.icon-share-alt:before { content: "\f064"; } +.icon-resize-full:before { content: "\f065"; } +.icon-resize-small:before { content: "\f066"; } +.icon-plus:before { content: "\f067"; } +.icon-minus:before { content: "\f068"; } +.icon-asterisk:before { content: "\f069"; } +.icon-exclamation-sign:before { content: "\f06a"; } +.icon-gift:before { content: "\f06b"; } +.icon-leaf:before { content: "\f06c"; } +.icon-fire:before { content: "\f06d"; } +.icon-eye-open:before { content: "\f06e"; } + +.icon-eye-close:before { content: "\f070"; } +.icon-warning-sign:before { content: "\f071"; } +.icon-plane:before { content: "\f072"; } +.icon-calendar:before { content: "\f073"; } +.icon-random:before { content: "\f074"; } +.icon-comment:before { content: "\f075"; } +.icon-magnet:before { content: "\f076"; } +.icon-chevron-up:before { content: "\f077"; } +.icon-chevron-down:before { content: "\f078"; } +.icon-retweet:before { content: "\f079"; } +.icon-shopping-cart:before { content: "\f07a"; } +.icon-folder-close:before { content: "\f07b"; } +.icon-folder-open:before { content: "\f07c"; } +.icon-resize-vertical:before { content: "\f07d"; } +.icon-resize-horizontal:before { content: "\f07e"; } + +.icon-bar-chart:before { content: "\f080"; } +.icon-twitter-sign:before { content: "\f081"; } +.icon-facebook-sign:before { content: "\f082"; } +.icon-camera-retro:before { content: "\f083"; } +.icon-key:before { content: "\f084"; } +.icon-cogs:before { content: "\f085"; } +.icon-comments:before { content: "\f086"; } +.icon-thumbs-up:before { content: "\f087"; } +.icon-thumbs-down:before { content: "\f088"; } +.icon-star-half:before { content: "\f089"; } +.icon-heart-empty:before { content: "\f08a"; } +.icon-signout:before { content: "\f08b"; } +.icon-linkedin-sign:before { content: "\f08c"; } +.icon-pushpin:before { content: "\f08d"; } +.icon-external-link:before { content: "\f08e"; } + +.icon-signin:before { content: "\f090"; } +.icon-trophy:before { content: "\f091"; } +.icon-github-sign:before { content: "\f092"; } +.icon-upload-alt:before { content: "\f093"; } +.icon-lemon:before { content: "\f094"; } +.icon-phone:before { content: "\f095"; } +.icon-check-empty:before { content: "\f096"; } +.icon-bookmark-empty:before { content: "\f097"; } +.icon-phone-sign:before { content: "\f098"; } +.icon-twitter:before { content: "\f099"; } +.icon-facebook:before { content: "\f09a"; } +.icon-github:before { content: "\f09b"; } +.icon-unlock:before { content: "\f09c"; } +.icon-credit-card:before { content: "\f09d"; } +.icon-rss:before { content: "\f09e"; } + +.icon-hdd:before { content: "\f0a0"; } +.icon-bullhorn:before { content: "\f0a1"; } +.icon-bell:before { content: "\f0a2"; } +.icon-certificate:before { content: "\f0a3"; } +.icon-hand-right:before { content: "\f0a4"; } +.icon-hand-left:before { content: "\f0a5"; } +.icon-hand-up:before { content: "\f0a6"; } +.icon-hand-down:before { content: "\f0a7"; } +.icon-circle-arrow-left:before { content: "\f0a8"; } +.icon-circle-arrow-right:before { content: "\f0a9"; } +.icon-circle-arrow-up:before { content: "\f0aa"; } +.icon-circle-arrow-down:before { content: "\f0ab"; } +.icon-globe:before { content: "\f0ac"; } +.icon-wrench:before { content: "\f0ad"; } +.icon-tasks:before { content: "\f0ae"; } + +.icon-filter:before { content: "\f0b0"; } +.icon-briefcase:before { content: "\f0b1"; } +.icon-fullscreen:before { content: "\f0b2"; } + +.icon-group:before { content: "\f0c0"; } +.icon-link:before { content: "\f0c1"; } +.icon-cloud:before { content: "\f0c2"; } +.icon-beaker:before { content: "\f0c3"; } +.icon-cut:before { content: "\f0c4"; } +.icon-copy:before { content: "\f0c5"; } +.icon-paper-clip:before { content: "\f0c6"; } +.icon-save:before { content: "\f0c7"; } +.icon-sign-blank:before { content: "\f0c8"; } +.icon-reorder:before { content: "\f0c9"; } +.icon-list-ul:before { content: "\f0ca"; } +.icon-list-ol:before { content: "\f0cb"; } +.icon-strikethrough:before { content: "\f0cc"; } +.icon-underline:before { content: "\f0cd"; } +.icon-table:before { content: "\f0ce"; } + +.icon-magic:before { content: "\f0d0"; } +.icon-truck:before { content: "\f0d1"; } +.icon-pinterest:before { content: "\f0d2"; } +.icon-pinterest-sign:before { content: "\f0d3"; } +.icon-google-plus-sign:before { content: "\f0d4"; } +.icon-google-plus:before { content: "\f0d5"; } +.icon-money:before { content: "\f0d6"; } +.icon-caret-down:before { content: "\f0d7"; } +.icon-caret-up:before { content: "\f0d8"; } +.icon-caret-left:before { content: "\f0d9"; } +.icon-caret-right:before { content: "\f0da"; } +.icon-columns:before { content: "\f0db"; } +.icon-sort:before { content: "\f0dc"; } +.icon-sort-down:before { content: "\f0dd"; } +.icon-sort-up:before { content: "\f0de"; } + +.icon-envelope-alt:before { content: "\f0e0"; } +.icon-linkedin:before { content: "\f0e1"; } +.icon-undo:before { content: "\f0e2"; } +.icon-legal:before { content: "\f0e3"; } +.icon-dashboard:before { content: "\f0e4"; } +.icon-comment-alt:before { content: "\f0e5"; } +.icon-comments-alt:before { content: "\f0e6"; } +.icon-bolt:before { content: "\f0e7"; } +.icon-sitemap:before { content: "\f0e8"; } +.icon-umbrella:before { content: "\f0e9"; } +.icon-paste:before { content: "\f0ea"; } +.icon-lightbulb:before { content: "\f0eb"; } +.icon-exchange:before { content: "\f0ec"; } +.icon-cloud-download:before { content: "\f0ed"; } +.icon-cloud-upload:before { content: "\f0ee"; } + +.icon-user-md:before { content: "\f0f0"; } +.icon-stethoscope:before { content: "\f0f1"; } +.icon-suitcase:before { content: "\f0f2"; } +.icon-bell-alt:before { content: "\f0f3"; } +.icon-coffee:before { content: "\f0f4"; } +.icon-food:before { content: "\f0f5"; } +.icon-file-alt:before { content: "\f0f6"; } +.icon-building:before { content: "\f0f7"; } +.icon-hospital:before { content: "\f0f8"; } +.icon-ambulance:before { content: "\f0f9"; } +.icon-medkit:before { content: "\f0fa"; } +.icon-fighter-jet:before { content: "\f0fb"; } +.icon-beer:before { content: "\f0fc"; } +.icon-h-sign:before { content: "\f0fd"; } +.icon-plus-sign-alt:before { content: "\f0fe"; } + +.icon-double-angle-left:before { content: "\f100"; } +.icon-double-angle-right:before { content: "\f101"; } +.icon-double-angle-up:before { content: "\f102"; } +.icon-double-angle-down:before { content: "\f103"; } +.icon-angle-left:before { content: "\f104"; } +.icon-angle-right:before { content: "\f105"; } +.icon-angle-up:before { content: "\f106"; } +.icon-angle-down:before { content: "\f107"; } +.icon-desktop:before { content: "\f108"; } +.icon-laptop:before { content: "\f109"; } +.icon-tablet:before { content: "\f10a"; } +.icon-mobile-phone:before { content: "\f10b"; } +.icon-circle-blank:before { content: "\f10c"; } +.icon-quote-left:before { content: "\f10d"; } +.icon-quote-right:before { content: "\f10e"; } + +.icon-spinner:before { content: "\f110"; } +.icon-circle:before { content: "\f111"; } +.icon-reply:before { content: "\f112"; } +.icon-github-alt:before { content: "\f113"; } +.icon-folder-close-alt:before { content: "\f114"; } +.icon-folder-open-alt:before { content: "\f115"; } + diff --git a/app/assets/stylesheets/vendor/normalize.css b/app/assets/stylesheets/vendor/normalize.css new file mode 100755 index 00000000000..a9c6f52f05e --- /dev/null +++ b/app/assets/stylesheets/vendor/normalize.css @@ -0,0 +1,396 @@ +/*! normalize.css v2.1.0 | MIT License | git.io/normalize */ + +/* ========================================================================== + HTML5 display definitions + ========================================================================== */ + +/** + * Correct `block` display not defined in IE 8/9. + */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +nav, +section, +summary { + display: block; +} + +/** + * Correct `inline-block` display not defined in IE 8/9. + */ + +audio, +canvas, +video { + display: inline-block; +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +[hidden] { + display: none; +} + +/* ========================================================================== + Base + ========================================================================== */ + +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS text size adjust after orientation change, without disabling + * user zoom. + */ + +html { + font-family: sans-serif; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + -ms-text-size-adjust: 100%; /* 2 */ +} + +/** + * Remove default margin. + */ + +body { + margin: 0; +} + +/* ========================================================================== + Links + ========================================================================== */ + +/** + * Address `outline` inconsistency between Chrome and other browsers. + */ + +a:focus { + outline: thin dotted; +} + +/** + * Improve readability when focused and also mouse hovered in all browsers. + */ + +a:active, +a:hover { + outline: 0; +} + +/* ========================================================================== + Typography + ========================================================================== */ + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari 5, and Chrome. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9, Safari 5, and Chrome. + */ + +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. + */ + +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari 5 and Chrome. + */ + +dfn { + font-style: italic; +} + +/** + * Address differences between Firefox and other browsers. + */ + +hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +mark { + background: #ff0; + color: #000; +} + +/** + * Correct font family set oddly in Safari 5 and Chrome. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, serif; + font-size: 1em; +} + +/** + * Improve readability of pre-formatted text in all browsers. + */ + +pre { + white-space: pre-wrap; +} + +/** + * Set consistent quote types. + */ + +q { + quotes: "\201C" "\201D" "\2018" "\2019"; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* ========================================================================== + Embedded content + ========================================================================== */ + +/** + * Remove border when inside `a` element in IE 8/9. + */ + +img { + border: 0; +} + +/** + * Correct overflow displayed oddly in IE 9. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* ========================================================================== + Figures + ========================================================================== */ + +/** + * Address margin not present in IE 8/9 and Safari 5. + */ + +figure { + margin: 0; +} + +/* ========================================================================== + Forms + ========================================================================== */ + +/** + * Define consistent border, margin, and padding. + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ + +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * 1. Correct font family not being inherited in all browsers. + * 2. Correct font size not being inherited in all browsers. + * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. + */ + +button, +input, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ + +button, +input { + line-height: normal; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. + * Correct `select` style inheritance in Firefox 4+ and Opera. + */ + +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; /* 2 */ + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * 1. Address box sizing set to `content-box` in IE 8/9. + * 2. Remove excess padding in IE 8/9. + */ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome + * (include `-moz` to future-proof). + */ + +input[type="search"] { + -webkit-appearance: textfield; /* 1 */ + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; /* 2 */ + box-sizing: content-box; +} + +/** + * Remove inner padding and search cancel button in Safari 5 and Chrome + * on OS X. + */ + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * 1. Remove default vertical scrollbar in IE 8/9. + * 2. Improve readability and alignment in all browsers. + */ + +textarea { + overflow: auto; /* 1 */ + vertical-align: top; /* 2 */ +} + +/* ========================================================================== + Tables + ========================================================================== */ + +/** + * Remove most spacing between table cells. + */ + +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/app/controllers/admin/admin_controller.rb b/app/controllers/admin/admin_controller.rb new file mode 100644 index 00000000000..78e6d27695e --- /dev/null +++ b/app/controllers/admin/admin_controller.rb @@ -0,0 +1,16 @@ +class Admin::AdminController < ApplicationController + + before_filter :ensure_logged_in + before_filter :ensure_is_admin + + def index + render nothing: true + end + + protected + + def ensure_is_admin + raise Discourse::InvalidAccess.new unless current_user.admin? + end + +end diff --git a/app/controllers/admin/email_logs_controller.rb b/app/controllers/admin/email_logs_controller.rb new file mode 100644 index 00000000000..edfe3f35c4c --- /dev/null +++ b/app/controllers/admin/email_logs_controller.rb @@ -0,0 +1,15 @@ +class Admin::EmailLogsController < Admin::AdminController + + def index + @email_logs = EmailLog.limit(50).includes(:user).order('created_at desc').all + + render_serialized(@email_logs, EmailLogSerializer) + end + + def test + requires_parameter(:email_address) + Jobs.enqueue(:test_email, to_address: params[:email_address]) + render nothing: true + end + +end \ No newline at end of file diff --git a/app/controllers/admin/export_controller.rb b/app/controllers/admin/export_controller.rb new file mode 100644 index 00000000000..36b7fd2a1eb --- /dev/null +++ b/app/controllers/admin/export_controller.rb @@ -0,0 +1,10 @@ +class Admin::ExportController < Admin::AdminController + def create + unless Export.is_export_running? or Import.is_import_running? + job_id = Jobs.enqueue( :exporter, user_id: current_user.id ) + render json: success_json.merge( job_id: job_id ) + else + render json: failed_json.merge( message: "An #{Export.is_export_running? ? 'export' : 'import'} is currently running. Can't start a new export job right now.") + end + end +end \ No newline at end of file diff --git a/app/controllers/admin/flags_controller.rb b/app/controllers/admin/flags_controller.rb new file mode 100644 index 00000000000..d20f268d422 --- /dev/null +++ b/app/controllers/admin/flags_controller.rb @@ -0,0 +1,103 @@ +require_dependency 'sql_builder' + +class Admin::FlagsController < Admin::AdminController + def index + + + sql = SqlBuilder.new "select p.id, t.title, p.cooked, p.user_id, p.topic_id, p.post_number, p.hidden, t.visible topic_visible +from posts p +join topics t on t.id = topic_id +join ( + select + post_id, + count(*) as cnt, + max(created_at) max, + min(created_at) min + from post_actions + /*where2*/ + group by post_id +) as a on a.post_id = p.id +/*where*/ +/*order_by*/ +limit 100 +" + + sql.where2 "post_action_type_id in (:flag_types)", flag_types: PostActionType.FlagTypes + + if params[:filter] == 'old' + sql.where "p.deleted_at is null" + sql.where2 "deleted_at is not null" + else + sql.where "p.deleted_at is null" + sql.where2 "deleted_at is null" + end + + if params[:filter] == 'old' + sql.order_by "max desc" + else + sql.order_by "cnt desc, max asc" + end + + posts = sql.exec.to_a + + if posts.length == 0 + render :json => {users: [], posts: []} + return + end + + map = {} + users = Set.new + + posts.each{ |p| + users << p["user_id"] + p["excerpt"] = Post.excerpt(p["cooked"]) + p.delete "cooked" + p[:topic_slug] = Slug.for(p["title"]) + map[p["id"]] = p + } + + sql = SqlBuilder.new "select a.id, a.user_id, post_action_type_id, a.created_at, post_id, a.message +from post_actions a +/*where*/ +" + sql.where("post_action_type_id in (:flag_types)", flag_types: PostActionType.FlagTypes) + sql.where("post_id in (:posts)", posts: posts.map{|p| p["id"].to_i}) + + if params[:filter] == 'old' + sql.where('deleted_at is not null') + else + sql.where('deleted_at is null') + end + + actions = sql.exec.each do |action| + p = map[action["post_id"]] + p[:post_actions] ||= [] + p[:post_actions] << action + + users << action["user_id"] + end + + sql = +"select id, username, name, email from users +where id in (?)" + + users = User.exec_sql(sql, users.to_a).to_a + + users.each { |u| + u["avatar_template"] = User.avatar_template(u["email"]) + u.delete("email") + } + + render json: MultiJson.dump({users: users, posts: posts}) + + end + + def clear + p = Post.find(params[:id]) + PostAction.clear_flags!(p, current_user.id) + p.hidden = false + p.hidden_reason_id = nil + p.save + render nothing: true + end +end diff --git a/app/controllers/admin/impersonate_controller.rb b/app/controllers/admin/impersonate_controller.rb new file mode 100644 index 00000000000..be422d8a274 --- /dev/null +++ b/app/controllers/admin/impersonate_controller.rb @@ -0,0 +1,20 @@ +class Admin::ImpersonateController < Admin::AdminController + + def create + requires_parameters(:username_or_email) + + user = User.where(['username_lower = lower(?) or lower(email) = lower(?) or lower(name) = lower(?)', + params[:username_or_email], + params[:username_or_email], + params[:username_or_email]]).first + raise Discourse::NotFound if user.blank? + + guardian.ensure_can_impersonate!(user) + + # Log on as the user + log_on_user(user) + + render nothing: true + end + +end diff --git a/app/controllers/admin/site_customizations_controller.rb b/app/controllers/admin/site_customizations_controller.rb new file mode 100644 index 00000000000..f98899febb6 --- /dev/null +++ b/app/controllers/admin/site_customizations_controller.rb @@ -0,0 +1,45 @@ +class Admin::SiteCustomizationsController < Admin::AdminController + + def index + @site_customizations = SiteCustomization.all + + respond_to do |format| + format.json { render json: @site_customizations } + end + end + + def create + @site_customization = SiteCustomization.new(params[:site_customization]) + @site_customization.user_id = current_user.id + + respond_to do |format| + if @site_customization.save + format.json { render json: @site_customization, status: :created} + else + format.json { render json: @site_customization.errors, status: :unprocessable_entity } + end + end + end + + def update + @site_customization = SiteCustomization.find(params[:id]) + + respond_to do |format| + if @site_customization.update_attributes(params[:site_customization]) + format.json { head :no_content } + else + format.json { render json: @site_customization.errors, status: :unprocessable_entity } + end + end + end + + def destroy + @site_customization = SiteCustomization.find(params[:id]) + @site_customization.destroy + + respond_to do |format| + format.json { head :no_content } + end + end + +end diff --git a/app/controllers/admin/site_settings_controller.rb b/app/controllers/admin/site_settings_controller.rb new file mode 100644 index 00000000000..93db8fe1217 --- /dev/null +++ b/app/controllers/admin/site_settings_controller.rb @@ -0,0 +1,14 @@ +class Admin::SiteSettingsController < Admin::AdminController + + def index + @site_settings = SiteSetting.all_settings + render_json_dump(@site_settings.as_json) + end + + def update + requires_parameter(:value) + SiteSetting.send("#{params[:id]}=", params[:value]) + render nothing: true + end + +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 00000000000..f9c842b199d --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,76 @@ +class Admin::UsersController < Admin::AdminController + + def index + # Sort order + if params[:query] == "active" + @users = User.order("COALESCE(last_seen_at, '01-01-1970') DESC, username") + else + @users = User.order("created_at DESC, username") + end + + @users = @users.where('approved = false') if params[:query] == 'pending' + @users = @users.where('username_lower like :filter or email like :filter', filter: "%#{params[:filter]}%") if params[:filter].present? + @users = @users.take(100) + render_serialized(@users, AdminUserSerializer) + end + + def show + @user = User.where(username_lower: params[:id]).first + render_serialized(@user, AdminDetailedUserSerializer, root: false) + end + + def ban + @user = User.where(id: params[:user_id]).first + guardian.ensure_can_ban!(@user) + @user.banned_till = params[:duration].to_i.days.from_now + @user.banned_at = DateTime.now + @user.save! + # TODO logging + render nothing: true + end + + def unban + @user = User.where(id: params[:user_id]).first + guardian.ensure_can_ban!(@user) + @user.banned_till = nil + @user.banned_at = nil + @user.save! + # TODO logging + render nothing: true + end + + def refresh_browsers + @user = User.where(id: params[:user_id]).first + MessageBus.publish "/file-change", ["refresh"], user_ids: [@user.id] + end + + def revoke_admin + @admin = User.where(id: params[:user_id]).first + guardian.ensure_can_revoke_admin!(@admin) + @admin.update_column(:admin, false) + render nothing: true + end + + def grant_admin + @user = User.where(id: params[:user_id]).first + guardian.ensure_can_grant_admin!(@user) + @user.update_column(:admin, true) + render_serialized(@user, AdminUserSerializer) + end + + def approve + @user = User.where(id: params[:user_id]).first + guardian.ensure_can_approve!(@user) + @user.approve(current_user) + render nothing: true + end + + def approve_bulk + User.where(id: params[:users]).each do |u| + u.approve(current_user) if guardian.can_approve?(u) + end + render nothing: true + end + +end + diff --git a/app/controllers/admin/versions_controller.rb b/app/controllers/admin/versions_controller.rb new file mode 100644 index 00000000000..54ba0aa0582 --- /dev/null +++ b/app/controllers/admin/versions_controller.rb @@ -0,0 +1,15 @@ +require_dependency 'mothership' +require_dependency 'version' + +class Admin::VersionsController < Admin::AdminController + def show + if SiteSetting.discourse_org_access_key.present? + render json: success_json.merge( latest_version: Mothership.current_discourse_version, installed_version: Discourse::VERSION::STRING ) + else + # Don't contact discourse.org + render json: success_json.merge( latest_version: Discourse::VERSION::STRING, installed_version: Discourse::VERSION::STRING ) + end + rescue RestClient::Forbidden + render json: {errors: [I18n.t("mothership.access_token_problem")]} + end +end \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 00000000000..ccdcf236738 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,240 @@ +require 'current_user' +require_dependency 'discourse' +require_dependency 'custom_renderer' +require 'archetype' +require_dependency 'rate_limiter' + +class ApplicationController < ActionController::Base + include CurrentUser + + serialization_scope :guardian + + protect_from_forgery + + before_filter :inject_preview_style + before_filter :block_if_maintenance_mode + before_filter :check_restricted_access + before_filter :authorize_mini_profiler + before_filter :store_incoming_links + before_filter :preload_json + before_filter :check_xhr + + rescue_from Exception do |exception| + unless [ ActiveRecord::RecordNotFound, ActionController::RoutingError, + ActionController::UnknownController, AbstractController::ActionNotFound].include? exception.class + begin + ErrorLog.report_async!(exception, self, request, current_user) + rescue + # dont care give up + end + end + raise + end + + + # Some exceptions + class RenderEmpty < Exception; end + class NotLoggedIn < Exception; end + + # Render nothing unless we are an xhr request + rescue_from RenderEmpty do + render 'default/empty' + end + + # If they hit the rate limiter + rescue_from RateLimiter::LimitExceeded do |e| + + time_left = "" + if e.available_in < 1.minute.to_i + time_left = I18n.t("rate_limiter.seconds", count: e.available_in) + elsif e.available_in < 1.hour.to_i + time_left = I18n.t("rate_limiter.minutes", count: (e.available_in / 1.minute.to_i)) + else + time_left = I18n.t("rate_limiter.hours", count: (e.available_in / 1.hour.to_i)) + end + + render json: {errors: [I18n.t("rate_limiter.too_many_requests", time_left: time_left)]}, status: 429 + end + + rescue_from Discourse::NotLoggedIn do |e| + raise e if Rails.env.test? + redirect_to root_path + end + + rescue_from Discourse::NotFound do + if request.format.html? + # for now do a simple remap, we may look at cleaner ways of doing the render + raise ActiveRecord::RecordNotFound + else + render file: 'public/404', layout: false, status: 404 + end + end + + rescue_from Discourse::InvalidAccess do + render file: 'public/403', layout: false, status: 403 + end + + def store_preloaded(key, json) + @preloaded ||= {} + @preloaded[key] = json + end + + # If we are rendering HTML, preload the session data + def preload_json + if request.format.html? + if guardian.current_user + guardian.current_user.sync_notification_channel_position + end + + store_preloaded("site", Site.cached_json) + + if current_user.present? + store_preloaded("currentUser", MultiJson.dump(CurrentUserSerializer.new(current_user, root: false))) + end + store_preloaded("siteSettings", SiteSetting.client_settings_json) + end + end + + + def inject_preview_style + style = request['preview-style'] + session[:preview_style] = style if style + end + + def guardian + @guardian ||= Guardian.new(current_user) + end + + def log_on_user(user) + session[:current_user_id] = user.id + unless user.auth_token + user.auth_token = SecureRandom.hex(16) + user.save! + end + cookies.permanent[:_t] = user.auth_token + end + + # This is odd, but it seems that in Rails `render json: obj` is about + # 20% slower than calling MultiJSON.dump ourselves. I'm not sure why + # Rails doesn't call MultiJson.dump when you pass it json: obj but + # it seems we don't need whatever Rails is doing. + def render_serialized(obj, serializer, opts={}) + + # If it's an array, apply the serializer as an each_serializer to the elements + serializer_opts = {scope: guardian}.merge!(opts) + if obj.is_a?(Array) + serializer_opts[:each_serializer] = serializer + render_json_dump(ActiveModel::ArraySerializer.new(obj, serializer_opts).as_json) + else + render_json_dump(serializer.new(obj, serializer_opts).as_json) + end + + end + + def render_json_dump(obj) + render json: MultiJson.dump(obj) + end + + # Helper method - if no logged in user (anonymous), use Rails' conditional GET + # support. Should be very fast behind a cache. + def anonymous_etag(*args) + if current_user.blank? and Rails.env.production? + yield if stale?(*args) + + # Add a one minute expiry + expires_in 1.minute, :public => true + else + yield + end + end + + private + + def render_json_error(obj) + if obj.present? + render json: MultiJson.dump(errors: obj.errors.full_messages), status: 422 + else + render json: MultiJson.dump(errors: [I18n.t('js.generic_error')]), status: 422 + end + end + + def success_json + {success: 'OK'} + end + + def failed_json + {failed: 'FAILED'} + end + + def json_result(obj, opts={}) + if yield(obj) + + json = success_json + + # If we were given a serializer, add the class to the json that comes back + if opts[:serializer].present? + json[obj.class.name.underscore] = opts[:serializer].new(obj).serializable_hash + end + + render json: MultiJson.dump(json) + else + render_json_error(obj) + end + end + + def block_if_maintenance_mode + if Discourse.maintenance_mode? + if request.format.json? + render status: 503, json: failed_json.merge( message: 'Site is currently undergoing maintenance.' ) + else + render status: 503, file: File.join( Rails.root, 'public', '503.html' ), layout: false + end + end + end + + def check_restricted_access + # note current_user is defined in the CurrentUser mixin + if SiteSetting.restrict_access? && cookies[:_access] != SiteSetting.access_password + redirect_to request_access_path(:return_path => request.fullpath) + return false + end + end + + def mini_profiler_enabled? + defined?(Rack::MiniProfiler) and current_user.try(:admin?) + end + + def authorize_mini_profiler + return unless mini_profiler_enabled? + Rack::MiniProfiler.authorize_request + end + + def requires_parameters(*required) + required.each do |p| + raise Discourse::InvalidParameters.new(p) unless params.has_key?(p) + end + end + + alias :requires_parameter :requires_parameters + + def store_incoming_links + if request.referer.present? + parsed = URI.parse(request.referer) + if parsed.host != request.host + IncomingLink.create(url: request.url, referer: request.referer[0..999]) + end + end + end + + def check_xhr + unless (controller_name == 'forums' || controller_name == 'user_open_ids') + # render 'default/empty' unless ((request.format && request.format.json?) or request.xhr?) + raise RenderEmpty.new unless ((request.format && request.format.json?) or request.xhr?) + end + end + + def ensure_logged_in + raise Discourse::NotLoggedIn.new unless current_user.present? + end + +end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb new file mode 100644 index 00000000000..fcf0b7770b7 --- /dev/null +++ b/app/controllers/categories_controller.rb @@ -0,0 +1,52 @@ +require_dependency 'category_serializer' + +class CategoriesController < ApplicationController + + before_filter :ensure_logged_in, except: [:index, :show] + + def index + list = CategoryList.new(current_user) + render_serialized(list, CategoryListSerializer) + end + + def show + @category = Category.where(slug: params[:id]).first + render_serialized(@category, CategorySerializer) + end + + def create + requires_parameters(*category_param_keys) + guardian.ensure_can_create!(Category) + + @category = Category.create(category_params.merge(user: current_user)) + return render_json_error(@category) unless @category.save + + render_serialized(@category, CategorySerializer) + end + + def update + requires_parameters(*category_param_keys) + + @category = Category.where(id: params[:id]).first + guardian.ensure_can_edit!(@category) + + json_result(@category, :serializer => CategorySerializer) {|cat| cat.update_attributes(category_params) } + end + + def destroy + category = Category.where(slug: params[:id]).first + guardian.ensure_can_delete!(category) + category.destroy + render nothing: true + end + + private + + def category_param_keys + [:name, :color] + end + + def category_params + params.slice(*category_param_keys) + end +end diff --git a/app/controllers/clicks_controller.rb b/app/controllers/clicks_controller.rb new file mode 100644 index 00000000000..a72a92049f0 --- /dev/null +++ b/app/controllers/clicks_controller.rb @@ -0,0 +1,25 @@ +class ClicksController < ApplicationController + + skip_before_filter :check_xhr + + def track + requires_parameter(:url) + if params[:topic_id].present? or params[:post_id].present? + args = {url: params[:url], ip: request.remote_ip} + args[:user_id] = current_user.id if current_user.present? + args[:post_id] = params[:post_id].to_i if params[:post_id].present? + args[:topic_id] = params[:topic_id].to_i if params[:topic_id].present? + + TopicLinkClick.create_from(args) + end + + # Sometimes we want to record a link without a 302. Since XHR has to load the redirected + # URL we want it to not return a 302 in those cases. + if params[:redirect] == 'false' + render nothing: true + else + redirect_to(params[:url]) + end + end + +end \ No newline at end of file diff --git a/app/controllers/draft_controller.rb b/app/controllers/draft_controller.rb new file mode 100644 index 00000000000..f45929c0b87 --- /dev/null +++ b/app/controllers/draft_controller.rb @@ -0,0 +1,20 @@ +class DraftController < ApplicationController + before_filter :ensure_logged_in + skip_before_filter :check_xhr + + def show + seq = params[:sequence] || DraftSequence.current(current_user, params[:draft_key]) + render :json => {draft: Draft.get(current_user, params[:draft_key], seq), draft_sequence: seq} + end + + def update + Draft.set(current_user, params[:draft_key], params[:sequence].to_i, params[:data]) + render :text => 'ok' + end + + def destroy + Draft.clear(current_user, params[:draft_key], params[:sequence].to_i) + render :text => 'ok' + end + +end diff --git a/app/controllers/email_controller.rb b/app/controllers/email_controller.rb new file mode 100644 index 00000000000..aa4ab33f0c6 --- /dev/null +++ b/app/controllers/email_controller.rb @@ -0,0 +1,30 @@ +class EmailController < ApplicationController + skip_before_filter :check_xhr + layout 'no_js' + + before_filter :ensure_logged_in, only: :preferences_redirect + + def preferences_redirect + redirect_to(email_preferences_path(current_user.username_lower)) + end + + def unsubscribe + @user = User.find_by_temporary_key(params[:key]) + + # Don't allow the use of a key while logged in as a different user + @user = nil if current_user.present? and (@user != current_user) + + if @user.present? + @user.update_column(:email_digests, false) + else + @not_found = true + end + end + + def resubscribe + @user = User.find_by_temporary_key(params[:key]) + raise Discourse::NotFound unless @user.present? + @user.update_column(:email_digests, true) + end + +end diff --git a/app/controllers/exceptions_controller.rb b/app/controllers/exceptions_controller.rb new file mode 100644 index 00000000000..1e3137e2fd2 --- /dev/null +++ b/app/controllers/exceptions_controller.rb @@ -0,0 +1,15 @@ +class ExceptionsController < ApplicationController + skip_before_filter :check_xhr + skip_before_filter :check_restricted_access + layout 'no_js' + + def not_found + f = Topic.where(deleted_at: nil, archetype: "regular") + + @popular = f.order('views desc').take(10) + @recent = f.order('created_at desc').take(10) + @slug = params[:slug].class == String ? params[:slug] : '' + @slug.gsub!('-',' ') + render status: 404 + end +end diff --git a/app/controllers/excerpt_controller.rb b/app/controllers/excerpt_controller.rb new file mode 100644 index 00000000000..ec8f0819185 --- /dev/null +++ b/app/controllers/excerpt_controller.rb @@ -0,0 +1,40 @@ +require_dependency 'post_excerpt_serializer' + +class ExcerptController < ApplicationController + + + def show + requires_parameter(:url) + + uri = URI.parse(params[:url]) + route = Rails.application.routes.recognize_path(uri.path) + + case route[:controller] + when 'topics' + + # If we have a post number, retrieve the last post. Otherwise, first post. + topic_posts = Post.where(topic_id: route[:topic_id].to_i).order(:post_number) + post = route.has_key?(:post_number) ? topic_posts.last : topic_posts.first + guardian.ensure_can_see!(post) + + render :json => post, serializer: PostExcerptSerializer, root: false + when 'users' + user = User.where(username_lower: route[:username].downcase).first + guardian.ensure_can_see!(user) + render :json => user, serializer: UserExcerptSerializer, root: false + when 'list' + if route[:action] == 'category' + category = Category.where(slug: route[:category]).first + guardian.ensure_can_see!(category) + render :json => category, serializer: CategoryExcerptSerializer, root: false + end + else + render nothing: true, status: 404 + end + + rescue ActionController::RoutingError, Discourse::NotFound + render nothing: true, status: 404 + end + + +end diff --git a/app/controllers/facebook_controller.rb b/app/controllers/facebook_controller.rb new file mode 100644 index 00000000000..ec7648c0f36 --- /dev/null +++ b/app/controllers/facebook_controller.rb @@ -0,0 +1,93 @@ +class FacebookController < ApplicationController + skip_before_filter :check_xhr, only: [:frame, :complete] + layout false + + def frame + redirect_to oauth_consumer.url_for_oauth_code(:permissions => "email") + end + + def complete + consumer = oauth_consumer + token = consumer.get_access_token(params[:code]) + + graph = Koala::Facebook::API.new(token) + me = graph.get_object("me") + + email = me["email"] + verified = me["verified"] + + name = me["name"] + username = User.suggest_username(me["username"]) + + verified = me["verified"] + + # non verified accounts are just trouble + unless verified + render text: "Your account must be verified with facebook, before authenticating with facebook" + return + end + + session[:authentication] = { + facebook: { + facebook_user_id: me["id"], + link: me["link"], + username: me["username"], + first_name: me["first_name"], + last_name: me["last_name"], + email: me["email"], + gender: me["gender"], + name: me["name"] + }, + email: me["email"], + email_valid: true + } + + user_info = FacebookUserInfo.where(:facebook_user_id => me["id"]).first + + @data = { + username: username, + name: name, + email: email, + auth_provider: "Facebook", + email_valid: true + } + + if user_info + user = user_info.user + if user + unless user.active + user.active = true + user.save + end + log_on_user(user) + @data[:authenticated] = true + end + else + user = User.where(email: me["email"]).first + if user + FacebookUserInfo.create!(session[:authentication][:facebook].merge(user_id: user.id)) + unless user.active + user.active = true + user.save + end + log_on_user(user) + @data[:authenticated] = true + end + end + + end + + + protected + + def oauth_consumer + require 'koala' + + host = request.host + host = "#{host}:#{request.port}" if request.port != 80 + callback_url = "http://#{host}/facebook/complete" + + oauth = Koala::Facebook::OAuth.new(SiteSetting.facebook_app_id, SiteSetting.facebook_app_secret, callback_url) + end + +end diff --git a/app/controllers/faq_controller.rb b/app/controllers/faq_controller.rb new file mode 100644 index 00000000000..82d580d2174 --- /dev/null +++ b/app/controllers/faq_controller.rb @@ -0,0 +1,9 @@ +class FaqController < ApplicationController + + skip_before_filter :check_xhr + + def index + render layout: false + end + +end \ No newline at end of file diff --git a/app/controllers/forums_controller.rb b/app/controllers/forums_controller.rb new file mode 100644 index 00000000000..b8cd6c9828c --- /dev/null +++ b/app/controllers/forums_controller.rb @@ -0,0 +1,19 @@ +class ForumsController < ApplicationController + + skip_before_filter :check_xhr, only: [:request_access, :request_access_submit, :status] + skip_before_filter :check_restricted_access, only: [:status] + skip_before_filter :authorize_mini_profiler, only: [:status] + + def status + if $shutdown + render :text => 'shutting down', :status => 500 + else + render :text => 'ok' + end + end + + def error + raise "WAT - #{Time.now.to_s}" + end + +end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb new file mode 100644 index 00000000000..1c1b817b6ac --- /dev/null +++ b/app/controllers/invites_controller.rb @@ -0,0 +1,41 @@ +class InvitesController < ApplicationController + + skip_before_filter :check_xhr, :check_restricted_access + before_filter :ensure_logged_in, only: [:destroy] + + def show + invite = Invite.where(invite_key: params[:id]).first + + if invite.present? + user = invite.redeem + if user.present? + log_on_user(user) + + # Send a welcome message if required + user.enqueue_welcome_message('welcome_invite') if user.send_welcome_message + + # We skip the access password if we come in via an invite link + cookies.permanent['_access'] = SiteSetting.access_password if SiteSetting.restrict_access? + + topic = invite.topics.first + if topic.present? + redirect_to topic.relative_url + return + end + end + end + + redirect_to root_path + end + + def destroy + requires_parameter(:email) + + invite = Invite.where(invited_by_id: current_user.id, email: params[:email]).first + raise Discourse::InvalidParameters.new(:email) if invite.blank? + invite.destroy + + render nothing: true + end + +end diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb new file mode 100644 index 00000000000..005b3d14c09 --- /dev/null +++ b/app/controllers/list_controller.rb @@ -0,0 +1,80 @@ +class ListController < ApplicationController + + before_filter :ensure_logged_in, except: [:index, :category] + skip_before_filter :check_xhr + + # Create our filters + [:popular, :favorited, :read, :posted, :unread, :new].each do |filter| + define_method(filter) do + + list_opts = {page: params[:page]} + + # html format means we need to farm exclude from the site options + if params[:format].blank? or params[:format] == "html" + #TODO objectify this stuff + SiteSetting.top_menu.split('|').each do |f| + s = f.split(",") + if s[0] == action_name || (action_name == "index" && s[0] == "popular") + list_opts[:exclude_category] = s[1][1..-1] if s.length == 2 + end + end + end + list_opts[:exclude_category] = params[:exclude_category] if params[:exclude_category].present? + + list = TopicQuery.new(current_user, list_opts).send("list_#{filter}") + list.more_topics_url = url_for(self.send "#{filter}_path".to_sym, list_opts.merge(format: 'json', page: next_page)) + + respond(list) + end + end + alias_method :index, :popular + + def category + + query = TopicQuery.new(current_user, page: params[:page]) + list = nil + + # If they choose uncategorized, return topics NOT in a category + if params[:category] == SiteSetting.uncategorized_name + list = query.list_uncategorized + else + category = Category.where(slug: params[:category]).includes(:featured_users).first + guardian.ensure_can_see!(category) + list = query.list_category(category) + end + + list.more_topics_url = url_for(category_path(params[:category], page: next_page, format: "json")) + respond(list) + end + + protected + + + def respond(list) + + list.draft_key = Draft::NEW_TOPIC + list.draft_sequence = DraftSequence.current(current_user, Draft::NEW_TOPIC) + + draft = Draft.get(current_user, list.draft_key, list.draft_sequence) if current_user + list.draft = draft + + # Add expiry of 1 minute for anonymous + expires_in 1.minute, :public => true if current_user.blank? + + respond_to do |format| + format.html do + @list = list + store_preloaded('topic_list', MultiJson.dump(TopicListSerializer.new(list, scope: guardian))) + render 'list' + end + format.json do + render_serialized(list, TopicListSerializer) + end + end + end + + def next_page + params[:page].to_i + 1 + end + +end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb new file mode 100644 index 00000000000..8c94cc4c14a --- /dev/null +++ b/app/controllers/notifications_controller.rb @@ -0,0 +1,14 @@ +class NotificationsController < ApplicationController + + before_filter :ensure_logged_in + + def index + notifications = current_user.notifications.recent.includes(:topic).all + current_user.saw_notification_id(notifications.first.id) if notifications.present? + current_user.reload + current_user.publish_notifications_state + + render_serialized(notifications, NotificationSerializer) + end + +end diff --git a/app/controllers/onebox_controller.rb b/app/controllers/onebox_controller.rb new file mode 100644 index 00000000000..9eb72cd3c1b --- /dev/null +++ b/app/controllers/onebox_controller.rb @@ -0,0 +1,10 @@ +require_dependency 'oneboxer' + +class OneboxController < ApplicationController + + def show + Oneboxer.invalidate(params[:url]) if params[:refresh].present? + render text: Oneboxer.preview(params[:url]) + end + +end diff --git a/app/controllers/post_actions_controller.rb b/app/controllers/post_actions_controller.rb new file mode 100644 index 00000000000..9031a4d03e6 --- /dev/null +++ b/app/controllers/post_actions_controller.rb @@ -0,0 +1,55 @@ +require_dependency 'discourse' + +class PostActionsController < ApplicationController + + before_filter :ensure_logged_in, except: :users + before_filter :fetch_post_from_params + + def create + id = params[:post_action_type_id].to_i + if action = PostActionType.where(id: id).first + guardian.ensure_post_can_act!(@post, PostActionType.Types.invert[id]) + PostAction.act(current_user, @post, action.id, params[:message]) + + # We need to reload or otherwise we are showing the old values on the front end + @post.reload + + post_serializer = PostSerializer.new(@post, scope: guardian, root: false) + render_json_dump(post_serializer) + else + raise Discourse::InvalidParameters.new(:post_action_type_id) + end + end + + def users + requires_parameter(:post_action_type_id) + post_action_type_id = params[:post_action_type_id].to_i + + guardian.ensure_can_see_post_actors!(@post.topic, post_action_type_id) + + users = User. + joins(:post_actions). + where(["post_actions.post_id = ? and post_actions.post_action_type_id = ? and post_actions.deleted_at IS NULL", @post.id, post_action_type_id]).all + + render_serialized(users, BasicUserSerializer) + end + + def destroy + requires_parameter(:post_action_type_id) + + post_action = current_user.post_actions.where(post_id: params[:id].to_i, post_action_type_id: params[:post_action_type_id].to_i, deleted_at: nil).first + raise Discourse::NotFound if post_action.blank? + guardian.ensure_can_delete!(post_action) + PostAction.remove_act(current_user, @post, post_action.post_action_type_id) + + render nothing: true + end + + private + + def fetch_post_from_params + requires_parameter(:id) + @post = Post.where(id: params[:id]).first + guardian.ensure_can_see!(@post) + end +end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb new file mode 100644 index 00000000000..f905f8f013c --- /dev/null +++ b/app/controllers/posts_controller.rb @@ -0,0 +1,138 @@ +require_dependency 'post_creator' + +class PostsController < ApplicationController + + # Need to be logged in for all actions here + before_filter :ensure_logged_in, except: [:show, :replies, :by_number] + + + def create + requires_parameter(:post) + + post_creator = PostCreator.new(current_user, + raw: params[:post][:raw], + topic_id: params[:post][:topic_id], + title: params[:title], + archetype: params[:archetype], + category: params[:post][:category], + target_usernames: params[:target_usernames], + reply_to_post_number: params[:post][:reply_to_post_number], + image_sizes: params[:image_sizes], + meta_data: params[:meta_data]) + post = post_creator.create + + if post_creator.errors.present? + render_json_error(post_creator) + else + post_serializer = PostSerializer.new(post, scope: guardian, root: false) + post_serializer.topic_slug = post.topic.slug if post.topic.present? + post_serializer.draft_sequence = DraftSequence.current(current_user, post.topic.draft_key) + render_json_dump(post_serializer) + end + + end + + def update + requires_parameter(:post) + + @post = Post.where(id: params[:id]).first + @post.image_sizes = params[:image_sizes] if params[:image_sizes].present? + guardian.ensure_can_edit!(@post) + if @post.revise(current_user, params[:post][:raw]) + TopicLink.extract_from(@post) + end + + if @post.errors.present? + render_json_error(@post) + return + end + + post_serializer = PostSerializer.new(@post, scope: guardian, root: false) + post_serializer.draft_sequence = DraftSequence.current(current_user, @post.topic.draft_key) + link_counts = TopicLinkClick.counts_for(@post.topic, [@post]) + post_serializer.single_post_link_counts = link_counts[@post.id] if link_counts.present? + render_json_dump(post_serializer) + end + + def by_number + @post = Post.where(topic_id: params[:topic_id], post_number: params[:post_number]).first + guardian.ensure_can_see!(@post) + @post.revert_to(params[:version].to_i) if params[:version].present? + post_serializer = PostSerializer.new(@post, scope: guardian, root: false) + post_serializer.add_raw = true + render_json_dump(post_serializer) + end + + def show + @post = Post.where(id: params[:id]).first + guardian.ensure_can_see!(@post) + + @post.revert_to(params[:version].to_i) if params[:version].present? + post_serializer = PostSerializer.new(@post, scope: guardian, root: false) + post_serializer.add_raw = true + render_json_dump(post_serializer) + end + + def destroy + Post.transaction do + post = Post.with_deleted.where(id: params[:id]).first + guardian.ensure_can_delete!(post) + if post.deleted_at.nil? + post.destroy + else + post.recover + end + Topic.reset_highest(post.topic_id) + end + render nothing: true + end + + def destroy_many + + requires_parameters(:post_ids) + + posts = Post.where(id: params[:post_ids]) + raise Discourse::InvalidParameters.new(:post_ids) if posts.blank? + + # Make sure we can delete the posts + posts.each {|p| guardian.ensure_can_delete!(p) } + + Post.transaction do + topic_id = posts.first.topic_id + posts.each {|p| p.destroy } + Topic.reset_highest(topic_id) + end + + render nothing: true + end + + # Retrieves a list of versions and who made them for a post + def versions + post = Post.where(id: params[:post_id]).first + guardian.ensure_can_see!(post) + + render_serialized(post.all_versions, VersionSerializer) + end + + # Direct replies to this post + def replies + post = Post.where(id: params[:post_id]).first + guardian.ensure_can_see!(post) + render_serialized(post.replies, PostSerializer) + end + + + def bookmark + post = Post.where(id: params[:post_id]).first + guardian.ensure_can_see!(post) + if current_user + if params[:bookmarked] == "true" + PostAction.act(current_user, post, PostActionType.Types[:bookmark]) + else + PostAction.remove_act(current_user, post, PostActionType.Types[:bookmark]) + end + end + render :nothing => true + end + +end diff --git a/app/controllers/privacy_controller.rb b/app/controllers/privacy_controller.rb new file mode 100644 index 00000000000..3432a7fabe4 --- /dev/null +++ b/app/controllers/privacy_controller.rb @@ -0,0 +1,9 @@ +class PrivacyController < ApplicationController + + skip_before_filter :check_xhr + + def index + render layout: false + end + +end \ No newline at end of file diff --git a/app/controllers/request_access_controller.rb b/app/controllers/request_access_controller.rb new file mode 100644 index 00000000000..d8a2f9d9669 --- /dev/null +++ b/app/controllers/request_access_controller.rb @@ -0,0 +1,22 @@ +class RequestAccessController < ApplicationController + + skip_before_filter :check_xhr, :check_restricted_access + + def new + @return_path = params[:return_path] || "/" + render layout: 'no_js' + end + + def create + @return_path = params[:return_path] || "/" + + if params[:password] == SiteSetting.access_password + cookies.permanent['_access'] = SiteSetting.access_password + redirect_to @return_path + else + flash[:error] = I18n.t(:'request_access.incorrect') + render :new, layout: 'no_js' + end + end + +end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb new file mode 100644 index 00000000000..5077dec26fe --- /dev/null +++ b/app/controllers/search_controller.rb @@ -0,0 +1,9 @@ +require_dependency 'search' + +class SearchController < ApplicationController + + def query + render_json_dump(Search.query(params[:term], params[:type_filter]).as_json) + end + +end diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb new file mode 100644 index 00000000000..2fb1af3b539 --- /dev/null +++ b/app/controllers/session_controller.rb @@ -0,0 +1,57 @@ +class SessionController < ApplicationController + + def create + requires_parameter(:login, :password) + + login = params[:login].downcase + login = login[1..-1] if login[0] == "@" + + if login =~ /@/ + @user = User.where(email: login).first + else + @user = User.where(username_lower: login).first + end + + if @user.present? + + # If the site requires user approval and the user is not approved yet + if SiteSetting.must_approve_users? and !@user.approved? + render :json => {error: I18n.t("login.not_approved")} + return + end + + # If their password is correct + if @user.confirm_password?(params[:password]) + log_on_user(@user) + render_serialized(@user, UserSerializer) + return + end + end + + render :json => {error: I18n.t("login.incorrect_username_email_or_password")} + end + + # Retrieve information about the site and session + def index + render_serialized(DiscourseSession.new, DiscourseSessionSerializer) + end + + def forgot_password + requires_parameter(:username) + + user = User.where('username_lower = :username or email = :username', username: params[:username].downcase).first + if user.present? + email_token = user.email_tokens.create(email: user.email) + Jobs.enqueue(:user_email, type: :forgot_password, user_id: user.id, email_token: email_token.token) + end + # always render of so we don't leak information + render :json => {result: "ok"} + end + + def destroy + session[:current_user_id] = nil + cookies[:_t] = nil + render nothing: true + end + +end diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb new file mode 100644 index 00000000000..396278912b8 --- /dev/null +++ b/app/controllers/static_controller.rb @@ -0,0 +1,22 @@ +class StaticController < ApplicationController + + skip_before_filter :check_xhr + + def show + + page = params[:id] + + # Don't allow paths like ".." or "/" or anything hacky like that + page.gsub!(/[^a-z0-9\_\-]/, '') + + file = "static/#{page}.html" + templates = lookup_context.find_all(file) + if templates.any? + render "static/#{page}", layout: !request.xhr?, formats: [:html] + return + end + + render file: 'public/404', layout: false, status: 404 + end + +end \ No newline at end of file diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb new file mode 100644 index 00000000000..9bb06739317 --- /dev/null +++ b/app/controllers/topics_controller.rb @@ -0,0 +1,252 @@ +require_dependency 'topic_view' +require_dependency 'promotion' + +class TopicsController < ApplicationController + + # Avatar is an image request, not XHR + before_filter :ensure_logged_in, only: [:timings, + :destroy_timings, + :update, + :star, + :destroy, + :status, + :invite, + :mute, + :unmute, + :set_notifications, + :move_posts] + + skip_before_filter :check_xhr, only: [:avatar, :show] + caches_action :avatar, :cache_path => Proc.new {|c| "#{c.params[:post_number]}-#{c.params[:topic_id]}" } + + def show + + # Consider the user for a promotion if they're new + if current_user.present? + Promotion.new(current_user).review if current_user.trust_level == TrustLevel.Levels[:new] + end + + @topic_view = TopicView.new(params[:id] || params[:topic_id], + current_user, + username_filters: params[:username_filters], + best_of: params[:best_of], + page: params[:page]) + + anonymous_etag(@topic_view.topic) do + # force the correct slug + if params[:slug] && @topic_view.topic.slug != params[:slug] + fullpath = request.fullpath + + split = fullpath.split('/') + split[2] = @topic_view.topic.slug + + redirect_to split.join('/'), status: 301 + return + end + + # Figure out what we're filter on + if params[:post_number].present? + # Get posts near a post + @topic_view.filter_posts_near(params[:post_number].to_i) + elsif params[:posts_before].present? + @topic_view.filter_posts_before(params[:posts_before].to_i) + elsif params[:posts_after].present? + @topic_view.filter_posts_after(params[:posts_after].to_i) + else + # No filter? Consider it a paged view, default to page 0 which is the first segment + @topic_view.filter_posts_paged(params[:page].to_i) + end + View.create_for(@topic_view.topic, request.remote_ip, current_user) + + @topic_view.draft_key = @topic_view.topic.draft_key + @topic_view.draft_sequence = DraftSequence.current(current_user, @topic_view.draft_key) + + if (!request.xhr? || params[:track_visit]) && current_user + TopicUser.track_visit! @topic_view.topic, current_user + @topic_view.draft = Draft.get(current_user, @topic_view.draft_key, @topic_view.draft_sequence) + end + + topic_view_serializer = TopicViewSerializer.new(@topic_view, scope: guardian, root: false) + + respond_to do |format| + format.html do + @canonical = "#{request.protocol}#{request.host_with_port}" + @topic_view.topic.relative_url + + if params[:post_number] + @post = @topic_view.posts.select{|p| p.post_number == params[:post_number].to_i}.first + page = ((params[:post_number].to_i - 1) / SiteSetting.posts_per_page) + 1 + @canonical << "?page=#{page}" if page > 1 + else + @canonical << "?page=#{params[:page]}" if params[:page] && params[:page].to_i > 1 + end + + last_post = @topic_view.posts[-1] + if last_post.present? and (@topic_view.topic.highest_post_number > last_post.post_number) + @next_page = (@topic_view.posts[0].post_number / SiteSetting.posts_per_page) + 2 + end + + store_preloaded("topic_#{@topic_view.topic.id}", MultiJson.dump(topic_view_serializer)) + end + + format.json do + render_json_dump(topic_view_serializer) + end + + end + end + + end + + def destroy_timings + PostTiming.destroy_for(current_user.id, params[:topic_id].to_i) + render nothing: true + end + + def update + topic = Topic.where(id: params[:topic_id]).first + guardian.ensure_can_edit!(topic) + topic.title = params[:title] if params[:title].present? + + # TODO: we may need smarter rules about converting archetypes + if current_user.admin? + topic.archetype = "regular" if params[:archetype] == 'regular' + end + + Topic.transaction do + topic.save + topic.change_category(params[:category]) + end + + render nothing: true + end + + def status + requires_parameters(:status, :enabled) + + raise Discourse::InvalidParameters.new(:status) unless %w(visible closed pinned archived).include?(params[:status]) + @topic = Topic.where(id: params[:topic_id].to_i).first + guardian.ensure_can_moderate!(@topic) + @topic.update_status(params[:status], (params[:enabled] == 'true'), current_user) + render nothing: true + end + + def star + @topic = Topic.where(id: params[:topic_id].to_i).first + guardian.ensure_can_see!(@topic) + + @topic.toggle_star(current_user, params[:starred] == 'true') + render nothing: true + end + + def mute + toggle_mute(true) + end + + def unmute + toggle_mute(false) + end + + + def destroy + topic = Topic.where(id: params[:id]).first + guardian.ensure_can_delete!(topic) + topic.destroy + render nothing: true + end + + def excerpt + render nothing: true + end + + def invite + requires_parameter(:user) + topic = Topic.where(id: params[:topic_id]).first + guardian.ensure_can_invite_to!(topic) + + if topic.invite(current_user, params[:user]) + render json: success_json + else + render json: failed_json, status: 422 + end + end + + def set_notifications + topic = Topic.find(params[:topic_id].to_i) + TopicUser.change(current_user, topic.id, notification_level: params[:notification_level].to_i) + render json: success_json + end + + def move_posts + requires_parameters(:title, :post_ids) + topic = Topic.where(id: params[:topic_id]).first + guardian.ensure_can_move_posts!(topic) + + # Move the posts + new_topic = topic.move_posts(current_user, params[:title], params[:post_ids].map {|p| p.to_i}) + + if new_topic.present? + render json: {success: true, url: new_topic.relative_url} + else + render json: {success: false} + end + end + + def timings + # TODO: all this should be optimised, tested better + + last_seen_key = "user-last-seen:#{current_user.id}" + last_seen = $redis.get(last_seen_key) + if last_seen.present? + diff = (Time.now.to_f - last_seen.to_f).round + if diff > 0 + User.update_all ["time_read = time_read + ?", diff], ["id = ? and time_read = ?", current_user.id, current_user.time_read] + end + end + $redis.set(last_seen_key, Time.now.to_f) + + original_unread = current_user.unread_notifications_by_type + + topic_id = params["topic_id"].to_i + highest_seen = params["highest_seen"].to_i + added_time = 0 + + + if params[:timings].present? + params[:timings].each do |post_number_str, t| + post_number = post_number_str.to_i + + if post_number >= 0 + if (highest_seen || 0) >= post_number + Notification.mark_post_read(current_user, topic_id, post_number) + end + + PostTiming.record_timing(topic_id: topic_id, + post_number: post_number, + user_id: current_user.id, + msecs: t.to_i) + end + end + end + + TopicUser.update_last_read(current_user, topic_id, highest_seen, params[:topic_time].to_i) + + current_user.reload + + if current_user.unread_notifications_by_type != original_unread + current_user.publish_notifications_state + end + + render nothing: true + end + + private + + def toggle_mute(v) + @topic = Topic.where(id: params[:topic_id].to_i).first + guardian.ensure_can_see!(@topic) + + @topic.toggle_mute(current_user, v) + render nothing: true + end + +end diff --git a/app/controllers/tos_controller.rb b/app/controllers/tos_controller.rb new file mode 100644 index 00000000000..0fbadf1b5b9 --- /dev/null +++ b/app/controllers/tos_controller.rb @@ -0,0 +1,9 @@ +class TosController < ApplicationController + + skip_before_filter :check_xhr + + def index + render layout: false + end + +end \ No newline at end of file diff --git a/app/controllers/twitter_controller.rb b/app/controllers/twitter_controller.rb new file mode 100644 index 00000000000..bd6d47d8519 --- /dev/null +++ b/app/controllers/twitter_controller.rb @@ -0,0 +1,85 @@ +class TwitterController < ApplicationController + skip_before_filter :check_xhr, only: [:frame, :complete] + layout false + + def frame + + # defer the require as late as possible + require 'oauth' + + consumer = oauth_consumer + host = request.host + host = "#{host}:#{request.port}" if request.port != 80 + request_token = consumer.get_request_token(:oauth_callback => "http://#{host}/twitter/complete") + + session[:request_token] = request_token.token + session[:request_token_secret] = request_token.secret + + redirect_to request_token.authorize_url + end + + def complete + + require 'oauth' + + consumer = oauth_consumer + + unless session[:request_token] && session[:request_token_secret] + render :text => ('No authentication information was found in the session. Please try again.') and return + end + + unless params[:oauth_token].blank? || session[:request_token] == params[:oauth_token] + render :text => ('Authentication information does not match session information. Please try again.') and return + end + + request_token = OAuth::RequestToken.new(consumer, session[:request_token], session[:request_token_secret]) + access_token = request_token.get_access_token(:oauth_verifier => params[:oauth_verifier]) + + session[:request_token] = request_token.token + session[:request_token_secret] = request_token.secret + + screen_name = access_token.params["screen_name"] + twitter_user_id = access_token.params["user_id"] + + session[:authentication] = { + twitter_user_id: twitter_user_id, + twitter_screen_name: screen_name + } + + user_info = TwitterUserInfo.where(:twitter_user_id => twitter_user_id).first + + @data = { + username: screen_name, + auth_provider: "Twitter" + } + + if user_info + if user_info.user.active + log_on_user(user_info.user) + @data[:authenticated] = true + else + @data[:awaiting_activation] = true + # send another email ? + end + else + #TODO typheous or some other webscale http request lib that does not block thins + require 'open-uri' + parsed = ::JSON.parse(open("http://api.twitter.com/1/users/show.json?screen_name=#{screen_name}").read) + @data[:name] = parsed["name"] + end + + end + + + protected + + def oauth_consumer + OAuth::Consumer.new( + SiteSetting.twitter_consumer_key, + SiteSetting.twitter_consumer_secret, + :site => "https://api.twitter.com", + :authorize_path => '/oauth/authenticate' + ) + end + +end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb new file mode 100644 index 00000000000..1e71187bedd --- /dev/null +++ b/app/controllers/uploads_controller.rb @@ -0,0 +1,7 @@ +class UploadsController < ApplicationController + def create + file = params[:file] || params[:files].first + upload = Upload.create_for(current_user, file, params[:topic_id]) + render_serialized(upload, UploadSerializer, root: false) + end +end diff --git a/app/controllers/user_actions_controller.rb b/app/controllers/user_actions_controller.rb new file mode 100644 index 00000000000..7b019b18d36 --- /dev/null +++ b/app/controllers/user_actions_controller.rb @@ -0,0 +1,24 @@ +class UserActionsController < ApplicationController + def index + requires_parameters(:user_id) + per_chunk = 60 + render :json => UserAction.stream( + user_id: params[:user_id].to_i, + offset: params[:offset], + limit: per_chunk, + action_types: (params[:filter] || "").split(","), + guardian: guardian, + ignore_private_messages: params[:filter] ? false : true + ) + end + + def show + requires_parameters(:id) + render :json => UserAction.stream_item(params[:id], guardian) + end + + def private_messages + # todo + end + +end diff --git a/app/controllers/user_open_ids_controller.rb b/app/controllers/user_open_ids_controller.rb new file mode 100644 index 00000000000..49399d38976 --- /dev/null +++ b/app/controllers/user_open_ids_controller.rb @@ -0,0 +1,192 @@ +require 'openid' +require 'openid/extensions/sreg' +require 'openid/extensions/ax' +require 'openid/store/filesystem' + +require_dependency 'email' + + +class UserOpenIdsController < ApplicationController + layout false + + # need to be able to call this + skip_before_filter :check_xhr + + # must be done, cause we may trigger a POST + skip_before_filter :verify_authenticity_token, :only => :complete + + def frame + if params[:provider] == 'google' + params[:user_open_id] = {url: "https://www.google.com/accounts/o8/id"} + end + if params[:provider] == 'yahoo' + params[:user_open_id] = {url: "https://me.yahoo.com"} + end + create + end + + def destroy + @open_id = UserOpenId.find(params[:id]) + if @open_id.user.id == current_user.id + @open_id.destroy + end + redirect_to current_user + end + + def new + @open_id = UserOpenId.new + end + + def create + url = params[:user_open_id] + + begin + # validations + @open_id = UserOpenId.new(url) + open_id_request = openid_consumer.begin @open_id.url + return_to, realm = ['complete','index'].map {|a| url_for :action => a, :only_path => false} + + add_ax_request(open_id_request) + add_sreg_request(open_id_request) + + # immediate mode is not required + if open_id_request.send_redirect?(realm, return_to, false) + redirect_to open_id_request.redirect_url(realm, return_to, false) + else + logger.warn("send_redirect? returned false") + render :text, open_id_request.html_markup(realm, return_to, false, {'id' => 'openid_form'}) + end + rescue => e + flash[:error] = "There seems to be something wrong with your open id url" + logger.warn("failed to load contact open id: " + e.to_s) + render :text => 'Something went wrong, we have been notified, try again soon' + end + end + + def complete + current_url = url_for(:action => 'complete', :only_path => false) + parameters = params.reject{|k,v|request.path_parameters[k]}.reject{|k,v| k == 'action' || k == 'controller'} + open_id_response = openid_consumer.complete(parameters, current_url) + + case open_id_response.status + when OpenID::Consumer::SUCCESS + data = {} + if params[:did_sreg] + data = get_sreg_response(open_id_response) + end + + if params[:did_ax] + info = get_ax_response(open_id_response) + data.merge!(info) + end + + trusted = open_id_response.endpoint.server_url =~ /^https:\/\/www.google.com\// || + open_id_response.endpoint.server_url =~ /^https:\/\/me.yahoo.com\// + + email = data[:email] + user_open_id = UserOpenId.where(url: open_id_response.display_identifier).first + + if trusted && user_open_id.nil? && user = User.where(email: email).first + # we trust so do an email lookup + user_open_id = UserOpenId.create(url: open_id_response.display_identifier, user_id: user.id, email: email, active: true) + end + + authenticated = !user_open_id.nil? + + if authenticated + user = user_open_id.user + + # If we have to approve users + if SiteSetting.must_approve_users? and !user.approved? + @data = {awaiting_approval: true} + else + log_on_user(user) + @data = {authenticated: true} + end + + else + @data = { + email: email, + name: User.suggest_name(email), + username: User.suggest_username(email), + email_valid: trusted, + auth_provider: "Google" + } + session[:authentication] = { + email: @data[:email], + email_valid: @data[:email_valid], + openid_url: open_id_response.display_identifier + } + end + + else + # note there are lots of failure reasons, we treat them all as failures + logger.warn("Verification #{open_id_response.display_identifier || "" }"\ + " failed: #{open_id_response.status.to_s}" ) + logger.warn(open_id_response.message) + flash[:error] = "Sorry, I seem to be having trouble confirming your open id account, please try again!" + render :text => "Apologies, something went wrong ... try again soon" + end + end + + + protected + + + def persist_session + if s = UserSession.find + s.remember_me = true + s.save + end + end + + def openid_consumer + @openid_consumer ||= OpenID::Consumer.new(session, + OpenID::Store::Filesystem.new("#{Rails.root}/tmp/openid")) + end + + def get_sreg_response(open_id_response) + data = {} + sreg_resp = OpenID::SReg::Response.from_success_response(open_id_response) + unless sreg_resp.empty? + data[:email] = sreg_resp.data['email'] + data[:nickname] = sreg_resp.data['nickname'] + end + data + end + + def get_ax_response(open_id_response) + data = {} + ax_resp = OpenID::AX::FetchResponse.from_success_response(open_id_response) + if ax_resp && !ax_resp.data.empty? + data[:email] = ax_resp.data['http://schema.openid.net/contact/email'][0] + end + data + end + + def add_sreg_request(open_id_request) + sreg_request = OpenID::SReg::Request.new + sreg_request.request_fields(['email'], true) + # optional + sreg_request.request_fields(['dob', 'fullname', 'nickname'], false) + open_id_request.add_extension(sreg_request) + open_id_request.return_to_args['did_sreg'] = 'y' + + end + + def add_ax_request(open_id_request) + ax_request = OpenID::AX::FetchRequest.new + requested_attrs = [ + ['namePerson', 'fullname'], + ['namePerson/friendly', 'nickname'], + ['contact/email', 'email', true], + ['contact/web/default', 'web_default'], + ['birthDate', 'dob'], + ['contact/country/home', 'country'] + ] + + requested_attrs.each {|a| ax_request.add(OpenID::AX::AttrInfo.new("http://schema.openid.net/#{a[0]}", a[1], a[2] || false))} + open_id_request.add_extension(ax_request) + open_id_request.return_to_args['did_ax'] = 'y' + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 00000000000..877470553c5 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,333 @@ +require_dependency 'mothership' + +class UsersController < ApplicationController + + skip_before_filter :check_xhr, only: [:password_reset, :update, :activate_account, :avatar, :authorize_email, :user_preferences_redirect] + skip_before_filter :authorize_mini_profiler, only: [:avatar] + skip_before_filter :check_restricted_access, only: [:avatar] + + before_filter :ensure_logged_in, only: [:username, :update, :change_email, :user_preferences_redirect] + + def show + @user = fetch_user_from_params + anonymous_etag(@user) do + render_serialized(@user, UserSerializer) + end + end + + def user_preferences_redirect + redirect_to email_preferences_path(current_user.username_lower) + end + + def update + user = User.where(:username_lower => params[:username].downcase).first + guardian.ensure_can_edit!(user) + json_result(user) do |u| + + website = params[:website] + if website + website = "http://" + website unless website =~ /^http/ + end + + u.bio_raw = params[:bio_raw] || u.bio_raw + u.name = params[:name] || u.name + u.website = website || u.website + u.digest_after_days = params[:digest_after_days] || u.digest_after_days + u.auto_track_topics_after_msecs = params[:auto_track_topics_after_msecs].to_i if params[:auto_track_topics_after_msecs] + + [:email_digests, :email_direct, :email_private_messages].each do |i| + if params[i].present? + u.send("#{i.to_s}=", params[i] == 'true') + end + end + + u.save + end + end + + def username + requires_parameter(:new_username) + + user = fetch_user_from_params + guardian.ensure_can_edit!(user) + + result = user.change_username(params[:new_username]) + raise Discourse::InvalidParameters.new(:new_username) unless result + + render nothing: true + end + + def preferences + render nothing: true + end + + def invited + invited_list = InvitedList.new(fetch_user_from_params) + render_serialized(invited_list, InvitedListSerializer) + end + + def is_local_username + requires_parameter(:username) + u = params[:username].downcase + r = User.exec_sql('select 1 from users where username_lower = ?', u).values + render json: {valid: r.length == 1} + end + + def check_username + requires_parameter(:username) + + if !SiteSetting.call_mothership? + if User.username_available?(params[:username]) + render json: {available: true} + else + render json: {available: false, suggestion: User.suggest_username(params[:username])} + end + else + + # Contact the mothership + email_given = (params[:email].present? or current_user.present?) + available_locally = User.username_available?(params[:username]) + global_match = false + available_globally, suggestion_from_mothership = begin + if email_given + global_match, available, suggestion = Mothership.nickname_match?( params[:username], params[:email] || current_user.email ) + [available || global_match, suggestion] + else + Mothership.nickname_available?(params[:username]) + end + end + + if available_globally and available_locally + render json: {available: true, global_match: (global_match ? true : false)} + elsif available_locally and !available_globally + if email_given + # Nickname and email do not match what's registered on the mothership. + render json: {available: false, global_match: false, suggestion: suggestion_from_mothership} + else + # The nickname is available locally, but is registered on the mothership. + # We need an email to see if the nickname belongs to this person. + # Don't give a suggestion until we get the email and try to match it with on the mothership. + render json: {available: false} + end + elsif available_globally and !available_locally + # Already registered on this site with the matching nickname and email address. Why are you signing up again? + render json: {available: false, suggestion: User.suggest_username(params[:username])} + else + # Not available anywhere. + render json: {available: false, suggestion: suggestion_from_mothership} + end + + end + rescue RestClient::Forbidden + render json: {errors: [I18n.t("mothership.access_token_problem")]} + end + + def create + user = User.new + user.name = params[:name] + user.email = params[:email] + user.password = params[:password] + user.username = params[:username] + + auth = session[:authentication] + if auth && auth[:email] == params[:email] && auth[:email_valid] + user.active = true + end + + Mothership.register_nickname( user.username, user.email ) if user.valid? and SiteSetting.call_mothership? + + if user.save + + msg = nil + active_result = user.active? + if active_result + + # If the user is active (remote authorized email) + if SiteSetting.must_approve_users? + msg = I18n.t("login.wait_approval") + active_result = false + else + log_on_user(user) + user.enqueue_welcome_message('welcome_user') + msg = I18n.t("login.active") + end + + else + msg = I18n.t("login.activate_email", email: user.email) + Jobs.enqueue(:user_email, type: :signup, user_id: user.id, email_token: user.email_tokens.first.token) + end + + # Create auth records + if auth.present? + if auth[:twitter_user_id] && auth[:twitter_screen_name] && TwitterUserInfo.find_by_twitter_user_id(auth[:twitter_user_id]).nil? + TwitterUserInfo.create(:user_id => user.id, :screen_name => auth[:twitter_screen_name], :twitter_user_id => auth[:twitter_user_id]) + end + + if auth[:facebook].present? and FacebookUserInfo.find_by_facebook_user_id(auth[:facebook][:facebook_user_id]).nil? + FacebookUserInfo.create!(auth[:facebook].merge(user_id: user.id)) + end + end + + + # Clear authentication session. + session[:authentication] = nil + + # JSON result + render :json => {success: true, active: active_result, message: msg } + else + render :json => {success: false, message: I18n.t("login.errors", errors: user.errors.full_messages.join("\n"))} + end + rescue Mothership::NicknameUnavailable + render :json => {success: false, message: I18n.t("login.errors", errors:I18n.t("login.not_available", suggestion: User.suggest_username(params[:username])) )} + rescue RestClient::Forbidden + render json: {errors: [I18n.t("mothership.access_token_problem")]} + end + + + # all avatars are funneled through here + def avatar + + # TEMP to catch all missing spots + # raise ActiveRecord::RecordNotFound + + user = User.select(:email).where(:username_lower => params[:username].downcase).first + if user + # for now we only support gravatar in square (redirect cached for a day), later we can use x-sendfile and/or a cdn to serve local + size = params[:size].to_i + size = 64 if size == 0 + size = 10 if size < 10 + size = 128 if size > 128 + + url = user.avatar_template.gsub("{size}", size.to_s) + expires_in 1.day + redirect_to url + else + raise ActiveRecord::RecordNotFound + end + end + + def password_reset + expires_now() + + @user = EmailToken.confirm(params[:token]) + if @user.blank? + flash[:error] = I18n.t('password_reset.no_token') + else + if request.put? and params[:password].present? + @user.password = params[:password] + if @user.save + + if SiteSetting.must_approve_users? and !@user.approved? + @requires_approval = true + flash[:success] = I18n.t('password_reset.success_unapproved') + else + # Log in the user + log_on_user(@user) + flash[:success] = I18n.t('password_reset.success') + end + end + end + end + render :layout => 'no_js' + end + + def change_email + requires_parameter(:email) + user = fetch_user_from_params + guardian.ensure_can_edit!(user) + + # Raise an error if the email is already in use + raise Discourse::InvalidParameters.new(:email) if User.where("lower(email) = ?", params[:email].downcase).exists? + + email_token = user.email_tokens.create(email: params[:email]) + Jobs.enqueue(:user_email, + to_address: params[:email], + type: :authorize_email, + user_id: user.id, + email_token: email_token.token) + + render nothing: true + end + + def authorize_email + expires_now() + if @user = EmailToken.confirm(params[:token]) + log_on_user(@user) + else + flash[:error] = I18n.t('change_email.error') + end + render :layout => 'no_js' + end + + def activate_account + expires_now() + if @user = EmailToken.confirm(params[:token]) + + # Log in the user unless they need to be approved + if SiteSetting.must_approve_users? + @needs_approval = true + else + @user.enqueue_welcome_message('welcome_user') if @user.send_welcome_message + log_on_user(@user) + end + + else + flash[:error] = I18n.t('activation.already_done') + end + render :layout => 'no_js' + end + + def search_users + + term = (params[:term] || "").strip.downcase + topic_id = params[:topic_id] + topic_id = topic_id.to_i if topic_id + + sql = "select username, name, email from users u " + if topic_id + sql << "left join (select distinct p.user_id from posts p where topic_id = :topic_id) s on + s.user_id = u.id " + end + + if term.length > 0 + sql << "where username_lower like :term_like or + to_tsvector('simple', name) @@ + to_tsquery('simple', + regexp_replace( + regexp_replace( + cast(plainto_tsquery(:term) as text) + ,'\''(?: |$)', ':*''', 'g'), + '''', '', 'g') + ) " + + end + + sql << "order by case when username_lower = :term then 0 else 1 end asc, " + if topic_id + sql << " case when s.user_id is null then 0 else 1 end desc, " + end + + sql << " case when last_seen_at is null then 0 else 1 end desc, last_seen_at desc, username asc limit(20)" + + results = User.exec_sql(sql, topic_id: topic_id, term_like: "#{term}%", term: term) + results = results.map do |r| + r["avatar_template"] = User.avatar_template(r["email"]) + r.delete("email") + r + end + render :json => results + end + + private + + def fetch_user_from_params + username_lower = params[:username].downcase + username_lower.gsub!(/\.json$/, '') + + user = User.where(username_lower: username_lower).first + raise Discourse::NotFound.new if user.blank? + + guardian.ensure_can_see!(user) + user + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 00000000000..b4c8bf342bd --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,33 @@ +require 'current_user' +require_dependency 'guardian' +require_dependency 'unread' +require_dependency 'age_words' + +module ApplicationHelper + include CurrentUser + + def with_format(format, &block) + old_formats = formats + self.formats = [format] + block.call + self.formats = old_formats + nil + end + + def age_words(secs) + AgeWords.age_words(secs) + end + + def guardian + @guardian ||= Guardian.new(current_user) + end + + def mini_profiler_enabled? + defined?(Rack::MiniProfiler) and admin? + end + + def admin? + current_user.try(:admin?) + end + +end diff --git a/app/helpers/forum_helper.rb b/app/helpers/forum_helper.rb new file mode 100644 index 00000000000..cfbd978b925 --- /dev/null +++ b/app/helpers/forum_helper.rb @@ -0,0 +1,2 @@ +module ForumHelper +end diff --git a/app/helpers/list_helper.rb b/app/helpers/list_helper.rb new file mode 100644 index 00000000000..eaa1d0e1566 --- /dev/null +++ b/app/helpers/list_helper.rb @@ -0,0 +1,2 @@ +module ListHelper +end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb new file mode 100644 index 00000000000..7342393a707 --- /dev/null +++ b/app/helpers/notifications_helper.rb @@ -0,0 +1,2 @@ +module NotificationsHelper +end diff --git a/app/helpers/user_notifications_helper.rb b/app/helpers/user_notifications_helper.rb new file mode 100644 index 00000000000..721eba5f461 --- /dev/null +++ b/app/helpers/user_notifications_helper.rb @@ -0,0 +1,3 @@ +module UserNotificationsHelper + +end diff --git a/app/mailers/.gitkeep b/app/mailers/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/mailers/invite_mailer.rb b/app/mailers/invite_mailer.rb new file mode 100644 index 00000000000..e97300c98fd --- /dev/null +++ b/app/mailers/invite_mailer.rb @@ -0,0 +1,21 @@ +require_dependency 'email_builder' + +class InviteMailer < ActionMailer::Base + include EmailBuilder + default from: SiteSetting.notification_email + + def send_invite(invite) + + # Find the first topic they were invited to + first_topic = invite.topics.order(:created_at).first + + # If they were invited to a topic + build_email invite.email, + 'invite_mailer', + invitee_name: invite.invited_by.username, + invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}", + topic_title: first_topic.try(:title) + + end + +end diff --git a/app/mailers/test_mailer.rb b/app/mailers/test_mailer.rb new file mode 100644 index 00000000000..eb9ffc17f7c --- /dev/null +++ b/app/mailers/test_mailer.rb @@ -0,0 +1,12 @@ +require_dependency 'email_builder' + +class TestMailer < ActionMailer::Base + include EmailBuilder + + default from: SiteSetting.notification_email + + def send_test(to_address) + build_email to_address, 'test_mailer' + end + +end diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb new file mode 100644 index 00000000000..469f91558d3 --- /dev/null +++ b/app/mailers/user_notifications.rb @@ -0,0 +1,80 @@ +require_dependency 'markdown_linker' +require_dependency 'email_builder' + +class UserNotifications < ActionMailer::Base + include EmailBuilder + + default from: SiteSetting.notification_email + + def signup(user, opts={}) + build_email(user.email, "user_notifications.signup", email_token: opts[:email_token]) + end + + def authorize_email(user, opts={}) + build_email(user.email, "user_notifications.authorize_email", email_token: opts[:email_token]) + end + + def forgot_password(user, opts={}) + build_email(user.email, "user_notifications.forgot_password", email_token: opts[:email_token]) + end + + def private_message(user, opts={}) + post = opts[:post] + + build_email user.email, + "user_notifications.private_message", + message: post.raw, + url: post.url, + subject_prefix: post.post_number != 1 ? "re: " : "", + topic_title: post.topic.title, + from: post.user.name, + add_unsubscribe_link: true + end + + def digest(user, opts={}) + @user = user + @base_url = Discourse.base_url + + min_date = @user.last_emailed_at || @user.last_seen_at || 1.month.ago + + @site_name = SiteSetting.title + + @last_seen_at = (@user.last_seen_at || @user.created_at).strftime("%m-%d-%Y") + + # A list of new topics to show the user + @new_topics = Topic.new_topics(min_date) + @notifications = @user.notifications.interesting_after(min_date) + + @markdown_linker = MarkdownLinker.new(Discourse.base_url) + + # Don't send email unless there is content in it + if @new_topics.present? or @notifications.present? + mail to: user.email, + subject: I18n.t('user_notifications.digest.subject_template', + :site_name => @site_name, + :date => Time.now.strftime("%m-%d-%Y")) + end + end + + def notification_template(user, opts) + @notification = opts[:notification] + return unless @notification.present? + + @post = opts[:post] + return unless @post.present? + + notification_type = Notification.InvertedTypes[opts[:notification].notification_type].to_s + build_email user.email, + "user_notifications.user_#{notification_type}", + topic_title: @notification.data_hash[:topic_title], + message: @post.raw, + url: @post.url, + username: @notification.data_hash[:display_username], + add_unsubscribe_link: true + end + alias :user_invited_to_private_message :notification_template + alias :user_replied :notification_template + alias :user_quoted :notification_template + alias :user_mentioned :notification_template + +end diff --git a/app/models/.gitkeep b/app/models/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/models/category.rb b/app/models/category.rb new file mode 100644 index 00000000000..3892e1c7b2b --- /dev/null +++ b/app/models/category.rb @@ -0,0 +1,86 @@ +class Category < ActiveRecord::Base + + belongs_to :topic + belongs_to :user + + has_many :topics + has_many :category_featured_topics + has_many :featured_topics, through: :category_featured_topics, source: :topic + + has_many :category_featured_users + has_many :featured_users, through: :category_featured_users, source: :user + + validates_presence_of :name + validates_uniqueness_of :name + validate :uncategorized_validator + + after_save :invalidate_site_cache + after_destroy :invalidate_site_cache + + + def uncategorized_validator + return errors.add(:name, I18n.t(:is_reserved)) if name == SiteSetting.uncategorized_name + return errors.add(:slug, I18n.t(:is_reserved)) if slug == SiteSetting.uncategorized_name + end + + def self.popular + order('topic_count desc') + end + + def self.update_stats + exec_sql "UPDATE categories + SET topics_week = (SELECT COUNT(*) + FROM topics as ft + WHERE ft.category_id = categories.id + AND ft.created_at > (CURRENT_TIMESTAMP - INTERVAL '1 WEEK') + AND ft.visible), + topics_month = (SELECT COUNT(*) + FROM topics as ft + WHERE ft.category_id = categories.id + AND ft.created_at > (CURRENT_TIMESTAMP - INTERVAL '1 MONTH') + AND ft.visible), + topics_year = (SELECT COUNT(*) + FROM topics as ft + WHERE ft.category_id = categories.id + AND ft.created_at > (CURRENT_TIMESTAMP - INTERVAL '1 YEAR') + AND ft.visible)" + end + + # Use the first paragraph of the topic's first post as the excerpt + def excerpt + if topic.present? + first_post = topic.posts.order(:post_number).first + body = first_post.cooked + matches = body.scan(/\(.*)\<\/p\>/) + if matches and matches[0] and matches[0][0] + return matches[0][0] + end + end + nil + end + + def topic_url + topic.try(:relative_url) + end + + before_save do + self.slug = Slug.for(self.name) + end + + after_create do + topic = Topic.create!(title: I18n.t("category.topic_prefix", category: name), user: user, visible: false) + topic.posts.create!(raw: SiteSetting.category_post_template, user: user) + update_column(:topic_id, topic.id) + topic.update_column(:category_id, self.id) + end + + # We cache the categories in the site json, so we need to invalidate it when they change + def invalidate_site_cache + Site.invalidate_cache + end + + before_destroy do + topic.destroy + end + +end diff --git a/app/models/category_featured_topic.rb b/app/models/category_featured_topic.rb new file mode 100644 index 00000000000..6ae7c2efeb0 --- /dev/null +++ b/app/models/category_featured_topic.rb @@ -0,0 +1,40 @@ +class CategoryFeaturedTopic < ActiveRecord::Base + belongs_to :category + belongs_to :topic + + # Populates the category featured topics + def self.feature_topics + + transaction do + Category.all.each do |c| + feature_topics_for(c) + CategoryFeaturedUser.feature_users_in(c) + end + end + + nil + end + + def self.feature_topics_for(c) + return unless c.present? + + CategoryFeaturedTopic.transaction do + exec_sql "DELETE FROM category_featured_topics WHERE category_id = :category_id", category_id: c.id + exec_sql "INSERT INTO category_featured_topics (category_id, topic_id, created_at, updated_at) + SELECT :category_id, + ft.id, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM topics AS ft + WHERE ft.category_id = :category_id + AND ft.visible + AND ft.deleted_at IS NULL + AND ft.archetype <> '#{Archetype.private_message}' + ORDER BY ft.bumped_at DESC + LIMIT :featured_limit", + category_id: c.id, + featured_limit: SiteSetting.category_featured_topics + end + end + +end diff --git a/app/models/category_featured_user.rb b/app/models/category_featured_user.rb new file mode 100644 index 00000000000..fc1ae701ab1 --- /dev/null +++ b/app/models/category_featured_user.rb @@ -0,0 +1,31 @@ +class CategoryFeaturedUser < ActiveRecord::Base + belongs_to :category + belongs_to :user + + def self.max_featured_users + 5 + end + + def self.feature_users_in(category) + # Figure out major posters in the category + user_counts = exec_sql " + SELECT p.user_id, + COUNT(*) AS category_posts + FROM posts AS p + INNER JOIN topics AS ft ON ft.id = p.topic_id + WHERE ft.category_id = :category_id + GROUP BY p.user_id + ORDER BY category_posts DESC + LIMIT :max_featured_users + ", category_id: category.id, max_featured_users: max_featured_users + + transaction do + CategoryFeaturedUser.delete_all ['category_id = ?', category.id] + user_counts.each do |uc| + create(category_id: category.id, user_id: uc['user_id']) + end + end + + end + +end diff --git a/app/models/category_list.rb b/app/models/category_list.rb new file mode 100644 index 00000000000..8334c82d8b6 --- /dev/null +++ b/app/models/category_list.rb @@ -0,0 +1,69 @@ +class CategoryList + include ActiveModel::Serialization + + attr_accessor :categories, :topic_users, :uncategorized + + def initialize(current_user) + @categories = Category + .includes(:featured_topics => [:category]) + .includes(:featured_users) + .order('topics_week desc, topics_month desc, topics_year desc') + .to_a + + # Support for uncategorized topics + uncategorized_topics = Topic + .listable_topics + .where(category_id: nil) + .topic_list_order + .limit(SiteSetting.category_featured_topics) + if uncategorized_topics.present? + + totals = Topic.exec_sql("SELECT SUM(CASE WHEN created_at >= (CURRENT_TIMESTAMP - INTERVAL '1 WEEK') THEN 1 ELSE 0 END) as topics_week, + SUM(CASE WHEN created_at >= (CURRENT_TIMESTAMP - INTERVAL '1 MONTH') THEN 1 ELSE 0 END) as topics_month, + SUM(CASE WHEN created_at >= (CURRENT_TIMESTAMP - INTERVAL '1 YEAR') THEN 1 ELSE 0 END) as topics_year, + COUNT(*) AS topic_count + FROM topics + WHERE topics.visible + AND topics.deleted_at IS NULL + AND topics.category_id IS NULL + AND topics.archetype <> '#{Archetype.private_message}'").first + + + uncategorized = Category.new({name: SiteSetting.uncategorized_name, + slug: SiteSetting.uncategorized_name, + featured_topics: uncategorized_topics}.merge(totals)) + + # Find the appropriate place to insert it: + insert_at = nil + @categories.each_with_index do |c, idx| + if totals['topics_week'].to_i > (c.topics_week || 0) + insert_at = idx + break + end + end + + @categories.insert(insert_at || @categories.size, uncategorized) + end + + # Remove categories with no featured topics + @categories.delete_if {|c| c.featured_topics.blank? } + + # Get forum topic user records if appropriate + if current_user.present? + topics = [] + @categories.each {|c| topics << c.featured_topics} + topics << @uncategorized + + topics.flatten! if topics.present? + topics.compact! if topics.present? + + + topic_lookup = TopicUser.lookup_for(current_user, topics) + + # Attach some data for serialization to each topic + topics.each {|ft| ft.user_data = topic_lookup[ft.id]} + end + + end + +end diff --git a/app/models/draft.rb b/app/models/draft.rb new file mode 100644 index 00000000000..69433ae934a --- /dev/null +++ b/app/models/draft.rb @@ -0,0 +1,45 @@ +class Draft < ActiveRecord::Base + + NEW_TOPIC = 'new_topic' + NEW_PRIVATE_MESSAGE = 'new_private_message' + EXISTING_TOPIC = 'topic_' + + def self.set(user, key, sequence, data) + d = find_draft(user,key) + if d + return if d.sequence > sequence + d.data = data + d.sequence = sequence + else + d = Draft.new(user_id: user.id, draft_key: key, data: data, sequence: sequence) + end + d.save! + end + + def self.get(user, key, sequence) + d = find_draft(user,key) + if d && d.sequence == sequence + d.data + else + nil + end + end + + def self.clear(user, key, sequence) + d = find_draft(user,key) + if d && d.sequence <= sequence + d.destroy + else + nil + end + end + + protected + + def self.find_draft(user,key) + user_id = user + user_id = user.id if User === user + Draft.where(user_id: user_id, draft_key: key).first + end + +end diff --git a/app/models/draft_sequence.rb b/app/models/draft_sequence.rb new file mode 100644 index 00000000000..a86cd3a5255 --- /dev/null +++ b/app/models/draft_sequence.rb @@ -0,0 +1,29 @@ +class DraftSequence < ActiveRecord::Base + def self.next!(user,key) + user_id = user + user_id = user.id unless user.class == Fixnum + h = {user_id: user_id, draft_key: key} + c = DraftSequence.where(h).first + c ||= DraftSequence.new(h) + c.sequence ||= 0 + c.sequence += 1 + c.save + c.sequence + end + + def self.current(user, key) + return nil unless user + + user_id = user + user_id = user.id unless user.class == Fixnum + + # perf critical path + r = exec_sql('select sequence from draft_sequences where user_id = ? and draft_key = ?', user_id, key).values + + if r.length == 0 + 0 + else + r[0][0].to_i + end + end +end diff --git a/app/models/email_log.rb b/app/models/email_log.rb new file mode 100644 index 00000000000..bebbfbcb0ea --- /dev/null +++ b/app/models/email_log.rb @@ -0,0 +1,12 @@ +class EmailLog < ActiveRecord::Base + + belongs_to :user + validates_presence_of :email_type + validates_presence_of :to_address + + after_create do + # Update last_emailed_at if the user_id is present + User.update_all("last_emailed_at = CURRENT_TIMESTAMP", ["id = ?", user_id]) if user_id.present? + end + +end diff --git a/app/models/email_token.rb b/app/models/email_token.rb new file mode 100644 index 00000000000..f9e905c0e38 --- /dev/null +++ b/app/models/email_token.rb @@ -0,0 +1,55 @@ +class EmailToken < ActiveRecord::Base + belongs_to :user + + validates_presence_of :token + validates_presence_of :user_id + validates_presence_of :email + + before_validation(:on => :create) do + self.token = EmailToken.generate_token + end + + after_create do + # Expire the previous tokens + EmailToken.update_all 'expired = true', ['user_id = ? and id != ?', self.user_id, self.id] + end + + def self.token_length + 16 + end + + def self.valid_after + 1.week.ago + end + + def self.generate_token + SecureRandom.hex(EmailToken.token_length) + end + + def self.confirm(token) + return unless token.present? + return unless token.length/2 == EmailToken.token_length + + email_token = EmailToken.where("token = ? AND expired = FALSE and created_at >= ?", token, EmailToken.valid_after).includes(:user).first + return if email_token.blank? + + user = email_token.user + User.transaction do + row_count = EmailToken.update_all 'confirmed = true', ['id = ? AND confirmed = false', email_token.id] + if row_count == 1 + + # If we are activating the user, send the welcome message + user.send_welcome_message = !user.active? + + user.active = true + user.email = email_token.email + user.save! + end + end + user + rescue ActiveRecord::RecordInvalid + # If the user's email is already taken, just return nil (failure) + nil + end + +end diff --git a/app/models/error_log.rb b/app/models/error_log.rb new file mode 100644 index 00000000000..ddc7724674d --- /dev/null +++ b/app/models/error_log.rb @@ -0,0 +1,111 @@ +# TODO: +# a mechanism to iterate through errors in reverse +# async logging should queue, if dupe stack traces are found in batch error should be merged into prev one + + +class ErrorLog + + @lock = Mutex.new + + def self.filename + "#{Rails.root}/log/#{Rails.env}_errors.log" + end + + def self.clear!(guid) + raise "not implemented" + end + + def self.clear_all!() + File.delete(ErrorLog.filename) if File.exists?(ErrorLog.filename) + end + + def self.report_async!(exception, controller, request, user) + Thread.new do + self.report!(exception, controller, request, user) + end + end + + def self.report!(exception, controller, request, user) + add_row!( + :date => DateTime.now, + :guid => SecureRandom.uuid, + :user_id => user && user.id, + :request => filter_sensitive_post_data_parameters(controller, request.parameters).inspect, + :action => controller.action_name, + :controller => controller.controller_name, + :backtrace => sanitize_backtrace(exception.backtrace).join("\n"), + :message => exception.message, + :url => "#{request.protocol}#{request.env["HTTP_X_FORWARDED_HOST"] || request.env["HTTP_HOST"]}#{request.fullpath}", + :exception_class => exception.class.to_s + ) + end + + def self.add_row!(hash) + data = hash.to_xml(skip_instruct: true) + # use background thread to write the log cause it may block if it gets backed up + @lock.synchronize do + File.open(self.filename, "a") do |f| + f.flock(File::LOCK_EX) + f.write(data) + f.close + end + end + end + + + def self.each(&blk) + skip(0,&blk) + end + + def self.skip(skip=0) + data = nil + pos = 0 + return [] unless File.exists?(self.filename) + + loop do + lines = "" + File.open(self.filename, "r") do |f| + f.flock(File::LOCK_SH) + f.pos = pos + while !f.eof? + line = f.readline + lines << line + break if line.starts_with? "" + end + pos = f.pos + end + if lines != "" && skip == 0 + h = {} + e = Nokogiri.parse(lines).children[0] + e.children.each do |inner| + h[inner.name] = inner.text + end + yield h + end + skip-=1 if skip > 0 + break if lines == "" + end + end + + private + + def self.sanitize_backtrace(trace) + re = Regexp.new(/^#{Regexp.escape(Rails.root.to_s)}/) + trace.map { |line| Pathname.new(line.gsub(re, "[RAILS_ROOT]")).cleanpath.to_s } + end + + def self.exclude_raw_post_parameters?(controller) + controller && controller.respond_to?(:filter_parameters) + end + + def self.filter_sensitive_post_data_parameters(controller, parameters) + exclude_raw_post_parameters?(controller) ? controller.__send__(:filter_parameters, parameters) : parameters + end + + def self.filter_sensitive_post_data_from_env(env_key, env_value, controller) + return env_value unless exclude_raw_post_parameters? + return PARAM_FILTER_REPLACEMENT if (env_key =~ /RAW_POST_DATA/i) + return controller.__send__(:filter_parameters, {env_key => env_value}).values[0] + end + +end diff --git a/app/models/facebook_user_info.rb b/app/models/facebook_user_info.rb new file mode 100644 index 00000000000..3e0a3a154e4 --- /dev/null +++ b/app/models/facebook_user_info.rb @@ -0,0 +1,4 @@ +class FacebookUserInfo < ActiveRecord::Base + attr_accessible :email, :facebook_user_id, :first_name, :gender, :last_name, :name, :user_id, :username, :link + belongs_to :user +end diff --git a/app/models/incoming_link.rb b/app/models/incoming_link.rb new file mode 100644 index 00000000000..6628ed32727 --- /dev/null +++ b/app/models/incoming_link.rb @@ -0,0 +1,47 @@ +class IncomingLink < ActiveRecord::Base + belongs_to :topic + + validates :domain, :length => { :in => 1..100 } + validates :referer, :length => { :in => 3..1000 } + validates_presence_of :url + + # Extract the domain + before_validation do + + # Referer (remote URL) + if referer.present? + parsed = URI.parse(referer) + self.domain = parsed.host + end + + # Our URL + if url.present? + + parsed = URI.parse(url) + + begin + params = Rails.application.routes.recognize_path(parsed.path) + self.topic_id = params[:topic_id] if params[:topic_id].present? + self.post_number = params[:post_number] if params[:post_number].present? + rescue ActionController::RoutingError + # If we can't route to the url, that's OK. Don't save those two fields. + end + end + + end + + # Update appropriate incoming link counts + after_create do + if topic_id.present? + exec_sql("UPDATE topics + SET incoming_link_count = incoming_link_count + 1 + WHERE id = ?", topic_id) + if post_number.present? + exec_sql("UPDATE posts + SET incoming_link_count = incoming_link_count + 1 + WHERE topic_id = ? and post_number = ?", topic_id, post_number) + end + end + end + +end diff --git a/app/models/invite.rb b/app/models/invite.rb new file mode 100644 index 00000000000..739744a166b --- /dev/null +++ b/app/models/invite.rb @@ -0,0 +1,87 @@ +class Invite < ActiveRecord::Base + + belongs_to :user + belongs_to :topic + belongs_to :invited_by, class_name: User + + has_many :topic_invites + has_many :topics, through: :topic_invites, source: :topic + validates_presence_of :email + validates_presence_of :invited_by_id + + acts_as_paranoid + + + before_create do + self.invite_key ||= SecureRandom.hex + end + + before_save do + self.email = self.email.downcase + end + + validate :user_doesnt_already_exist + attr_accessor :email_already_exists + + def user_doesnt_already_exist + @email_already_exists = false + return if email.blank? + if User.where("lower(email) = ?", email.downcase).exists? + @email_already_exists = true + errors.add(:email) + end + end + + def redeemed? + redeemed_at.present? + end + + def expired? + created_at < SiteSetting.invite_expiry_days.days.ago + end + + def redeem + result = nil + Invite.transaction do + # Avoid a race condition + row_count = Invite.update_all('redeemed_at = CURRENT_TIMESTAMP', + ['id = ? AND redeemed_at IS NULL AND created_at >= ?', self.id, SiteSetting.invite_expiry_days.days.ago]) + + if row_count == 1 + + # Create the user if we are redeeming the invite and the user doesn't exist + result = User.where(email: email).first + result = User.create_for_email(email, trust_level: SiteSetting.default_invitee_trust_level) if result.blank? + result.send_welcome_message = false + + # If there are topic invites for private topics + topics.private_messages.each do |t| + t.topic_allowed_users.create(user_id: result.id) + end + + # Check for other invites by the same email. Don't redeem them, but approve their + # topics. + Invite.where('invites.email = ? and invites.id != ?', self.email, self.id).includes(:topics).where('topics.archetype = ?', Archetype::private_message).each do |i| + i.topics.each do |t| + t.topic_allowed_users.create(user_id: result.id) + end + end + + if Invite.update_all(['user_id = ?', result.id], ['email = ?', self.email]) == 1 + result.send_welcome_message = true + end + + # Notify the invitee + invited_by.notifications.create(notification_type: Notification.Types[:invitee_accepted], + data: {display_username: result.username}.to_json) + + else + # Otherwise return the existing user + result = User.where(email: email).first + end + end + + result + end + +end diff --git a/app/models/invited_list.rb b/app/models/invited_list.rb new file mode 100644 index 00000000000..fcbda45c54f --- /dev/null +++ b/app/models/invited_list.rb @@ -0,0 +1,25 @@ +# A nice object to help keep track of invited users +class InvitedList + + attr_accessor :pending + attr_accessor :redeemed + attr_accessor :by_user + + def initialize(user) + @pending = [] + @redeemed = [] + @by_user = user + + invited = Invite.where(invited_by_id: @by_user.id) + .includes(:user) + .order(:redeemed_at) + invited.each do |i| + if i.redeemed? + @redeemed << i + else + @pending << i unless i.expired? + end + end + end + +end diff --git a/app/models/message_bus_observer.rb b/app/models/message_bus_observer.rb new file mode 100644 index 00000000000..bca0f2d06bc --- /dev/null +++ b/app/models/message_bus_observer.rb @@ -0,0 +1,58 @@ +require_dependency 'message_bus' +require_dependency 'discourse_observer' + +# This class is responsible for notifying the message bus of various +# events. +class MessageBusObserver < DiscourseObserver + observe :post, :notification, :user_action, :topic + + def after_create_post(post) + MessageBus.publish("/topic/#{post.topic_id}", + id: post.id, + created_at: post.created_at, + user: BasicUserSerializer.new(post.user).as_json(root: false), + post_number: post.post_number) + end + + def after_create_notification(notification) + refresh_notification_count(notification) + end + + def after_destroy_notification(notification) + refresh_notification_count(notification) + end + + def after_create_user_action(user_action) + MessageBus.publish("/users/#{user_action.user.username.downcase}", user_action.id) + end + + def after_create_topic(topic) + + # Don't publish invisible topics + return unless topic.visible? + + return if topic.private_message? + + topic.posters = topic.posters_summary + topic.posts_count = 1 + topic_json = TopicListItemSerializer.new(topic).as_json + MessageBus.publish("/popular", topic_json) + + # If it has a category, add it to the category views too + if topic.category.present? + MessageBus.publish("/category/#{topic.category.slug}", topic_json) + end + + end + + protected + + def refresh_notification_count(notification) + user_id = notification.user.id + MessageBus.publish("/notification", + {unread_notifications: notification.user.unread_notifications, + unread_private_messages: notification.user.unread_private_messages}, + user_ids: [notification.user.id] # only publish the notification to this user + ) + end +end diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 00000000000..18ad82b05d1 --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,95 @@ +class Notification < ActiveRecord::Base + + belongs_to :user + belongs_to :topic + + validates_presence_of :data + validates_presence_of :notification_type + + def self.Types + {:mentioned => 1, + :replied => 2, + :quoted => 3, + :edited => 4, + :liked => 5, + :private_message => 6, + :invited_to_private_message => 7, + :invitee_accepted => 8, + :posted => 9, + :moved_post => 10} + end + + def self.InvertedTypes + @inverted_types ||= Notification.Types.invert + end + + def self.unread + where(read: false) + end + + def self.mark_post_read(user, topic_id, post_number) + Notification.update_all "read = true", ["user_id = ? and topic_id = ? and post_number = ?", user.id, topic_id, post_number] + end + + def self.recent + order('created_at desc').limit(10) + end + + def self.interesting_after(min_date) + result = where("created_at > ?", min_date) + .includes(:topic) + .unread + .limit(20) + .order("CASE WHEN notification_type = #{Notification.Types[:replied]} THEN 1 + WHEN notification_type = #{Notification.Types[:mentioned]} THEN 2 + ELSE 3 + END, created_at DESC").to_a + + # Remove any duplicates by type and topic + if result.present? + seen = {} + to_remove = Set.new + + result.each do |r| + seen[r.notification_type] ||= Set.new + if seen[r.notification_type].include?(r.topic_id) + to_remove << r.id + else + seen[r.notification_type] << r.topic_id + end + end + result.reject! {|r| to_remove.include?(r.id) } + end + + result + end + + # Be wary of calling this frequently. O(n) JSON parsing can suck. + def data_hash + @data_hash ||= begin + return nil if data.blank? + ::JSON.parse(data).with_indifferent_access + end + end + + def text_description + link = block_given? ? yield : "" + I18n.t("notification_types.#{Notification.InvertedTypes[notification_type]}", data_hash.merge(link: link)) + end + + def url + if topic.present? + return topic.relative_url(post_number) + end + nil + end + + def post + return nil unless topic_id.present? + return nil unless post_number.present? + + Post.where(topic_id: topic_id, post_number: post_number).first + end + +end + diff --git a/app/models/onebox_render.rb b/app/models/onebox_render.rb new file mode 100644 index 00000000000..9992d1dd80a --- /dev/null +++ b/app/models/onebox_render.rb @@ -0,0 +1,10 @@ +class OneboxRender < ActiveRecord::Base + + validates_presence_of :url + validates_presence_of :cooked + validates_presence_of :expires_at + + has_many :post_onebox_renders, :dependent => :delete_all + has_many :posts, through: :post_onebox_renders + +end diff --git a/app/models/post.rb b/app/models/post.rb new file mode 100644 index 00000000000..37207a3d1eb --- /dev/null +++ b/app/models/post.rb @@ -0,0 +1,469 @@ +require_dependency 'jobs' +require_dependency 'pretty_text' +require_dependency 'rate_limiter' + +require 'archetype' +require 'hpricot' +require 'digest/sha1' + +class Post < ActiveRecord::Base + include RateLimiter::OnCreateRecord + + module HiddenReason + FLAG_THRESHOLD_REACHED = 1 + FLAG_THRESHOLD_REACHED_AGAIN = 2 + end + + # A custom rate limiter for edits + class EditRateLimiter < RateLimiter + def initialize(user) + super(user, "edit-post:#{Date.today.to_s}", SiteSetting.max_edits_per_day, 1.day.to_i) + end + end + + versioned + + rate_limit + + acts_as_paranoid + + belongs_to :user + belongs_to :topic, counter_cache: :posts_count + + has_many :post_replies + has_many :replies, through: :post_replies + has_many :post_actions + + validates_presence_of :raw, :user_id, :topic_id + validates :raw, length: {in: SiteSetting.min_post_length..SiteSetting.max_post_length} + validate :max_mention_validator + validate :max_images_validator + validate :max_links_validator + validate :unique_post_validator + + # We can pass a hash of image sizes when saving to prevent crawling those images + attr_accessor :image_sizes, :quoted_post_numbers, :no_bump, :invalidate_oneboxes + + SHORT_POST_CHARS = 1200 + + # Post Types + REGULAR = 1 + MODERATOR_ACTION = 2 + + before_save :extract_quoted_post_numbers + after_commit :feature_topic_users, on: :create + after_commit :trigger_post_process, on: :create + after_commit :email_private_message, on: :create + + # Related to unique post tracking + after_commit :store_unique_post_key, on: :create + + after_create do + TopicUser.auto_track(self.user_id, self.topic_id, TopicUser::NotificationReasons::CREATED_POST) + end + + before_validation do + self.raw.strip! if self.raw.present? + end + + # Stop us from posting the same thing too quickly + def unique_post_validator + return if SiteSetting.unique_posts_mins == 0 + return if user.admin? or user.has_trust_level?(:moderator) + + # If the post is empty, default to the validates_presence_of + return if raw.blank? + + if $redis.exists(unique_post_key) + errors.add(:raw, I18n.t(:just_posted_that)) + end + end + + # On successful post, store a hash key to prevent the same post from happening again + def store_unique_post_key + return if SiteSetting.unique_posts_mins == 0 + $redis.setex(unique_post_key, SiteSetting.unique_posts_mins.minutes.to_i, "1") + end + + # The key we use in reddit to ensure unique posts + def unique_post_key + "post-#{user_id}:#{raw_hash}" + end + + def raw_hash + return nil if raw.blank? + Digest::SHA1.hexdigest(raw.gsub(/\s+/, "").downcase) + end + + def cooked_document + self.cooked ||= cook(self.raw, topic_id: topic_id) + @cooked_document ||= Nokogiri::HTML.fragment(self.cooked) + end + + def image_count + return 0 unless self.raw.present? + cooked_document.search("img.emoji").remove + cooked_document.search("img").count + end + + def link_count + return 0 unless self.raw.present? + cooked_document.search("a[href]").count + end + + def max_mention_validator + errors.add(:raw, I18n.t(:too_many_mentions)) if raw_mentions.size > SiteSetting.max_mentions_per_post + end + + def max_images_validator + return if user.present? and user.has_trust_level?(:basic) + errors.add(:raw, I18n.t(:too_many_images)) if image_count > 0 + end + + def max_links_validator + return if user.present? and user.has_trust_level?(:basic) + errors.add(:raw, I18n.t(:too_many_links)) if link_count > 1 + end + + + def raw_mentions + return [] if raw.blank? + + # We don't count mentions in quotes + return @raw_mentions if @raw_mentions.present? + raw_stripped = raw.gsub(/\[quote=(.*)\]([^\[]*?)\[\/quote\]/im, '') + + # Strip pre and code tags + doc = Nokogiri::HTML.fragment(raw_stripped) + doc.search("pre").remove + doc.search("code").remove + + results = doc.to_html.scan(PrettyText.mention_matcher) + if results.present? + @raw_mentions = results.uniq.map {|un| un.first.downcase.gsub!(/^@/, '')} + else + @raw_mentions = [] + end + + end + + def archetype + topic.archetype + end + + def self.regular_order + order(:sort_order, :post_number) + end + + def self.reverse_order + order('sort_order desc, post_number desc') + end + + def self.best_of + where("(post_number = 1) or (score >= ?)", SiteSetting.best_of_score_threshold) + end + + def filter_quotes(parent_post=nil) + return cooked if parent_post.blank? + + # We only filter quotes when there is exactly 1 + return cooked unless (quote_count == 1) + + parent_raw = parent_post.raw.sub(/\[quote.+\/quote\]/m, '').strip + + if raw[parent_raw] or (parent_raw.size < SHORT_POST_CHARS) + return cooked.sub(/\/m, '') + end + + cooked + end + + def username + user.username + end + + def external_id + "#{topic_id}/#{post_number}" + end + + def quoteless? + (quote_count == 0) and (reply_to_post_number.present?) + end + + # Get the post that we reply to. + def reply_to_user + return nil unless reply_to_post_number.present? + User.where('id = (select user_id from posts where topic_id = ? and post_number = ?)', topic_id, reply_to_post_number).first + end + + def reply_notification_target + return nil unless reply_to_post_number.present? + reply_post = Post.where("topic_id = :topic_id AND post_number = :post_number AND user_id <> :user_id", + topic_id: topic_id, + post_number: reply_to_post_number, + user_id: user_id).first + return reply_post.try(:user) + end + + def self.excerpt(cooked, maxlength=nil) + maxlength ||= SiteSetting.post_excerpt_maxlength + PrettyText.excerpt(cooked, maxlength) + end + + # Strip out most of the markup + def excerpt(maxlength=nil) + Post.excerpt(cooked, maxlength) + end + + # What we use to cook posts + def cook(*args) + cooked = PrettyText.cook(*args) + + # If we have any of the oneboxes in the cache, throw them in right away, don't + # wait for the post processor. + dirty = false + doc = Oneboxer.each_onebox_link(cooked) do |url, elem| + cached = Oneboxer.render_from_cache(url) + if cached.present? + elem.swap(cached.cooked) + dirty = true + end + end + + cooked = doc.to_html if dirty + cooked + end + + # A list of versions including the initial version + def all_versions + result = [] + result << {number: 1, display_username: user.name, created_at: created_at} + versions.order(:number).includes(:user).each do |v| + result << {number: v.number, display_username: v.user.name, created_at: v.created_at} + end + result + end + + # Update the body of a post. Will create a new version when appropriate + def revise(updated_by, new_raw, opts={}) + + # Only update if it changes + return false if self.raw == new_raw + + updater = lambda do |new_version=false| + + # Raw is about to change, enable validations + @cooked_document = nil + self.cooked = nil + + self.raw = new_raw + self.updated_by = updated_by + self.last_editor_id = updated_by.id + + if self.hidden && self.hidden_reason_id == HiddenReason::FLAG_THRESHOLD_REACHED + self.hidden = false + self.hidden_reason_id = nil + self.topic.update_attributes(visible: true) + + PostAction.clear_flags!(self, -1) + end + + self.save + end + + # We can optionally specify when this version was revised. Defaults to now. + revised_at = opts[:revised_at] || Time.now + new_version = false + + # We always create a new version if the poster has changed + new_version = true if (self.last_editor_id != updated_by.id) + + # We always create a new version if it's been greater than the ninja edit window + new_version = true if (revised_at - last_version_at) > SiteSetting.ninja_edit_window.to_i + + # Create the new version (or don't) + if new_version + + self.cached_version = version + 1 + + Post.transaction do + self.last_version_at = revised_at + updater.call(true) + EditRateLimiter.new(updated_by).performed! + + # If a new version is created of the last post, bump it. + unless Post.where('post_number > ? and topic_id = ?', self.post_number, self.topic_id).exists? + topic.update_column(:bumped_at, Time.now) + end + end + + else + skip_version(&updater) + end + + # Invalidate any oneboxes + self.invalidate_oneboxes = true + trigger_post_process + + true + end + + + def url + "/t/#{Slug.for(topic.title)}/#{topic.id}/#{post_number}" + end + + # Various callbacks + before_create do + self.post_number ||= Topic.next_post_number(topic_id, reply_to_post_number.present?) + self.cooked ||= cook(raw, topic_id: topic_id) + self.sort_order = post_number + DiscourseEvent.trigger(:before_create_post, self) + self.last_version_at ||= Time.now + end + + # TODO: Move some of this into an asynchronous job? + after_create do + + # Update attributes on the topic - featured users and last posted. + attrs = {last_posted_at: self.created_at, last_post_user_id: self.user_id} + attrs[:bumped_at] = self.created_at unless no_bump + topic.update_attributes(attrs) + + # Update the user's last posted at date + user.update_column(:last_posted_at, self.created_at) + + # Update topic user data + TopicUser.change(user, + topic.id, + posted: true, + last_read_post_number: self.post_number, + seen_post_count: self.post_number) + end + + def email_private_message + # send a mail to notify users in case of a private message + if topic.private_message? + topic.allowed_users.where(["users.email_private_messages = true and users.id != ?", self.user_id]).each do |u| + Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes, :user_email, type: :private_message, user_id: u.id, post_id: self.id) + end + end + end + + def feature_topic_users + Jobs.enqueue(:feature_topic_users, topic_id: self.topic_id) + end + + # This calculates the geometric mean of the post timings and stores it along with + # each post. + def self.calculate_avg_time + exec_sql("UPDATE posts + SET avg_time = (x.gmean / 1000) + FROM (SELECT post_timings.topic_id, + post_timings.post_number, + round(exp(avg(ln(msecs)))) AS gmean + FROM post_timings + INNER JOIN posts AS p2 + ON p2.post_number = post_timings.post_number + AND p2.topic_id = post_timings.topic_id + AND p2.user_id <> post_timings.user_id + GROUP BY post_timings.topic_id, post_timings.post_number) AS x + WHERE x.topic_id = posts.topic_id + AND x.post_number = posts.post_number") + end + + before_save do + self.last_editor_id ||= self.user_id + self.cooked = cook(raw, topic_id: topic_id) unless new_record? + end + + before_destroy do + + # Update the last post id to the previous post if it exists + last_post = Post.where("topic_id = ? and id <> ?", self.topic_id, self.id).order('created_at desc').limit(1).first + if last_post.present? + topic.update_attributes(last_posted_at: last_post.created_at, + last_post_user_id: last_post.user_id, + highest_post_number: last_post.post_number) + + # If the poster doesn't have any other posts in the topic, clear their posted flag + unless Post.exists?(["topic_id = ? and user_id = ? and id <> ?", self.topic_id, self.user_id, self.id]) + TopicUser.update_all 'posted = false', ['topic_id = ? and user_id = ?', self.topic_id, self.user_id] + end + end + + # Feature users in the topic + Jobs.enqueue(:feature_topic_users, topic_id: topic_id, except_post_id: self.id) + + end + + after_destroy do + + # Remove any reply records that point to deleted posts + post_ids = PostReply.select(:post_id).where(reply_id: self.id).map(&:post_id) + PostReply.delete_all ["reply_id = ?", self.id] + + if post_ids.present? + Post.where(id: post_ids).each {|p| p.update_column :reply_count, p.replies.count} + end + + # Remove any notifications that point to this deleted post + Notification.delete_all ["topic_id = ? and post_number = ?", self.topic_id, self.post_number] + end + + after_save do + + DraftSequence.next! self.last_editor_id, self.topic.draft_key if self.topic # could be deleted + + quoted_post_numbers << reply_to_post_number if reply_to_post_number.present? + + # Create a reply relationship between quoted posts and this new post + if self.quoted_post_numbers.present? + self.quoted_post_numbers.map! {|pid| pid.to_i}.uniq! + self.quoted_post_numbers.each do |p| + post = Post.where(topic_id: topic_id, post_number: p).first + if post.present? + post_reply = post.post_replies.new(reply_id: self.id) + if post_reply.save + Post.update_all ['reply_count = reply_count + 1, reply_below_post_number = COALESCE(reply_below_post_number, ?)', self.post_number], + ["id = ?", post.id] + end + end + end + end + end + + def extract_quoted_post_numbers + self.quoted_post_numbers = [] + + # Create relationships for the quotes + raw.scan(/\[quote=\"([^"]+)"\]/).each do |m| + if m.present? + args = {} + m.first.scan(/([a-z]+)\:(\d+)/).each do |arg| + args[arg[0].to_sym] = arg[1].to_i + end + + if args[:topic].present? + # If the topic attribute is present, ensure it's the same topic + self.quoted_post_numbers << args[:post] if self.topic_id == args[:topic] + else + self.quoted_post_numbers << args[:post] + end + + end + end + + self.quoted_post_numbers.uniq! + self.quote_count = self.quoted_post_numbers.size + end + + # Process this post after comitting it + def trigger_post_process + args = {post_id: self.id} + args[:image_sizes] = self.image_sizes if self.image_sizes.present? + args[:invalidate_oneboxes] = true if self.invalidate_oneboxes.present? + Jobs.enqueue(:process_post, args) + end + +end diff --git a/app/models/post_action.rb b/app/models/post_action.rb new file mode 100644 index 00000000000..631cb56a604 --- /dev/null +++ b/app/models/post_action.rb @@ -0,0 +1,168 @@ +require_dependency 'rate_limiter' +require_dependency 'system_message' + +class PostAction < ActiveRecord::Base + include RateLimiter::OnCreateRecord + + attr_accessible :deleted_at, :post_action_type_id, :post_id, :user_id, :post, :user, :post_action_type, :message + + belongs_to :post + belongs_to :user + belongs_to :post_action_type + + rate_limit :post_action_rate_limiter + + def self.update_flagged_posts_count + val = exec_sql('select count(*) from posts where deleted_at is null and id in (select post_id from post_actions where post_action_type_id in (?) and deleted_at is null)', PostActionType.FlagTypes).values[0][0].to_i + $redis.set('posts_flagged_count', val) + + admins = User.exec_sql("select id from users where admin = 't'").map{|r| r["id"].to_i} + MessageBus.publish('/flagged_counts', {total: val}, {user_ids: admins}) + end + + def self.flagged_posts_count + $redis.get('posts_flagged_count').to_i + end + + def self.counts_for(collection, user) + + return {} if collection.blank? + + collection_ids = collection.map {|p| p.id} + + user_id = user.present? ? user.id : 0 + + result = PostAction.where(post_id: collection_ids, user_id: user_id, deleted_at: nil) + user_actions = {} + result.each do |r| + user_actions[r.post_id] ||= {} + user_actions[r.post_id][r.post_action_type_id] = r + end + + user_actions + end + + def self.clear_flags!(post, moderator_id) + + # -1 is the automatic system cleary + actions = moderator_id == -1 ? PostActionType.AutoActionFlagTypes : PostActionType.FlagTypes + + PostAction.exec_sql('update post_actions set deleted_at = ?, deleted_by = ? + where post_id = ? and deleted_at is null and post_action_type_id in (?)', + DateTime.now, moderator_id, post.id, actions + ) + + r = PostActionType.Types.invert + f = actions.map{|t| ["#{r[t]}_count", 0]} + + Post.update_all(Hash[*f.flatten], id: post.id) + + update_flagged_posts_count + end + + def self.act(user, post, post_action_type_id, message = nil) + begin + create(post_id: post.id, user_id: user.id, post_action_type_id: post_action_type_id, message: message) + rescue ActiveRecord::RecordNotUnique + # can happen despite being .create + # since already bookmarked + true + end + end + + def self.remove_act(user, post, post_action_type_id) + if action = self.where(post_id: post.id, user_id: user.id, post_action_type_id: post_action_type_id, deleted_at: nil).first + + transaction do + d = DateTime.now + count = PostAction.update_all({deleted_at: d},{id: action.id, deleted_at: nil}) + + if(count == 1) + action.deleted_at = DateTime.now + action.run_callbacks(:save) + action.run_callbacks(:destroy) + end + end + end + end + + def is_bookmark? + post_action_type_id == PostActionType.Types[:bookmark] + end + + def is_like? + post_action_type_id == PostActionType.Types[:like] + end + + def is_flag? + PostActionType.FlagTypes.include?(post_action_type_id) + end + + # A custom rate limiter for this model + def post_action_rate_limiter + return nil unless is_flag? or is_bookmark? or is_like? + + return @rate_limiter if @rate_limiter.present? + + %w(like flag bookmark).each do |type| + if send("is_#{type}?") + @rate_limiter = RateLimiter.new(user, "create_#{type}:#{Date.today.to_s}", SiteSetting.send("max_#{type}s_per_day"), 1.day.to_i) + return @rate_limiter + end + end + end + + after_save do + + # Update denormalized counts + post_action_type = PostActionType.Types.invert[post_action_type_id] + column = "#{post_action_type.to_s}_count" + delta = deleted_at.nil? ? 1 : -1 + + # Voting also changes the sort_order + if post_action_type == :vote + Post.update_all ["vote_count = vote_count + :delta, sort_order = :max - (vote_count + :delta)", delta: delta, max: Topic::MAX_SORT_ORDER], ["id = ?", post_id] + else + Post.update_all ["#{column} = #{column} + ?", delta], ["id = ?", post_id] + end + + exec_sql "UPDATE topics SET #{column} = #{column} + ? WHERE id = (select p.topic_id from posts p where p.id = ?)", delta, post_id + + if PostActionType.FlagTypes.include?(post_action_type_id) + PostAction.update_flagged_posts_count + end + + if SiteSetting.flags_required_to_hide_post > 0 + # automatic hiding of posts + info = exec_sql("select case when deleted_at is null then 'new' else 'old' end, count(*) from post_actions + where post_id = ? and + post_action_type_id in (?) + group by case when deleted_at is null then 'new' else 'old' end + ", self.post_id, PostActionType.AutoActionFlagTypes).values + + old_flags = new_flags = 0 + info.each do |r,v| + old_flags = v.to_i if r == 'old' + new_flags = v.to_i if r == 'new' + end + + + if new_flags >= SiteSetting.flags_required_to_hide_post + exec_sql("update posts set hidden = ?, hidden_reason_id = coalesce(hidden_reason_id, ?) where id = ?", + true, old_flags > 0 ? Post::HiddenReason::FLAG_THRESHOLD_REACHED_AGAIN : Post::HiddenReason::FLAG_THRESHOLD_REACHED, self.post_id) + + exec_sql("update topics set visible = 'f' + where id = ? and not exists (select 1 from posts where hidden = 'f' and topic_id = ?)", self.post.topic_id, self.post.topic_id) + + # inform user + if self.post.user + SystemMessage.create(self.post.user, :post_hidden, + url: self.post.url, + edit_delay: SiteSetting.cooldown_minutes_after_hiding_posts) + end + end + + end + + end +end diff --git a/app/models/post_action_type.rb b/app/models/post_action_type.rb new file mode 100644 index 00000000000..39b58a2a9f6 --- /dev/null +++ b/app/models/post_action_type.rb @@ -0,0 +1,31 @@ +class PostActionType < ActiveRecord::Base + attr_accessible :id, :is_flag, :name_key, :icon + + def self.ordered + self.order('position asc').all + end + + def self.Types + @types ||= {:bookmark => 1, + :like => 2, + :off_topic => 3, + :inappropriate => 4, + :vote => 5, + :custom_flag => 6, + :spam => 8 + } + end + + def self.is_flag?(sym) + self.FlagTypes.include?(self.Types[sym]) + end + + def self.AutoActionFlagTypes + @auto_action_flag_types ||= [self.Types[:off_topic], self.Types[:spam], self.Types[:inappropriate]] + end + + def self.FlagTypes + @flag_types ||= self.AutoActionFlagTypes + [self.Types[:custom_flag]] + end + +end diff --git a/app/models/post_alert_observer.rb b/app/models/post_alert_observer.rb new file mode 100644 index 00000000000..6b9209ed748 --- /dev/null +++ b/app/models/post_alert_observer.rb @@ -0,0 +1,141 @@ +class PostAlertObserver < ActiveRecord::Observer + observe :post, VestalVersions::Version, :post_action + + # Dispatch to an after_save_#{class_name} method + def after_save(model) + method_name = callback_for('after_save', model) + send(method_name, model) if respond_to?(method_name) + end + + # Dispatch to an after_create_#{class_name} method + def after_create(model) + method_name = callback_for('after_create', model) + send(method_name, model) if respond_to?(method_name) + end + + # We need to consider new people to mention / quote when a post is edited + def after_save_post(post) + mentioned_users = extract_mentioned_users(post) + quoted_users = extract_quoted_users(post) + + reply_to_user = post.reply_notification_target + notify_users(mentioned_users - [reply_to_user], :mentioned, post) + notify_users(quoted_users - mentioned_users - [reply_to_user], :quoted, post) + end + + def after_save_post_action(post_action) + # We only care about deleting post actions for now + return unless post_action.deleted_at.present? + Notification.where(["post_action_id = ?", post_action.id]).each {|n| n.destroy} + end + + def after_create_post_action(post_action) + + # We only notify on likes for now + return unless post_action.is_like? + + post = post_action.post + return if post_action.user.blank? + return if post.topic.private_message? + + create_notification(post.user, + Notification.Types[:liked], + post, + display_username: post_action.user.username, + post_action_id: post_action.id) + end + + def after_create_version(version) + post = version.versioned + + return unless post.is_a?(Post) + return if version.user.blank? + return if version.user_id == post.user_id + return if post.topic.private_message? + + create_notification(post.user, Notification.Types[:edited], post, display_username: version.user.username) + end + + def after_create_post(post) + if post.topic.private_message? + # If it's a private message, notify the topic_allowed_users + post.topic.topic_allowed_users.reject{|a| a.user_id == post.user_id}.each do |a| + create_notification(a.user, Notification.Types[:private_message], post) + end + else + # If it's not a private message, notify the users + notify_post_users(post) + end + end + + protected + + def callback_for(action, model) + "#{action}_#{model.class.name.underscore.gsub(/.+\//, '')}" + end + + def create_notification(user, type, post, opts={}) + return if user.blank? + + # skip if muted on the topic + return if TopicUser.get(post.topic, user).try(:notification_level) == TopicUser::NotificationLevel::MUTED + + # Don't notify the same user about the same notification on the same post + return if user.notifications.exists?(notification_type: type, topic_id: post.topic_id, post_number: post.post_number) + + user.notifications.create(notification_type: type, + topic_id: post.topic_id, + post_number: post.post_number, + post_action_id: opts[:post_action_id], + data: {topic_title: post.topic.title, + display_username: opts[:display_username] || post.user.username}.to_json) + end + + # Returns a list users who have been mentioned + def extract_mentioned_users(post) + User.where("username_lower in (?)", post.raw_mentions).where("id <> ?", post.user_id) + end + + # Returns a list of users who were quoted in the post + def extract_quoted_users(post) + result = [] + post.raw.scan(/\[quote=\"([^,]+),.+\"\]/).uniq.each do |m| + username = m.first.strip.downcase + user = User.where("(LOWER(username_lower) = :username or LOWER(name) = :username) and id != :id", username: username, id: post.user_id).first + result << user if user.present? + end + result + end + + # Notify a bunch of users + def notify_users(users, type, post) + users = [users] unless users.is_a?(Array) + users.each do |u| + create_notification(u, Notification.Types[type], post) + end + end + + # TODO: This should use javascript for parsing rather than re-doing it this way. + def notify_post_users(post) + + # Is this post a reply to a user? + reply_to_user = post.reply_notification_target + notify_users(reply_to_user, :replied, post) + + + # find all users watching + if post.post_number > 1 + exclude_user_ids = [] + exclude_user_ids << post.user_id + exclude_user_ids << reply_to_user.id if reply_to_user.present? + exclude_user_ids << extract_mentioned_users(post).map{|u| u.id} + exclude_user_ids << extract_quoted_users(post).map{|u| u.id} + exclude_user_ids.flatten! + + TopicUser.where(topic_id: post.topic_id, notification_level: TopicUser::NotificationLevel::WATCHING).includes(:user).each do |tu| + create_notification(tu.user, Notification.Types[:posted], post) unless exclude_user_ids.include?(tu.user_id) + end + end + end + +end diff --git a/app/models/post_onebox_render.rb b/app/models/post_onebox_render.rb new file mode 100644 index 00000000000..cc3c0931bf8 --- /dev/null +++ b/app/models/post_onebox_render.rb @@ -0,0 +1,6 @@ +class PostOneboxRender < ActiveRecord::Base + belongs_to :post + belongs_to :onebox_render + + validates_uniqueness_of :post_id, scope: :onebox_render_id +end diff --git a/app/models/post_reply.rb b/app/models/post_reply.rb new file mode 100644 index 00000000000..d5bf806ad47 --- /dev/null +++ b/app/models/post_reply.rb @@ -0,0 +1,7 @@ +class PostReply < ActiveRecord::Base + + belongs_to :post + belongs_to :reply, class_name: 'Post' + + validates_uniqueness_of :reply_id, scope: :post_id +end diff --git a/app/models/post_timing.rb b/app/models/post_timing.rb new file mode 100644 index 00000000000..412ef6dfc4e --- /dev/null +++ b/app/models/post_timing.rb @@ -0,0 +1,42 @@ +class PostTiming < ActiveRecord::Base + + belongs_to :topic + belongs_to :user + + validates_presence_of :post_number + validates_presence_of :msecs + + + # Increases a timer if a row exists, otherwise create it + def self.record_timing(args) + + rows = exec_sql_row_count("UPDATE post_timings + SET msecs = msecs + :msecs + WHERE topic_id = :topic_id + AND user_id = :user_id + AND post_number = :post_number", + args) + + if rows == 0 + Post.update_all 'reads = reads + 1', ['topic_id = :topic_id and post_number = :post_number', args] + exec_sql("INSERT INTO post_timings (topic_id, user_id, post_number, msecs) + SELECT :topic_id, :user_id, :post_number, :msecs + WHERE NOT EXISTS(SELECT 1 FROM post_timings + WHERE topic_id = :topic_id + AND user_id = :user_id + AND post_number = :post_number)", + args) + + end + + end + + + def self.destroy_for(user_id, topic_id) + PostTiming.transaction do + PostTiming.delete_all(['user_id = ? and topic_id = ?', user_id, topic_id]) + TopicUser.delete_all(['user_id = ? and topic_id = ?', user_id, topic_id]) + end + end + +end diff --git a/app/models/search_observer.rb b/app/models/search_observer.rb new file mode 100644 index 00000000000..17cdb9105ac --- /dev/null +++ b/app/models/search_observer.rb @@ -0,0 +1,103 @@ +class SearchObserver < ActiveRecord::Observer + observe :topic, :post, :user, :category + + def self.scrub_html_for_search(html) + HtmlScrubber.scrub(html) + end + + def self.update_index(table, id, idx) + Post.exec_sql("delete from #{table} where id = ?", id) + sql = "insert into #{table} (id, search_data) values (?, to_tsvector('english', ?))" + begin + Post.exec_sql(sql, id, idx) + rescue + # don't allow concurrency to mess up saving a post + end + end + + def self.update_posts_index(post_id, cooked, title, category) + idx = scrub_html_for_search(cooked) + idx << " " << title + idx << " " << category if category + update_index('posts_search', post_id, idx) + end + + def self.update_users_index(user_id, username, name) + idx = username.dup + idx << " " << (name || "") + + update_index('users_search', user_id, idx) + end + + def self.update_categories_index(category_id, name) + update_index('categories_search', category_id, name) + end + + def after_save(obj) + if obj.class == Post && obj.cooked_changed? + category_name = obj.topic.category.name if obj.topic.category + SearchObserver.update_posts_index(obj.id, obj.cooked, obj.topic.title, category_name) + end + if obj.class == User && (obj.username_changed? || obj.name_changed?) + SearchObserver.update_users_index(obj.id, obj.username, obj.name) + end + + if obj.class == Topic && obj.title_changed? + if obj.posts + post = obj.posts.where(post_number: 1).first + if post + category_name = obj.category.name if obj.category + SearchObserver.update_posts_index(post.id, post.cooked, obj.title, category_name) + end + end + end + + if obj.class == Category && obj.name_changed? + SearchObserver.update_categories_index(obj.id, obj.name) + end + end + + + + class HtmlScrubber < Nokogiri::XML::SAX::Document + attr_reader :scrubbed + + def initialize + @scrubbed = "" + end + + def self.scrub(html) + me = self.new + parser = Nokogiri::HTML::SAX::Parser.new(me) + begin + copy = "
            " + copy << html unless html.nil? + copy << "
            " + parser.parse(html) unless html.nil? + end + me.scrubbed + end + + def start_element(name, attributes=[]) + attributes = Hash[*attributes.flatten] + if attributes["alt"] + scrubbed << " " + scrubbed << attributes["alt"] + scrubbed << " " + end + if attributes["title"] + scrubbed << " " + scrubbed << attributes["title"] + scrubbed << " " + end + end + + def characters(string) + scrubbed << " " + scrubbed << string + scrubbed << " " + end + end + +end + diff --git a/app/models/site.rb b/app/models/site.rb new file mode 100644 index 00000000000..ffdc8600e62 --- /dev/null +++ b/app/models/site.rb @@ -0,0 +1,49 @@ +# A class we can use to serialize the site data +require_dependency 'score_calculator' +require_dependency 'trust_level' + +class Site + include ActiveModel::Serialization + + def site_setting + SiteSetting + end + + def post_action_types + PostActionType.ordered + end + + def notification_types + Notification.Types + end + + def trust_levels + TrustLevel.all + end + + def categories + Category.popular + end + + def archetypes + Archetype.list.reject{|t| t.id==Archetype.private_message} + end + + def self.cache_key + "site_json" + end + + def self.cached_json + # Sam: bumping this way down, SiteSerializer will serialize post actions as well, + # On my local this was not being flushed as post actions types changed, it turn this + # broke local. + Rails.cache.fetch(Site.cache_key, expires_in: 1.minute) do + MultiJson.dump(SiteSerializer.new(Site.new, root: false)) + end + end + + def self.invalidate_cache + Rails.cache.delete(Site.cache_key) + end + +end diff --git a/app/models/site_customization.rb b/app/models/site_customization.rb new file mode 100644 index 00000000000..af2dca2f063 --- /dev/null +++ b/app/models/site_customization.rb @@ -0,0 +1,144 @@ +class SiteCustomization < ActiveRecord::Base + + CACHE_PATH = 'stylesheet-cache' + @lock = Mutex.new + + before_create do + self.position ||= (SiteCustomization.maximum(:position) || 0) + 1 + self.enabled ||= false + self.key ||= SecureRandom.uuid + true + end + + before_save do + if self.stylesheet_changed? + begin + self.stylesheet_baked = Sass.compile self.stylesheet + rescue Sass::SyntaxError => e + error = e.sass_backtrace_str("custom stylesheet") + error.gsub!("\n", '\A ') + error.gsub!("'", '\27 ') + + self.stylesheet_baked = +"#main {display: none;} +footer {white-space: pre; margin-left: 100px;} +footer:after{ content: '#{error}' }" + end + end + end + + after_save do + if self.stylesheet_changed? + if File.exists?(self.stylesheet_fullpath) + File.delete self.stylesheet_fullpath + end + end + self.remove_from_cache! + if self.stylesheet_changed? + self.ensure_stylesheet_on_disk! + MessageBus.publish "/file-change/#{self.key}", self.stylesheet_hash + end + MessageBus.publish "/header-change/#{self.key}", self.header if self.header_changed? + + end + + after_destroy do + if File.exists?(self.stylesheet_fullpath) + File.delete self.stylesheet_fullpath + end + self.remove_from_cache! + end + + + def self.custom_stylesheet(preview_style) + style = lookup_style(preview_style) + style.stylesheet_link_tag.html_safe if style + end + + def self.custom_header(preview_style) + style = lookup_style(preview_style) + style.header.html_safe if style + end + + def self.override_default_style(preview_style) + style = lookup_style(preview_style) + style.override_default_style if style + end + + def self.lookup_style(key) + + return if key.blank? + + # cache is cross site resiliant cause key is secure random + @cache ||= {} + ensure_cache_listener + style = @cache[key] + return style if style + + @lock.synchronize do + style = self.where(key: key).first + @cache[key] = style + end + end + + def self.ensure_cache_listener + unless @subscribed + klass = self + MessageBus.subscribe("/site_customization") do |msg| + message = msg.data + klass.remove_from_cache!(message["key"], false) + end + + @subscribed = true + end + end + + def self.remove_from_cache!(key, broadcast=true) + MessageBus.publish('/site_customization', {key: key}) if broadcast + if @cache + @lock.synchronize do + @cache[key] = nil + end + end + end + + def remove_from_cache! + self.class.remove_from_cache!(self.key) + end + + def stylesheet_hash + Digest::MD5.hexdigest(self.stylesheet) + end + + def ensure_stylesheet_on_disk! + path = stylesheet_fullpath + dir = "#{Rails.root}/public/#{CACHE_PATH}" + FileUtils.mkdir_p(dir) + unless File.exists?(path) + File.open(path, "w") do |f| + f.puts self.stylesheet_baked + end + end + end + + def stylesheet_filename + file = "" + dir = "#{Rails.root}/public/#{CACHE_PATH}" + path = dir + file + + "/#{CACHE_PATH}/#{self.key}.css" + end + + def stylesheet_fullpath + "#{Rails.root}/public#{self.stylesheet_filename}" + end + + def stylesheet_link_tag + return "" unless self.stylesheet.present? + return @stylesheet_link_tag if @stylesheet_link_tag + ensure_stylesheet_on_disk! + @stylesheet_link_tag = "" + end + + +end diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb new file mode 100644 index 00000000000..c89f930f34f --- /dev/null +++ b/app/models/site_setting.rb @@ -0,0 +1,129 @@ +require 'site_setting_extension' + +class SiteSetting < ActiveRecord::Base + extend SiteSettingExtension + + validates_presence_of :name + validates_presence_of :data_type + + attr_accessible :description, :name, :value, :data_type + + # settings available in javascript under Discourse.SiteSettings + client_setting(:title, "Discourse") + client_setting(:logo_url, '/assets/logo.png') + client_setting(:logo_small_url, '') + client_setting(:traditional_markdown_linebreaks, false) + client_setting(:popup_delay, 1500) + client_setting(:top_menu, 'popular|new|unread|favorited|categories') + client_setting(:post_menu, 'like|edit|flag|delete|share|bookmark|reply') + client_setting(:max_length_show_reply, 1500) + client_setting(:track_external_right_clicks, false) + client_setting(:must_approve_users, false) + client_setting(:ga_tracking_code, "") + client_setting(:new_topics_rollup, 1) + client_setting(:enable_long_polling, true) + client_setting(:polling_interval, 3000) + client_setting(:anon_polling_interval, 30000) + client_setting(:min_post_length, Rails.env.test? ? 5 : 20) + client_setting(:max_post_length, 16000) + client_setting(:min_topic_title_length, 5) + client_setting(:max_topic_title_length, 255) + client_setting(:flush_timings_secs, 5) + + + # settings only available server side + setting(:auto_track_topics_after, 60000) + setting(:long_polling_interval, 15000) + setting(:flags_required_to_hide_post, 3) + setting(:cooldown_minutes_after_hiding_posts, 10) + setting(:port, Rails.env.development? ? 3000 : '') + setting(:enable_private_messages, true) + setting(:use_ssl, false) + setting(:secret_token) + setting(:restrict_access, false) + setting(:access_password) + setting(:queue_jobs, !Rails.env.test?) + setting(:crawl_images, !Rails.env.test?) + setting(:enable_imgur, false) + setting(:imgur_api_key, '') + setting(:imgur_endpoint, "http://api.imgur.com/2/upload.json") + setting(:max_image_width, 690) + setting(:category_featured_topics, 6) + setting(:topics_per_page, 30) + setting(:posts_per_page, 20) + setting(:invite_expiry_days, 14) + setting(:active_user_rate_limit_secs, 60) + setting(:previous_visit_timeout_hours, 1) + setting(:favicon_url, '/assets/favicon.ico') + + setting(:ninja_edit_window, 5.minutes.to_i) + setting(:post_undo_action_window_mins, 10) + setting(:system_username, '') + setting(:max_mentions_per_post, 5) + + setting(:uncategorized_name, 'uncategorized') + + setting(:unique_posts_mins, Rails.env.test? ? 0 : 5) + + # Rate Limits + setting(:rate_limit_create_topic, 5) + setting(:rate_limit_create_post, 5) + setting(:max_topics_per_day, 20) + setting(:max_private_messages_per_day, 20) + setting(:max_likes_per_day, 20) + setting(:max_bookmarks_per_day, 20) + setting(:max_flags_per_day, 20) + setting(:max_edits_per_day, 30) + setting(:max_favorites_per_day, 20) + + + setting(:email_time_window_mins, 5) + + # How many characters we can import into a onebox + setting(:onebox_max_chars, 5000) + + setting(:suggested_topics, 5) + + setting(:allow_duplicate_topic_titles, false) + + setting(:post_excerpt_maxlength, 300) + setting(:post_onebox_maxlength, 500) + setting(:best_of_score_threshold, 15) + setting(:best_of_posts_required, 50) + setting(:best_of_likes_required, 1) + setting(:category_post_template, + "[Replace this first paragraph with a short description of your new category. Try to keep it below 200 characters.]\n\nUse this space below for a longer description, as well as to establish any rules or discussion!") + + # we need to think of a way to force users to enter certain settings, this is a minimal config thing + setting(:notification_email, 'info@discourse.org') + + setting(:send_welcome_message, true) + + setting(:twitter_consumer_key, '') + setting(:twitter_consumer_secret, '') + + setting(:facebook_app_id, '') + setting(:facebook_app_secret, '') + + setting(:enforce_global_nicknames, true) + setting(:discourse_org_access_key, '') + setting(:enable_s3_uploads, false) + setting(:s3_upload_bucket, '') + + setting(:default_trust_level, 0) + setting(:default_invitee_trust_level, 1) + + # Import/Export settings + setting(:allow_import, false) + + # Trust related + setting(:basic_requires_topics_entered, 5) + setting(:basic_requires_read_posts, 100) + setting(:basic_requires_time_spent_mins, 30) + + + def self.call_mothership? + self.enforce_global_nicknames? and self.discourse_org_access_key.present? + end + +end diff --git a/app/models/topic.rb b/app/models/topic.rb new file mode 100644 index 00000000000..ae70bd123be --- /dev/null +++ b/app/models/topic.rb @@ -0,0 +1,516 @@ +require_dependency 'slug' +require_dependency 'avatar_lookup' +require_dependency 'topic_view' +require_dependency 'rate_limiter' + +class Topic < ActiveRecord::Base + include RateLimiter::OnCreateRecord + + MAX_SORT_ORDER = 2147483647 + FEATURED_USERS = 4 + + versioned :if => :new_version_required? + acts_as_paranoid + + rate_limit :default_rate_limiter + rate_limit :limit_topics_per_day + rate_limit :limit_private_messages_per_day + + validates_presence_of :title + validates :title, length: {in: SiteSetting.min_topic_title_length..SiteSetting.max_topic_title_length} + + serialize :meta_data, ActiveRecord::Coders::Hstore + + validate :unique_title + + belongs_to :category + has_many :posts + has_many :topic_allowed_users + has_many :allowed_users, through: :topic_allowed_users, source: :user + belongs_to :user + belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id + belongs_to :featured_user1, class_name: 'User', foreign_key: :featured_user1_id + belongs_to :featured_user2, class_name: 'User', foreign_key: :featured_user2_id + belongs_to :featured_user3, class_name: 'User', foreign_key: :featured_user3_id + belongs_to :featured_user4, class_name: 'User', foreign_key: :featured_user4_id + + has_many :topic_users + has_many :topic_links + has_many :topic_invites + has_many :invites, through: :topic_invites, source: :invite + + # When we want to temporarily attach some data to a forum topic (usually before serialization) + attr_accessor :user_data + attr_accessor :posters # TODO: can replace with posters_summary once we remove old list code + + + # The regular order + scope :topic_list_order, lambda { order('topics.bumped_at desc') } + + # Return private message topics + scope :private_messages, lambda { + where(archetype: Archetype::private_message) + } + + scope :listable_topics, lambda { where('topics.archetype <> ?', [Archetype.private_message]) } + + # Helps us limit how many favorites can be made in a day + class FavoriteLimiter < RateLimiter + def initialize(user) + super(user, "favorited:#{Date.today.to_s}", SiteSetting.max_favorites_per_day, 1.day.to_i) + end + end + + before_validation do + self.title.strip! if self.title.present? + end + + before_create do + self.bumped_at ||= Time.now + self.last_post_user_id ||= self.user_id + end + + after_create do + changed_to_category(category) + TopicUser.change( + self.user_id, self.id, + notification_level: TopicUser::NotificationLevel::WATCHING, + notifications_reason_id: TopicUser::NotificationReasons::CREATED_TOPIC + ) + if self.archetype == Archetype.private_message + DraftSequence.next!(self.user, Draft::NEW_PRIVATE_MESSAGE) + else + DraftSequence.next!(self.user, Draft::NEW_TOPIC) + end + end + + # Additional rate limits on topics: per day and private messages per day + def limit_topics_per_day + RateLimiter.new(user, "topics-per-day:#{Date.today.to_s}", SiteSetting.max_topics_per_day, 1.day.to_i) + end + + def limit_private_messages_per_day + return unless private_message? + RateLimiter.new(user, "pms-per-day:#{Date.today.to_s}", SiteSetting.max_private_messages_per_day, 1.day.to_i) + end + + # Validate unique titles if a site setting is set + def unique_title + return if SiteSetting.allow_duplicate_topic_titles? + + # Let presence validation catch it if it's blank + return if title.blank? + + # Private messages can be called whatever they want + return if private_message? + + finder = Topic.listable_topics.where("lower(title) = ?", title.downcase) + finder = finder.where("id != ?", self.id) if self.id.present? + + errors.add(:title, I18n.t(:has_already_been_used)) if finder.exists? + end + + + def new_version_required? + return true if title_changed? + return true if category_id_changed? + false + end + + # Returns new topics since a date + def self.new_topics(since) + Topic + .visible + .where("created_at >= ?", since) + .listable_topics + .topic_list_order + .includes(:user) + .limit(5) + end + + def update_meta_data(data) + self.meta_data = (self.meta_data || {}).merge(data.stringify_keys) + save + end + + def post_numbers + @post_numbers ||= posts.order(:post_number).pluck(:post_number) + end + + def has_meta_data_boolean?(key) + meta_data_string(key) == 'true' + end + + def meta_data_string(key) + return nil unless meta_data.present? + meta_data[key.to_s] + end + + def self.visible + where(visible: true) + end + + def private_message? + self.archetype == Archetype.private_message + end + + def links_grouped + exec_sql("SELECT ftl.url, + ft.title, + ftl.link_topic_id, + ftl.reflection, + ftl.internal, + MIN(ftl.user_id) AS user_id, + SUM(clicks) AS clicks + FROM topic_links AS ftl + LEFT OUTER JOIN topics AS ft ON ftl.link_topic_id = ft.id + WHERE ftl.topic_id = ? + GROUP BY ftl.url, ft.title, ftl.link_topic_id, ftl.reflection, ftl.internal + ORDER BY clicks DESC", + self.id).to_a + end + + def update_status(property, status, user) + Topic.transaction do + update_column(property, status) + key = "topic_statuses.#{property}_" + key << (status ? 'enabled' : 'disabled') + + opts = {} + + # We don't bump moderator posts except for the re-open post. + opts[:bump] = true if property == 'closed' and (!status) + + add_moderator_post(user, I18n.t(key), opts) + end + end + + # Atomically creates the next post number + def self.next_post_number(topic_id, reply=false) + highest = exec_sql("select coalesce(max(post_number),0) as max from posts where topic_id = ?", topic_id).first['max'].to_i + + reply_sql = reply ? ", reply_count = reply_count + 1" : "" + result = exec_sql("UPDATE topics SET highest_post_number = ? + 1#{reply_sql} + WHERE id = ? RETURNING highest_post_number", highest, topic_id) + result.first['highest_post_number'].to_i + end + + # If a post is deleted we have to update our highest post counters + def self.reset_highest(topic_id) + result = exec_sql "UPDATE topics + SET highest_post_number = (SELECT COALESCE(MAX(post_number), 0) FROM posts WHERE topic_id = :topic_id AND deleted_at IS NULL), + posts_count = (SELECT count(*) FROM posts WHERE deleted_at IS NULL AND topic_id = :topic_id) + WHERE id = :topic_id + RETURNING highest_post_number", topic_id: topic_id + highest_post_number = result.first['highest_post_number'].to_i + + # Update the forum topic user records + exec_sql "UPDATE topic_users + SET last_read_post_number = CASE + WHEN last_read_post_number > :highest THEN :highest + ELSE last_read_post_number + END, + seen_post_count = CASE + WHEN seen_post_count > :highest THEN :highest + ELSE seen_post_count + END + WHERE topic_id = :topic_id", + highest: highest_post_number, + topic_id: topic_id + end + + # This calculates the geometric mean of the posts and stores it with the topic + def self.calculate_avg_time + exec_sql("UPDATE topics + SET avg_time = x.gmean + FROM (SELECT topic_id, + round(exp(avg(ln(avg_time)))) AS gmean + FROM posts + GROUP BY topic_id) AS x + WHERE x.topic_id = topics.id") + end + + def changed_to_category(cat) + + return if cat.blank? + return if Category.where(topic_id: self.id).first.present? + + Topic.transaction do + old_category = category + + if category_id.present? and category_id != cat.id + Category.update_all 'topic_count = topic_count - 1', ['id = ?', category_id] + end + + self.category_id = cat.id + self.save + + CategoryFeaturedTopic.feature_topics_for(old_category) + Category.update_all 'topic_count = topic_count + 1', ['id = ?', cat.id] + CategoryFeaturedTopic.feature_topics_for(cat) unless old_category.try(:id) == cat.try(:id) + end + end + + def add_moderator_post(user, text, opts={}) + new_post = nil + Topic.transaction do + new_post = posts.create(user: user, raw: text, post_type: Post::MODERATOR_ACTION, no_bump: opts[:bump].blank?) + increment!(:moderator_posts_count) + new_post + end + + + if new_post.present? + # If we are moving posts, we want to insert the moderator post where the previous posts were + # in the stream, not at the end. + new_post.update_attributes(post_number: opts[:post_number], sort_order: opts[:post_number]) if opts[:post_number].present? + + # Grab any links that are present + TopicLink.extract_from(new_post) + end + + new_post + end + + # Changes the category to a new name + def change_category(name) + # If the category name is blank, reset the attribute + if name.blank? + if category_id.present? + CategoryFeaturedTopic.feature_topics_for(category) + Category.update_all 'topic_count = topic_count - 1', ['id = ?', category_id] + end + self.category_id = nil + self.save + return + end + + cat = Category.where(name: name).first + return if cat == category + changed_to_category(cat) + end + + def featured_user_ids + [featured_user1_id, featured_user2_id, featured_user3_id, featured_user4_id].uniq.compact + end + + # Invite a user to the topic by username or email. Returns success/failure + def invite(invited_by, username_or_email) + if private_message? + # If the user exists, add them to the topic. + user = User.where("lower(username) = :user OR lower(email) = :user", user: username_or_email.downcase).first + if user.present? + if topic_allowed_users.create!(user_id: user.id) + # Notify the user they've been invited + user.notifications.create(notification_type: Notification.Types[:invited_to_private_message], + topic_id: self.id, + post_number: 1, + data: {topic_title: self.title, + display_username: invited_by.username}.to_json) + return true + end + elsif username_or_email =~ /^.+@.+$/ + # If the user doesn't exist, but it looks like an email, invite the user by email. + return invite_by_email(invited_by, username_or_email) + end + else + # Success is whether the invite was created + return invite_by_email(invited_by, username_or_email).present? + end + + false + end + + # Invite a user by email and return the invite. Return the previously existing invite + # if already exists. Returns nil if the invite can't be created. + def invite_by_email(invited_by, email) + lower_email = email.downcase + + # + invite = Invite.with_deleted.where('invited_by_id = ? and email = ?', invited_by.id, lower_email).first + + + if invite.blank? + invite = Invite.create(invited_by: invited_by, email: lower_email) + unless invite.valid? + + # If the email already exists, grant permission to that user + if invite.email_already_exists and private_message? + user = User.where(email: lower_email).first + topic_allowed_users.create!(user_id: user.id) + end + + return nil + end + end + + # Recover deleted invites if we invite them again + invite.recover if invite.deleted_at.present? + + topic_invites.create(invite_id: invite.id) + Jobs.enqueue(:invite_email, invite_id: invite.id) + invite + end + + def move_posts(moved_by, new_title, post_ids) + topic = nil + first_post_number = nil + Topic.transaction do + topic = Topic.create(user: moved_by, title: new_title, category: self.category) + + to_move = posts.where(id: post_ids).order(:created_at) + raise Discourse::InvalidParameters.new(:post_ids) if to_move.blank? + + to_move.each_with_index do |post, i| + first_post_number ||= post.post_number + row_count = Post.update_all ["post_number = :post_number, topic_id = :topic_id, sort_order = :post_number", post_number: i+1, topic_id: topic.id], + ['id = ? AND topic_id = ?', post.id, self.id] + + # We raise an error if any of the posts can't be moved + raise Discourse::InvalidParameters.new(:post_ids) if row_count == 0 + end + + # Update denormalized values since we've manually moved stuff + Topic.reset_highest(topic.id) + Topic.reset_highest(self.id) + end + + # Add a moderator post explaining that the post was moved + if topic.present? + topic_url = "#{Discourse.base_url}#{topic.relative_url}" + topic_link = "[#{new_title}](#{topic_url})" + + post = add_moderator_post(moved_by, I18n.t("move_posts.moderator_post", count: post_ids.size, topic_link: topic_link), post_number: first_post_number) + Jobs.enqueue(:notify_moved_posts, post_ids: post_ids, moved_by_id: moved_by.id) + end + + topic + end + + + # Create the summary of the interesting posters in a topic. Cheats to avoid + # many queries. + def posters_summary(topic_user=nil, current_user=nil, opts={}) + return @posters_summary if @posters_summary.present? + descriptions = {} + + # Use an avatar lookup object if we have it, otherwise create one just for this forum topic + al = opts[:avatar_lookup] + if al.blank? + al = AvatarLookup.new([user_id, last_post_user_id, featured_user1_id, featured_user2_id, featured_user3_id]) + end + + # Helps us add a description to a poster + add_description = lambda do |u, desc| + if u.present? + descriptions[u.id] ||= [] + descriptions[u.id] << I18n.t(desc) + end + end + + posted = if topic_user.present? and current_user.present? + current_user if topic_user.posted? + end + + add_description.call(current_user, :youve_posted) if posted.present? + add_description.call(al[user_id], :original_poster) + add_description.call(al[featured_user1_id], :most_posts) + add_description.call(al[featured_user2_id], :frequent_poster) + add_description.call(al[featured_user3_id], :frequent_poster) + add_description.call(al[featured_user4_id], :frequent_poster) + add_description.call(al[last_post_user_id], :most_recent_poster) + + + @posters_summary = [al[user_id], + posted, + al[last_post_user_id], + al[featured_user1_id], + al[featured_user2_id], + al[featured_user3_id], + al[featured_user4_id] + ].compact.uniq[0..4] + + unless @posters_summary[0] == al[last_post_user_id] + # shuffle last_poster to back + @posters_summary.reject!{|u| u == al[last_post_user_id]} + @posters_summary << al[last_post_user_id] + end + @posters_summary.map! do |p| + result = TopicPoster.new + result.user = p + result.description = descriptions[p.id].join(', ') + result.extras = "latest" if al[last_post_user_id] == p + result + end + + @posters_summary + end + + # Enable/disable the star on the topic + def toggle_star(user, starred) + + Topic.transaction do + TopicUser.change(user, self.id, starred: starred, starred_at: starred ? DateTime.now : nil) + + # Update the star count + exec_sql "UPDATE topics + SET star_count = (SELECT COUNT(*) + FROM topic_users AS ftu + WHERE ftu.topic_id = topics.id + AND ftu.starred = true) + WHERE id = ?", self.id + + if starred + FavoriteLimiter.new(user).performed! + else + FavoriteLimiter.new(user).rollback! + end + end + end + + # Enable/disable the mute on the topic + def toggle_mute(user, muted) + TopicUser.change(user, self.id, notification_level: muted?(user) ? TopicUser::NotificationLevel::REGULAR : TopicUser::NotificationLevel::MUTED ) + end + + def slug + Slug.for(title) + end + + def last_post_url + "/t/#{slug}/#{id}/#{posts_count}" + end + + def relative_url(post_number=nil) + url = "/t/#{slug}/#{id}" + url << "/#{post_number}" if post_number.present? and post_number.to_i > 1 + url + end + + def muted?(user) + return false unless user && user.id + tu = topic_users.where(user_id: user.id).first + tu && tu.notification_level == TopicUser::NotificationLevel::MUTED + end + + def draft_key + "#{Draft::EXISTING_TOPIC}#{self.id}" + end + + # notification stuff + def notify_watch!(user) + TopicUser.change(user, self.id, notification_level: TopicUser::NotificationLevel::WATCHING) + end + + def notify_tracking!(user) + TopicUser.change(user, self.id, notification_level: TopicUser::NotificationLevel::TRACKING) + end + + def notify_regular!(user) + TopicUser.change(user, self.id, notification_level: TopicUser::NotificationLevel::REGULAR) + end + + def notify_muted!(user) + TopicUser.change(user, self.id, notification_level: TopicUser::NotificationLevel::MUTED) + end +end diff --git a/app/models/topic_allowed_user.rb b/app/models/topic_allowed_user.rb new file mode 100644 index 00000000000..031c88700f5 --- /dev/null +++ b/app/models/topic_allowed_user.rb @@ -0,0 +1,7 @@ +class TopicAllowedUser < ActiveRecord::Base + belongs_to :topic + belongs_to :user + attr_accessible :topic_id, :user_id + + validates_uniqueness_of :topic_id, scope: :user_id +end diff --git a/app/models/topic_invite.rb b/app/models/topic_invite.rb new file mode 100644 index 00000000000..c252cfe1f8a --- /dev/null +++ b/app/models/topic_invite.rb @@ -0,0 +1,10 @@ +class TopicInvite < ActiveRecord::Base + + belongs_to :topic + belongs_to :invite + + validates_presence_of :topic_id + validates_presence_of :invite_id + + validates_uniqueness_of :topic_id, scope: :invite_id +end diff --git a/app/models/topic_link.rb b/app/models/topic_link.rb new file mode 100644 index 00000000000..35596e7b3cb --- /dev/null +++ b/app/models/topic_link.rb @@ -0,0 +1,112 @@ +require 'uri' +require_dependency 'slug' + +class TopicLink < ActiveRecord::Base + + belongs_to :topic + belongs_to :user + belongs_to :post + belongs_to :link_topic, class_name: 'Topic' + + validates_presence_of :url + + validates_length_of :url, maximum: 500 + + validates_uniqueness_of :url, scope: [:topic_id, :post_id] + + has_many :topic_link_clicks + + validate :link_to_self + + # Make sure a topic can't link to itself + def link_to_self + errors.add(:base, "can't link to the same topic") if (topic_id == link_topic_id) + end + + # Extract any urls in body + def self.extract_from(post) + return unless post.present? + + TopicLink.transaction do + + added_urls = [] + reflected_urls = [] + + PrettyText + .extract_links(post.cooked) + .map{|u| [u, URI.parse(u)] rescue nil} + .reject{|u,p| p.nil?} + .uniq{|u,p| u} + .each do |url, parsed| + begin + + internal = false + topic_id = nil + post_number = nil + if parsed.host == Discourse.current_hostname || !parsed.host + internal = true + + route = Rails.application.routes.recognize_path(parsed.path) + topic_id = route[:topic_id] + post_number = route[:post_number] || 1 + end + + # Skip linking to ourselves + next if topic_id == post.topic_id + + added_urls << url + TopicLink.create(post_id: post.id, + user_id: post.user_id, + topic_id: post.topic_id, + url: url, + domain: parsed.host || Discourse.current_hostname, + internal: internal, + link_topic_id: topic_id) + + # Create the reflection if we can + if topic_id.present? + topic = Topic.where(id: topic_id).first + + if topic && post.topic.archetype != 'private_message' && topic.archetype != 'private_message' + + prefix = Discourse.base_url + + reflected_post = nil + if post_number.present? + reflected_post = Post.where(topic_id: topic_id, post_number: post_number.to_i).first + end + + reflected_url = "#{prefix}#{post.topic.relative_url(post.post_number)}" + + reflected_urls << reflected_url + TopicLink.create(user_id: post.user_id, + topic_id: topic_id, + post_id: reflected_post.try(:id), + url: reflected_url, + domain: Discourse.current_hostname, + reflection: true, + internal: true, + link_topic_id: post.topic_id, + link_post_id: post.id) + end + end + + rescue URI::InvalidURIError + # if the URI is invalid, don't store it. + rescue ActionController::RoutingError + # If we can't find the route, no big deal + end + end + + # Remove links that aren't there anymore + if added_urls.present? + TopicLink.delete_all ["(url not in (:urls)) AND (post_id = :post_id)", urls: added_urls, post_id: post.id] + TopicLink.delete_all ["(url not in (:urls)) AND (link_post_id = :post_id)", urls: reflected_urls, post_id: post.id] + else + TopicLink.delete_all ["post_id = :post_id OR link_post_id = :post_id", post_id: post.id] + end + + end + end + +end diff --git a/app/models/topic_link_click.rb b/app/models/topic_link_click.rb new file mode 100644 index 00000000000..c0a041af80a --- /dev/null +++ b/app/models/topic_link_click.rb @@ -0,0 +1,58 @@ +require_dependency 'discourse' +require 'ipaddr' + +class TopicLinkClick < ActiveRecord::Base + + belongs_to :topic_link, counter_cache: :clicks + belongs_to :user + + has_ip_address :ip + + validates_presence_of :topic_link_id + validates_presence_of :ip + + # Create a click from a URL and post_id + def self.create_from(args={}) + + # Find the forum topic link + link = TopicLink.select(:id).where(url: args[:url]) + link = link.where("user_id <> ?", args[:user_id]) if args[:user_id].present? + link = link.where(post_id: args[:post_id]) if args[:post_id].present? + + # If we don't have a post, just find the first occurance of the link + link = link.where(topic_id: args[:topic_id]) if args[:topic_id].present? + link = link.first + + return unless link.present? + + # Rate limit the click counts to once in 24 hours + rate_key = "link-clicks:#{link.id}:#{args[:user_id] || args[:ip]}" + if $redis.setnx(rate_key, "1") + $redis.expire(rate_key, 1.day.to_i) + create!(topic_link_id: link.id, user_id: args[:user_id], ip: args[:ip]) + end + + args[:url] + end + + def self.counts_for(topic, posts) + return {} if posts.blank? + links = TopicLink + .includes(:link_topic) + .where(topic_id: topic.id, post_id: posts.map(&:id)) + .order('reflection asc, clicks desc') + + result = {} + links.each do |l| + result[l.post_id] ||= [] + result[l.post_id] << {url: l.url, + clicks: l.clicks, + title: l.link_topic.try(:title), + internal: l.internal, + reflection: l.reflection} + end + + result + end + +end diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb new file mode 100644 index 00000000000..11e4a2a099e --- /dev/null +++ b/app/models/topic_list.rb @@ -0,0 +1,76 @@ +require_dependency 'avatar_lookup' + +class TopicList + include ActiveModel::Serialization + + attr_accessor :more_topics_url, :draft, :draft_key, :draft_sequence + + def initialize(current_user, topics) + @current_user = current_user + @topics_input = topics + end + + # Lazy initialization + def topics + return @topics if @topics.present? + + @topics = @topics_input.to_a + + # Attach some data for serialization to each topic + @topic_lookup = TopicUser.lookup_for(@current_user, @topics) if @current_user.present? + + # Create a lookup for all the user ids we need + user_ids = [] + @topics.each do |ft| + user_ids << ft.user_id << ft.last_post_user_id << ft.featured_user_ids + end + + avatar_lookup = AvatarLookup.new(user_ids) + + @topics.each do |ft| + ft.user_data = @topic_lookup[ft.id] if @topic_lookup.present? + ft.posters = ft.posters_summary(ft.user_data, @current_user, avatar_lookup: avatar_lookup) + end + + return @topics + end + + def filter_summary + @filter_summary ||= get_summary + end + + def attributes + {'more_topics_url' => page} + end + + protected + + def get_summary + s = {} + return s unless @current_user + split = SiteSetting.top_menu.split("|") + + split.each do |i| + name, filter = i.split(",") + + exclude = nil + if filter && filter[0] == "-" + exclude = filter[1..-1] + end + + query = TopicQuery.new(@current_user, exclude_category: exclude) + s["unread"] = query.unread_count if name == 'unread' + s["new"] = query.new_count if name == 'new' + + catSplit = name.split("/") + if catSplit[0] == "category" && catSplit.length == 2 && @current_user + query = TopicQuery.new(@current_user, only_category: catSplit[1], limit: false) + s[name] = query.unread_count + query.new_count + end + + end + + s + end + +end diff --git a/app/models/topic_poster.rb b/app/models/topic_poster.rb new file mode 100644 index 00000000000..5e30171afd6 --- /dev/null +++ b/app/models/topic_poster.rb @@ -0,0 +1,18 @@ +class TopicPoster < OpenStruct + include ActiveModel::Serialization + + attr_accessor :user, :description, :extras, :id + + def attributes + {'user' => user, + 'description' => description, + 'extras' => extras, + 'id' => id} + end + + # TODO: Remove when old list is removed + def [](attr) + send(attr) + end + +end diff --git a/app/models/topic_user.rb b/app/models/topic_user.rb new file mode 100644 index 00000000000..2edf38c30c6 --- /dev/null +++ b/app/models/topic_user.rb @@ -0,0 +1,197 @@ +class TopicUser < ActiveRecord::Base + + belongs_to :user + belongs_to :topic + + module NotificationLevel + WATCHING = 3 + TRACKING = 2 + REGULAR = 1 + MUTED = 0 + end + + module NotificationReasons + CREATED_TOPIC = 1 + USER_CHANGED = 2 + USER_INTERACTED = 3 + CREATED_POST = 4 + end + + def self.auto_track(user_id, topic_id, reason) + if exec_sql("select 1 from topic_users where user_id = ? and topic_id = ? and notifications_reason_id is null", user_id, topic_id).count == 1 + self.change(user_id, topic_id, + notification_level: NotificationLevel::TRACKING, + notifications_reason_id: reason + ) + + MessageBus.publish("/topic/#{topic_id}", { + notification_level_change: NotificationLevel::TRACKING, + notifications_reason_id: reason + }, user_ids: [user_id]) + end + end + + + # Find the information specific to a user in a forum topic + def self.lookup_for(user, topics) + + # If the user isn't logged in, there's no last read posts + return {} if user.blank? + return {} if topics.blank? + + topic_ids = topics.map {|ft| ft.id} + create_lookup(TopicUser.where(topic_id: topic_ids, user_id: user.id)) + end + + def self.create_lookup(topic_users) + topic_users = topic_users.to_a + + result = {} + return result if topic_users.blank? + + topic_users.each do |ftu| + result[ftu.topic_id] = ftu + end + result + end + + def self.get(topic,user) + if Topic === topic + topic = topic.id + end + if User === user + user = user.id + end + + TopicUser.where('topic_id = ? and user_id = ?', topic, user).first + end + + # Change attributes for a user (creates a record when none is present). First it tries an update + # since there's more likely to be an existing record than not. If the update returns 0 rows affected + # it then creates the row instead. + def self.change(user_id, topic_id, attrs) + + # Sometimes people pass objs instead of the ids. We can handle that. + topic_id = topic_id.id if topic_id.is_a?(Topic) + user_id = user_id.id if user_id.is_a?(User) + + TopicUser.transaction do + attrs = attrs.dup + attrs[:starred_at] = DateTime.now if attrs[:starred_at].nil? && attrs[:starred] + + if attrs[:notification_level] + attrs[:notifications_changed_at] ||= DateTime.now + attrs[:notifications_reason_id] ||= TopicUser::NotificationReasons::USER_CHANGED + end + attrs_array = attrs.to_a + + attrs_sql = attrs_array.map {|t| "#{t[0]} = ?"}.join(", ") + vals = attrs_array.map {|t| t[1]} + rows = TopicUser.update_all([attrs_sql, *vals], ["topic_id = ? and user_id = ?", topic_id.to_i, user_id]) + + if rows == 0 + now = DateTime.now + auto_track_after = self.exec_sql("select auto_track_topics_after_msecs from users where id = ?", user_id).values[0][0] + auto_track_after ||= SiteSetting.auto_track_topics_after + auto_track_after = auto_track_after.to_i + + if auto_track_after >= 0 && auto_track_after <= (attrs[:total_msecs_viewed] || 0) + attrs[:notification_level] ||= TopicUser::NotificationLevel::TRACKING + end + + TopicUser.create(attrs.merge!(user_id: user_id, topic_id: topic_id.to_i, first_visited_at: now ,last_visited_at: now)) + end + + end + rescue ActiveRecord::RecordNotUnique + # In case of a race condition to insert, do nothing + end + + def self.track_visit!(topic,user) + now = DateTime.now + rows = exec_sql_row_count( + "update topic_users set last_visited_at=? where topic_id=? and user_id=?", + now, topic.id, user.id + ) + + if rows == 0 + exec_sql('insert into topic_users(topic_id, user_id, last_visited_at, first_visited_at) + values(?,?,?,?)', + topic.id, user.id, now, now) + end + + end + + # Update the last read and the last seen post count, but only if it doesn't exist. + # This would be a lot easier if psql supported some kind of upsert + def self.update_last_read(user, topic_id, post_number, msecs) + return if post_number.blank? + msecs = 0 if msecs.to_i < 0 + + args = { + user_id: user.id, + topic_id: topic_id, + post_number: post_number, + now: DateTime.now, + msecs: msecs, + tracking: TopicUser::NotificationLevel::TRACKING, + threshold: SiteSetting.auto_track_topics_after + } + + rows = exec_sql("UPDATE topic_users + SET + last_read_post_number = greatest(:post_number, tu.last_read_post_number), + seen_post_count = t.highest_post_number, + total_msecs_viewed = tu.total_msecs_viewed + :msecs, + notification_level = + case when tu.notifications_reason_id is null and (tu.total_msecs_viewed + :msecs) > + coalesce(u.auto_track_topics_after_msecs,:threshold) and + coalesce(u.auto_track_topics_after_msecs, :threshold) >= 0 then + :tracking + else + tu.notification_level + end + FROM topic_users tu + join topics t on t.id = tu.topic_id + join users u on u.id = :user_id + WHERE + tu.topic_id = topic_users.topic_id AND + tu.user_id = topic_users.user_id AND + tu.topic_id = :topic_id AND + tu.user_id = :user_id + RETURNING + topic_users.notification_level, tu.notification_level old_level + ", + args).values + + if rows.length == 1 + before = rows[0][1].to_i + after = rows[0][0].to_i + + if before != after + MessageBus.publish("/topic/#{topic_id}", {notification_level_change: after}, user_ids: [user.id]) + end + end + + if rows.length == 0 + + self + + args[:tracking] = TopicUser::NotificationLevel::TRACKING + args[:regular] = TopicUser::NotificationLevel::REGULAR + args[:site_setting] = SiteSetting.auto_track_topics_after + exec_sql("INSERT INTO topic_users (user_id, topic_id, last_read_post_number, seen_post_count, last_visited_at, first_visited_at, notification_level) + SELECT :user_id, :topic_id, :post_number, ft.highest_post_number, :now, :now, + case when coalesce(u.auto_track_topics_after_msecs, :site_setting) = 0 then :tracking else :regular end + FROM topics AS ft + JOIN users u on u.id = :user_id + WHERE ft.id = :topic_id + AND NOT EXISTS(SELECT 1 + FROM topic_users AS ftu + WHERE ftu.user_id = :user_id and ftu.topic_id = :topic_id)", + args) + end + end + + +end diff --git a/app/models/twitter_user_info.rb b/app/models/twitter_user_info.rb new file mode 100644 index 00000000000..e1675b06911 --- /dev/null +++ b/app/models/twitter_user_info.rb @@ -0,0 +1,3 @@ +class TwitterUserInfo < ActiveRecord::Base + belongs_to :user +end diff --git a/app/models/upload.rb b/app/models/upload.rb new file mode 100644 index 00000000000..8e4568b4006 --- /dev/null +++ b/app/models/upload.rb @@ -0,0 +1,98 @@ +require 'digest/sha1' + +class Upload < ActiveRecord::Base + # attr_accessible :title, :body + + belongs_to :user + belongs_to :topic + + validates_presence_of :filesize + validates_presence_of :original_filename + + + # Create an upload given a user, file and optional topic_id + def self.create_for(user, file, topic_id = nil) + # TODO: Need specs/tests for this functionality + return create_on_imgur(user, file, topic_id) if SiteSetting.enable_imgur? + return create_on_s3(user, file, topic_id) if SiteSetting.enable_s3_uploads? + return create_locally(user, file, topic_id) + end + + # Store uploads on s3 + def self.create_on_imgur(user, file, topic_id) + + @imgur_loaded = require 'imgur' unless @imgur_loaded + + + info = Imgur.upload_file(file) + Upload.create!({user_id: user.id, + topic_id: topic_id, + original_filename: file.original_filename}.merge!(info)) + end + + def self.create_locally(user, file, topic_id) + upload = Upload.create!(user_id: user.id, + topic_id: topic_id, + url: "", + filesize: File.size(file.tempfile), + original_filename: file.original_filename) + + # populate the rest of the info + clean_name = file.original_filename.gsub(" ", "_").downcase.gsub(/[^a-z0-9\._]/, "") + split = clean_name.split(".") + if split.length > 1 + clean_name = split[0..-2].join("_") + end + image_info = FastImage.new(file.tempfile) + clean_name += ".#{image_info.type}" + url_root = "/uploads/#{RailsMultisite::ConnectionManagement.current_db}/#{upload.id}" + path = "#{Rails.root}/public#{url_root}" + upload.width, upload.height = ImageSizer.resize(*image_info.size) + FileUtils.mkdir_p path + # not using cause mv, cause permissions are no good on move + File.open("#{path}/#{clean_name}", "wb") do |f| + f.write File.read(file.tempfile) + end + upload.url = "#{url_root}/#{clean_name}" + upload.save + + upload + end + + def self.create_on_s3(user, file, topic_id) + + @fog_loaded = require 'fog' unless @fog_loaded + + tempfile = file.tempfile + + upload = Upload.new(user_id: user.id, + topic_id: topic_id, + filesize: File.size(tempfile), + original_filename: file.original_filename) + + image_info = FastImage.new(tempfile) + blob = file.read + sha1 = Digest::SHA1.hexdigest(blob) + + + Fog.credentials_path = "#{Rails.root}/config/fog_credentials.yml" + fog = Fog::Storage.new(provider: 'AWS') + + remote_filename = "#{sha1[2..-1]}.#{image_info.type}" + path = "/uploads/#{sha1[0]}/#{sha1[1]}" + location = "#{SiteSetting.s3_upload_bucket}#{path}" + directory = fog.directories.create(key: location) + + Rails.logger.info "#{blob.size.inspect}" + file = directory.files.create(key: remote_filename, + body: tempfile, + public: true, + content_type: file.content_type) + upload.width, upload.height = ImageSizer.resize(*image_info.size) + upload.url = "#{Rails.configuration.action_controller.asset_host}#{path}/#{remote_filename}" + upload.save + + upload + end + +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 00000000000..e1c75a45192 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,451 @@ +require_dependency 'email_token' +require_dependency 'trust_level' +require_dependency 'sql_builder' + +class User < ActiveRecord::Base + + attr_accessible :name, :username, :password, :email, :bio_raw, :website + + has_many :posts + has_many :notifications + has_many :topic_users + has_many :topics + has_many :user_open_ids + has_many :user_actions + has_many :post_actions + has_many :email_logs + has_many :post_timings + has_many :topic_allowed_users + has_many :topics_allowed, through: :topic_allowed_users, source: :topic + has_many :email_tokens + has_many :views + has_many :user_visits + has_many :invites + has_one :twitter_user_info + belongs_to :approved_by, class_name: 'User' + + validates_presence_of :username + validates_presence_of :email + validates_uniqueness_of :email + validate :username_validator + validate :password_validator + + before_save :cook + before_save :update_username_lower + before_save :ensure_password_is_hashed + after_initialize :add_trust_level + + after_save :update_tracked_topics + + after_create :create_email_token + + # Whether we need to be sending a system message after creation + attr_accessor :send_welcome_message + + # This is just used to pass some information into the serializer + attr_accessor :notification_channel_position + + def self.username_length + 3..15 + end + + def self.suggest_username(name) + # If it's an email + if name =~ /([^@]+)@([^\.]+)/ + name = Regexp.last_match[1] + + # Special case, if it's me @ something, take the something. + name = Regexp.last_match[2] if name == 'me' + end + + name.gsub!(/^[^A-Za-z0-9]+/, "") + name.gsub!(/[^A-Za-z0-9_]+$/, "") + name.gsub!(/[^A-Za-z0-9_]+/, "_") + + # Pad the length with 1s + missing_chars = User.username_length.begin - name.length + name << ('1' * missing_chars) if missing_chars > 0 + + # Trim extra length + name = name[0..User.username_length.end-1] + + i = 1 + attempt = name + while !username_available?(attempt) + suffix = i.to_s + max_length = User.username_length.end - 1 - suffix.length + attempt = "#{name[0..max_length]}#{suffix}" + i+=1 + end + attempt + end + + def self.create_for_email(email, opts={}) + username = suggest_username(email) + + if SiteSetting.call_mothership? + begin + match, available, suggestion = Mothership.nickname_match?( username, email ) + username = suggestion unless match or available + rescue => e + Rails.logger.error e.message + "\n" + e.backtrace.join("\n") + end + end + + user = User.new(email: email, username: username, name: username) + user.trust_level = opts[:trust_level] if opts[:trust_level].present? + user.save! + + if SiteSetting.call_mothership? + begin + Mothership.register_nickname( username, email ) + rescue => e + Rails.logger.error e.message + "\n" + e.backtrace.join("\n") + end + end + + user + end + + def self.username_available?(username) + lower = username.downcase + !User.where(username_lower: lower).exists? + end + + def enqueue_welcome_message(message_type) + return unless SiteSetting.send_welcome_message? + Jobs.enqueue(:send_system_message, user_id: self.id, message_type: message_type) + end + + def self.suggest_name(email) + return "" unless email + name = email.split(/[@\+]/)[0] + name = name.sub(".", " ") + name.split(" ").collect{|word| word[0] = word[0].upcase; word}.join(" ") + end + + def change_username(new_username) + self.username = new_username + + if SiteSetting.call_mothership? and self.valid? + begin + Mothership.register_nickname( self.username, self.email ) + rescue Mothership::NicknameUnavailable + return false + rescue => e + Rails.logger.error e.message + "\n" + e.backtrace.join("\n") + end + end + + self.save + end + + # Use a temporary key to find this user, store it in redis with an expiry + def temporary_key + key = SecureRandom.hex(32) + $redis.setex "temporary_key:#{key}", 1.week, id.to_s + key + end + + # Find a user by temporary key, nil if not found or key is invalid + def self.find_by_temporary_key(key) + user_id = $redis.get("temporary_key:#{key}") + if user_id.present? + User.where(id: user_id.to_i).first + end + end + + # tricky, we need our bus to be subscribed from the right spot + def sync_notification_channel_position + @unread_notifications_by_type = nil + self.notification_channel_position = MessageBus.last_id('/notification') + end + + def invited_by + used_invite = invites.where("redeemed_at is not null").includes(:invited_by).first + return nil unless used_invite.present? + used_invite.invited_by + end + + # Approve this user + def approve(approved_by) + self.approved = true + self.approved_by = approved_by + self.approved_at = Time.now + enqueue_welcome_message('welcome_approved') if save + end + + def self.email_hash(email) + Digest::MD5.hexdigest(email) + end + + def email_hash + User.email_hash(self.email) + end + + def unread_notifications_by_type + @unread_notifications_by_type ||= notifications.where("id > ? and read = false", seen_notification_id).group(:notification_type).count + end + + def reload + @unread_notifications_by_type = nil + super + end + + + def unread_private_messages + return 0 if unread_notifications_by_type.blank? + return unread_notifications_by_type[Notification.Types[:private_message]] || 0 + end + + def unread_notifications + result = 0 + unread_notifications_by_type.each do |k,v| + result += v unless k == Notification.Types[:private_message] + end + result + end + + def saw_notification_id(notification_id) + User.update_all ["seen_notification_id = ?", notification_id], ["seen_notification_id < ?", notification_id] + end + + def publish_notifications_state + MessageBus.publish("/notification", + {unread_notifications: self.unread_notifications, + unread_private_messages: self.unread_private_messages}, + user_ids: [self.id] # only publish the notification to this user + ) + end + + # A selection of people to autocomplete on @mention + def self.mentionable_usernames + User.select(:username).order('last_posted_at desc').limit(20) + end + + def regular? + (not admin?) and (not has_trust_level?(:moderator)) + end + + def password=(password) + # special case for passwordless accounts + unless password.blank? + @raw_password = password + end + end + + def confirm_password?(password) + return false unless self.password_hash && self.salt + self.password_hash == hash_password(password,self.salt) + end + + def update_last_seen! + now = DateTime.now + now_date = now.to_date + + # Only update last seen once every minute + redis_key = "user:#{self.id}:#{now_date.to_s}" + if $redis.setnx(redis_key, "1") + $redis.expire(redis_key, SiteSetting.active_user_rate_limit_secs) + + if self.last_seen_at.nil? || self.last_seen_at.to_date < now_date + # count it + row_count = User.exec_sql('insert into user_visits(user_id,visited_at) select :user_id, :visited_at + where not exists(select 1 from user_visits where user_id = :user_id and visited_at = :visited_at)', user_id: self.id, visited_at: now.to_date) + if row_count.cmd_tuples == 1 + User.update_all "days_visited = days_visited + 1", ["id = ? and days_visited = ?", self.id, self.days_visited] + end + end + + # using a builder to avoid the AR transaction + sql = SqlBuilder.new "update users /*set*/ where id = :id" + # Keep track of our last visit + if self.last_seen_at.present? and (self.last_seen_at < (now - SiteSetting.previous_visit_timeout_hours.hours)) + self.previous_visit_at = self.last_seen_at + sql.set('previous_visit_at = :prev', prev: self.previous_visit_at) + end + self.last_seen_at = now + sql.set('last_seen_at = :last', last: self.last_seen_at) + sql.exec(id: self.id) + end + + end + + def self.avatar_template(email) + email_hash = self.email_hash(email) + # robohash was possibly causing caching issues + # robohash = CGI.escape("http://robohash.org/size_") << "{size}x{size}" << CGI.escape("/#{email_hash}.png") + "http://www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon" + end + + # return null for local avatars, a template for gravatar + def avatar_template + # robohash = CGI.escape("http://robohash.org/size_") << "{size}x{size}" << CGI.escape("/#{email_hash}.png") + "http://www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon" + end + + + # Updates the denormalized view counts for all users + def self.update_view_counts + + # Update denormalized topics_entered + exec_sql "UPDATE users SET topics_entered = x.c + FROM + (SELECT v.user_id, + COUNT(DISTINCT parent_id) AS c + FROM views AS v + WHERE parent_type = 'Topic' + GROUP BY v.user_id) AS X + WHERE x.user_id = users.id" + + # Update denormalzied posts_read_count + exec_sql "UPDATE users SET posts_read_count = x.c + FROM + (SELECT pt.user_id, + COUNT(*) AS c + FROM post_timings AS pt + GROUP BY pt.user_id) AS X + WHERE x.user_id = users.id" + + end + + # The following count methods are somewhat slow - definitely don't use them in a loop. + # They might need to be denormialzied + def like_count + UserAction.where(user_id: self.id, action_type: UserAction::WAS_LIKED).count + end + + def post_count + posts.count + end + + def flags_given_count + PostAction.where(user_id: self.id, post_action_type_id: PostActionType.FlagTypes).count + end + + def flags_received_count + posts.includes(:post_actions).where('post_actions.post_action_type_id in (?)', PostActionType.FlagTypes).count + end + + def private_topics_count + topics_allowed.where(archetype: Archetype.private_message).count + end + + def bio_excerpt + PrettyText.excerpt(bio_cooked, 350) + end + + def is_banned? + !banned_till.nil? && banned_till > DateTime.now + end + + # Use this helper to determine if the user has a particular trust level. + # Takes into account admin, etc. + def has_trust_level?(level) + raise "Invalid trust level #{level}" unless TrustLevel.Levels.has_key?(level) + + # Admins can do everything + return true if admin? + + # Otherwise compare levels + (self.trust_level || TrustLevel.Levels[:new]) >= TrustLevel.Levels[level] + end + + def guardian + Guardian.new(self) + end + + protected + + def cook + if self.bio_raw.present? + self.bio_cooked = PrettyText.cook(bio_raw) if bio_raw_changed? + else + self.bio_cooked = nil + end + end + + def update_tracked_topics + if self.auto_track_topics_after_msecs_changed? + + if auto_track_topics_after_msecs < 0 + + User.exec_sql('update topic_users set notification_level = ? + where notifications_reason_id is null and + user_id = ?' , TopicUser::NotificationLevel::REGULAR , self.id) + else + + User.exec_sql('update topic_users set notification_level = ? + where notifications_reason_id is null and + user_id = ? and + total_msecs_viewed < ?' , TopicUser::NotificationLevel::REGULAR , self.id, auto_track_topics_after_msecs) + + User.exec_sql('update topic_users set notification_level = ? + where notifications_reason_id is null and + user_id = ? and + total_msecs_viewed >= ?' , TopicUser::NotificationLevel::TRACKING , self.id, auto_track_topics_after_msecs) + end + end + end + + + def create_email_token + email_tokens.create(email: self.email) + end + + def ensure_password_is_hashed + if @raw_password + self.salt = SecureRandom.hex(16) + self.password_hash = hash_password(@raw_password, salt) + end + end + + def hash_password(password, salt) + PBKDF2.new(:password => password, :salt => salt, :iterations => Rails.configuration.pbkdf2_iterations).hex_string + end + + def add_trust_level + self.trust_level ||= SiteSetting.default_trust_level + rescue ActiveModel::MissingAttributeError + # Ignore it, safely - see http://www.tatvartha.com/2011/03/activerecordmissingattributeerror-missing-attribute-a-bug-or-a-features/ + end + + def update_username_lower + self.username_lower = username.downcase + end + + def password_validator + if @raw_password + return errors.add(:password, "must be 6 letters or longer") if @raw_password.length < 6 + end + end + + def username_validator + unless username + return errors.add(:username, I18n.t(:'user.username.blank')) + end + + if username.length < User.username_length.begin + return errors.add(:username, I18n.t(:'user.username.short', min: User.username_length.begin)) + end + + if username.length > User.username_length.end + return errors.add(:username, I18n.t(:'user.username.long', max: User.username_length.end)) + end + + if username =~ /[^A-Za-z0-9_]/ + return errors.add(:username, I18n.t(:'user.username.characters')) + end + + if username[0,1] =~ /[^A-Za-z0-9]/ + return errors.add(:username, I18n.t(:'user.username.must_begin_with_alphanumeric')) + end + + lower = username.downcase + if username_changed? && User.where(username_lower: lower).exists? + return errors.add(:username, I18n.t(:'user.username.unique')) + end + + end + +end diff --git a/app/models/user_action.rb b/app/models/user_action.rb new file mode 100644 index 00000000000..aeadb1f4c2c --- /dev/null +++ b/app/models/user_action.rb @@ -0,0 +1,213 @@ +require_dependency 'message_bus' +require_dependency 'sql_builder' + +class UserAction < ActiveRecord::Base + belongs_to :user + attr_accessible :acting_user_id, :action_type, :target_topic_id, :target_post_id, :target_user_id, :user_id + + validates_presence_of :action_type + validates_presence_of :user_id + + LIKE = 1 + WAS_LIKED = 2 + BOOKMARK = 3 + NEW_TOPIC = 4 + POST = 5 + RESPONSE= 6 + MENTION = 7 + TOPIC_RESPONSE = 8 + QUOTE = 9 + STAR = 10 + EDIT = 11 + NEW_PRIVATE_MESSAGE = 12 + GOT_PRIVATE_MESSAGE = 13 + + ORDER = Hash[*[ + NEW_PRIVATE_MESSAGE, + GOT_PRIVATE_MESSAGE, + BOOKMARK, + NEW_TOPIC, + POST, + RESPONSE, + TOPIC_RESPONSE, + LIKE, + WAS_LIKED, + MENTION, + QUOTE, + STAR, + EDIT + ].each_with_index.to_a.flatten] + + def self.stats(user_id, guardian) + sql = < ORDER[b["action_type"].to_i]} + results.each do |row| + row["description"] = self.description(row["action_type"], detailed: true) + end + + results + end + + def self.stream_item(action_id, guardian) + stream(action_id:action_id, guardian: guardian)[0] + end + + def self.stream(opts={}) + user_id = opts[:user_id] + offset = opts[:offset]||0 + limit = opts[:limit] ||60 + action_id = opts[:action_id] + action_types = opts[:action_types] + guardian = opts[:guardian] + ignore_private_messages = opts[:ignore_private_messages] + + builder = SqlBuilder.new(" +select t.title, a.action_type, a.created_at, + t.id topic_id, coalesce(p.post_number, 1) post_number, u.email ,u.username, u.name, u.id user_id, coalesce(p.cooked, p2.cooked) cooked +from user_actions as a +join topics t on t.id = a.target_topic_id +left join posts p on p.id = a.target_post_id +left join users u on u.id = a.acting_user_id +left join posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1 +/*where*/ +/*order_by*/ +/*offset*/ +/*limit*/ +") + + unless guardian.can_see_deleted_posts? + builder.where("p.deleted_at is null and p2.deleted_at is null") + end + + if !guardian.can_see_private_messages?(user_id) || ignore_private_messages + builder.where("a.action_type not in (#{NEW_PRIVATE_MESSAGE},#{GOT_PRIVATE_MESSAGE})") + end + + if action_id + builder.where("a.id = :id", id: action_id.to_i) + data = builder.exec.to_a + else + builder.where("a.user_id = :user_id", user_id: user_id.to_i) + builder.where("a.action_type in (:action_types)", action_types: action_types) if action_types && action_types.length > 0 + builder.order_by("a.created_at desc") + builder.offset(offset.to_i) + builder.limit(limit.to_i) + data = builder.exec.to_a + end + + data.each do |row| + row["description"] = self.description(row["action_type"]) + row["created_at"] = DateTime.parse(row["created_at"]) + # we should probably cache the excerpts in the db at some point + row["excerpt"] = PrettyText.excerpt(row["cooked"],300) if row["cooked"] + row["cooked"] = nil + row["avatar_template"] = User.avatar_template(row["email"]) + row.delete("email") + row["slug"] = Slug.for(row["title"]) + end + + data + end + + def self.description(row, opts = {}) + t = I18n.t('user_action_descriptions') + if opts[:detailed] + # will localize as soon as we stablize the names here + desc = case row.to_i + when BOOKMARK + t[:bookmarks] + when NEW_TOPIC + t[:topics] + when WAS_LIKED + t[:likes_received] + when LIKE + t[:likes_given] + when RESPONSE + t[:responses] + when TOPIC_RESPONSE + t[:topic_responses] + when POST + t[:posts] + when MENTION + t[:mentions] + when QUOTE + t[:quotes] + when EDIT + t[:edits] + when STAR + t[:favorites] + when NEW_PRIVATE_MESSAGE + t[:sent_items] + when GOT_PRIVATE_MESSAGE + t[:inbox] + end + else + desc = + case row.to_i + when NEW_TOPIC + then t[:posted] + when LIKE,WAS_LIKED + then t[:liked] + when RESPONSE, TOPIC_RESPONSE,POST + then t[:responded_to] + when BOOKMARK + then t[:bookmarked] + when MENTION + then t[:mentioned] + when QUOTE + then t[:quoted] + when STAR + then t[:favorited] + when EDIT + then t[:edited] + end + end + desc + end + + def self.log_action!(hash) + require_parameters(hash, :action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id) + transaction(requires_new: true) do + begin + action = self.new(hash) + + if hash[:created_at] + action.created_at = hash[:created_at] + end + action.save! + rescue ActiveRecord::RecordNotUnique + # can happen, don't care already logged + raise ActiveRecord::Rollback + end + end + end + + def self.remove_action!(hash) + require_parameters(hash, :action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id) + if action = UserAction.where(hash).first + action.destroy + MessageBus.publish("/user/#{hash[:user_id]}", {user_action_id: action.id, remove: true}) + end + end + + protected + def self.require_parameters(data, *params) + params.each do |p| + raise Discourse::InvalidParameters.new(p) if data[p].nil? + end + end + +end diff --git a/app/models/user_action_observer.rb b/app/models/user_action_observer.rb new file mode 100644 index 00000000000..a51aad0fda7 --- /dev/null +++ b/app/models/user_action_observer.rb @@ -0,0 +1,189 @@ +class UserActionObserver < ActiveRecord::Observer + observe :post_action, :topic, :post, :notification, :topic_user + + + def after_save(model) + case + when (model.is_a?(PostAction) and (model.is_bookmark? or model.is_like?)) + log_post_action(model) + when (model.is_a?(Topic)) + log_topic(model) + when (model.is_a?(Post)) + log_post(model) + when (model.is_a?(Notification)) + log_notification(model) + when (model.is_a?(TopicUser)) + log_topic_user(model) + end + end + + protected + + def log_topic_user(model) + action = UserAction::STAR + + row = { + action_type: action, + user_id: model.user_id, + acting_user_id: model.user_id, + target_topic_id: model.topic_id, + target_post_id: -1, + created_at: model.starred_at + } + + if model.starred + UserAction.log_action!(row) + else + UserAction.remove_action!(row) + end + end + + def log_notification(model) + + action = + case model.notification_type + when Notification.Types[:quoted] + UserAction::QUOTE + when Notification.Types[:replied] + UserAction::RESPONSE + when Notification.Types[:mentioned] + UserAction::MENTION + when Notification.Types[:edited] + UserAction::EDIT + end + + # like is skipped + return unless action + + post = Post.where(post_number: model.post_number, topic_id: model.topic_id).first + + # stray data + return unless post + + row = { + action_type: action, + user_id: model.user_id, + acting_user_id: (action == UserAction::EDIT) ? post.last_editor_id : post.user_id, + target_topic_id: model.topic_id, + target_post_id: post.id, + created_at: model.created_at + } + + if post.deleted_at.nil? + UserAction.log_action!(row) + else + UserAction.remove_action!(row) + end + end + + def log_post(model) + + # first post gets nada + return if model.post_number == 1 + + + row = { + action_type: UserAction::POST, + user_id: model.user_id, + acting_user_id: model.user_id, + target_post_id: model.id, + target_topic_id: model.topic_id, + created_at: model.created_at + } + + rows = [row] + + if model.topic.private_message? + rows = [] + model.topic.topic_allowed_users.each do |ta| + row = row.dup + row[:user_id] = ta.user_id + row[:action_type] = ta.user_id == model.user_id ? UserAction::NEW_PRIVATE_MESSAGE : UserAction::GOT_PRIVATE_MESSAGE + rows << row + end + end + + rows.each do |row| + if model.deleted_at.nil? + UserAction.log_action!(row) + else + UserAction.remove_action!(row) + end + end + + return if model.topic.private_message? + + # a bit odd but we may have stray records + if model.topic and model.topic.user_id != model.user_id + row[:action_type] = UserAction::TOPIC_RESPONSE + row[:user_id] = model.topic.user_id + + if model.deleted_at.nil? + UserAction.log_action!(row) + else + UserAction.remove_action!(row) + end + end + + end + + def log_topic(model) + row = { + action_type: model.archetype == Archetype.private_message ? UserAction::NEW_PRIVATE_MESSAGE : UserAction::NEW_TOPIC, + user_id: model.user_id, + acting_user_id: model.user_id, + target_topic_id: model.id, + target_post_id: -1, + created_at: model.created_at + } + + rows = [row] + + if model.private_message? + model.topic_allowed_users.reject{|a| a.user_id == model.user_id}.each do |ta| + row = row.dup + row[:user_id] = ta.user_id + row[:action_type] = UserAction::GOT_PRIVATE_MESSAGE + rows << row + end + end + + rows.each do |row| + if model.deleted_at.nil? + UserAction.log_action!(row) + else + UserAction.remove_action!(row) + end + end + end + + def log_post_action(model) + action = UserAction::BOOKMARK if model.is_bookmark? + action = UserAction::LIKE if model.is_like? + + row = { + action_type: action, + user_id: model.user_id, + acting_user_id: model.user_id, + target_post_id: model.post_id, + target_topic_id: model.post.topic_id, + created_at: model.created_at + } + + if model.deleted_at.nil? + UserAction.log_action!(row) + else + UserAction.remove_action!(row) + end + + if model.is_like? + row[:action_type] = UserAction::WAS_LIKED + row[:user_id] = model.post.user_id + if model.deleted_at.nil? + UserAction.log_action!(row) + else + UserAction.remove_action!(row) + end + end + end +end diff --git a/app/models/user_email_observer.rb b/app/models/user_email_observer.rb new file mode 100644 index 00000000000..bc31d53b486 --- /dev/null +++ b/app/models/user_email_observer.rb @@ -0,0 +1,50 @@ +class UserEmailObserver < ActiveRecord::Observer + observe :notification + + def after_commit(notification) + if notification.send(:transaction_include_action?, :create) + notification_type = Notification.InvertedTypes[notification.notification_type] + + # Delegate to email_user_{{NOTIFICATION_TYPE}} if exists + email_method = :"email_user_#{notification_type.to_s}" + send(email_method, notification) if respond_to?(email_method) + end + end + + def email_user_mentioned(notification) + return unless notification.user.email_direct? + Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes, + :user_email, + type: :user_mentioned, + user_id: notification.user_id, + notification_id: notification.id) + end + + def email_user_quoted(notification) + return unless notification.user.email_direct? + Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes, + :user_email, + type: :user_quoted, + user_id: notification.user_id, + notification_id: notification.id) + end + + def email_user_replied(notification) + return unless notification.user.email_direct? + Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes, + :user_email, + type: :user_replied, + user_id: notification.user_id, + notification_id: notification.id) + end + + def email_user_invited_to_private_message(notification) + return unless notification.user.email_direct? + Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes, + :user_email, + type: :user_invited_to_private_message, + user_id: notification.user_id, + notification_id: notification.id) + end + +end diff --git a/app/models/user_open_id.rb b/app/models/user_open_id.rb new file mode 100644 index 00000000000..1da95001c28 --- /dev/null +++ b/app/models/user_open_id.rb @@ -0,0 +1,8 @@ +class UserOpenId < ActiveRecord::Base + belongs_to :user + attr_accessible :email, :url, :user_id, :active + + validates_presence_of :email + validates_presence_of :url + +end diff --git a/app/models/user_visit.rb b/app/models/user_visit.rb new file mode 100644 index 00000000000..17a937ebb5c --- /dev/null +++ b/app/models/user_visit.rb @@ -0,0 +1,3 @@ +class UserVisit < ActiveRecord::Base + attr_accessible :visited_at, :user_id +end diff --git a/app/models/view.rb b/app/models/view.rb new file mode 100644 index 00000000000..f3b225b24d6 --- /dev/null +++ b/app/models/view.rb @@ -0,0 +1,36 @@ +require 'ipaddr' + +class View < ActiveRecord::Base + + belongs_to :parent, polymorphic: true + belongs_to :user + validates_presence_of :parent_type, :parent_id, :ip, :viewed_at + + # TODO: This could happen asyncronously + def self.create_for(parent, ip, user=nil) + + # Only store a view once per day per thing per user per ip + redis_key = "view:#{parent.class.name}:#{parent.id}:#{Date.today.to_s}" + if user.present? + redis_key << ":user-#{user.id}" + else + redis_key << ":ip-#{ip}" + end + + if $redis.setnx(redis_key, "1") + $redis.expire(redis_key, 1.day.to_i) + + View.transaction do + view = View.create(parent: parent, ip: IPAddr.new(ip).to_i, viewed_at: Date.today, user: user) + + # Update the views count in the parent, if it exists. + if parent.respond_to?(:views) + parent.class.update_all 'views = views + 1', ['id = ?', parent.id] + end + + end + + end + end + +end diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb new file mode 100644 index 00000000000..c23bc79ff6a --- /dev/null +++ b/app/serializers/admin_detailed_user_serializer.rb @@ -0,0 +1,27 @@ +class AdminDetailedUserSerializer < AdminUserSerializer + + attributes :moderator, + :can_grant_admin, + :can_impersonate, + :can_revoke_admin, + :like_count, + :post_count, + :flags_given_count, + :flags_received_count, + :private_topics_count + + has_one :approved_by, serializer: BasicUserSerializer, embed: :objects + + def can_revoke_admin + scope.can_revoke_admin?(object) + end + + def can_grant_admin + scope.can_grant_admin?(object) + end + + def moderator + object.has_trust_level?(:moderator) + end + +end diff --git a/app/serializers/admin_user_serializer.rb b/app/serializers/admin_user_serializer.rb new file mode 100644 index 00000000000..f3f8d622b27 --- /dev/null +++ b/app/serializers/admin_user_serializer.rb @@ -0,0 +1,64 @@ +class AdminUserSerializer < BasicUserSerializer + + attributes :email, + :active, + :admin, + :last_seen_age, + :days_visited, + :last_emailed_age, + :created_at_age, + :username_lower, + :trust_level, + :flag_level, + :username, + :avatar_template, + :topics_entered, + :posts_read_count, + :time_read, + :can_approve, + :approved, + :banned_at, + :banned_till, + :is_banned, + :ip_address + + def is_banned + object.is_banned? + end + + def can_impersonate + scope.can_impersonate?(object) + end + + def last_emailed_age + return nil if object.last_emailed_at.blank? + AgeWords.age_words(Time.now - object.last_emailed_at) + end + + def last_seen_age + return nil if object.last_seen_at.blank? + AgeWords.age_words(Time.now - object.last_seen_at) + end + + def time_read + return nil if object.time_read.blank? + AgeWords.age_words(object.time_read) + end + + def created_at_age + AgeWords.age_words(Time.now - object.created_at) + end + + def can_approve + scope.can_approve?(object) + end + + def include_can_approve? + SiteSetting.must_approve_users + end + + def include_approved? + SiteSetting.must_approve_users + end + +end diff --git a/app/serializers/application_serializer.rb b/app/serializers/application_serializer.rb new file mode 100644 index 00000000000..8c4205398a2 --- /dev/null +++ b/app/serializers/application_serializer.rb @@ -0,0 +1,3 @@ +class ApplicationSerializer < ActiveModel::Serializer + embed :ids, :include => true +end \ No newline at end of file diff --git a/app/serializers/archetype_serializer.rb b/app/serializers/archetype_serializer.rb new file mode 100644 index 00000000000..2b89a1a0053 --- /dev/null +++ b/app/serializers/archetype_serializer.rb @@ -0,0 +1,20 @@ +class ArchetypeSerializer < ApplicationSerializer + + attributes :id, :name, :options + + def options + object.options.keys.collect do |k| + { + key: k, + title: I18n.t("archetypes.#{object.id}.options.#{k}.title"), + description: I18n.t("archetypes.#{object.id}.options.#{k}.description"), + option_type: object.options[k] + } + end + end + + def name + I18n.t("archetypes.#{object.id}.title") + end + +end diff --git a/app/serializers/basic_topic_serializer.rb b/app/serializers/basic_topic_serializer.rb new file mode 100644 index 00000000000..593faa6e65d --- /dev/null +++ b/app/serializers/basic_topic_serializer.rb @@ -0,0 +1,53 @@ +require_dependency 'age_words' + +class BasicTopicSerializer < ApplicationSerializer + include ActionView::Helpers + + attributes :id, :title, :reply_count, :posts_count, :highest_post_number, :image_url, :created_at, + :last_posted_at, :age, :unseen, :last_read_post_number, :unread, :new_posts + + def age + AgeWords.age_words(Time.now - (object.created_at || Time.now)) + end + + def seen + object.user_data.present? + end + + def unseen + return false if scope.blank? + return false if scope.user.blank? + return false if object.user_data.present? + return false if object.created_at < scope.user.created_at + + # Only mark things as new since your last visit + if scope.user.previous_visit_at.present? + return false if object.created_at < scope.user.previous_visit_at + end + + + true + end + + def last_read_post_number + object.user_data.last_read_post_number + end + alias :include_last_read_post_number? :seen + + def unread + unread_helper.unread_posts + end + alias :include_unread? :seen + + def new_posts + unread_helper.new_posts + end + alias :include_new_posts? :seen + + protected + + def unread_helper + @unread_helper ||= Unread.new(object, object.user_data) + end + +end \ No newline at end of file diff --git a/app/serializers/basic_user_serializer.rb b/app/serializers/basic_user_serializer.rb new file mode 100644 index 00000000000..1deede21e82 --- /dev/null +++ b/app/serializers/basic_user_serializer.rb @@ -0,0 +1,3 @@ +class BasicUserSerializer < ApplicationSerializer + attributes :id, :username, :avatar_template +end diff --git a/app/serializers/category_detailed_serializer.rb b/app/serializers/category_detailed_serializer.rb new file mode 100644 index 00000000000..68b80a50810 --- /dev/null +++ b/app/serializers/category_detailed_serializer.rb @@ -0,0 +1,20 @@ +class CategoryDetailedSerializer < CategorySerializer + + attributes :topic_count, :topics_week, :topics_month, :topics_year + + has_many :featured_users, serializer: BasicUserSerializer + has_many :featured_topics, serializer: CategoryTopicSerializer, embed: :objects, key: :topics + + def topics_week + object.topics_week || 0 + end + + def topics_month + object.topics_month || 0 + end + + def topics_year + object.topics_year || 0 + end + +end diff --git a/app/serializers/category_excerpt_serializer.rb b/app/serializers/category_excerpt_serializer.rb new file mode 100644 index 00000000000..fd0b4e77751 --- /dev/null +++ b/app/serializers/category_excerpt_serializer.rb @@ -0,0 +1,34 @@ +require_dependency 'excerpt_type' + +class CategoryExcerptSerializer < ActiveModel::Serializer + include ExcerptType + + attributes :excerpt, :name, :color, :slug, :topic_url, :topics_year, + :topics_month, :topics_week, :category_url, :can_edit, :can_delete + + + def topics_year + object.topics_year || 0 + end + + def topics_month + object.topics_month || 0 + end + + def topics_week + object.topics_week || 0 + end + + def category_url + "/category/#{object.slug}" + end + + def can_edit + scope.can_edit?(object) + end + + def can_delete + scope.can_delete?(object) + end + +end diff --git a/app/serializers/category_featured_users_serializer.rb b/app/serializers/category_featured_users_serializer.rb new file mode 100644 index 00000000000..2ecd67fcdd4 --- /dev/null +++ b/app/serializers/category_featured_users_serializer.rb @@ -0,0 +1,5 @@ +class CategoryFeaturedUsersSerializer < CategorySerializer + + has_many :featured_users, serializer: BasicUserSerializer, embed: :objects + +end diff --git a/app/serializers/category_list_serializer.rb b/app/serializers/category_list_serializer.rb new file mode 100644 index 00000000000..1b6580f193d --- /dev/null +++ b/app/serializers/category_list_serializer.rb @@ -0,0 +1,11 @@ +class CategoryListSerializer < ApplicationSerializer + + attributes :can_create_category + + has_many :categories, serializer: CategoryDetailedSerializer, embed: :objects + + def can_create_category + scope.can_create?(Category) + end + +end diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb new file mode 100644 index 00000000000..40b459406d9 --- /dev/null +++ b/app/serializers/category_serializer.rb @@ -0,0 +1,3 @@ +class CategorySerializer < ApplicationSerializer + attributes :id, :name, :color, :slug, :topic_count +end diff --git a/app/serializers/category_topic_serializer.rb b/app/serializers/category_topic_serializer.rb new file mode 100644 index 00000000000..05b0a45cde7 --- /dev/null +++ b/app/serializers/category_topic_serializer.rb @@ -0,0 +1,7 @@ +class CategoryTopicSerializer < BasicTopicSerializer + + attributes :slug + + has_one :category + +end diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb new file mode 100644 index 00000000000..7e424c3660f --- /dev/null +++ b/app/serializers/current_user_serializer.rb @@ -0,0 +1,14 @@ +class CurrentUserSerializer < BasicUserSerializer + + attributes :name, :unread_notifications, :unread_private_messages, :admin, :notification_channel_position, :site_flagged_posts_count + + # we probably want to move this into site, but that json is cached so hanging it off current user seems okish + + def include_site_flagged_posts_count? + object.admin + end + + def site_flagged_posts_count + PostAction.flagged_posts_count + end +end diff --git a/app/serializers/email_log_serializer.rb b/app/serializers/email_log_serializer.rb new file mode 100644 index 00000000000..280e18bac79 --- /dev/null +++ b/app/serializers/email_log_serializer.rb @@ -0,0 +1,6 @@ +class EmailLogSerializer < ApplicationSerializer + + attributes :id, :to_address, :email_type, :user_id, :created_at + has_one :user, serializer: BasicUserSerializer, embed: :objects + +end diff --git a/app/serializers/excerpt_type.rb b/app/serializers/excerpt_type.rb new file mode 100644 index 00000000000..25893abec31 --- /dev/null +++ b/app/serializers/excerpt_type.rb @@ -0,0 +1,11 @@ +module ExcerptType + + def self.included(base) + base.attributes :type + end + + def type + self.class.name.sub(/ExcerptSerializer/, '') + end + +end diff --git a/app/serializers/invite_serializer.rb b/app/serializers/invite_serializer.rb new file mode 100644 index 00000000000..76d5805abfa --- /dev/null +++ b/app/serializers/invite_serializer.rb @@ -0,0 +1,12 @@ +class InviteSerializer < ApplicationSerializer + + attributes :email, :created_at, :redeemed_at + has_one :user, embed: :objects, serializer: InvitedUserSerializer + + + + def include_email? + !object.redeemed? + end + +end diff --git a/app/serializers/invited_list_serializer.rb b/app/serializers/invited_list_serializer.rb new file mode 100644 index 00000000000..58d9d1a8d36 --- /dev/null +++ b/app/serializers/invited_list_serializer.rb @@ -0,0 +1,10 @@ +class InvitedListSerializer < ApplicationSerializer + + has_many :pending, serializer: InviteSerializer, embed: :objects + has_many :redeemed, serializer: InviteSerializer, embed: :objects + + + def include_pending? + scope.can_see_pending_invites_from?(object.by_user) + end +end diff --git a/app/serializers/invited_user_serializer.rb b/app/serializers/invited_user_serializer.rb new file mode 100644 index 00000000000..35b0b3d238b --- /dev/null +++ b/app/serializers/invited_user_serializer.rb @@ -0,0 +1,19 @@ +class InvitedUserSerializer < BasicUserSerializer + + attributes :topics_entered, + :posts_read_count, + :last_seen_at, + :time_read, + :days_visited, + :days_since_created + + def time_read + return nil if object.time_read.blank? + AgeWords.age_words(object.time_read) + end + + def days_since_created + ((Time.now - object.created_at) / 60 / 60 / 24).ceil + end + +end diff --git a/app/serializers/notification_serializer.rb b/app/serializers/notification_serializer.rb new file mode 100644 index 00000000000..c054ff92aa6 --- /dev/null +++ b/app/serializers/notification_serializer.rb @@ -0,0 +1,19 @@ +class NotificationSerializer < ApplicationSerializer + + attributes :notification_type, + :read, + :created_at, + :post_number, + :topic_id, + :slug, + :data + + def slug + Slug.for(object.topic.title) if object.topic.present? + end + + def data + object.data_hash + end + +end diff --git a/app/serializers/post_action_type_serializer.rb b/app/serializers/post_action_type_serializer.rb new file mode 100644 index 00000000000..3100c817836 --- /dev/null +++ b/app/serializers/post_action_type_serializer.rb @@ -0,0 +1,27 @@ +class PostActionTypeSerializer < ApplicationSerializer + + attributes :name_key, :name, :description, :long_form, :is_flag, :icon, :id, :is_custom_flag + + def is_custom_flag + object.id == PostActionType.Types[:custom_flag] + end + + def name + i18n('title') + end + + def long_form + i18n('long_form') + end + + def description + i18n('description') + end + + protected + + def i18n(field) + I18n.t("post_action_types.#{object.name_key}.#{field}") + end + +end diff --git a/app/serializers/post_excerpt_serializer.rb b/app/serializers/post_excerpt_serializer.rb new file mode 100644 index 00000000000..c9abe562c73 --- /dev/null +++ b/app/serializers/post_excerpt_serializer.rb @@ -0,0 +1,37 @@ +require_dependency 'excerpt_type' + +class PostExcerptSerializer < ActiveModel::Serializer + include ExcerptType + + attributes :topic_id, :muted, :excerpt, :username, :created_at, :has_multiple_posts, :last_post_url, :first_post_url, :avatar_template + + def muted + object.topic.muted?(scope.current_user) + end + + def avatar_template + object.user.avatar_template + end + + def has_multiple_posts + (object.topic.posts_count > 1) + end + + def last_post_url + object.topic.last_post_url + end + + def first_post_url + object.topic.relative_url + end + + def include_last_post_url? + object.post_number == 1 + end + + def include_first_post_url? + object.post_number > 1 + end + + +end diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb new file mode 100644 index 00000000000..45d01eedb2b --- /dev/null +++ b/app/serializers/post_serializer.rb @@ -0,0 +1,194 @@ +class PostSerializer < ApplicationSerializer + + # To pass in additional information we might need + attr_accessor :topic_slug + attr_accessor :topic_view + attr_accessor :parent_post + attr_accessor :add_raw + attr_accessor :single_post_link_counts + attr_accessor :draft_sequence + + attributes :id, + :post_number, + :post_type, + :created_at, + :updated_at, + :reply_count, + :reply_to_post_number, + :reply_below_post_number, + :quote_count, + :avg_time, + :incoming_link_count, + :reads, + :score, + :yours, + :topic_slug, + :topic_id, + :display_username, + :version, + :can_edit, + :can_delete, + :link_counts, + :cooked, + :read, + :username, + :name, + :reply_to_user, + :bookmarked, + :raw, + :actions_summary, + :avatar_template, + :user_id, + :draft_sequence, + :hidden, + :hidden_reason_id, + :deleted_at + + + def avatar_template + object.user.avatar_template + end + + def yours + scope.user == object.user + end + + def can_edit + scope.can_edit?(object) + end + + def can_delete + scope.can_delete?(object) + end + + def link_counts + + return @single_post_link_counts if @single_post_link_counts.present? + + # TODO: This could be better, just porting the old one over + @topic_view.link_counts[object.id].map do |link| + result = {} + result[:url] = link[:url] + result[:internal] = link[:internal] + result[:reflection] = link[:reflection] + result[:title] = link[:title] if link[:title].present? + result[:clicks] = link[:clicks] || 0 + result + end + end + + def cooked + if object.hidden && !scope.is_admin? + if scope.current_user && object.user_id == scope.current_user.id + I18n.t('flagging.you_must_edit') + else + I18n.t('flagging.user_must_edit') + end + else + object.filter_quotes(@parent_post) + end + end + + def read + @topic_view.read?(object.post_number) + end + + def score + object.score || 0 + end + + def display_username + object.user.name + end + + def version + object.cached_version + end + + def username + object.user.username + end + + def name + object.user.name + end + + def reply_to_user + { + username: object.reply_to_user.username, + name: object.reply_to_user.name + } + end + + def bookmarked + true + end + + # Summary of the actions taken on this post + def actions_summary + result = [] + PostActionType.Types.each do |sym, id| + next if [:bookmark].include?(sym) + count_col = "#{sym}_count".to_sym + + count = object.send(count_col) if object.respond_to?(count_col) + count ||= 0 + action_summary = {id: id, + count: count, + hidden: (sym == :vote), + can_act: scope.post_can_act?(object, sym, taken_actions: post_actions)} + + next if !action_summary[:can_act] && !scope.current_user + + if post_actions.present? and post_actions.has_key?(id) + action_summary[:acted] = true + action_summary[:can_undo] = scope.can_delete?(post_actions[id]) + end + + # anonymize flags + if !scope.is_admin? && PostActionType.FlagTypes.include?(id) + action_summary[:count] = action_summary[:acted] ? 1 : 0 + end + + result << action_summary + end + + result + end + + def include_draft_sequence? + @draft_sequence.present? + end + + def include_slug_title? + @topic_slug.present? + end + + def include_raw? + @add_raw.present? + end + + def include_link_counts? + return true if @single_post_link_counts.present? + + @topic_view.present? and @topic_view.link_counts.present? and @topic_view.link_counts[object.id].present? + end + + def include_read? + @topic_view.present? + end + + def include_reply_to_user? + object.quoteless? and object.reply_to_user + end + + def include_bookmarked? + post_actions.present? and post_actions.keys.include?(PostActionType.Types[:bookmark]) + end + + private + + def post_actions + @post_actions ||= (@topic_view.present? && @topic_view.all_post_actions.present?) ? @topic_view.all_post_actions[object.id] : nil + end +end diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb new file mode 100644 index 00000000000..0cf996fce93 --- /dev/null +++ b/app/serializers/site_serializer.rb @@ -0,0 +1,13 @@ +class SiteSerializer < ApplicationSerializer + + attributes :default_archetype, :notification_types + has_many :categories, embed: :objects + has_many :post_action_types, embed: :objects + has_many :trust_levels, embed: :objects + has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer + + def default_archetype + Archetype.default + end + +end diff --git a/app/serializers/suggested_topic_serializer.rb b/app/serializers/suggested_topic_serializer.rb new file mode 100644 index 00000000000..e25b3ba1fea --- /dev/null +++ b/app/serializers/suggested_topic_serializer.rb @@ -0,0 +1,11 @@ +class SuggestedTopicSerializer < BasicTopicSerializer + + attributes :archetype, :slug, :like_count, :views, :last_post_age + has_one :category, embed: :objects + + def last_post_age + return nil if object.last_posted_at.blank? + AgeWords.age_words(Time.now - object.last_posted_at) + end + +end diff --git a/app/serializers/topic_link_serializer.rb b/app/serializers/topic_link_serializer.rb new file mode 100644 index 00000000000..be1192e47b6 --- /dev/null +++ b/app/serializers/topic_link_serializer.rb @@ -0,0 +1,32 @@ +class TopicLinkSerializer < ApplicationSerializer + + attributes :url, :title, :internal, :reflection, :clicks, :user_id + + def url + object['url'] + end + + def title + object['title'] + end + + def internal + object['internal'] == 't' + end + + def reflection + object['reflection'] == 't' + end + + def clicks + object['clicks'] || 0 + end + + def user_id + object['user_id'].to_i + end + def include_user_id? + object['user_id'].present? + end + +end \ No newline at end of file diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb new file mode 100644 index 00000000000..63d8c2ba426 --- /dev/null +++ b/app/serializers/topic_list_item_serializer.rb @@ -0,0 +1,22 @@ +class TopicListItemSerializer < BasicTopicSerializer + + attributes :views, :like_count, :visible, :pinned, :closed, :archived, :last_post_age, :starred, :has_best_of, :archetype, :slug + + has_one :category + has_many :posters, serializer: TopicPosterSerializer, embed: :objects + + def last_post_age + return nil if object.last_posted_at.blank? + AgeWords.age_words(Time.now - object.last_posted_at) + end + + def starred + object.user_data.starred? + end + alias :include_starred? :seen + + def posters + object.posters || [] + end + +end diff --git a/app/serializers/topic_list_serializer.rb b/app/serializers/topic_list_serializer.rb new file mode 100644 index 00000000000..09aa55cbd78 --- /dev/null +++ b/app/serializers/topic_list_serializer.rb @@ -0,0 +1,15 @@ +class TopicListSerializer < ApplicationSerializer + + attributes :can_create_topic, :more_topics_url, :filter_summary, :draft, :draft_key, :draft_sequence + + has_many :topics, serializer: TopicListItemSerializer, embed: :objects + + def can_create_topic + scope.can_create?(Topic) + end + + def include_more_topics_url? + object.more_topics_url.present? and (object.topics.size == SiteSetting.topics_per_page) + end + +end diff --git a/app/serializers/topic_post_count_serializer.rb b/app/serializers/topic_post_count_serializer.rb new file mode 100644 index 00000000000..c2a087a9d7c --- /dev/null +++ b/app/serializers/topic_post_count_serializer.rb @@ -0,0 +1,22 @@ +class TopicPostCountSerializer < BasicUserSerializer + + attributes :post_count + + def id + object[:user].id + end + + def username + object[:user].username + end + + def avatar_template + object[:user].avatar_template + end + + def post_count + object[:post_count] + end + + +end \ No newline at end of file diff --git a/app/serializers/topic_poster_serializer.rb b/app/serializers/topic_poster_serializer.rb new file mode 100644 index 00000000000..41a7341dfa1 --- /dev/null +++ b/app/serializers/topic_poster_serializer.rb @@ -0,0 +1,6 @@ +class TopicPosterSerializer < ApplicationSerializer + + attributes :extras, :description + has_one :user, serializer: BasicUserSerializer + +end \ No newline at end of file diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb new file mode 100644 index 00000000000..490d3071d08 --- /dev/null +++ b/app/serializers/topic_view_serializer.rb @@ -0,0 +1,208 @@ +class TopicViewSerializer < ApplicationSerializer + + # These attributes will be delegated to the topic + def self.topic_attributes + [:id, + :title, + :posts_count, + :highest_post_number, + :created_at, + :views, + :reply_count, + :last_posted_at, + :visible, + :closed, + :pinned, + :archived, + :moderator_posts_count, + :has_best_of, + :archetype, + :slug] + end + + def self.guardian_attributes + [:can_moderate, :can_edit, :can_delete, :can_invite_to, :can_move_posts] + end + + attributes *topic_attributes + attributes *guardian_attributes + + attributes :draft, + :draft_key, + :draft_sequence, + :post_action_visibility, + :voted_in_topic, + :can_create_post, + :can_reply_as_new_topic, + :categoryName, + :starred, + :last_read_post_number, + :posted, + :notification_level, + :notifications_reason_id, + :posts, + :at_bottom + + has_one :created_by, serializer: BasicUserSerializer, embed: :objects + has_one :last_poster, serializer: BasicUserSerializer, embed: :objects + has_many :allowed_users, serializer: BasicUserSerializer, embed: :objects + + has_many :links, serializer: TopicLinkSerializer, embed: :objects + has_many :participants, serializer: TopicPostCountSerializer, embed: :objects + has_many :suggested_topics, serializer: SuggestedTopicSerializer, embed: :objects + + # Define a delegator for each attribute of the topic we want + topic_attributes.each do |ta| + class_eval %{def #{ta} + object.topic.#{ta} + end} + end + + # Define the guardian attributes + guardian_attributes.each do |ga| + class_eval %{ + def #{ga} + true + end + + def include_#{ga}? + scope.#{ga}?(object.topic) + end + } + end + + def draft + object.draft + end + + def include_allowed_users? + object.topic.private_message? + end + + def draft_key + object.draft_key + end + + def draft_sequence + object.draft_sequence + end + + def post_action_visibility + object.post_action_visibility + end + + def include_post_action_visibility? + object.post_action_visibility.present? + end + + def voted_in_topic + object.voted_in_topic? + end + + def can_reply_as_new_topic + scope.can_reply_as_new_topic?(object.topic) + end + + def include_can_reply_as_new_topic? + scope.can_create?(Post, object.topic) + end + + def can_create_post + true + end + + def include_can_create_post? + scope.can_create?(Post, object.topic) + end + + def categoryName + object.topic.category.name + end + def include_categoryName? + object.topic.category.present? + end + + # Topic user stuff + def has_topic_user? + object.topic_user.present? + end + + def starred + object.topic_user.starred? + end + alias_method :include_starred?, :has_topic_user? + + def last_read_post_number + object.topic_user.last_read_post_number + end + alias_method :include_last_read_post_number?, :has_topic_user? + + def posted + object.topic_user.posted? + end + alias_method :include_posted?, :has_topic_user? + + def notification_level + object.topic_user.notification_level + end + alias_method :include_notification_level?, :has_topic_user? + + def notifications_reason_id + object.topic_user.notifications_reason_id + end + alias_method :include_notifications_reason_id?, :has_topic_user? + + def created_by + object.topic.user + end + + def last_poster + object.topic.last_poster + end + + def allowed_users + object.topic.allowed_users + end + + def include_links? + object.links.present? + end + + def participants + object.posts_count.collect {|tuple| {user: object.participants[tuple.first], post_count: tuple[1]}} + end + + def include_participants? + object.initial_load? and object.posts_count.present? + end + + def suggested_topics + object.suggested_topics.topics + end + def include_suggested_topics? + at_bottom and object.suggested_topics.present? + end + + # Whether we're at the bottom of a topic (last page) + def at_bottom + posts.present? and (@highest_number_in_posts == object.topic.highest_post_number) + end + + def posts + return @posts if @posts.present? + @posts = [] + @highest_number_in_posts = 0 + if object.posts.present? + object.posts.each do |p| + @highest_number_in_posts = p.post_number if p.post_number > @highest_number_in_posts + ps = PostSerializer.new(p, scope: scope, root: false) + ps.topic_slug = object.topic.slug + ps.topic_view = object + p.topic = object.topic + @posts << ps.as_json + end + end + @posts + end + +end diff --git a/app/serializers/trust_level_serializer.rb b/app/serializers/trust_level_serializer.rb new file mode 100644 index 00000000000..b2ce92a4589 --- /dev/null +++ b/app/serializers/trust_level_serializer.rb @@ -0,0 +1,5 @@ +class TrustLevelSerializer < ApplicationSerializer + + attributes :id, :name + +end diff --git a/app/serializers/upload_serializer.rb b/app/serializers/upload_serializer.rb new file mode 100644 index 00000000000..9f00b3af22d --- /dev/null +++ b/app/serializers/upload_serializer.rb @@ -0,0 +1,5 @@ +class UploadSerializer < ApplicationSerializer + + attributes :url, :filesize, :original_filename, :width, :height + +end diff --git a/app/serializers/user_excerpt_serializer.rb b/app/serializers/user_excerpt_serializer.rb new file mode 100644 index 00000000000..040f0f78893 --- /dev/null +++ b/app/serializers/user_excerpt_serializer.rb @@ -0,0 +1,14 @@ +require_dependency 'excerpt_type' + +class UserExcerptSerializer < ActiveModel::Serializer + include ExcerptType + + # TODO: Inherit from basic user serializer? + + attributes :bio_cooked, :username, :url, :name, :avatar_template + + def url + user_path(object.username.downcase) + end + +end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb new file mode 100644 index 00000000000..e9c3bc75d7e --- /dev/null +++ b/app/serializers/user_serializer.rb @@ -0,0 +1,68 @@ +class UserSerializer < BasicUserSerializer + + attributes :name, + :email, + :last_posted_at, + :last_seen_at, + :bio_raw, + :bio_cooked, + :created_at, + :website, + :can_edit, + :stream, + :stats, + :can_send_private_message_to_user, + :bio_excerpt, + :invited_by, + :trust_level + + + def self.private_attributes(*attrs) + attributes *attrs + attrs.each do |attr| + define_method "include_#{attr}?" do + can_edit + end + end + end + + def bio_excerpt + e = object.bio_excerpt + unless e && e.length > 0 + e = if scope.user && scope.user.id == object.id + I18n.t('user_profile.no_info_me', username_lower: object.username_lower) + else + I18n.t('user_profile.no_info_other', name: object.name) + end + end + e + end + + private_attributes :email, + :email_digests, + :email_private_messages, + :email_direct, + :digest_after_days, + :auto_track_topics_after_msecs + + def auto_track_topics_after_msecs + object.auto_track_topics_after_msecs || SiteSetting.auto_track_topics_after + end + + def can_send_private_message_to_user + scope.can_send_private_message?(object) + end + + def stats + UserAction.stats(object.id, scope) + end + + def stream + UserAction.stream(user_id: object.id, offset: 0, limit: 60, guardian: scope) + end + + def can_edit + scope.can_edit?(object) + end + +end diff --git a/app/serializers/version_serializer.rb b/app/serializers/version_serializer.rb new file mode 100644 index 00000000000..d83e2722a79 --- /dev/null +++ b/app/serializers/version_serializer.rb @@ -0,0 +1,21 @@ +class VersionSerializer < ApplicationSerializer + + attributes :number, :display_username, :created_at, :description + + def number + object[:number] + end + + def display_username + object[:display_username] + end + + def created_at + object[:created_at] + end + + def description + "v#{object[:number]} - #{FreedomPatches::Rails4.time_ago_in_words(object[:created_at])} ago by #{object[:display_username]}" + end + +end diff --git a/app/views/default/empty.html.erb b/app/views/default/empty.html.erb new file mode 100644 index 00000000000..7b4d68d70fc --- /dev/null +++ b/app/views/default/empty.html.erb @@ -0,0 +1 @@ +empty \ No newline at end of file diff --git a/app/views/email/resubscribe.html.erb b/app/views/email/resubscribe.html.erb new file mode 100644 index 00000000000..f83d460de7d --- /dev/null +++ b/app/views/email/resubscribe.html.erb @@ -0,0 +1,7 @@ +
            + +

            <%= t :'resubscribe.title' %>

            + +

            <%= t :'resubscribe.description' %>

            + +
            diff --git a/app/views/email/unsubscribe.html.erb b/app/views/email/unsubscribe.html.erb new file mode 100644 index 00000000000..758f903da1e --- /dev/null +++ b/app/views/email/unsubscribe.html.erb @@ -0,0 +1,20 @@ +
            + + <%- unless @not_found %> +

            <%= t :'unsubscribed.title' %>

            + +

            <%= t :'unsubscribed.description' %>

            + +

            <%= t :'unsubscribed.oops' %>

            + + <%= form_tag(email_resubscribe_path(key: params[:key])) do %> + <%= submit_tag t(:'resubscribe.action'), class: 'btn btn-danger' %> + <% end %> + <%- else %> +

            <%= t :'unsubscribed.not_found' %>

            +

            <%= t :'unsubscribed.not_found_description' %>

            + <%- end %> + + + +
            diff --git a/app/views/exceptions/not_found.html.erb b/app/views/exceptions/not_found.html.erb new file mode 100644 index 00000000000..a6ed88fd805 --- /dev/null +++ b/app/views/exceptions/not_found.html.erb @@ -0,0 +1,27 @@ +

            The page you requested doesn't exist on this discussion forum. Perhaps we can help find it, or another topic like it:

            + + + + + +
            +

            Most popular topics

            + <% @popular.each do |t| %> + <%= t.title %>
            + <% end %> + See More... +
            +

            Recent topics

            + <% @recent.each do |t| %> + <%= t.title %>
            + <% end %> + See More... +
            +

            Search for this topic

            +

            +

            + + + +
            +

            diff --git a/app/views/facebook/complete.haml b/app/views/facebook/complete.haml new file mode 100644 index 00000000000..7d96447a236 --- /dev/null +++ b/app/views/facebook/complete.haml @@ -0,0 +1,6 @@ +%html + %head + %body + :javascript + window.opener.Discourse.authenticationComplete(#{@data.to_json}); + window.close(); diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 00000000000..94ec95c7e43 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,149 @@ + + + + + <%=t :title%> + + + + <%- + canonical = capture{yield :canonical} + if canonical + %> + + <%- + end + %> + + > + <%= javascript_include_tag "preload_store" %> + + + <%# + The fonts are loaded outside of the stylesheet so that we can dynamically change + the path. This is to get around CDN caching on the Origin: + + https://forums.aws.amazon.com/thread.jspa?threadID=114646 + %> + + <%- font_domain = "#{request.protocol}#{request.host_with_port}" %> + + + <%- unless SiteCustomization.override_default_style(session[:preview_style]) %> + <%- if params[:shiny] %> + <%=stylesheet_link_tag "shiny/shiny"%> + <%- else %> + <%=stylesheet_link_tag "application"%> + <%- end %> + <%- end %> + + <%- if mini_profiler_enabled? %> + <%- Rack::MiniProfiler.step "stylsheet" do%> + <%= stylesheet_link_tag "admin"%> + <%-end%> + <%- elsif admin? %> + <%= stylesheet_link_tag "admin"%> + <%-end%> + <%=SiteCustomization.custom_stylesheet(session[:preview_style])%> + <%=csrf_meta_tags%> + + + + <%=SiteCustomization.custom_header(session[:preview_style])%> +
            + + +
            + + <%- if @preloaded.present? %> + + <%- end %> + + <%= yield :data %> + +
            + + <%- if mini_profiler_enabled? %> + <%- Rack::MiniProfiler.step "application" do %> + <%= javascript_include_tag "application" %> + <%-end%> + + <%- Rack::MiniProfiler.step "admin" do %> + <%= javascript_include_tag "admin"%> + <%-end%> + <%- else %> + <%= javascript_include_tag "application" %> + <%- if admin? %> + <%= javascript_include_tag "admin"%> + <%- end %> + <%- end%> + + + + <%- if Rails.env == "production" and SiteSetting.ga_tracking_code.present? %> + + <%-end%> + + + diff --git a/app/views/layouts/no_js.html.erb b/app/views/layouts/no_js.html.erb new file mode 100644 index 00000000000..7092e40324b --- /dev/null +++ b/app/views/layouts/no_js.html.erb @@ -0,0 +1,53 @@ + + + + + <%=t :title%> + + + + > + + <%- if mini_profiler_enabled? %> + <%- Rack::MiniProfiler.step "stylsheet" do%> + <%=stylesheet_link_tag "application"%> + <%- end %> + <%- if current_user.try(:admin) %> + <%- Rack::MiniProfiler.step "stylsheet" do%> + <%= stylesheet_link_tag "admin"%> + <%-end%> + <%- end %> + + <%- else %> + <%=stylesheet_link_tag "application"%> + <%- if current_user.try(:admin) %> + <%= stylesheet_link_tag "admin"%> + <%- end %> + <%- end %> + + <%=csrf_meta_tags%> + + + + +
            +
            +
            +
            +
            +
            + +
            +
            +
            +
            +
            +
            + <%= yield %> +
            +
            + +
            +
            + + diff --git a/app/views/list/list.erb b/app/views/list/list.erb new file mode 100644 index 00000000000..10cce08499b --- /dev/null +++ b/app/views/list/list.erb @@ -0,0 +1,10 @@ + +<% @list.topics.each do |t| %> + + + +<% end %> +
            <%= t.title %> [<%= t.posts_count %>]
            +<% if @list.topics.length > 0 %> +next page +<% end %> diff --git a/app/views/request_access/new.html.erb b/app/views/request_access/new.html.erb new file mode 100644 index 00000000000..14c2bde13c7 --- /dev/null +++ b/app/views/request_access/new.html.erb @@ -0,0 +1,21 @@ +
            +

            <%= t :'request_access.code' %>

            + +

            <%= t :'request_access.instructions' %>

            + + <%- if flash[:error].present? %> +
            + <%= flash[:error] %> +
            + <% end %> + + <%= form_tag do |f| %> + <%= hidden_field_tag :return_path, @return_path %> + <%= text_field_tag :password, @password %> + <%= submit_tag t(:'request_access.enter'), class: 'btn' %> + <% end %> +
            + + diff --git a/app/views/static/faq.html.erb b/app/views/static/faq.html.erb new file mode 100644 index 00000000000..88b5722382d --- /dev/null +++ b/app/views/static/faq.html.erb @@ -0,0 +1,138 @@ +
            +
            + +
            +
            +

            This is a Civilized Place for Public Discussion

            +

            + Please treat this discussion forum with the same respect you would a public park. We’re a community resource: a place to share skills, knowledge and interests. Use these guidelines to help keep this a clean, well-lighted, civilized place for public discourse. +

            +
            + These guidelines are not intended as a comprehensive list of hard and fast rules, merely as aids for the human judgment of moderators and the overall community. +
            +
            +
            +

            Improve the Discussion

            +

            + Help us make this a great place for discussion by always working to improve the discussion in some way. If you are not sure your post adds to the discussion or might detract from its usefulness, think over what you want to say and try again later. +

            +
            +

            + The topics discussed here matter to us and we want you to act as if they matter to you too. Treat the topics and the people discussing them with respect, even if you disagree with some of what is being said. +

            +

            + One way to improve the discussion is by searching for ones that are already happening. Please use the search function before starting a new discussion. You have a better chance of meeting others who share your interests. +

            +
            +

            +
            +
            +

            Be Agreeable, Even When You Disagree

            +

            + You may wish to respond to something by disagreeing with it. That's fine. But, remember to criticize ideas, not people. + Please avoid: +

              +
            • name-calling
            • +
            • ad hominem attacks
            • +
            • responding to a post's tone instead of its actual content
            • +
            • knee-jerk contradiction
            • +
            +

            +
            +

            + Instead, provide reasoned counter-arguments that improve the conversation. +

            +
            +
            + +
            +

            Your Participation Counts

            +

            + The discussions here set the tone for everyone. Help us influence the future of this community by choosing to engage in discussions that make this forum an interesting place to be. +

            +
            +

            + Discourse provides tools that enable the community to collectively identify the best (and worst) contributions: favorites, bookmarks, likes, flags, replies, edits, and so forth. Please make use of these tools to improve your own experience — and everyone else’s, too. +

            +

            + Let’s endeavor to leave our park better than we found it. +

            +
            +
            + +
            +

            If You See a Problem, Flag It

            +

            + Moderators have special responsibility and authority; they are technically responsible for this forum. But so are you. Moderators should be facilitators more than janitors or police — and they can be, with your help. +

            +
            +

            + When you see bad behavior, you may have the urge to call it out with a quick reply. Resist this urge. It encourages the bad behavior by acknowledging it, consumes your energy, and wastes everyone’s time. Just flag it. If enough flags accrue, action will be taken, either automatically or by moderator intervention. +

            +

            + In order to maintain our community, moderators reserve the right to remove any content and any user account for any reason at any time. Moderators do not preview new posts or take any preemptive action, therefore, the moderators and site operators take no responsibility for any content posted by the community. +

            +
            +
            + +
            +

            Always Be Civil

            +

            + Nothing sabotages a healthy conversation like rudeness: +

              +
            • Be civil. Don’t post anything that a reasonable person would consider offensive, abusive, or hate speech.
            • +
            • Keep it clean. Don’t post anything obscene or sexually explicit.
            • +
            • Respect each other. Don’t impersonate anyone or expose their private information.
            • +
            • Respect our forum. Don’t post spam or otherwise vandalize the forum.
            • +
            +

            +
            +

            + These are not concrete terms with precise definitions — avoid even the appearance of any of these things. If you're searching this list to see if you are in violation, you're thinking about this the wrong way. If you're unsure, ask yourself: "How would I feel if my post was on the front page of The New York Times?" +

            +

            + This is a public forum, and search engines index these discussions. Keep the language, links, and images safe for family and friends. Remember, your mom uses the Internet, too. +

            +
            +
            + +
            +

            Keep It Tidy

            +

            + Make the effort to put things in the right place, so that we can spend more time discussing and less cleaning up. So: +

              +
            • Don't start a topic in the wrong category.
            • +
            • Don't cross-post the same thing in multiple topics.
            • +
            • Don't post no-content replies.
            • +
            • Don't divert a topic by changing it midstream.
            • +
            +

            +
            +

            + Discourse can help keep things orderly — for example, the Like functionality heart lets a user increase the popularity of a post without having to make an entire post that says "+1" or "Agreed," etc. Traditional workarounds, such as "bump" and blank postings are not used here. You can even reply-as-a-new-topic! +

            +

            + Also, don't sign your posts — every post has your profile information and links in the header. +

            +
            +
            + +
            +

            Post Only Your Own Stuff

            +

            + You may not post anything digital that belongs to someone else without permission. You may not post descriptions of, links to, or methods for stealing someone's intellectual property (software, video, audio, images), or for breaking any other law. +

            +
            +
            +
            + +
            +

            Terms of Service

            +

            + Yes, legalese is boring, but we must protect ourselves (and by extension, you and your data) against unfriendly folks. So, like everyone else, we have a Terms of Service TOS describing your (and our) behavior and rights related to content, privacy, and laws. To use this service, you must agree to abide by the TOS. +

            +
            +
            +
            +
            diff --git a/app/views/static/privacy.html.erb b/app/views/static/privacy.html.erb new file mode 100644 index 00000000000..25687e08571 --- /dev/null +++ b/app/views/static/privacy.html.erb @@ -0,0 +1,64 @@ +

            Privacy Policy

            + +

            What information do we collect?

            +

            +We collect information from you when you register on our site and gather data when you participate in the forum by reading, writing, and evaluating the content shared here. +

            + +

            + When participating on our site, you may be asked to enter your: name, age and or e-mail address. You may, however, visit our site anonymously. +

            + +

            What do we use your information for?

            +

            Any of the information we collect from you may be used in one of the following ways:

            +
              +
            • To personalize your experience - your information helps us to better respond to your individual needs.
            • +
            • To improve our site - we continually strive to improve our site offerings based on the information and feedback we receive from you.
            • +
            • To improve customer service - your information helps us to more effectively respond to your customer service requests and support needs.
            • +
            • To send periodic emails - The email address you provide may be used to send you information, notifications that you request about changes to topics or in response to your user name, respond to inquiries, and/or other requests or questions.
            • +
            + +

            How do we protect your information?

            +

            + We implement a variety of security measures to maintain the safety of your personal +information when you enter, submit, or access your personal information. +

            + +

            Do we use cookies?

            +

            + Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow) that enables the sites or service providers systems to recognize your browser and capture and remember certain information. +

            + +

            + We use cookies to understand and save your preferences for future visits and compile aggregate data about site traffic and site interaction so that we can offer better site experiences and tools in the future. We may contract with third-party service providers to assist us in better understanding our site visitors. These service providers are not permitted to use the information collected on our behalf except to help us conduct and improve our business. +

            + +

            Do we disclose any information to outside parties?

            +

            +We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This does not include trusted third parties who assist us in operating our site, conducting our business, or servicing you, so long as those parties agree to keep this information confidential. We may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety. However, non-personally identifiable visitor information may be provided to other parties for marketing, advertising, or other uses. +

            + +

            Third party links

            +

            + Occasionally, at our discretion, we may include or offer third party products or services on our site. These third party sites have separate and independent privacy policies. We therefore have no responsibility or liability for the content and activities of these linked sites. Nonetheless, we seek to protect the integrity of our site and welcome any feedback about these sites. +

            + +

            Children's Online Privacy Protection Act Compliance

            +

            +We are in compliance with the requirements of COPPA (Childrens Online Privacy Protection Act), we do not collect any information from anyone under 13 years of age. Our site, products and services are all directed to people who are at least 13 years old or older. +

            + +

            Online Privacy Policy Only

            +

            +This online privacy policy applies only to information collected through our site and not to information collected offline. +

            + +

            Your Consent

            +

            +By using our site, you consent to our web site privacy policy. +

            + +

            Changes to our Privacy Policy

            +

            +If we decide to change our privacy policy, we will post those changes on this page. +

            diff --git a/app/views/static/tos.html.erb b/app/views/static/tos.html.erb new file mode 100644 index 00000000000..398c8d30cb1 --- /dev/null +++ b/app/views/static/tos.html.erb @@ -0,0 +1,126 @@ +

            Terms of Service

            + +

            + The following terms and conditions govern all use of the Discourse.org website and all content, services and products available at or through the website, including, but not limited to, Discourse Forum Software, Support Forums and the Discourse.org Hosting service (“Hosting”), (taken together, the Website). The Website is owned and operated by Civilized Discourse Construction Kit, Inc. (“CDCK”). The Website is offered subject to your acceptance without modification of all of the terms and conditions contained herein and all other operating rules, policies (including, without limitation, Discourse.org’s Privacy Policy and Community Guidelines) and procedures that may be published from time to time on this Site by CDCK (collectively, the “Agreement”). +

            + +

            + Please read this Agreement carefully before accessing or using the Website. By accessing or using any part of the web site, you agree to become bound by the terms and conditions of this agreement. If you do not agree to all the terms and conditions of this agreement, then you may not access the Website or use any services. If these terms and conditions are considered an offer by CDCK, acceptance is expressly limited to these terms. The Website is available only to individuals who are at least 13 years old. +

            + +

            1. Your Discourse.org Account

            +

            + If you create an account on the Website, you are responsible for maintaining the security of your account and you are fully responsible for all activities that occur under the account. You must immediately notify CDCK of any unauthorized uses of your account or any other breaches of security. CDCK will not be liable for any acts or omissions by you, including any damages of any kind incurred as a result of such acts or omissions. +

            + +

            2. Responsibility of Contributors

            +

            If you post material to the Website, post links on the Website, or otherwise make (or allow any third party to make) material available by means of the Website (any such material, “Content”), You are entirely responsible for the content of, and any harm resulting from, that Content. That is the case regardless of whether the Content in question constitutes text, graphics, an audio file, or computer software. By making Content available, you represent and warrant that: +

            +
              + +
            • the downloading, copying and use of the Content will not infringe the proprietary rights, including but not limited to the copyright, patent, trademark or trade secret rights, of any third party;
            • + +
            • if your employer has rights to intellectual property you create, you have either (i) received permission from your employer to post or make available the Content, including but not limited to any software, or (ii) secured from your employer a waiver as to all rights in or to the Content;
            • + +
            • you have fully complied with any third-party licenses relating to the Content, and have done all things necessary to successfully pass through to end users any required terms;
            • + +
            • the Content does not contain or install any viruses, worms, malware, Trojan horses or other harmful or destructive content;
            • + +
            • the Content is not spam, is not machine- or randomly-generated, and does not contain unethical or unwanted commercial content designed to drive traffic to third party sites or boost the search engine rankings of third party sites, or to further unlawful acts (such as phishing) or mislead recipients as to the source of the material (such as spoofing);
            • + +
            • the Content is not pornographic, does not contain threats or incite violence, and does not violate the privacy or publicity rights of any third party;
            • + +
            • your content is not getting advertised via unwanted electronic messages such as spam links on newsgroups, email lists, blogs and web sites, and similar unsolicited promotional methods;
            • + +
            • your content is not named in a manner that misleads your readers into thinking that you are another person or company; and
            • + +
            • you have, in the case of Content that includes computer code, accurately categorized and/or described the type, nature, uses and effects of the materials, whether requested to do so by CDCK or otherwise.
            • +
            + +

            3. User Content License

            + + +

            + User contributions are licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License. Without limiting any of those representations or warranties, CDCK has the right (though not the obligation) to, in CDCK’s sole discretion (i) refuse or remove any content that, in CDCK’s reasonable opinion, violates any CDCK policy or is in any way harmful or objectionable, or (ii) terminate or deny access to and use of the Website to any individual or entity for any reason, in CDCK’s sole discretion. CDCK will have no obligation to provide a refund of any amounts previously paid. +

            + +

            + Without limiting any of those representations or warranties, CDCK has the right (though not the obligation) to, in CDCK’s sole discretion (i) refuse or remove any content that, in CDCK’s reasonable opinion, violates any CDCK policy or is in any way harmful or objectionable, or (ii) terminate or deny access to and use of the Website to any individual or entity for any reason, in CDCK’s sole discretion. CDCK will have no obligation to provide a refund of any amounts previously paid. +

            + +

            4. Payment and Renewal

            +

            General Terms

            +

            + Optional paid services such as extra storage, or domain purchases are available on the Website (any such services, an “Upgrade”). By selecting an Upgrade you agree to pay CDCK the monthly or annual subscription fees indicated for that service. Payments will be charged on a pre-pay basis on the day you sign up for an Upgrade and will cover the use of that service for a monthly or annual subscription period as indicated. Upgrade fees are not refundable. +

            +

            Automatic Renewal

            +

            Unless you notify CDCK before the end of the applicable subscription period that you want to cancel an Upgrade, your Upgrade subscription will automatically renew and you authorize us to collect the then-applicable annual or monthly subscription fee for such Upgrade (as well as any taxes) using any credit card or other payment mechanism we have on record for you. Upgrades can be canceled at any time in the Upgrades section of your site’s dashboard. +

            + +

            5. Services

            +

            Hosting, Support Services

            +

            Hosting and Support services are provided by CDCK under the terms and conditions for each such service, which are located at Discourse.org/hosting-tos and Discourse.org/support-tos, respectively. By signing up for a Hosting/Support or Support services account, you agree to abide by such terms and conditions. +

            + +

            6. Responsibility of Website Visitors

            +

            + CDCK has not reviewed, and cannot review, all of the material, including computer software, posted to the Website, and cannot therefore be responsible for that material’s content, use or effects. By operating the Website, CDCK does not represent or imply that it endorses the material there posted, or that it believes such material to be accurate, useful or non-harmful. You are responsible for taking precautions as necessary to protect yourself and your computer systems from viruses, worms, Trojan horses, and other harmful or destructive content. The Website may contain content that is offensive, indecent, or otherwise objectionable, as well as content containing technical inaccuracies, typographical mistakes, and other errors. The Website may also contain material that violates the privacy or publicity rights, or infringes the intellectual property and other proprietary rights, of third parties, or the downloading, copying or use of which is subject to additional terms and conditions, stated or unstated. CDCK disclaims any responsibility for any harm resulting from the use by visitors of the Website, or from any downloading by those visitors of content there posted. +

            + +

            7. Content Posted on Other Websites

            +

            We have not reviewed, and cannot review, all of the material, including computer software, made available through the websites and webpages to which Discourse.org links, and that link to Discourse.org. CDCK does not have any control over those non-Discourse.org websites and webpages, and is not responsible for their contents or their use. By linking to a non-Discourse.org website or webpage, CDCK does not represent or imply that it endorses such website or webpage. You are responsible for taking precautions as necessary to protect yourself and your computer systems from viruses, worms, Trojan horses, and other harmful or destructive content. CDCK disclaims any responsibility for any harm resulting from your use of non-WordPress websites and webpages. +

            + +

            8. Copyright Infringement and DMCA Policy

            +

            + As CDCK asks others to respect its intellectual property rights, it respects the intellectual property rights of others. If you believe that material located on or linked to by Discourse.org violates your copyright, you are encouraged to notify CDCK in accordance with CDCK’s Digital Millennium Copyright Act (“DMCA”) Policy. CDCK will respond to all such notices, including as required or appropriate by removing the infringing material or disabling all links to the infringing material. CDCK will terminate a visitor’s access to and use of the Website if, under appropriate circumstances, the visitor is determined to be a repeat infringer of the copyrights or other intellectual property rights of CDCK or others. In the case of such termination, CDCK will have no obligation to provide a refund of any amounts previously paid to CDCK. +

            + +

            9. Intellectual Property

            +

            + This Agreement does not transfer from CDCK to you any CDCK or third party intellectual property, and all right, title and interest in and to such property will remain (as between the parties) solely with CDCK. CDCK, Discourse.org, the Discourse.org logo, and all other trademarks, service marks, graphics and logos used in connection with Discourse.org, or the Website are trademarks or registered trademarks of CDCK or CDCK’s licensors. Other trademarks, service marks, graphics and logos used in connection with the Website may be the trademarks of other third parties. Your use of the Website grants you no right or license to reproduce or otherwise use any CDCK or third-party trademarks. +

            + +

            10. Advertisements

            +

            CDCK reserves the right to display advertisements on your content unless you have purchased an Ad-free Upgrade or a Services account.

            + +

            11. Attribution

            +

            CDCK reserves the right to display attribution links such as ‘Powered by Discourse.org,’ theme author, and font attribution in your content footer or toolbar. Footer credits and the Discourse.org toolbar may not be removed regardless of upgrades purchased.

            + +

            12. Changes

            +

            + CDCK reserves the right, at its sole discretion, to modify or replace any part of this Agreement. It is your responsibility to check this Agreement periodically for changes. Your continued use of or access to the Website following the posting of any changes to this Agreement constitutes acceptance of those changes. CDCK may also, in the future, offer new services and/or features through the Website (including, the release of new tools and resources). Such new features and/or services shall be subject to the terms and conditions of this Agreement. +

            + +

            13. Termination

            +

            + CDCK may terminate your access to all or any part of the Website at any time, with or without cause, with or without notice, effective immediately. If you wish to terminate this Agreement or your Discourse.org account (if you have one), you may simply discontinue using the Website. All provisions of this Agreement which by their nature should survive termination shall survive termination, including, without limitation, ownership provisions, warranty disclaimers, indemnity and limitations of liability. +

            + +

            14.Disclaimer of Warranties.

            +

            + The Website is provided “as is”. CDCK and its suppliers and licensors hereby disclaim all warranties of any kind, express or implied, including, without limitation, the warranties of merchantability, fitness for a particular purpose and non-infringement. Neither CDCK nor its suppliers and licensors, makes any warranty that the Website will be error free or that cess thereto will be continuous or uninterrupted. If you’re actually reading this, here’s a treat (or maybe a trick). You understand that you download from, or otherwise obtain content or services through, the Website at your own discretion and risk. +

            + +

            15. Limitation of Liability.

            +

            + In no event will CDCK, or its suppliers or licensors, be liable with respect to any subject matter of this agreement under any contract, negligence, strict liability or other legal or equitable theory for: (i) any special, incidental or consequential damages; (ii) the cost of procurement for substitute products or services; (iii) for interruption of use or loss or corruption of data; or (iv) for any amounts that exceed the fees paid by you to CDCK under this agreement during the twelve (12) month period prior to the cause of action. CDCK shall have no liability for any failure or delay due to matters beyond their reasonable control. The foregoing shall not apply to the extent prohibited by applicable law. +

            + +

            16. General Representation and Warranty.

            +

            + You represent and warrant that (i) your use of the Website will be in strict accordance with the CDCK Privacy Policy, Community Guidelines, with this Agreement and with all applicable laws and regulations (including without limitation any local laws or regulations in your country, state, city, or other governmental area, regarding online conduct and acceptable content, and including all applicable laws regarding the transmission of technical data exported from the United States or the country in which you reside) and (ii) your use of the Website will not infringe or misappropriate the intellectual property rights of any third party. +

            + +

            17.Indemnification

            +

            + You agree to indemnify and hold harmless CDCK, its contractors, and its licensors, and their respective directors, officers, employees and agents from and against any and all claims and expenses, including attorneys’ fees, arising out of your use of the Website, including but not limited to your violation of this Agreement. +

            + +

            18. Miscellaneous

            +

            + This Agreement constitutes the entire agreement between CDCK and you concerning the subject matter hereof, and they may only be modified by a written amendment signed by an authorized executive of CDCK, or by the posting by CDCK of a revised version. Except to the extent applicable law, if any, provides otherwise, this Agreement, any access to or use of the Website will be governed by the laws of the state of California, U.S.A., excluding its conflict of law provisions, and the proper venue for any disputes arising out of or relating to any of the same will be the state and federal courts located in San Francisco County, California. Except for claims for injunctive or equitable relief or claims regarding intellectual property rights (which may be brought in any competent court without the posting of a bond), any dispute arising under this Agreement shall be finally settled in accordance with the Comprehensive Arbitration Rules of the Judicial Arbitration and Mediation Service, Inc. (“JAMS”) by three arbitrators appointed in accordance with such Rules. The arbitration shall take place in San Francisco, California, in the English language and the arbitral decision may be enforced in any court. The prevailing party in any action or proceeding to enforce this Agreement shall be entitled to costs and attorneys’ fees. If any part of this Agreement is held invalid or unenforceable, that part will be construed to reflect the parties’ original intent, and the remaining portions will remain in full force and effect. A waiver by either party of any term or condition of this Agreement or any breach thereof, in any one instance, will not waive such term or condition or any subsequent breach thereof. You may assign your rights under this Agreement to any party that consents to, and agrees to be bound by, its terms and conditions; CDCK may assign its rights under this Agreement without condition. This Agreement will be binding upon and will inure to the benefit of the parties, their successors and permitted assigns. +

            \ No newline at end of file diff --git a/app/views/topics/show.html.erb b/app/views/topics/show.html.erb new file mode 100644 index 00000000000..09b8906fe29 --- /dev/null +++ b/app/views/topics/show.html.erb @@ -0,0 +1,19 @@ +

            + <%= @topic_view.topic.title %> +

            +<% (@post ? [@post] : @topic_view.posts).each do |post| %> +
            + #<%=post.post_number%> By: <%= post.user.name %>, <%= post.created_at.to_formatted_s(:long_ordinal) %> +
            +
            + <%= post.cooked.html_safe %> +
            +<% end %> + +<% if @next_page%> +

            + next page +

            +<% end %> + +<%- content_for :canonical do %><%= @canonical %><%- end %> diff --git a/app/views/twitter/complete.haml b/app/views/twitter/complete.haml new file mode 100644 index 00000000000..7d96447a236 --- /dev/null +++ b/app/views/twitter/complete.haml @@ -0,0 +1,6 @@ +%html + %head + %body + :javascript + window.opener.Discourse.authenticationComplete(#{@data.to_json}); + window.close(); diff --git a/app/views/user_notifications/digest.text.erb b/app/views/user_notifications/digest.text.erb new file mode 100644 index 00000000000..869987f6f02 --- /dev/null +++ b/app/views/user_notifications/digest.text.erb @@ -0,0 +1,29 @@ +<%- site_link = raw(@markdown_linker.create(@site_name, '/')) %> +<%= raw(t 'user_notifications.digest.why', + site_link: site_link, + last_seen_at: @last_seen_at) %> + +<%- if @notifications.present? %> +### <%=t 'user_notifications.digest.new_activity' %> + +<%- @notifications.each do |n| %> +* <%= raw(n.text_description { raw(@markdown_linker.create(n.data_hash[:topic_title], n.url)) }) %> +<%- end %> + +<%- end %> +<%- if @new_topics.present? %> +### <%=t 'user_notifications.digest.new_topics' %> + +<%- @new_topics.each do |t| %> +* <%= raw(@markdown_linker.create(t.title, t.relative_url)) %> +<%- end %> +<%- end %> + +<%= raw(@markdown_linker.references) %> + +<%=raw(t :'user_notifications.digest.unsubscribe', + site_link: site_link, + unsubscribe_link: raw(@markdown_linker.create('click here', email_unsubscribe_path(key: @user.temporary_key)))) %> + +<%= raw(@markdown_linker.references) %> + diff --git a/app/views/user_open_ids/complete.haml b/app/views/user_open_ids/complete.haml new file mode 100644 index 00000000000..7d96447a236 --- /dev/null +++ b/app/views/user_open_ids/complete.haml @@ -0,0 +1,6 @@ +%html + %head + %body + :javascript + window.opener.Discourse.authenticationComplete(#{@data.to_json}); + window.close(); diff --git a/app/views/users/activate_account.html.erb b/app/views/users/activate_account.html.erb new file mode 100644 index 00000000000..c2bbe1338f5 --- /dev/null +++ b/app/views/users/activate_account.html.erb @@ -0,0 +1,19 @@ +
            + + <%if flash[:error]%> +
            + <%=flash[:error]%> +
            + <%else%> +

            <%= t 'activation.welcome_to', site_name: SiteSetting.title %>

            +

            + <% if @needs_approval %> + <%= t 'activation.approval_required' %> + <% else %> + <%= raw t('activation.please_continue', link: link_to(SiteSetting.title, root_path)) %>. + <% end %> +

            + + <%end%> + +
            \ No newline at end of file diff --git a/app/views/users/authorize_email.html.erb b/app/views/users/authorize_email.html.erb new file mode 100644 index 00000000000..fc477e49ee4 --- /dev/null +++ b/app/views/users/authorize_email.html.erb @@ -0,0 +1,12 @@ +
            + <%if flash[:error]%> +
            + <%=flash[:error]%> +
            + <%else%> +

            <%= t 'change_email.confirmed' %>

            +

            + <%= raw t('change_email.please_continue', link: link_to(SiteSetting.title, root_path)) %> +

            + <%end%> +
            \ No newline at end of file diff --git a/app/views/users/password_reset.html.erb b/app/views/users/password_reset.html.erb new file mode 100644 index 00000000000..d37ec396252 --- /dev/null +++ b/app/views/users/password_reset.html.erb @@ -0,0 +1,46 @@ +
            + <%if flash[:error]%> +
            + <%=flash[:error]%> +
            + <%end%> + <% if @user.present? and @user.errors.any? %> +
            + <% @user.errors.full_messages.each do |msg| %> +
          • <%= msg %>
          • + <% end %> +
            + <% end %> + + <%if flash[:success]%> +
            + <%=flash[:success]%> +
            +

            + <%- if @requires_approval %> + <%= t 'login.not_approved' %> + <% else %> + <%= raw t 'activation.please_continue', link: link_to(SiteSetting.title, root_path) %> + <% end %> + + +

            + <% else %> + <%if @user.present? %> +

            <%= t 'password_reset.choose_new' %>

            + + <%=form_tag({}, method: :put) do %> +

            + +

            +

            + <%=submit_tag(t('password_reset.update'), class: 'btn')%> +

            + <%end%> + <%end%> + <%end%> +
            + + diff --git a/config.ru b/config.ru new file mode 100644 index 00000000000..6b0326b323e --- /dev/null +++ b/config.ru @@ -0,0 +1,3 @@ +# This file is used by Rack-based servers to start the application. +require ::File.expand_path('../config/environment', __FILE__) +run Discourse::Application diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 00000000000..0f3b40e2edf --- /dev/null +++ b/config/application.rb @@ -0,0 +1,102 @@ +require File.expand_path('../boot', __FILE__) + +require 'rails/all' +require "redis-store" # HACK + +# Plugin related stuff +require './lib/discourse_plugin_registry' + +if defined?(Bundler) + # If you precompile assets before deploying to production, use this line + Bundler.require(*Rails.groups(:assets => %w(development test))) + # If you want your assets lazily compiled in production, use this line + # Bundler.require(:default, :assets, Rails.env) +end + +module Discourse + class Application < Rails::Application + # 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. + + require 'discourse' + + # Custom directories with classes and modules you want to be autoloadable. + config.autoload_paths += %W(#{config.root}/app/serializers) + + # Only load the plugins named here, in the order given (default is alphabetical). + # :all can be used as a placeholder for all plugins not explicitly named. + # config.plugins = [ :exception_notification, :ssl_requirement, :all ] + + config.assets.paths += %W(#{config.root}/config/locales) + + config.assets.precompile += ['admin.js', 'admin.css', 'shiny/shiny.css', 'preload_store.js', 'jquery.js'] + + # Activate observers that should always be running. + config.active_record.observers = [ + :user_email_observer, + :user_action_observer, + :message_bus_observer, + :post_alert_observer, + :search_observer + ] + + # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. + # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. + config.time_zone = 'Eastern Time (US & Canada)' + + # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. + # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] + # config.i18n.default_locale = :de + + # Configure the default encoding used in templates for Ruby 1.9. + config.encoding = "utf-8" + + # Configure sensitive parameters which will be filtered from the log file. + config.filter_parameters += [:password] + + # Enable the asset pipeline + config.assets.enabled = true + + # Version of your assets, change this if you want to expire all your assets + config.assets.version = '1.2.4' + + # We need to be able to spin threads + config.active_record.thread_safe! + + # see: http://stackoverflow.com/questions/11894180/how-does-one-correctly-add-custom-sql-dml-in-migrations/11894420#11894420 + config.active_record.schema_format = :sql + + # per https://www.owasp.org/index.php/Password_Storage_Cheat_Sheet + config.pbkdf2_iterations = 64000 + + # dumping rack lock cause the message bus does not work with it (throw :async, it catches Exception) + # see: https://github.com/sporkrb/spork/issues/66 + # rake assets:precompile also fails + config.threadsafe! unless $PROGRAM_NAME =~ /spork|rake/ + + # route all exceptions via our router + config.exceptions_app = self.routes + + # Our templates shouldn't start with 'discourse/templates' + config.handlebars.templates_root = 'discourse/templates' + + # Use redis for our cache + redis_config = YAML::load(File.open("#{Rails.root}/config/redis.yml"))[Rails.env] + redis_store = ActiveSupport::Cache::RedisStore.new "redis://#{redis_config['host']}:#{redis_config['port']}/#{redis_config['cache_db']}" + redis_store.options[:namespace] = -> { DiscourseRedis.namespace } + config.cache_store = redis_store + + # Test with rack::cache disabled. Nginx does this for us + config.action_dispatch.rack_cache = nil + + # So open id logs somewhere sane + config.after_initialize do + OpenID::Util.logger = Rails.logger + + # latest possible so earliest in the stack + # require 'rack/message_bus' + # config.middleware.insert(0, Rack::MessageBus) + end + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 00000000000..4489e58688c --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,6 @@ +require 'rubygems' + +# Set up gems listed in the Gemfile. +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) + +require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE']) diff --git a/config/cdn.yml.sample b/config/cdn.yml.sample new file mode 100644 index 00000000000..ea46cd6064e --- /dev/null +++ b/config/cdn.yml.sample @@ -0,0 +1,5 @@ +# at the moment only cdn77 is supported +provider: cdn77 +password: your api password +login: your login +id: your cdn id diff --git a/config/clock.rb b/config/clock.rb new file mode 100644 index 00000000000..a1e74432b61 --- /dev/null +++ b/config/clock.rb @@ -0,0 +1,19 @@ +require 'clockwork' +require_relative 'boot' +require_relative 'environment' + +# These are jobs you should run on a regular basis to make your +# forum work properly. + +module Clockwork + handler do |job| + Jobs.enqueue(job, all_sites: true) + end + + every(1.day, 'enqueue_digest_emails', at: '06:00') + every(1.day, 'category_stats', at: '04:00') + every(10.minutes, 'calculate_avg_time') + every(10.minutes, 'feature_topics') + every(1.minute, 'calculate_score') + every(20.minutes, 'calculate_view_counts') +end diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 00000000000..1fd6a466e59 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,30 @@ +development: + adapter: postgresql + database: discourse_development + host: localhost + pool: 5 + timeout: 5000 + host_names: + - "localhost" + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + adapter: postgresql + database: discourse_test + host: localhost + pool: 5 + timeout: 5000 + host_names: + - test.localhost + +# using the test db, so jenkins can run this config +# we need it to be in production so it minifies assets +production: + adapter: postgresql + database: discourse_development + pool: 5 + timeout: 5000 + host_names: + - production.localhost diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 00000000000..2cbc66af8ed --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the rails application +require File.expand_path('../application', __FILE__) + +# Initialize the rails application +Discourse::Application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 00000000000..a0e2c99c278 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,47 @@ +Discourse::Application.configure do + # Settings specified here will take precedence over those in config/application.rb + + # In the development environment your application's code is reloaded on + # every request. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.cache_classes = false + + # Log error messages when you accidentally call methods on nil. + config.whiny_nils = true + + # Show full error reports and disable caching + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + + # Print deprecation notices to the Rails logger + config.active_support.deprecation = :log + + # Only use best-standards-support built into browsers + config.action_dispatch.best_standards_support = :builtin + + # Do not compress assets + config.assets.compress = false + + # Expands the lines which load the assets + config.assets.debug = true + + config.watchable_dirs['lib'] = [:rb] + + config.sass.debug_info = false + config.ember.variant = :development + config.ember.handlebars_location = "#{Rails.root}/app/assets/javascripts/external/handlebars-1.0.rc.2.js" + config.ember.ember_location = "#{Rails.root}/app/assets/javascripts/external/ember.js" + config.handlebars.precompile = false + + # a bit hacky but works + config.after_initialize do + config.middleware.delete Airbrake::UserInformer + config.middleware.delete Airbrake::Rack + end + + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { :address => "localhost", :port => 1025 } + config.action_mailer.raise_delivery_errors = true + +end + diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 00000000000..73fe4e7d1e8 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,43 @@ +Discourse::Application.configure do + # Settings specified here will take precedence over those in config/application.rb + + # Code is not reloaded between requests + config.cache_classes = true + + # Full error reports are disabled and caching is turned on + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Disable Rails's static asset server (Apache or nginx will already do this) + config.serve_static_assets = false + + # Compress JavaScripts and CSS + config.assets.compress = true + + # stuff should be pre-compiled + config.assets.compile = false + + # Generate digests for assets URLs + config.assets.digest = true + + # Specifies the header that your server uses for sending files + config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation can not be found) + config.i18n.fallbacks = true + + config.action_mailer.delivery_method = :sendmail + config.action_mailer.sendmail_settings = {arguments: '-i'} + + # Send deprecation notices to registered listeners + config.active_support.deprecation = :notify + + # I dunno ... perhaps the built-in minifier is using closure + # regardless it is blowing up + config.ember.variant = :development + config.ember.ember_location = "#{Rails.root}/app/assets/javascripts/external_production/ember.js" + config.ember.handlebars_location = "#{Rails.root}/app/assets/javascripts/external/handlebars-1.0.rc.2.js" + config.handlebars.precompile = true + +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 00000000000..6aeabe65e6b --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,45 @@ +Discourse::Application.configure do + # Settings specified here will take precedence over those in config/application.rb + + # The test environment is used exclusively to run your application's + # test suite. You never need to work with it otherwise. Remember that + # your test database is "scratch space" for the test suite and is wiped + # and recreated between test runs. Don't rely on the data there! + config.cache_classes = false + + # Configure static asset server for tests with Cache-Control for performance + config.serve_static_assets = true + + # Needed for jasmine specs to work + config.assets.debug = true + + # Log error messages when you accidentally call methods on nil + config.whiny_nils = true + + # Show full error reports and disable caching + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + + # Raise exceptions instead of rendering exception templates + config.action_dispatch.show_exceptions = false + + # Disable request forgery protection in test environment + config.action_controller.allow_forgery_protection = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Use SQL instead of Active Record's schema dumper when creating the test database. + # This is necessary if your schema can't be completely dumped by the schema dumper, + # like if you have constraints or database-specific column types + # config.active_record.schema_format = :sql + + # Print deprecation notices to the stderr + config.active_support.deprecation = :stderr + + # lower iteration count for test + config.pbkdf2_iterations = 10 + config.ember.variant = :development +end diff --git a/config/fog_credentials.yml.sample b/config/fog_credentials.yml.sample new file mode 100644 index 00000000000..5856280429b --- /dev/null +++ b/config/fog_credentials.yml.sample @@ -0,0 +1,4 @@ +default: + aws_access_key_id: 'your-aws-access-key-id' + aws_secret_access_key: 'your-aws-secret-access-key/' + region: 'your-aws-region' \ No newline at end of file diff --git a/config/i18n-js.yml b/config/i18n-js.yml new file mode 100644 index 00000000000..b31fed9cefa --- /dev/null +++ b/config/i18n-js.yml @@ -0,0 +1,28 @@ +# Split context in several files. +# By default only one file with all translations is exported and +# no configuration is required. Your settings for asset pipeline +# are automatically recognized. +# +# If you want to split translations into several files or specify +# locale contexts that will be exported, just use this file to do +# so. +# +# If you're going to use the Rails 3.1 asset pipeline, change +# the following configuration to something like this: +# +# translations: +# - file: "app/assets/javascripts/i18n/translations.js" +# +# If you're running an old version, you can use something +# like this: +# +# translations: +# - file: "public/javascripts/translations.js" +# only: "*" +# + +translations: + - file: 'app/assets/javascripts/i18n/en.js' + only: 'en.js.*' + - file: 'app/assets/javascripts/i18n/admin.en.js' + only: 'en.admin_js.*' \ No newline at end of file diff --git a/config/initializers/backtrace_silencers.rb b/config/initializers/backtrace_silencers.rb new file mode 100644 index 00000000000..59385cdf379 --- /dev/null +++ b/config/initializers/backtrace_silencers.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. +# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } + +# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. +# Rails.backtrace_cleaner.remove_silencers! diff --git a/config/initializers/freedom_patches.rb b/config/initializers/freedom_patches.rb new file mode 100644 index 00000000000..d5d4f76d800 --- /dev/null +++ b/config/initializers/freedom_patches.rb @@ -0,0 +1,3 @@ +Dir["#{Rails.root}/lib/freedom_patches/*.rb"].each do |f| + require(f) +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 00000000000..9e8b0131f8f --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,10 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format +# (all these examples are active by default): +# ActiveSupport::Inflector.inflections do |inflect| +# inflect.plural /^(ox)$/i, '\1en' +# inflect.singular /^(ox)en/i, '\1' +# inflect.irregular 'person', 'people' +# inflect.uncountable %w( fish sheep ) +# end diff --git a/config/initializers/message_bus.rb b/config/initializers/message_bus.rb new file mode 100644 index 00000000000..bc218d40bb1 --- /dev/null +++ b/config/initializers/message_bus.rb @@ -0,0 +1,27 @@ +MessageBus.site_id_lookup do + RailsMultisite::ConnectionManagement.current_db +end + +MessageBus.user_id_lookup do |env| + request = Rack::Request.new(env) + auth_token = request.cookies["_t"] + user = nil + if auth_token && auth_token.length == 32 + user = User.where(auth_token: auth_token).first + end + user.id if user +end + +MessageBus.on_connect do |site_id| + RailsMultisite::ConnectionManagement.establish_connection(:db => site_id) +end + +MessageBus.on_disconnect do |site_id| + ActiveRecord::Base.connection_handler.clear_active_connections! +end + +# Point at our redis +MessageBus.redis_config = YAML::load(File.open("#{Rails.root}/config/redis.yml"))[Rails.env].symbolize_keys + +MessageBus.long_polling_enabled = SiteSetting.enable_long_polling +MessageBus.long_polling_interval = SiteSetting.long_polling_interval diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb new file mode 100644 index 00000000000..72aca7e441e --- /dev/null +++ b/config/initializers/mime_types.rb @@ -0,0 +1,5 @@ +# Be sure to restart your server when you modify this file. + +# Add new mime types for use in respond_to blocks: +# Mime::Type.register "text/richtext", :rtf +# Mime::Type.register_alias "text/html", :iphone diff --git a/config/initializers/mini_profiler.rb b/config/initializers/mini_profiler.rb new file mode 100644 index 00000000000..1a1b1915116 --- /dev/null +++ b/config/initializers/mini_profiler.rb @@ -0,0 +1,49 @@ +# If Mini Profiler is included via gem +if defined?(Rack::MiniProfiler) + + Rack::MiniProfiler.config.storage_options = YAML::load(File.open("#{Rails.root}/config/redis.yml"))[Rails.env].symbolize_keys + Rack::MiniProfiler.config.storage = Rack::MiniProfiler::RedisStore + + # For our app, let's just show mini profiler always, polling is chatty so nuke that + Rack::MiniProfiler.config.pre_authorize_cb = lambda do |env| + (env['HTTP_USER_AGENT'] !~ /iPad|iPhone|Nexus 7/) and + (env['PATH_INFO'] !~ /^\/message-bus/) and + (env['PATH_INFO'] !~ /topics\/timings/) and + (env['PATH_INFO'] !~ /assets/) and + (env['PATH_INFO'] !~ /jasmine/) and + (env['PATH_INFO'] !~ /users\/.*\/avatar/) and + (env['PATH_INFO'] !~ /srv\/status/) + end + + Rack::MiniProfiler.config.position = 'left' + Rack::MiniProfiler.config.backtrace_ignores ||= [] + Rack::MiniProfiler.config.backtrace_ignores << /lib\/rack\/message_bus.rb/ + Rack::MiniProfiler.config.backtrace_ignores << /config\/initializers\/silence_logger/ + Rack::MiniProfiler.config.backtrace_ignores << /config\/initializers\/quiet_logger/ + #Rack::MiniProfiler.config.style = :awesome_bar + + # require "#{Rails.root}/vendor/backports/notification" + + inst = Class.new + class << inst + def start(name,id,payload) + if Rack::MiniProfiler.current and name !~ /(process_action.action_controller)|(render_template.action_view)/ + @prf ||= {} + @prf[id] ||= [] + @prf[id] << Rack::MiniProfiler.start_step("#{payload[:serializer] if name =~ /serialize.serializer/} #{name}") + end + end + + def finish(name,id,payload) + if Rack::MiniProfiler.current and name !~ /(process_action.action_controller)|(render_template.action_view)/ + t = @prf[id].pop + @prf.delete id unless t + Rack::MiniProfiler.finish_step t + end + end + end + # disabling for now cause this slows stuff down too much + # ActiveSupport::Notifications.subscribe(/.*/, inst) + + # Rack::MiniProfiler.profile_method ActionView::PathResolver, 'find_templates' +end diff --git a/config/initializers/oj.rb b/config/initializers/oj.rb new file mode 100644 index 00000000000..a2f77ffc375 --- /dev/null +++ b/config/initializers/oj.rb @@ -0,0 +1,2 @@ +# Not sure why it's not using this by default! +MultiJson.engine = :oj \ No newline at end of file diff --git a/config/initializers/quiet_logger.rb b/config/initializers/quiet_logger.rb new file mode 100644 index 00000000000..8025fcb5218 --- /dev/null +++ b/config/initializers/quiet_logger.rb @@ -0,0 +1,15 @@ +Rails.application.assets.logger = Logger.new('/dev/null') +Rails::Rack::Logger.class_eval do + def call_with_quiet_assets(env) + previous_level = Rails.logger.level + if (env['PATH_INFO'].index("/assets/") == 0) or + (env['PATH_INFO'].index("mini-profiler-resources") == 0) + Rails.logger.level = Logger::ERROR + end + + call_without_quiet_assets(env).tap do + Rails.logger.level = previous_level + end + end + alias_method_chain :call, :quiet_assets +end \ No newline at end of file diff --git a/config/initializers/rails3_ar_after_commit_tests.rb b/config/initializers/rails3_ar_after_commit_tests.rb new file mode 100644 index 00000000000..d9b4c6d457c --- /dev/null +++ b/config/initializers/rails3_ar_after_commit_tests.rb @@ -0,0 +1,23 @@ +# Allow after commits to work in test mode +if Rails.env.test? + + class ActiveRecord::Base + class << self + def after_commit(*args, &block) + opts = args.extract_options! || {} + + case opts[:on] + when :create + after_create(*args, &block) + when :update + after_update(*args, &block) + when :destroy + after_destroy(*args, &block) + else + after_save(*args, &block) + end + end + end + end + +end \ No newline at end of file diff --git a/config/initializers/secret_token.rb b/config/initializers/secret_token.rb new file mode 100644 index 00000000000..61d320aa42b --- /dev/null +++ b/config/initializers/secret_token.rb @@ -0,0 +1,3 @@ + +# Definitely change this when you deploy to production. Ours is replaced by jenkins. +Discourse::Application.config.secret_token = "47f5390004bf6d25bb97083fb98e7cc133ab450ba814dd19638a78282b4ca291" diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 00000000000..2a14105c920 --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +Discourse::Application.config.session_store :cookie_store, key: '_forum_session' + +# Use the database for sessions instead of the cookie-based default, +# which shouldn't be used to store highly confidential information +# (create the session table with "rails generate session_migration") +# Discourse::Application.config.session_store :active_record_store diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 00000000000..6502b991a2b --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,16 @@ +require "#{Rails.root}/lib/discourse_redis" + +$redis = DiscourseRedis.new + +if Rails.env.development? + puts "Flushing redis (development mode)" + $redis.flushall +end + +Sidekiq.configure_server do |config| + config.redis = { :url => $redis.url, :namespace => 'sidekiq' } +end + +Sidekiq.configure_client do |config| + config.redis = { :url => $redis.url, :namespace => 'sidekiq' } +end \ No newline at end of file diff --git a/config/initializers/silence_logger.rb b/config/initializers/silence_logger.rb new file mode 100644 index 00000000000..76c156725e5 --- /dev/null +++ b/config/initializers/silence_logger.rb @@ -0,0 +1,27 @@ +class SilenceLogger < Rails::Rack::Logger + def initialize(app, opts = {}) + @app = app + @opts = opts + @opts[:silenced] ||= [] + + # Rails introduces something called taggers in the Logger, needs to be initialized + super(app) + end + + def call(env) + prev_level = Rails.logger.level + + if env['HTTP_X_SILENCE_LOGGER'] || @opts[:silenced].include?(env['PATH_INFO']) + Rails.logger.level = Logger::WARN + result = @app.call(env) + result + else + super(env) + end + ensure + Rails.logger.level = prev_level + end +end + +silenced = ["/mini-profiler-resources/results", "/mini-profiler-resources/includes.js", "/mini-profiler-resources/includes.css", "/mini-profiler-resources/jquery.tmpl.js"] +Rails.configuration.middleware.swap Rails::Rack::Logger, SilenceLogger, :silenced => silenced diff --git a/config/initializers/site_settings.rb b/config/initializers/site_settings.rb new file mode 100644 index 00000000000..f41eea89e19 --- /dev/null +++ b/config/initializers/site_settings.rb @@ -0,0 +1,5 @@ + + +RailsMultisite::ConnectionManagement.each_connection do + SiteSetting.refresh! +end diff --git a/config/initializers/vestal_versions.rb b/config/initializers/vestal_versions.rb new file mode 100644 index 00000000000..36700e6dba8 --- /dev/null +++ b/config/initializers/vestal_versions.rb @@ -0,0 +1,9 @@ +VestalVersions.configure do |config| + # Place any global options here. For example, in order to specify your own version model to use + # throughout the application, simply specify: + # + # config.class_name = "MyCustomVersion" + # + # Any options passed to the "versioned" method in the model itself will override this global + # configuration. +end diff --git a/config/initializers/watch_for_restart.rb b/config/initializers/watch_for_restart.rb new file mode 100644 index 00000000000..94fe024f26a --- /dev/null +++ b/config/initializers/watch_for_restart.rb @@ -0,0 +1,61 @@ +Thread.new do + file = "#{Rails.root}/tmp/restart" + did_exist = nil + old_time = nil + + return if $PROGRAM_NAME !~ /thin/ + + processes = {} + got_new = false + MessageBus.subscribe "/processes" do |msg| + filetime = msg.data["filetime"] + pid = msg.data["pid"] + got_new = processes[pid].nil? || (processes[pid][:filetime] != filetime) + # puts "#{got_new} #{pid}" + processes[pid] = {time: Time.now.to_i, filetime: filetime} + end + + while true + exists = File.exists? file + time = File.ctime(file).to_i if exists + + if (did_exist != nil && did_exist != exists) || + (old_time != nil && time != nil && old_time != time) + + got_new = false + probably_restarted = [] + + give_up_time = Time.now.to_i + 60 + + while Time.now.to_i < give_up_time + candidates = [] + processes.each do |pid,data| + if data[:filetime] == old_time && data[:time] > Time.now.to_i - 40 + candidates << pid + end + end + + candidates = candidates - probably_restarted + + break if (candidates.min || $$) >= $$ + sleep 1 + probably_restarted << candidates.min if got_new + got_new = false + end + + + Rails.logger.info "attempting to reload #{$$} #{$PROGRAM_NAME} in 3 seconds restarted #{probably_restarted.inspect}" + $shutdown = true + sleep 4 + Rails.logger.info "restarting #{$$}" + Process.kill("HUP", $$) + + break + end + + MessageBus.publish "/processes", {pid: $$, filetime: time} + did_exist = exists + old_time = time + sleep 1 + end +end diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb new file mode 100644 index 00000000000..02e47b12b8a --- /dev/null +++ b/config/initializers/wrap_parameters.rb @@ -0,0 +1,15 @@ +# Be sure to restart your server when you modify this file. +# +# This file contains settings for ActionController::ParamsWrapper which +# is enabled by default. + +# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. +ActiveSupport.on_load(:action_controller) do + wrap_parameters format: [:json] +end + +# Disable root element in JSON by default. +ActiveSupport.on_load(:active_record) do + self.include_root_in_json = false +end + diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 00000000000..31974f7cfa3 --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,1322 @@ +en: + title: "Discourse" + topics: "Topics" + + is_reserved: "is reserved" + too_many_mentions: "has too many users mentioned" + too_many_images: "has too many images" + too_many_links: "has too many links" + just_posted_that: "is too similar to what you recently posted" + has_already_been_used: "has already been used" + + activerecord: + attributes: + category: + name: "Category Name" + post: + raw: "Body" + errors: + models: + topic: + attributes: + archetype: + cant_send_pm: "Sorry, you cannot send a private message to that user." + + user_profile: + no_info_me: "
            the About Me field of your profile is currently blank, would you like to fill it out?
            " + no_info_other: "
            %{name} hasn't entered anything in the About Me field of their profile yet
            " + + category: + topic_prefix: "Category definition for %{category}" + + trust_levels: + new: + title: "new user" + basic: + title: "basic user" + regular: + title: "regular user" + experienced: + title: "experienced user" + advanced: + title: "advanced user" + moderator: + title: "moderator" + + rate_limiter: + too_many_requests: "You're doing that too often. Please wait %{time_left} before trying again." + hours: + one: "1 hour" + other: "%{count} hours" + minutes: + one: "1 minute" + other: "%{count} minutes" + seconds: + one: "1 second" + other: "%{count} seconds" + + datetime: + distance_in_words: + half_a_minute: "< 1m" + less_than_x_seconds: + one: "< 1s" + other: "< %{count}s" + x_seconds: + one: "1s" + other: "%{count}s" + less_than_x_minutes: + one: "< 1m" + other: "< %{count}m" + x_minutes: + one: "1m" + other: "%{count}m" + about_x_hours: + one: "1h" + other: "%{count}h" + x_days: + one: "1d" + other: "%{count}d" + about_x_months: + one: "1mon" + other: "%{count}mon" + x_months: + one: "1mon" + other: "%{count}mon" + about_x_years: + one: "1y" + other: "%{count}y" + over_x_years: + one: "> 1y" + other: "> %{count}y" + almost_x_years: + one: "1y" + other: "%{count}y" + + distance_in_words_verbose: + half_a_minute: "just now" + less_than_x_seconds: + one: "just now" + other: "just now" + x_seconds: + one: "1 second ago" + other: "%{count} seconds ago" + less_than_x_minutes: + one: "less than 1 minute ago" + other: "less than %{count} minutes ago" + x_minutes: + one: "1 minute ago" + other: "%{count} minutes ago" + about_x_hours: + one: "1 hour ago" + other: "%{count} hours ago" + x_days: + one: "1 day ago" + other: "%{count} days ago" + about_x_months: + one: "about 1 month ago" + other: "about %{count} months ago" + x_months: + one: "1 month ago" + other: "%{count} months ago" + about_x_years: + one: "about 1 year ago" + other: "about %{count} years ago" + over_x_years: + one: "over 1 year ago" + other: "over %{count} years ago" + almost_x_years: + one: "almost 1 year ago" + other: "almost %{count} years ago" + + password_reset: + no_token: "Sorry, your token has expired. Please try resetting your password again." + choose_new: "Please choose a new password" + update: 'update password' + title: 'reset password' + success: "You successfully changed your password and are now logged in." + success_unapproved: "You successfully changed your password." + + change_email: + confirmed: "Your email has been updated." + please_continue: "Please continue to %{link}" + error: "There was an error changing your email address. Perhaps the address is already in use?" + + activation: + already_done: "Sorry, this account confirmation link is no longer valid. Perhaps your account is already active?" + please_continue: "Your new account is confirmed, and you are now logged in. Continue to %{link}" + welcome_to: "Welcome to %{site_name}!" + approval_required: "A moderator must manually approve your new account before you can access this forum. You'll get an email when your account is approved!" + + post_action_types: + + off_topic: + title: 'Off-Topic' + description: 'This post is radically off-topic in the current conversation, and should probably be moved to a different topic.' + long_form: 'flagged this as off-topic' + spam: + title: 'Spam' + description: 'This post is effectively an advertisement with no disclosure. It is not useful or relevant to the current conversation, but promotional in nature.' + long_form: 'flagged this as spam' + inappropriate: + title: 'Inappropriate' + description: 'This post contains content that a reasonable person would consider offensive, abusive, or hate speech.' + long_form: 'flagged this as inappropriate' + custom_flag: + title: 'Other' + description: 'This post requires general moderator attention based on the FAQ, TOS, or for another reason not listed above.' + long_form: 'flagged this for moderation' + bookmark: + title: 'Bookmark' + description: 'Bookmark this post' + long_form: 'bookmarked this post' + like: + title: 'Like' + description: 'Like this post' + long_form: 'liked this' + + flagging: + you_must_edit: '

            Your post reached the flagging threshold. Please see your private messages.

            ' + user_must_edit: '

            Flagged content hidden.

            ' + + archetypes: + regular: + title: "Regular Topic" + + unsubscribed: + title: 'Unsubscribed' + description: "You have been unsubscribed. We won't contact you again!" + oops: "In case you didn't mean to do this, click below." + not_found: "Error Unsubscribing" + not_found_description: "Sorry, we couldn't unsubscribe you. It's possible the link in your email has expired." + + resubscribe: + action: "Re-Subscribe" + title: "Re-Subscribed!" + description: "You have been re-subscribed." + + site_settings: + min_post_length: "minimum post length" + max_post_length: "maximum post length" + min_topic_title_length: "minimum topic title length" + max_topic_title_length: "maximum topic title length" + allow_duplicate_topic_titles: "whether users can create topics with the same titles" + unique_posts_mins: "How many minutes before a user can make a post with the same content again" + enforce_global_nicknames: "Enforce global nickname uniqueness. Note: Only change during initial setup." + discourse_org_access_key: "The access key used to access the discourse.org nickname registry" + + title: "title of this website" + secret_token: "secret token used to secure cookies" + restrict_access: "restrict forum access unless this password is entered" + access_password: "restrict_access is enabled ensure that this password is entered" + queue_jobs: "queue various jobs in sidekiq, if false queues are inline" + crawl_images: "enable retrieving of images from third party sources" + ninja_edit_window: "how quickly you can make an edit without it saving a new version" + enable_imgur: "enable imgur api for uploading, don't host files locally" + imgur_api_key: "imgur.com api key - required for image upload" + imgur_endpoint: "end point for uploading imgur.com images" + max_image_width: "maximum width for an image in a post" + category_featured_topics: "number of topics displayed in the category list" + popup_delay: "Length of time in ms before popups appear on the screen" + post_excerpt_maxlength: "Maximum length in chars of a post's excerpt." + post_onebox_maxlength: "Maximum length of a oneboxed discourse post." + category_post_template: "The post template that appears once you create a category" + new_topics_rollup: "How many topics can be inserted on the topic list before rolling up." + onebox_max_chars: "The maximum amount of characteres a onebox will import in a text blob." + logo_url: "The logo for your site eg: http://xyz.com/x.png" + logo_small_url: "The small logo for your site (shows up on topic pages) eg: http://xyz.com/x.png" + favicon_url: "A favicon for your site" + notification_email: "The email address used to notify users of lost passwords, new accounts etc." + use_ssl: "Should the site be accessible via SSL?" + best_of_score_threshold: "The minimum score of a post to be included in the 'best of'" + best_of_posts_required: "A topic needs this many posts before the 'best of' mode will be enabled." + best_of_likes_required: "A topic needs this many likes before the 'best of' mode will be enabled." + enable_private_messages: "Allow members to create private messages and conversations" + + enable_long_polling: "Message bus used for notification can use long polling." + long_polling_interval: "Interval in milliseconds before a new long poll is issued" + + polling_interval: "How often should the client poll in milliseconds" + anon_polling_interval: "How often should the anon clients poll in milliseconds" + + auto_track_topics_after: "How many milliseconds before a topic is automatically tracked (0 for always, -1 for never)" + + flags_required_to_hide_post: "Posts will be automaically hidden once the flag count reaches this threshold (0 for never)" + cooldown_minutes_after_hiding_posts: "How many minutes must a user wait till they can edit thier post after it is hidden due to flagging" + + traditional_markdown_linebreaks: "Use traditional linebreaks in markdown, wrapping is implicit unless postfixed with 2 spaces" + post_undo_action_window_mins: "The window in which someone can reverse an action on a post (such as liking)" + must_approve_users: "The owners of the forum must approve users before they gain access." + ga_tracking_code: "Google analytics tracking code, see: http://google.com/analytics" + top_menu: "The order of the items in the top menu. Example popular|read|favorited|unread|new|posted|categories" + post_menu: "The order of the items on the post menu." + max_length_show_reply: "Embedded replies to posts won't be shown if they directly below and are below this length." + track_external_right_clicks: "Track external links that are right clicked (eg: open in new tab) disabled by default cause it has to re-write urls, hurting usability" + topics_per_page: "How many topics to show on the topics list page." + posts_per_page: "How many posts are returned on a topic page" + system_username: "Username that sends system messages" + send_welcome_message: "Do new users get a welcome private message?" + port: "If you'd like to specify a port in the URL. Useful in development mode. Leave blank for none." + + invite_expiry_days: "How long (in days) that invite keys are valid." + + # TODO: perhaps we need a way of protecting these settings for hosted solution, global settings ... + twitter_consumer_key: "consumer key registered at dev.twitter.com (used for twitter auth)" + twitter_consumer_secret: "consumer secret registered at dev.twitter.com (used for twitter auth)" + + facebook_app_id: "app id, registered at https://developers.facebook.com/apps (used for facebook auth)" + facebook_app_secret: "app secret, registered at https://developers.facebook.com/apps (used for facebook auth)" + + allow_import: "Allow import, which will replace ALL site data. Set to false unless you plan to do imports." + + active_user_rate_limit_secs: "How frequently we update the 'last_seen_at' field, in seconds." + previous_visit_timeout_hours: "How long a visit lasts before we consider it the 'previous' visit, in hours." + + uncategorized_name: "The name for the uncategorized topics on the category list" + max_mentions_per_post: "The maximum amount of @notifications you can add to a post" + + rate_limit_create_topic: "How many seconds before you can create another topic" + rate_limit_create_post: "How many seconds before you can create another post" + + max_likes_per_day: "The maximum amount of likes a user can perform in a day" + max_flags_per_day: "The maximum amount of flags a user can perform in a day" + max_bookmarks_per_day: "The maximum amount of bookmarks a user can create in a day" + max_edits_per_day: "The maximum amount of edits a user can perform in a day" + max_favorites_per_day: "The maximum amount of topics that can be favorited in a day" + max_topics_per_day: "The maximum amount of topics you can create in a day" + max_private_messages_per_day: "The maximum amount of private messages you can send in a day" + + suggested_topics: "The amount of suggested topics you'll see at the bottom of a topic" + + enable_s3_uploads: "Whether or not to put uploads on s3" + s3_upload_bucket: "The bucket we want to upload into for s3" + + default_invitee_trust_level: "The default trust level for invited users" + default_trust_level: "The default trust level for users" + + basic_requires_topics_entered: "How many a topics a user needs to have entered to be promoted to basic level" + basic_requires_read_posts: "How many posts a user needs to have read to be promoted to basic level" + basic_requires_time_spent_mins: "How many mins a user needs to have spent reading posts to be promoted to basic level" + + email_time_window_mins: "How many minutes we wait before sending a user mail, to give them a chance to see it first." + flush_timings_secs: "How frequently we flush timing data to the server, in seconds." + + # This section is exported to the javascript for i18n in the admin section + admin_js: + type_to_filter: "Type to Filter..." + + admin: + title: 'Discourse Admin' + dashboard: 'Admin Dashboard' + + flags: + title: "Flags" + old: "Old" + active: "Active" + + customize: + title: "Customize" + header: "Header" + css: "Stylesheet" + override_default: "Override default?" + enabled: "Enabled?" + preview: "preview" + undo_preview: "undo preview" + save: "Save" + delete: "Delete" + delete_confirm: "Delte this customization?" + + email_logs: + title: "Email Logs" + sent_at: "Sent At" + email_type: "Email Type" + to_address: "To Address" + test_email_address: "email address to test" + send_test: "send test email" + sent_test: "sent!" + + impersonate: + title: "Impersonate User" + username_or_email: "Username or Email of User" + help: "Use this tool to impersonate a user account for debugging purposes." + not_found: "That user can't be found." + invalid: "Sorry, you may not impersonate that user." + + users: + title: 'Users' + create: 'Add Admin User' + last_emailed: "Last Emailed" + not_found: "Sorry that username doesn't exist in our system." + new: "New" + active: "Active" + pending: "Pending" + approved: "Approved?" + approved_selected: + one: "approve user" + other: "approve users ({{count}})" + + user: + ban_failed: "Something went wrong banning this user {{error}}" + unban_failed: "Something went wrong unbanning this user {{error}}" + ban_duration: "How long would you like to ban the user for? (days)" + ban: "Ban" + unban: "Unban" + banned: "Banned?" + moderator: "Moderator?" + admin: "Admin?" + show_admin_profile: "Admin" + refresh_browsers: "Force browser refresh" + show_public_profile: "Show Public Profile" + impersonate: 'Impersonate' + revoke_admin: 'Revoke Admin' + grant_admin: 'Grant Admin' + basics: Basics + reputation: Reputation + permissions: Permissions + activity: Activity + like_count: Likes Received + private_topics_count: Private Topics Count + posts_read_count: Posts Read + post_count: Posts Created + topics_entered: Topics Entered + flags_given_count: Flags Given + flags_received_count: Flags Received + approve: 'Approve' + approved_by: "approved by" + time_read: "Read Time" + + site_settings: + show_overriden: 'Only show overridden' + title: 'Site Settings' + reset: 'reset to default' + + notification_types: + mentioned: "%{display_username} mentioned you in %{link}" + liked: "%{display_username} liked your post in %{link}" + replied: "%{display_username} replied to your post in %{link}" + quoted: "%{display_username} quoted your post in %{link}" + edited: "%{display_username} edited your post in %{link}" + posted: "%{display_username} posted in %{link}" + moved_post: "%{display_username} moved your post to %{link}" + private_message: "%{display_username} sent you a private message: %{link}" + invited_to_private_message: "%{display_username} invited you to a private message: %{link}" + invitee_accepted: "%{display_username} accepted your invitation" + + # This section is exported to the javascript for i18n + js: + share: + topic: 'share a link to this topic' + post: 'share a link to this post' + + edit: 'edit the title and category of this topic' + not_implemented: "That feature hasn't been implemented yet, sorry!" + no_value: "No" + yes_value: "Yes" + of_value: "of" + generic_error: "Sorry, an error has occurred." + log_in: "Log In" + age: "Age" + last_post: "Last Post" + admin_title: "Admin" + flags_title: "Flags" + show_more: "show more" + links: Links + faq: "FAQ" + + suggested_topics: + title: "Suggested Topics" + + bookmarks: + not_logged_in: "Sorry you must be logged in to bookmark posts." + created: "You've bookmarked this post." + not_bookmarked: "You've read this post; click to bookmark it." + last_read: "This is the last post you've read." + + new_topics_inserted: "{{count}} new topics." + show_new_topics: "Click to show." + preview: "preview" + cancel: "cancel" + + save: "Save Changes" + saving: "Saving..." + saved: "Saved!" + + user: + information: "User Information" + profile: Profile + title: "User" + mute: Mute + edit: Edit Preferences + download_archive: "download archive of my posts" + private_message: "Private Message" + private_messages: "Messages" + activity_stream: "Activity" + preferences: "Preferences" + bio: "About me" + change_password: "change password" + invited_by: "Invited By" + trust_level: "Trust Level" + + change_username: + action: "change username" + title: "Change Username" + confirm: "There could be consequences to changing your username. Are you absolutely sure you want to?" + taken: "Sorry that username is taken." + error: "There was an error changing your username." + change_email: + action: 'change email' + title: "Change Email" + taken: "Sorry that email is not available." + error: "There was an error changing your email. Perhaps that address is already in use?" + success: "We've sent an email to that address. Please follow the confirmation instructions." + + email: + title: "Email" + instructions: "Your email will never be shown to the public." + ok: "Looks good. We will email you to confirm." + invalid: "Please enter a valid email address." + authenticated: "Your email has been authenticated by {{provider}}." + frequency: "We'll only email you if we haven't seen you recently and you haven't already seen the thing we're emailing you about." + + name: + title: "Name" + instructions: "The longer version of your name; does not need to be unique." + too_short: "Your name is too short." + ok: "Your name looks good." + username: + title: "Username" + #instructions: "People can mention you as @{{username}}. This is an unregistered nickname. You can register it at discourse.org." + instructions: "People can mention you as @{{username}}." + available: "Your username is available." + global_match: "Email matches the registered username." + global_mismatch: "Already registered. Try {{suggestion}}?" + not_available: "Not available. Try {{suggestion}}?" + too_short: "Your username is too short." + checking: "Checking username availability..." + enter_email: 'Username found. Enter matching email.' + + last_posted: "Last Post" + last_emailed: "Last Emailed" + last_seen: "Last Seen" + created: "Created At" + log_out: "Log Out" + website: "Web Site" + email_settings: "Email" + email_digests: + title: "Receive an email digest of what's new" + daily: "daily" + weekly: "weekly" + bi_weekly: "bi-weekly" + + email_direct: "Receive an email when someone talks directly to you" + email_private_messages: "Receive an email when someone sends you a private message" + + other_settings: "Other" + auto_track_topics: "Automatically track topics I enter" + auto_track_options: + never: "never" + always: "always" + after_n_seconds: + one: "after 1 second" + other: "after {{count}} seconds" + after_n_minutes: + one: "after 1 minute" + other: "after {{count}} minutes" + + invited: + title: "Invites" + user: "Invited User" + none: "{{username}} hasn't invited any users to the site." + redeemed: "Redeemed Invites" + redeemed_at: "Redeemed At" + pending: "Pending Invites" + topics_entered: "Topics Entered" + posts_read_count: "Posts Read" + rescind: "Remove Invitation" + rescinded: "Invite removed" + time_read: "Read Time" + days_visited: "Days Visited" + account_age_days: "Account age in days" + + password: + title: "Password" + too_short: "Your password is too short." + ok: "Your password looks good." + + ip_address: + title: "Last IP Address" + avatar: + title: "Avatar" + instructions: "We use Gravatar for avatars based on your email" + + filters: + all: "All" + + loading: "Loading..." + close: "Close" + learn_more: "learn more..." + + year: 'year' + year_desc: 'topics posted in the last 365 days' + month: 'month' + month_desc: 'topics posted in the last 30 days' + week: 'week' + week_desc: 'topics posted in the last 7 days' + + first_post: First post + mute: Mute + unmute: Unmute + last_post: Last post + + best_of: + title: "Best Of" + description: "There are {{count}} posts in this topic. That's a lot! Would you like to save time by switching your view to show only the posts with the most interactions and responses?" + button: 'Switch to "Best Of" view' + + private_message_info: + title: "Private Conversation" + description: "Participants in this private conversation" + invite: "invite a participant" + + email: 'Email' + username: 'Username' + last_seen: 'Last Seen' + created: 'Created' + trust_level: 'Trust Level' + + create_account: + title: "Create Account" + action: "Create one now!" + invite: "Don't have an account yet?" + failed: "Something went wrong, perhaps this email is already registered, try the forgot password link" + + forgot_password: + title: "Forgot Password" + action: "I forgot my password" + invite: "Enter your username or email address, and we'll send you a password reset email." + reset: "Reset Password" + complete: "You should receive an email with instructions on how to reset your password shortly." + + login: + title: "Log In" + username: "Login" + password: "Password" + email_placeholder: "email address or username" + error: "Unknown error" + reset_password: 'Reset Password' + logging_in: "Logging In..." + or: "Or" + authenticating: "Authenticating..." + awaiting_confirmation: "Your account is awaiting activation, use the forgot password link to issue another activation email." + awaiting_approval: "Your account has not been approved by a moderator yet. You will receive an email when it is approved." + google: + title: "Log In with Google" + message: "Authenticating with Google (make sure pop up blockers are not enabled)" + twitter: + title: "Log In with Twitter" + message: "Authenticating with Twitter (make sure pop up blockers are not enabled)" + facebook: + title: "Log In with Facebook" + message: "Authenticating with Facebook (make sure pop up blockers are not enabled)" + yahoo: + title: "Log In with Yahoo" + message: "Authenticating with Yahoo (make sure pop up blockers are not enabled)" + + composer: + saving_draft_tip: "saving" + saved_draft_tip: "saved" + saved_local_draft_tip: "saved locally" + + save_edit: "Save Edit" + reply: "Reply" + create_topic: "Create Topic" + create_pm: "Create Private Message" + + users_placeholder: "Add a user" + title_placeholder: "Type your title here. What is this discussion about in one brief sentence?" + reply_placeholder: "Type your reply here. Use Markdown or BBCode to format. Drag or paste an image here to upload it." + view_new_post: "View your new post." + saving: "Saving..." + saved: "Saved!" + saved_draft: "You have a post draft in progress. Click anywhere in this box to resume editing." + uploading: "Uploading..." + show_preview: 'show preview »' + hide_preview: '« hide preview' + + notifications: + title: "notifications of @name mentions, replies to your posts and topics, private messages, etc" + none: "You have no notifications right now." + more: "view older notifications" + mentioned: "{{username}} mentioned you in {{link}}" + quoted: "{{username}} quoted you in {{link}}" + replied: "{{username}} replied to you in {{link}}" + posted: "{{username}} replied to {{link}}" + edited: "{{username}} edited your post {{link}}" + liked: "{{username}} liked your post {{link}}" + private_message: "{{username}} sent you a private message: {{link}}" + invited_to_private_message: "{{username}} invited you to a private conversation: {{link}}" + invitee_accepted: "{{username}} accepted your invite and signed up to participate." + moved_post: "{{username}} moved your post to {{link}}" + + image_selector: + from_my_computer: "From My Device" + from_the_web: "From The Web" + add_image: "Add Image" + remote_tip: "enter address of an image in the form http://example.com/image.jpg" + local_tip: "click to select an image from your device." + upload: "Upload" + + search: + title: "search for topics, posts, users, or categories" + placeholder: "type your search terms here" + no_results: "No results found." + searching: "Searching ..." + + site_map: "go to another topic list or category" + go_back: 'go back' + current_user: 'go to your user page' + + favorite: + title: 'Favorite' + help: 'add this topic to your favorites list' + + topics: + no_favorited: "You haven't favorited any topics yet. To favorite a topic, click or tap the star next to the title." + no_unread: "You have no unread topics to read." + no_new: "You have no new topics to read." + no_read: "You haven't read any topics yet." + no_posted: "You haven't posted in any topics yet." + no_popular: "There are no popular topics. That's sad." + + topic: + create_in: 'Create {{categoryName}} Topic' + create: 'Create Topic' + create_long: 'Create a new Topic' + private_message: 'Start a private conversation' + list: 'Topics' + new: 'new topic' + title: 'Topic' + loading_more: "Loading more Topics..." + loading: 'Loading topic...' + missing: "Topic Not Found" + not_found: + title: "Topic Not Found" + description: "Sorry, we couldn't find that topic. Perhaps it has been deleted?" + unread_posts: "you have {{unread}} unread posts in this topic" + new_posts: "there are {{new_posts}} new posts in this topic since you last read it" + likes: "there are {{likes}} likes in this topic" + back_to_list: "Back to Topic List" + options: "Topic Options" + show_links: "show links within this topic" + toggle_information: "toggle topic details" + read_more_in_category: "Want to read more? Browse other topics in {{catLink}} or {{popularLink}}." + read_more: "Want to read more? {{catLink}} or {{popularLink}}." + browse_all_categories: Browse all categories + view_popular_topics: view popular topics + + progress: + title: topic progress + jump_top: jump to first post + jump_bottom: jump to last post + total: total posts + current: current post + + notifications: + title: '' + reasons: + "3_2": 'You will receive notifications because you are watching this topic.' + "3_1": 'You will receive notifications because you created this topic.' + "2_4": 'You will receive notifications because you posted a reply to this topic.' + "2_2": 'You will receive notifications because you are tracking this topic.' + "2": 'You will receive notifications because you read this topic.' + "1": 'You will be notified only if someone mentions your @name or replies to your post.' + "1_2": 'You will be notified only if someone mentions your @name or replies to your post.' + "0": 'You are ignoring all notifications on this topic.' + "0_2": 'You are ignoring all notifications on this topic.' + watching: + title: "Watching" + description: "you will see unread and new post counts on this topic, plus notifications of @name mentions and all new posts." + tracking: + title: "Tracking" + description: "you will see unread and new post counts on this topic, plus notifications of @name mentions and replies to your posts." + regular: + title: "Regular" + description: "you will be notified only if someone mentions your @name or replies to your post." + muted: + title: "Muted" + description: "you will not be notified of anything about this topic, and it will not appear on your unread tab." + + actions: + delete: "Delete Topic" + open: "Open Topic" + close: "Close Topic" + unpin: "Un-Pin Topic" + pin: "Pin Topic" + unarchive: "Unarchive Topic" + archive: "Archive Topic" + invisible: "Make Invisible" + visible: "Make Visible" + reset_read: "Reset Read Data" + multi_select: "Toggle Multi-Select" + convert_to_topic: "Convert to Regular Topic" + + reply: + title: 'Reply' + help: 'begin composing a reply to this topic' + below: 'reply below' + + share: + title: 'Share' + help: 'share a link to this topic' + + inviting: "Inviting..." + + invite_private: + title: 'Invite to Private Conversation' + email_or_username: "Invitee's Email or Username" + email_or_username_placeholder: "email address or username" + action: "Invite" + success: "Thanks! We've invited that user to participate in this private conversation." + error: "Sorry there was an error inviting that user." + + invite_reply: + title: 'Invite Friends to Reply' + help: 'send invitations to friends so they can reply to this topic with a single click' + email: "We'll send your friend a brief email allowing them to reply to this topic by clicking a link." + email_placeholder: 'email address' + success: "Thanks! We mailed out an invitation to {{email}}. We'll let you know when they redeem your invitation. Check the invitations tab on your user page to keep track of who you've invited." + error: "Sorry we couldn't invite that person. Perhaps they are already a user?" + + login_reply: 'Log In to Reply' + + filters: + user: "You're viewing only posts by specific user(s)." + best_of: "You're viewing only the 'Best Of' posts." + cancel: "Show all posts in this topic again." + + move_selected: + title: "Move Selected Posts" + topic_name: "New Topic Name:" + error: "Sorry, there was an error moving those posts." + instructions: + one: "You are about to create a new topic and populate it with the post you've selected." + other: "You are about to create a new topic and populate it with the {{count}} posts you've selected." + + multi_select: + select: 'select' + selected: 'selected ({{count}})' + delete: delete selected + cancel: cancel selecting + move: move selected + description: + one: You have selected 1 post. + other: "You have selected {{count}} posts." + + post: + reply: "Replying to {{link}} by {{replyAvatar}} {{username}}" + reply_topic: "Reply to {{link}}" + edit: "Edit {{link}}" + in_reply_to: "in reply to" + reply_as_new_topic: "Reply as new Topic" + continue_discussion: "Continuing the discussion from {{postLink}}:" + follow_quote: "go to the quoted post" + + has_replies_below: + one: "Reply Below" + other: "Replies Below" + + has_replies: + one: "Reply" + other: "Replies" + + errors: + create: "Sorry, there was an error creating your post. Please try again." + edit: "Sorry, there was an error editing your post. Please try again." + upload: "Sorry, there was an error uploading that file. Please try again." + + abandon: "Are you sure you want to abandon your post?" + + archetypes: + save: 'Save Options' + + controls: + reply: "begin composing a reply to this post" + like: "like this post" + edit: "edit this post" + flag: "flag this post for moderator attention" + delete: "delete this post" + undelete: "undelete this post" + share: "share a link to this post" + bookmark: "bookmark this post to your user page" + more: "More" + + actions: + flag: 'Flag' + it_too: "{{alsoName}} it too" + undo: "Undo {{alsoName}}" + by_you_and_others: + zero: "You {{long_form}}" + one: "You and 1 other person {{long_form}}" + other: "You and {{count}} other people {{long_form}}" + by_others: + one: "1 person {{long_form}}" + other: "{{count}} people {{long_form}}" + + edits: + one: 1 edit + other: "{{count}} edits" + zero: no edits + + delete: + confirm: + one: "Are you sure you want to delete that post?" + other: "Are you sure you want to delete all those posts?" + + category: + none: '(no category)' + edit: 'edit' + view: 'View Topics in Category' + delete: 'Delete Category' + create: 'Create Category' + more_posts: "view all {{posts}}..." + name: "Category Name" + description: "Description" + topic: "category topic" + color: "Color" + name_placeholder: "Should be short and succinct." + color_placeholder: "Any web color" + delete_confirm: "Are you sure you want to delete that category?" + list: "List Categories" + + flagging: + title: 'Why are you flagging this post?' + action: 'Flag Post' + cant: "Sorry, you can't flag this post at this time." + custom_placeholder: "Why does this post require moderator attention? Let us know specifically what you are concerned about, and provide relevant links where possible." + custom_message: + at_least: "enter at least {{n}} characters" + more: "{{n}} to go..." + left: "{{n}} remaining" + + topic_summary: + title: "Topic Summary" + links_shown: "show all {{totalLinks}} links..." + + topic_statuses: + locked: + help: "this topic is closed; it no longer accepts new replies" + pinned: + help: "this topic is pinned; it will display at the top of its category" + archived: + help: "this topic is archived; it is frozen and cannot be changed" + invisible: + help: "this topic is invisible; it will not be displayed in topic lists, and can only be accessed via a direct link" + + posts: "Posts" + posts_long: "{{number}} posts in this topic" + original_post: "Original Post" + views: "Views" + replies: "Replies" + views_long: "this topic has been viewed {{number}} times" + activity: "Activity" + likes: "Likes" + top_contributors: "Participants" + category_title: "Category" + + categories_list: "Categories List" + + filters: + popular: + title: "Popular" + help: "the most popular recent topics" + favorited: + title: "Favorited" + help: "topics you marked as favorites" + read: + title: "Read" + help: "topics you've read" + categories: + title: "Categories" + title_in: "Category - {{categoryName}}" + help: "all topics grouped by category" + unread: + title: + zero: "Unread" + one: "Unread (1)" + other: "Unread ({{count}})" + help: "tracked topics with unread posts" + new: + title: + zero: "New" + one: "New (1)" + other: "New ({{count}})" + help: "new topics since your last visit, and tracked topics with new posts" + posted: + title: "My Posts" + help: "topics you have posted in" + category: + title: + zero: "{{categoryName}}" + one: "{{categoryName}} (1)" + other: "{{categoryName}} ({{count}})" + help: "popular topics in the {{categoryName}} category" + + search: + types: + category: 'Categories' + topic: 'Topics' + user: 'Users' + + youve_posted: "You've Posted" + original_poster: "Original Poster" + most_posts: "Most Posts" + most_recent_poster: "Most Recent Poster" + frequent_poster: "Frequent Poster" + + user_action_descriptions: + bookmarks: "Bookmarks" + topics: "Topics" + likes_received: "Likes Received" + likes_given: "Likes Given" + responses: "Responses" + topic_responses: "Topic Responses" + posts: "Posts" + mentions: "Mentions" + quotes: "Quotes" + edits: "Edits" + favorites: "Favorites" + sent_items: "Sent Items" + inbox: "Inbox" + + was_liked: "was liked" + liked: "liked" + bookmarked: "bookmarked" + posted: "posted" + responded_to: "replied to" + mentioned: "mentioned" + quoted: "quoted" + favorited: "favorited" + edited: "edited" + + move_posts: + moderator_post: + one: "I moved a post to a new topic: %{topic_link}" + other: "I moved %{count} posts to a new topic: %{topic_link}" + + topic_statuses: + archived_enabled: "This topic is now archived. It is frozen and cannot be changed in any way." + archived_disabled: "This topic is now unarchived. It is no longer frozen, and can be changed." + closed_enabled: "This topic is now closed. New replies are no longer allowed." + closed_disabled: "This topic is now opened. New replies are allowed." + pinned_enabled: "This topic is now pinned. It will appear at the top of its category." + pinned_disabled: "This topic is now unpinned. It will no longer appear at the top of its category." + visible_enabled: "This topic is now visible. It will be displayed in topic lists." + visible_disabled: "This topic is now invisible. It will no longer be displayed in any topic lists. The only way to access this topic is via direct link." + + login: + not_approved: "Your account hasn't been approved yet. You will be notified by email when you are ready to log in." + incorrect_username_email_or_password: "Incorrect username, email or password" + wait_approval: "Thanks for signing up. We will notify you when your account has been approved." + active: "Your account is active and ready." + activate_email: "You're almost done! We sent an activation email to %{email}. Please follow the instructions in the email to activate your account." + errors: "Failed to create account: %{errors}" + not_available: "Not available. Try %{suggestion}?" + + user: + username: + short: "must be longer than %{min} characters" + long: "must be shorter than %{max} characters" + characters: "must only include numbers and letters" + unique: "must be unique" + blank: "must be present" + must_begin_with_alphanumeric: "must begin with a letter or number" + + invite_mailer: + subject_template: "[%{site_name}] %{invitee_name} invited you to join a discussion on %{site_name}" + text_body_template: | + %{invitee_name} invited you to the topic "%{topic_title}" at %{site_name}. + + If you're interested, click the link below to visit the discussion: + + [Visit %{site_name}][1] + + You were invited by a trusted user, so you'll be able to post a reply immediately, without needing to log in. + + [1]: %{invite_link} + + test_mailer: + subject_template: "[%{site_name}] Email Deliverability Test" + text_body_template: | + This is an email test from the %{site_name} forum. You can access this forum via your web browser at + + [*%{base_url}*][0] + + Email deliverability can be complex, but a few things you want to check immediately are: + + - Be sure you know how to view the *raw source of the email* in your mail client, so you can examine the email headers for important clues. + + - **IMPORTANT:** Does your ISP have a reverse DNS record entered to associate the domain name you mail from with the IPs you use to email? You can [test your Reverse PTR record][2] here. If the proper reverse DNS pointer record isn't entered by your ISP, it's very unlikely any of your email will be delivered. + + - Is your domain's SPF record correct? You can [test your SPF record][1] here. + + - Have you configured [DKIM email key signing][3] in your email software, and put the public DKIM key in your DNS records? + + - Have you checked to make sure the IPs of the server the mail originates from are [not on any email blacklists][4]? + + We hope you received this email deliverability test OK! + + Good luck, + + Your friends at %{site_name}. + + [0]: %{base_url} + [1]: http://www.kitterman.com/spf/validate.html + [2]: http://mxtoolbox.com/SuperTool.aspx + [3]: http://www.dkim.org/ + [4]: http://whatismyipaddress.com/blacklist-check + [5]: %{base_url}/unsubscribe + + ---- + + There should be an unsubscribe footer on every email you send, so let's mock one up. This email was sent by Name of Company, 55 Main Street, Anytown, USA 12345. If you would like to opt out of future emails, [click here to unsubscribe][5]. + + request_access: + code: "Access Code" + instructions: "This site has restricted access, enter the access code below:" + enter: "Enter" + incorrect: "access code was incorrect" + + system_messages: + site_password: "Also, the site password is `%{access_password}` if you need it." + + post_hidden: + subject_template: "%{site_name} Notice: Posting Hidden due to Community Flagging" + text_body_template: | + Hello, + + This is an automated message from %{site_name} to inform you that the following post was hidden as a result of community flagging. + + %{base_url}%{url} + + Your post was hidden because it was flagged by the community. + + Keep in mind that multiple community members flagged this post before it was hidden, so please consider how you might revise your post to reflect their feedback. + + You can edit the post after %{edit_delay} minutes, and it will be automatically unhidden. This will increase your forum trust level. + + However, if the post is hidden by the community a second time, the moderators will be notified -- and there may be further action, including the possible suspension of your account. + + For additional guidance, please refer to our [FAQ](/faq). + + usage_tips: + text_body_template: | + This private message has a few quick tips to get you started: + + ### Keep Scrolling + + There is no next page button or page numbers -- to keep reading, just keep scrolling down, and more content will load! + + As new replies come in, they will appear automatically at the bottom of the topic. No need to refresh the page or re-enter the topic to see new posts. + + ### How Do I Reply? + + - To reply to a specific post, use the Reply button at the bottom of that post. + + - If you want to reply to the overall *theme* of the topic, rather than any specific person in the topic, use the Reply button at the very bottom of the topic. + + - If you want to take the conversation in a different direction, but keep them linked together, use Reply as New Topic to the right of the post. + + ### Who is Talking to Me? + + When someone replies to your post, quotes you, or mentions your @username, a notification will appear at the top of the page. Click or tap the notification number to see who's talking to you, and where. Join the conversation! + + - To mention someone's name, start typing `@` and an autocompleter will pop up. + + - To quote an entire post, use the Import Quote button on the composer toolbar. + + - To quote just a section of a post, highlight it, then click the Reply button that appears over the highlight. + + ### Look at That Post! + + To let someone know that you enjoyed their post, click the like button at the bottom of the post. If you see a problem with a post, don't hesitate to click the flag button and let the moderators -- and your fellow community members -- know about it. + + ### Where am I? + + - To get back to the home page at any time, **click the icon at the upper left.** + + - To search, visit your user page, or otherwise navigate, click on the icons at the upper right. + - While reading a topic, you can move to the top by clicking its title at the top of the page. To reach the *bottom*, click the down arrow on the topic progress indicator at the bottom of the page, or click the last post field on the topic summary under the first post. + + welcome_approved: + subject_template: "You've been approved on %{site_name}!" + text_body_template: | + Congratulations! + + You're approved to join %{site_name}, welcome to our discussion forum! + + %{new_user_tips} + + We believe in [civilized community behavior](/faq) at all times. + + Enjoy your stay! + + welcome_user: + subject_template: "Welcome to %{site_name}!" + text_body_template: | + Hi there! + + Thanks for joining %{site_name}, and welcome to our discussion forum! + + %{new_user_tips} + + We believe in [civilized community behavior](/faq) at all times. + + Enjoy your stay! + + welcome_invite: + subject_template: "Welcome to %{site_name}!" + text_body_template: | + Thanks for accepting your invitation to %{site_name}, and welcome to our discussion forum! + + We've automatically generated a username for you: **%{username}**, but you can change that any time by visiting [your user profile][prefs]. + + To log in again, either: + + 1. Use Facebook, Google, Twitter, or many other supported credentials -- but that credential must resolve to the **same email address** that you received your original invitation email at. Otherwise we won't be able to tell it is you! + + 2. Create a unique password for %{site_name} on [your user profile][prefs], then log in with that. + + %{site_password} + + %{new_user_tips} + + We believe in [civilized community behavior](/faq) at all times. + + Enjoy your stay! + + [prefs]: %{user_preferences_url} + + flag_threshold_reached: + subject_template: "Posting Hidden due to Community Flagging" + text_body_template: | + Hello, + + This is an automated message from (Community Name) to inform you that the following post was hidden as a result of community flagging: + + (onebox of actual flagged post goes here) + + Your post was hidden for this flag reason: + + > (Largest Flag vote category name goes here, and brief description from the flag dialog, and link. Vote counts are not displayed.) + + Keep in mind that it took multiple community members to flag this item before it was hidden. Consider how you might change your post to reflect their feedback. + + You can edit the post after (x) minutes, and it will be automatically unhidden. This will increase your forum trust level. + + However, if the post is hidden by the community a second time, the moderators will be notified and there may be further action, including the possible suspension of your account. + + For additional guidance, [refer to our FAQ](/faq). + + + export_succeeded: + subject_template: "Export completed successfully" + text_body_template: "The export was successful." + + import_succeeded: + subject_template: "Import completed successfully" + text_body_template: "The import was successful." + + unsubscribe_link: "If you'd like to unsubscribe from these emails, visit your [user preferences](%{user_preferences_url})." + + user_notifications: + unsubscribe: + title: "Unsubscribe" + description: "Not interested in getting these emails? No problem! Click below to unsubscribe instantly:" + + user_invited_to_private_message: + subject_template: "[%{site_name}] %{username} invited you to a private conversation '%{topic_title}'" + text_body_template: | + %{username} invited you to a private conversation '%{topic_title}' on %{site_name}: + + Please visit this link to view the topic: %{base_url}%{url} + + user_replied: + subject_template: "[%{site_name}] %{username} replied to your post in '%{topic_title}'" + text_body_template: | + %{username} replied to your post in '%{topic_title}' on %{site_name}: + + --- + %{message} + + --- + Please visit this link to respond: %{base_url}%{url} + + user_quoted: + subject_template: "[%{site_name}] %{username} quoted you in '%{topic_title}'" + text_body_template: | + %{username} quoted you in '%{topic_title}' on %{site_name}: + + --- + %{message} + + --- + Please visit this link to respond: %{base_url}%{url} + + user_mentioned: + subject_template: "[%{site_name}] %{username} mentioned you in '%{topic_title}'" + text_body_template: | + %{username} mentioned you in '%{topic_title}' on %{site_name}: + + --- + %{message} + + --- + Please visit this link to respond: %{base_url}%{url} + + + digest: + why: "Here's a brief summary of what happened on %{site_link} since we last saw you on %{last_seen_at}." + subject_template: "[%{site_name}] Forum Activity for %{date}" + new_activity: "New activity on your topics and posts:" + new_topics: "New topics:" + unsubscribe: "This summary email is sent as a courtesy notification from %{site_link} when we haven't seen you for 7 days.\nIf you'd like to turn it off or change your email preferences, %{unsubscribe_link}." + + private_message: + subject_template: "[%{site_name}] %{subject_prefix}%{topic_title}" + text_body_template: | + %{from} just sent you a private message + + --- + %{message} + + --- + Please visit this link to respond: %{base_url}%{url} + + forgot_password: + subject_template: "[%{site_name}] Password reset" + text_body_template: | + Somebody asked to reset your password on [%{site_name}](%{base_url}). + + If it was not you, you can safely ignore this email. + + Click the following link to choose a new password: + %{base_url}/users/password-reset/%{email_token} + + authorize_email: + subject_template: "[%{site_name}] Confirm your new email address" + text_body_template: | + Confirm your new email address for %{site_name} by clicking on the following link: + + %{base_url}/users/authorize-email/%{email_token} + + signup: + subject_template: "[%{site_name}] Activate your new account" + text_body_template: | + Welcome to %{site_name}! + + Click the following link to confirm and activate your new account: + %{base_url}/users/activate-account/%{email_token} + + If the above link is not clickable, try copying and pasting it into the address bar of your web browser. + + mothership: + access_token_problem: "Tell an admin: Please update the site settings to include the correct discourse_org_access_key." diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf new file mode 100644 index 00000000000..62fabf4a207 --- /dev/null +++ b/config/nginx.sample.conf @@ -0,0 +1,54 @@ +upstream discourse { + server unix:///var/www/discourse/tmp/sockets/puma0.sock; + server unix:///var/www/discourse/tmp/sockets/puma1.sock; + server unix:///var/www/discourse/tmp/sockets/puma2.sock; + server unix:///var/www/discourse/tmp/sockets/puma3.sock; +} + +server { + + listen 80; + gzip on; + gzip_min_length 1000; + gzip_types application/json text/css application/x-javascript; + + server_name meta.discourse.org; + + sendfile on; + + keepalive_timeout 65; + + location / { + root /var/www/discourse/public; + + location ~ ^/t\/[0-9]+\/[0-9]+\/avatar { + expires 1d; + add_header Cache-Control public; + add_header ETag ""; + } + + location ~ ^/assets/ { + expires 1y; + add_header Cache-Control public; + add_header ETag ""; + break; + } + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + + # If the file exists as a static file serve it directly without + # running all the other rewite tests on it + if (-f $request_filename) { + break; + } + + if (!-f $request_filename) { + proxy_pass http://discourse; + break; + } + + } + +} diff --git a/config/redis.yml b/config/redis.yml new file mode 100644 index 00000000000..68d138b5ff6 --- /dev/null +++ b/config/redis.yml @@ -0,0 +1,18 @@ +defaults: &defaults + host: localhost + port: 6379 + db: 0 + cache_db: 2 + +development: + <<: *defaults + +test: + <<: *defaults + db: 1 + +staging: + <<: *defaults + +production: + <<: *defaults diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 00000000000..3331be7f080 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,210 @@ +require 'sidekiq/web' + +require_dependency 'admin_constraint' + +# This used to be User#username_format, but that causes a preload of the User object +# and makes Guard not work properly. +USERNAME_ROUTE_FORMAT = /[A-Za-z0-9\._]+/ + +Discourse::Application.routes.draw do + + match "/404", :to => "exceptions#not_found" + + mount Sidekiq::Web => '/sidekiq', constraints: AdminConstraint.new + + resources :forums do + collection do + get 'request_access' + post 'request_access_submit' + end + end + get 'srv/status' => 'forums#status' + + namespace :admin, constraints: AdminConstraint.new do + get '' => 'admin#index' + + resources :site_settings + resources :users, id: USERNAME_ROUTE_FORMAT do + collection do + get 'list/:query' => 'users#index' + put 'approve-bulk' => 'users#approve_bulk' + end + put 'ban' => 'users#ban' + put 'unban' => 'users#unban' + put 'revoke_admin' => 'users#revoke_admin' + put 'grant_admin' => 'users#grant_admin' + put 'approve' => 'users#approve' + post 'refresh_browsers' => 'users#refresh_browsers' + end + + resources :impersonate + resources :email_logs do + collection do + post 'test' => 'email_logs#test' + end + end + get 'customize' => 'site_customizations#index' + get 'flags' => 'flags#index' + get 'flags/:filter' => 'flags#index' + post 'flags/clear/:id' => 'flags#clear' + resources :site_customizations + resources :export + get 'version_check' => 'versions#show' + end + + get 'email_preferences' => 'email#preferences_redirect' + get 'email/unsubscribe/:key' => 'email#unsubscribe', as: 'email_unsubscribe' + post 'email/resubscribe/:key' => 'email#resubscribe', as: 'email_resubscribe' + + + resources :session, id: USERNAME_ROUTE_FORMAT do + collection do + post 'forgot_password' + end + end + + resources :users, :except => [:show, :update] do + collection do + get 'check_username' + get 'is_local_username' + end + end + + resources :static + get 'faq' => 'static#show', id: 'faq' + get 'tos' => 'static#show', id: 'tos' + get 'privacy' => 'static#show', id: 'privacy' + + get 'users/search/users' => 'users#search_users' + get 'users/password-reset/:token' => 'users#password_reset' + put 'users/password-reset/:token' => 'users#password_reset' + get 'users/activate-account/:token' => 'users#activate_account' + get 'users/authorize-email/:token' => 'users#authorize_email' + + get 'user_preferences' => 'users#user_preferences_redirect' + get 'users/:username/private-messages' => 'user_actions#private_messages', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} + get 'users/:username' => 'users#show', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} + put 'users/:username' => 'users#update', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} + get 'users/:username/preferences' => 'users#preferences', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT}, :as => :email_preferences + get 'users/:username/preferences/email' => 'users#preferences', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} + put 'users/:username/preferences/email' => 'users#change_email', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} + get 'users/:username/preferences/username' => 'users#preferences', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} + put 'users/:username/preferences/username' => 'users#username', :format => false, :constraints => {:username => USERNAME_ROUTE_FORMAT} + get 'users/:username/avatar(/:size)' => 'users#avatar', :constraints => {:username => USERNAME_ROUTE_FORMAT} + get 'users/:username/invited' => 'users#invited', :constraints => {:username => USERNAME_ROUTE_FORMAT} + + resources :uploads + + + get 'posts/by_number/:topic_id/:post_number' => 'posts#by_number' + resources :posts do + get 'versions' + put 'bookmark' + get 'replies' + collection do + delete 'destroy_many' + end + end + + resources :notifications + resources :categories + resources :user_open_ids do + collection do + get 'frame' + get 'complete' + end + end + + get 'twitter/frame' => 'twitter#frame' + get 'twitter/complete' => 'twitter#complete' + + get 'facebook/frame' => 'facebook#frame' + get 'facebook/complete' => 'facebook#complete' + + resources :clicks do + collection do + get 'track' => 'clicks#track' + end + end + + get 'excerpt' => 'excerpt#show' + + resources :post_actions do + collection do + get 'users' => 'post_actions#users' + end + end + resources :user_actions + + get 'category/:category' => 'list#category' + get 'popular' => 'list#index' + get 'popular/more' => 'list#index' + get 'categories' => 'categories#index' + get 'favorited' => 'list#favorited' + get 'favorited/more' => 'list#favorited' + get 'read' => 'list#read' + get 'read/more' => 'list#read' + get 'unread' => 'list#unread' + get 'unread/more' => 'list#unread' + get 'new' => 'list#new' + get 'new/more' => 'list#new' + get 'posted' => 'list#posted' + get 'posted/more' => 'list#posted' + get 'category/:category' => 'list#category', as: 'category' + get 'category/:category/more' => 'list#category', as: 'category' + + get 'search' => 'search#query' + + # Topics resource + get 't/:id' => 'topics#show' + delete 't/:id' => 'topics#destroy' + put 't/:id' => 'topics#update' + post 't' => 'topics#create' + post 'topics/timings' => 'topics#timings' + + # Legacy route for old avatars + get 'threads/:topic_id/:post_number/avatar' => 'topics#avatar', :constraints => {:topic_id => /\d+/, :post_number => /\d+/} + + # Topic routes + get 't/:slug/:topic_id/best_of' => 'topics#show', :constraints => {:topic_id => /\d+/, :post_number => /\d+/} + get 't/:topic_id/best_of' => 'topics#show', :constraints => {:topic_id => /\d+/, :post_number => /\d+/} + put 't/:slug/:topic_id' => 'topics#update', :constraints => {:topic_id => /\d+/} + put 't/:slug/:topic_id/star' => 'topics#star', :constraints => {:topic_id => /\d+/} + put 't/:topic_id/star' => 'topics#star', :constraints => {:topic_id => /\d+/} + put 't/:slug/:topic_id/status' => 'topics#status', :constraints => {:topic_id => /\d+/} + put 't/:topic_id/status' => 'topics#status', :constraints => {:topic_id => /\d+/} + put 't/:topic_id/mute' => 'topics#mute', :constraints => {:topic_id => /\d+/} + put 't/:topic_id/unmute' => 'topics#unmute', :constraints => {:topic_id => /\d+/} + + get 't/:topic_id/:post_number' => 'topics#show', :constraints => {:topic_id => /\d+/, :post_number => /\d+/} + get 't/:slug/:topic_id' => 'topics#show', :constraints => {:topic_id => /\d+/} + get 't/:slug/:topic_id/:post_number' => 'topics#show', :constraints => {:topic_id => /\d+/, :post_number => /\d+/} + post 't/:topic_id/timings' => 'topics#timings', :constraints => {:topic_id => /\d+/} + post 't/:topic_id/invite' => 'topics#invite', :constraints => {:topic_id => /\d+/} + post 't/:topic_id/move-posts' => 'topics#move_posts', :constraints => {:topic_id => /\d+/} + delete 't/:topic_id/timings' => 'topics#destroy_timings', :constraints => {:topic_id => /\d+/} + + post 't/:topic_id/notifications' => 'topics#set_notifications' , :constraints => {:topic_id => /\d+/} + + + resources :invites + delete 'invites' => 'invites#destroy' + + get 'request_access' => 'request_access#new' + post 'request_access' => 'request_access#create' + + get 'onebox' => 'onebox#show' + + get 'error' => 'forums#error' + + get 'message-bus/poll' => 'message_bus#poll' + + get 'draft' => 'draft#show' + post 'draft' => 'draft#update' + delete 'draft' => 'draft#destroy' + + # You can have the root of your site routed with "root" + # just remember to delete public/index.html. + root :to => 'list#index' + +end diff --git a/db/fixtures/post_action_types.rb b/db/fixtures/post_action_types.rb new file mode 100644 index 00000000000..863fc8c3c51 --- /dev/null +++ b/db/fixtures/post_action_types.rb @@ -0,0 +1,43 @@ +PostActionType.seed do |s| + s.id = PostActionType.Types[:bookmark] + s.name_key = 'bookmark' + s.is_flag = false + s.position = 1 +end + +PostActionType.seed do |s| + s.id = PostActionType.Types[:like] + s.name_key = 'like' + s.is_flag = false + s.icon = 'heart' + s.position = 2 +end + +PostActionType.seed do |s| + s.id = PostActionType.Types[:off_topic] + s.name_key = 'off_topic' + s.is_flag = true + s.position = 3 +end + +PostActionType.seed do |s| + s.id = PostActionType.Types[:inappropriate] + s.name_key = 'inappropriate' + s.is_flag = true + s.position = 4 +end + +PostActionType.seed do |s| + s.id = PostActionType.Types[:spam] + s.name_key = 'spam' + s.is_flag = true + s.position = 6 +end + +PostActionType.seed do |s| + s.id = PostActionType.Types[:custom_flag] + s.name_key = 'custom_flag' + s.is_flag = true + s.position = 7 +end + diff --git a/db/migrate/20120311163914_create_forum_threads.rb b/db/migrate/20120311163914_create_forum_threads.rb new file mode 100644 index 00000000000..43ff07f39a5 --- /dev/null +++ b/db/migrate/20120311163914_create_forum_threads.rb @@ -0,0 +1,11 @@ +class CreateForumThreads < ActiveRecord::Migration + def change + create_table :forum_threads do |t| + t.integer :forum_id, null: false + t.string :title, null: false + t.integer :last_post_id + t.datetime :last_posted_at + t.timestamps + end + end +end diff --git a/db/migrate/20120311164326_create_posts.rb b/db/migrate/20120311164326_create_posts.rb new file mode 100644 index 00000000000..9529d01deb3 --- /dev/null +++ b/db/migrate/20120311164326_create_posts.rb @@ -0,0 +1,14 @@ +class CreatePosts < ActiveRecord::Migration + def change + create_table :posts do |t| + t.integer :user_id, null: false + t.integer :forum_thread_id, null: false + t.integer :post_number, null: false + t.text :content, null: false + t.text :formatted_content, null: false + t.timestamps + end + + add_index :posts, [:forum_thread_id, :created_at] + end +end diff --git a/db/migrate/20120311170118_create_users.rb b/db/migrate/20120311170118_create_users.rb new file mode 100644 index 00000000000..625867c9069 --- /dev/null +++ b/db/migrate/20120311170118_create_users.rb @@ -0,0 +1,9 @@ +class CreateUsers < ActiveRecord::Migration + def change + create_table :users do |t| + t.string :username, :limit => 20, null: false + t.string :avatar_url, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20120311201341_create_forums.rb b/db/migrate/20120311201341_create_forums.rb new file mode 100644 index 00000000000..4148a0694d7 --- /dev/null +++ b/db/migrate/20120311201341_create_forums.rb @@ -0,0 +1,9 @@ +class CreateForums < ActiveRecord::Migration + def change + create_table :forums do |t| + t.integer :site_id, null: false + t.string :title, limit: 100, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20120311210245_create_sites.rb b/db/migrate/20120311210245_create_sites.rb new file mode 100644 index 00000000000..d281148641c --- /dev/null +++ b/db/migrate/20120311210245_create_sites.rb @@ -0,0 +1,8 @@ +class CreateSites < ActiveRecord::Migration + def change + create_table :sites do |t| + t.string :title, limit: 100, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20120416201606_add_reply_to_to_posts.rb b/db/migrate/20120416201606_add_reply_to_to_posts.rb new file mode 100644 index 00000000000..0bdabad4052 --- /dev/null +++ b/db/migrate/20120416201606_add_reply_to_to_posts.rb @@ -0,0 +1,6 @@ +class AddReplyToToPosts < ActiveRecord::Migration + def change + add_column :posts, :reply_to_post_number, :integer, null: true + add_index :posts, :reply_to_post_number + end +end diff --git a/db/migrate/20120420183447_add_views_to_forum_threads.rb b/db/migrate/20120420183447_add_views_to_forum_threads.rb new file mode 100644 index 00000000000..2309cad5938 --- /dev/null +++ b/db/migrate/20120420183447_add_views_to_forum_threads.rb @@ -0,0 +1,5 @@ +class AddViewsToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :views, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20120423140906_add_posts_count_to_forum_threads.rb b/db/migrate/20120423140906_add_posts_count_to_forum_threads.rb new file mode 100644 index 00000000000..6f319e75010 --- /dev/null +++ b/db/migrate/20120423140906_add_posts_count_to_forum_threads.rb @@ -0,0 +1,7 @@ +class AddPostsCountToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :posts_count, :integer, default: 0, null: false + + execute "UPDATE forum_threads SET posts_count = (SELECT count(*) FROM posts WHERE posts.forum_thread_id = forum_threads.id)" + end +end diff --git a/db/migrate/20120423142820_fix_post_indices.rb b/db/migrate/20120423142820_fix_post_indices.rb new file mode 100644 index 00000000000..571db8f8ecc --- /dev/null +++ b/db/migrate/20120423142820_fix_post_indices.rb @@ -0,0 +1,11 @@ +class FixPostIndices < ActiveRecord::Migration + def up + remove_index :posts, [:forum_thread_id, :created_at] + add_index :posts, [:forum_thread_id, :post_number] + end + + def down + remove_index :posts, [:forum_thread_id, :post_number] + add_index :posts, [:forum_thread_id, :created_at] + end +end diff --git a/db/migrate/20120423151548_remove_last_post_id.rb b/db/migrate/20120423151548_remove_last_post_id.rb new file mode 100644 index 00000000000..ea0f2066cc2 --- /dev/null +++ b/db/migrate/20120423151548_remove_last_post_id.rb @@ -0,0 +1,9 @@ +class RemoveLastPostId < ActiveRecord::Migration + def up + remove_column :forum_threads, :last_post_id + end + + def down + add_column :forum_threads, :last_post_id, :integer, :default => 0 + end +end diff --git a/db/migrate/20120425145456_add_display_username_to_users.rb b/db/migrate/20120425145456_add_display_username_to_users.rb new file mode 100644 index 00000000000..dc9402ebac7 --- /dev/null +++ b/db/migrate/20120425145456_add_display_username_to_users.rb @@ -0,0 +1,15 @@ +class AddDisplayUsernameToUsers < ActiveRecord::Migration + def up + add_column :users, :display_username, :string + execute "UPDATE users SET display_username = username" + execute "UPDATE users SET username = REPLACE(username, ' ', '')" + add_index :users, :username, :unique + end + + def down + remove_index :users, :username + execute "UPDATE users SET username = display_username" + remove_column :users, :display_username + end + +end diff --git a/db/migrate/20120427150624_add_user_id_index_to_posts.rb b/db/migrate/20120427150624_add_user_id_index_to_posts.rb new file mode 100644 index 00000000000..78f8ac68f6d --- /dev/null +++ b/db/migrate/20120427150624_add_user_id_index_to_posts.rb @@ -0,0 +1,5 @@ +class AddUserIdIndexToPosts < ActiveRecord::Migration + def change + add_index :posts, :user_id + end +end diff --git a/db/migrate/20120427151452_cooked_migration.rb b/db/migrate/20120427151452_cooked_migration.rb new file mode 100644 index 00000000000..d027fe8ff21 --- /dev/null +++ b/db/migrate/20120427151452_cooked_migration.rb @@ -0,0 +1,6 @@ +class CookedMigration < ActiveRecord::Migration + def change + rename_column :posts, :content, :raw + rename_column :posts, :formatted_content, :cooked + end +end diff --git a/db/migrate/20120427154330_create_vestal_versions.rb b/db/migrate/20120427154330_create_vestal_versions.rb new file mode 100644 index 00000000000..2d658ee086f --- /dev/null +++ b/db/migrate/20120427154330_create_vestal_versions.rb @@ -0,0 +1,28 @@ +class CreateVestalVersions < ActiveRecord::Migration + def self.up + create_table :versions do |t| + t.belongs_to :versioned, :polymorphic => true + t.belongs_to :user, :polymorphic => true + t.string :user_name + t.text :modifications + t.integer :number + t.integer :reverted_from + t.string :tag + + t.timestamps + end + + change_table :versions do |t| + t.index [:versioned_id, :versioned_type] + t.index [:user_id, :user_type] + t.index :user_name + t.index :number + t.index :tag + t.index :created_at + end + end + + def self.down + drop_table :versions + end +end diff --git a/db/migrate/20120427172031_add_version_to_posts.rb b/db/migrate/20120427172031_add_version_to_posts.rb new file mode 100644 index 00000000000..408dc293c42 --- /dev/null +++ b/db/migrate/20120427172031_add_version_to_posts.rb @@ -0,0 +1,5 @@ +class AddVersionToPosts < ActiveRecord::Migration + def change + add_column :posts, :cached_version, :integer, null: false, default: 1 + end +end diff --git a/db/migrate/20120502183240_add_created_by_to_forum_threads.rb b/db/migrate/20120502183240_add_created_by_to_forum_threads.rb new file mode 100644 index 00000000000..888fb86f441 --- /dev/null +++ b/db/migrate/20120502183240_add_created_by_to_forum_threads.rb @@ -0,0 +1,15 @@ +class AddCreatedByToForumThreads < ActiveRecord::Migration + def up + add_column :forum_threads, :user_id, :integer + + execute "update forum_threads t + set user_id = (select user_id from posts where forum_thread_id = t.Id order by post_number asc limit 1)" + + change_column :forum_threads, :user_id, :integer, null: false + end + + def down + remove_column :forum_threads, :user_id + end + +end diff --git a/db/migrate/20120502192121_add_last_post_user_id_to_forum_threads.rb b/db/migrate/20120502192121_add_last_post_user_id_to_forum_threads.rb new file mode 100644 index 00000000000..cd9a872bd96 --- /dev/null +++ b/db/migrate/20120502192121_add_last_post_user_id_to_forum_threads.rb @@ -0,0 +1,17 @@ +class AddLastPostUserIdToForumThreads < ActiveRecord::Migration + + def up + add_column :forum_threads, :last_post_user_id, :integer + + + execute "update forum_threads t + set last_post_user_id = (select user_id from posts where forum_thread_id = t.Id order by post_number desc limit 1)" + + change_column :forum_threads, :last_post_user_id, :integer, null: false + end + + def down + remove_column :forum_threads, :last_post_user_id + end + +end diff --git a/db/migrate/20120503205521_add_site_id_to_users.rb b/db/migrate/20120503205521_add_site_id_to_users.rb new file mode 100644 index 00000000000..c6f8eab49d2 --- /dev/null +++ b/db/migrate/20120503205521_add_site_id_to_users.rb @@ -0,0 +1,9 @@ +class AddSiteIdToUsers < ActiveRecord::Migration + def change + add_column :users, :site_id, :integer + add_column :users, :bio, :text + + add_index :users, :site_id + execute "UPDATE users SET site_id = 1" + end +end diff --git a/db/migrate/20120507144132_create_expressions.rb b/db/migrate/20120507144132_create_expressions.rb new file mode 100644 index 00000000000..2cf61712fe4 --- /dev/null +++ b/db/migrate/20120507144132_create_expressions.rb @@ -0,0 +1,13 @@ +class CreateExpressions < ActiveRecord::Migration + def change + create_table :expressions, id: false, force: true do |t| + t.integer :parent_id, null: false + t.string :parent_type, null: false, limit: 50 + t.integer :expression_type_id, null: false + t.integer :user_id, null: false + t.timestamps + end + + add_index :expressions, [:parent_id, :parent_type, :expression_type_id, :user_id], unique: true, name: "expressions_pk" + end +end diff --git a/db/migrate/20120507144222_create_expression_types.rb b/db/migrate/20120507144222_create_expression_types.rb new file mode 100644 index 00000000000..37cd4d0a360 --- /dev/null +++ b/db/migrate/20120507144222_create_expression_types.rb @@ -0,0 +1,12 @@ +class CreateExpressionTypes < ActiveRecord::Migration + def change + create_table :expression_types do |t| + t.integer :site_id, null: false + t.string :name, null: false, limit: 50 + t.string :long_form, null: false, limit: 100 + t.timestamps + end + + add_index :expression_types, [:site_id, :name], unique: true + end +end diff --git a/db/migrate/20120514144549_add_reply_count_to_posts.rb b/db/migrate/20120514144549_add_reply_count_to_posts.rb new file mode 100644 index 00000000000..31c7cf9e1a9 --- /dev/null +++ b/db/migrate/20120514144549_add_reply_count_to_posts.rb @@ -0,0 +1,13 @@ +class AddReplyCountToPosts < ActiveRecord::Migration + def up + add_column :posts, :reply_count, :integer, null: false, default: 0 + + execute "UPDATE posts + SET reply_count = (SELECT count(*) FROM posts AS p2 WHERE p2.reply_to_post_number = posts.post_number)" + end + + def down + remove_column :posts, :reply_count + end + +end diff --git a/db/migrate/20120514173920_add_flag_to_expression_types.rb b/db/migrate/20120514173920_add_flag_to_expression_types.rb new file mode 100644 index 00000000000..4251f61d24a --- /dev/null +++ b/db/migrate/20120514173920_add_flag_to_expression_types.rb @@ -0,0 +1,5 @@ +class AddFlagToExpressionTypes < ActiveRecord::Migration + def change + add_column :expression_types, :flag, :boolean, default: false + end +end diff --git a/db/migrate/20120514204934_add_description_to_expression_types.rb b/db/migrate/20120514204934_add_description_to_expression_types.rb new file mode 100644 index 00000000000..fcb697ab9ba --- /dev/null +++ b/db/migrate/20120514204934_add_description_to_expression_types.rb @@ -0,0 +1,5 @@ +class AddDescriptionToExpressionTypes < ActiveRecord::Migration + def change + add_column :expression_types, :description, :text, null: true + end +end diff --git a/db/migrate/20120517200130_add_quoteless_to_post.rb b/db/migrate/20120517200130_add_quoteless_to_post.rb new file mode 100644 index 00000000000..5b3ba541fd5 --- /dev/null +++ b/db/migrate/20120517200130_add_quoteless_to_post.rb @@ -0,0 +1,5 @@ +class AddQuotelessToPost < ActiveRecord::Migration + def change + add_column :posts, :quoteless, :boolean, default: false + end +end diff --git a/db/migrate/20120518200115_create_read_posts.rb b/db/migrate/20120518200115_create_read_posts.rb new file mode 100644 index 00000000000..53f9bac6381 --- /dev/null +++ b/db/migrate/20120518200115_create_read_posts.rb @@ -0,0 +1,17 @@ +class CreateReadPosts < ActiveRecord::Migration + def up + create_table :read_posts, id: false do |t| + t.integer :forum_thread_id, null: false + t.integer :user_id, null: false + t.column :page, :integer, null: false + t.column :seen, :integer, null: false + end + + add_index :read_posts, [:forum_thread_id, :user_id, :page], :unique => true + end + + def down + drop_table :read_posts + end + +end diff --git a/db/migrate/20120519182212_create_last_read_posts.rb b/db/migrate/20120519182212_create_last_read_posts.rb new file mode 100644 index 00000000000..c2d1e7b3821 --- /dev/null +++ b/db/migrate/20120519182212_create_last_read_posts.rb @@ -0,0 +1,12 @@ +class CreateLastReadPosts < ActiveRecord::Migration + def change + create_table :last_read_posts do |t| + t.integer :user_id, null: false + t.integer :forum_thread_id, null: false + t.integer :post_number, null: false + t.timestamps + end + + add_index :last_read_posts, [:user_id, :forum_thread_id], unique: true + end +end diff --git a/db/migrate/20120523180723_create_views.rb b/db/migrate/20120523180723_create_views.rb new file mode 100644 index 00000000000..750c6cb88ca --- /dev/null +++ b/db/migrate/20120523180723_create_views.rb @@ -0,0 +1,14 @@ +class CreateViews < ActiveRecord::Migration + def change + create_table :views, id: false do |t| + t.integer :parent_id, null: false + t.string :parent_type, limit: 50, null: false + t.integer :ip, limit: 8, null: false + t.datetime :viewed_at, null: false + t.integer :user_id, null: true + end + + add_index :views, [:parent_id, :parent_type] + add_index :views, [:parent_id, :parent_type, :ip, :viewed_at], unique: true, name: "unique_views" + end +end diff --git a/db/migrate/20120523184307_add_replies_to_forum_threads.rb b/db/migrate/20120523184307_add_replies_to_forum_threads.rb new file mode 100644 index 00000000000..a8648b7e3e3 --- /dev/null +++ b/db/migrate/20120523184307_add_replies_to_forum_threads.rb @@ -0,0 +1,7 @@ +class AddRepliesToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :reply_count, :integer, default: 0, null: false + + execute "UPDATE forum_threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE posts.reply_to_post_number IS NOT NULL AND posts.forum_thread_id = forum_threads.id)" + end +end diff --git a/db/migrate/20120523201329_add_featured_to_forum_threads.rb b/db/migrate/20120523201329_add_featured_to_forum_threads.rb new file mode 100644 index 00000000000..20ce8263545 --- /dev/null +++ b/db/migrate/20120523201329_add_featured_to_forum_threads.rb @@ -0,0 +1,21 @@ +class AddFeaturedToForumThreads < ActiveRecord::Migration + def up + add_column :forum_threads, :featured_user1_id, :integer, null: true + add_column :forum_threads, :featured_user2_id, :integer, null: true + add_column :forum_threads, :featured_user3_id, :integer, null: true + + # Migrate old threads + ForumThread.all.each do |forum_thread| + posts_count = Post.where(forum_thread_id: forum_thread.id).group(:user_id).order('count_all desc').limit(3).count + posts_count.keys.each_with_index {|user_id, i| forum_thread.send("featured_user#{i+1}_id=", user_id) } + forum_thread.save + end + + end + + def down + remove_column :forum_threads, :featured_user1_id + remove_column :forum_threads, :featured_user2_id + remove_column :forum_threads, :featured_user3_id + end +end diff --git a/db/migrate/20120525194845_add_avg_time_to_forum_threads.rb b/db/migrate/20120525194845_add_avg_time_to_forum_threads.rb new file mode 100644 index 00000000000..aa364962f39 --- /dev/null +++ b/db/migrate/20120525194845_add_avg_time_to_forum_threads.rb @@ -0,0 +1,12 @@ +class AddAvgTimeToForumThreads < ActiveRecord::Migration + def up + add_column :forum_threads, :avg_time, :integer + + execute "update forum_threads SET avg_time = abs(random() * 1200)" + end + + def down + remove_column :forum_threads, :avg_time + end + +end diff --git a/db/migrate/20120529175956_create_uploads.rb b/db/migrate/20120529175956_create_uploads.rb new file mode 100644 index 00000000000..b0eb7984473 --- /dev/null +++ b/db/migrate/20120529175956_create_uploads.rb @@ -0,0 +1,18 @@ +class CreateUploads < ActiveRecord::Migration + def change + create_table :uploads do |t| + t.integer :user_id, null: false + t.integer :forum_thread_id, null: false + t.string :original_filename, null: false + t.integer :filesize, null: false + t.integer :width, null: true + t.integer :height, null: true + t.string :url, null: false + t.timestamps + end + + add_index :uploads, :forum_thread_id + add_index :uploads, :user_id + end + +end diff --git a/db/migrate/20120529202707_create_stars.rb b/db/migrate/20120529202707_create_stars.rb new file mode 100644 index 00000000000..0c885bde4d8 --- /dev/null +++ b/db/migrate/20120529202707_create_stars.rb @@ -0,0 +1,12 @@ +class CreateStars < ActiveRecord::Migration + def change + create_table :stars, id: false do |t| + t.integer :parent_id, null: false + t.string :parent_type, limit: 50, null: false + t.integer :user_id, null: true + t.timestamps + end + + add_index :stars, [:parent_id, :parent_type, :user_id] + end +end diff --git a/db/migrate/20120530150726_create_forum_thread_user.rb b/db/migrate/20120530150726_create_forum_thread_user.rb new file mode 100644 index 00000000000..dfa16bf7e83 --- /dev/null +++ b/db/migrate/20120530150726_create_forum_thread_user.rb @@ -0,0 +1,41 @@ +class CreateForumThreadUser < ActiveRecord::Migration + def up + create_table :forum_thread_users, id: false do |t| + t.integer :user_id, null: false + t.integer :forum_thread_id, null: false + t.boolean :starred, null: false, default: false + t.boolean :posted, null: false, default: false + t.integer :last_read_post_number, null: false, default: 1 + t.timestamps + end + + execute "DELETE FROM read_posts" + + add_index :forum_thread_users, [:forum_thread_id, :user_id], unique: true + + drop_table :stars + drop_table :last_read_posts + end + + def down + drop_table :forum_thread_users + + create_table :stars, id: false do |t| + t.integer :parent_id, null: false + t.string :parent_type, limit: 50, null: false + t.integer :user_id, null: true + t.timestamps + end + + add_index :stars, [:parent_id, :parent_type, :user_id] + + create_table :last_read_posts do |t| + t.integer :user_id, null: false + t.integer :forum_thread_id, null: false + t.integer :post_number, null: false + t.timestamps + end + + add_index :last_read_posts, [:user_id, :forum_thread_id], unique: true + end +end diff --git a/db/migrate/20120530160745_migrate_posted.rb b/db/migrate/20120530160745_migrate_posted.rb new file mode 100644 index 00000000000..924f3f72b1d --- /dev/null +++ b/db/migrate/20120530160745_migrate_posted.rb @@ -0,0 +1,10 @@ +class MigratePosted < ActiveRecord::Migration + def up + Post.all.each do |p| + ForumThreadUser.change(p.user, p.forum_thread_id, posted: true) + end + end + + def down + end +end diff --git a/db/migrate/20120530200724_add_index_to_forum_threads.rb b/db/migrate/20120530200724_add_index_to_forum_threads.rb new file mode 100644 index 00000000000..cfe14bff143 --- /dev/null +++ b/db/migrate/20120530200724_add_index_to_forum_threads.rb @@ -0,0 +1,5 @@ +class AddIndexToForumThreads < ActiveRecord::Migration + def change + add_index :forum_threads, :last_posted_at + end +end diff --git a/db/migrate/20120530212912_create_forum_thread_links.rb b/db/migrate/20120530212912_create_forum_thread_links.rb new file mode 100644 index 00000000000..2decb4d7686 --- /dev/null +++ b/db/migrate/20120530212912_create_forum_thread_links.rb @@ -0,0 +1,16 @@ +class CreateForumThreadLinks < ActiveRecord::Migration + def change + create_table :forum_thread_links do |t| + t.integer :forum_thread_id, null: false + t.integer :post_id, null: false + t.integer :user_id, null: false + t.string :url, limit: 500, null: false + t.string :domain, limit: 100, null: false + t.boolean :internal, null: false, default: false + t.integer :link_forum_thread_id, null: true + t.timestamps + end + + add_index :forum_thread_links, :forum_thread_id + end +end diff --git a/db/migrate/20120614190726_add_tags_to_forum_threads.rb b/db/migrate/20120614190726_add_tags_to_forum_threads.rb new file mode 100644 index 00000000000..c65eaf3c32c --- /dev/null +++ b/db/migrate/20120614190726_add_tags_to_forum_threads.rb @@ -0,0 +1,5 @@ +class AddTagsToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :tag, :string, null: true, limit: 25 + end +end diff --git a/db/migrate/20120614202024_add_quote_count_to_posts.rb b/db/migrate/20120614202024_add_quote_count_to_posts.rb new file mode 100644 index 00000000000..5d7c549a352 --- /dev/null +++ b/db/migrate/20120614202024_add_quote_count_to_posts.rb @@ -0,0 +1,12 @@ +class AddQuoteCountToPosts < ActiveRecord::Migration + def up + add_column :posts, :quote_count, :integer, default: 0, null: false + execute "UPDATE posts SET quote_count = 1 WHERE quoteless = 'f'" + remove_column :posts, :quoteless + end + + def down + remove_column :posts, :quote_count + add_column :posts, :quoteless, :boolean, default: false + end +end diff --git a/db/migrate/20120615180517_create_bookmarks.rb b/db/migrate/20120615180517_create_bookmarks.rb new file mode 100644 index 00000000000..5e129e89500 --- /dev/null +++ b/db/migrate/20120615180517_create_bookmarks.rb @@ -0,0 +1,11 @@ +class CreateBookmarks < ActiveRecord::Migration + def change + create_table :bookmarks do |t| + t.integer :user_id + t.integer :post_id + t.timestamps + end + + add_index :bookmarks, [:user_id, :post_id], unique: true + end +end diff --git a/db/migrate/20120618152946_add_reply_below_to_posts.rb b/db/migrate/20120618152946_add_reply_below_to_posts.rb new file mode 100644 index 00000000000..99c23a87d98 --- /dev/null +++ b/db/migrate/20120618152946_add_reply_below_to_posts.rb @@ -0,0 +1,5 @@ +class AddReplyBelowToPosts < ActiveRecord::Migration + def change + add_column :posts, :reply_below_post_number, :integer, null: true + end +end diff --git a/db/migrate/20120618212349_create_post_timings.rb b/db/migrate/20120618212349_create_post_timings.rb new file mode 100644 index 00000000000..b795ca902c3 --- /dev/null +++ b/db/migrate/20120618212349_create_post_timings.rb @@ -0,0 +1,13 @@ +class CreatePostTimings < ActiveRecord::Migration + def change + create_table :post_timings do |t| + t.integer :thread_id, null: false + t.integer :post_number, null: false + t.integer :user_id, null: false + t.integer :msecs, null: false + end + + add_index :post_timings, [:thread_id, :post_number] + add_index :post_timings, [:thread_id, :post_number, :user_id], unique: true + end +end diff --git a/db/migrate/20120618214856_create_message_bus.rb b/db/migrate/20120618214856_create_message_bus.rb new file mode 100644 index 00000000000..f2d81c6d20d --- /dev/null +++ b/db/migrate/20120618214856_create_message_bus.rb @@ -0,0 +1,13 @@ +class CreateMessageBus < ActiveRecord::Migration + def change + create_table :message_bus do |t| + t.string :name + t.string :context + t.text :data + t.datetime :created_at + end + + add_index :message_bus, [:created_at] + end + +end diff --git a/db/migrate/20120619150807_fix_post_timings.rb b/db/migrate/20120619150807_fix_post_timings.rb new file mode 100644 index 00000000000..90ab38c2d00 --- /dev/null +++ b/db/migrate/20120619150807_fix_post_timings.rb @@ -0,0 +1,14 @@ +class FixPostTimings < ActiveRecord::Migration + def up + remove_index :post_timings, [:thread_id, :post_number] + remove_index :post_timings, [:thread_id, :post_number, :user_id] + rename_column :post_timings, :thread_id, :forum_thread_id + add_index :post_timings, [:forum_thread_id, :post_number], name: 'post_timings_summary' + add_index :post_timings, [:forum_thread_id, :post_number, :user_id], unique: true, name: 'post_timings_unique' + + end + + def down + rename_column :post_timings, :forum_thread_id, :thread_id + end +end diff --git a/db/migrate/20120619153349_drop_read_posts.rb b/db/migrate/20120619153349_drop_read_posts.rb new file mode 100644 index 00000000000..6902574e775 --- /dev/null +++ b/db/migrate/20120619153349_drop_read_posts.rb @@ -0,0 +1,14 @@ +class DropReadPosts < ActiveRecord::Migration + def up + drop_table :read_posts + end + + def down + create_table :read_posts, id: false do |t| + t.integer :forum_thread_id, null: false + t.integer :user_id, null: false + t.column :page, :integer, null: false + t.column :seen, :integer, null: false + end + end +end diff --git a/db/migrate/20120619172714_add_post_number_to_bookmarks.rb b/db/migrate/20120619172714_add_post_number_to_bookmarks.rb new file mode 100644 index 00000000000..6cbf8151ba4 --- /dev/null +++ b/db/migrate/20120619172714_add_post_number_to_bookmarks.rb @@ -0,0 +1,14 @@ +class AddPostNumberToBookmarks < ActiveRecord::Migration + def change + drop_table :bookmarks + + create_table :bookmarks do |t| + t.integer :user_id, null: false + t.integer :forum_thread_id, null: false + t.integer :post_number, null: false + t.timestamps + end + + add_index :bookmarks, [:user_id, :forum_thread_id, :post_number], unique: true + end +end diff --git a/db/migrate/20120621155351_add_seen_post_count_to_forum_thread_users.rb b/db/migrate/20120621155351_add_seen_post_count_to_forum_thread_users.rb new file mode 100644 index 00000000000..eff20e632e2 --- /dev/null +++ b/db/migrate/20120621155351_add_seen_post_count_to_forum_thread_users.rb @@ -0,0 +1,8 @@ +class AddSeenPostCountToForumThreadUsers < ActiveRecord::Migration + def change + remove_column :post_timings, :id + remove_column :forum_thread_users, :created_at + remove_column :forum_thread_users, :updated_at + add_column :forum_thread_users, :seen_post_count, :integer + end +end diff --git a/db/migrate/20120621190310_add_deleted_at_to_forum_threads.rb b/db/migrate/20120621190310_add_deleted_at_to_forum_threads.rb new file mode 100644 index 00000000000..2aeb019f5ae --- /dev/null +++ b/db/migrate/20120621190310_add_deleted_at_to_forum_threads.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :deleted_at, :datetime + end +end diff --git a/db/migrate/20120622200242_create_notifications.rb b/db/migrate/20120622200242_create_notifications.rb new file mode 100644 index 00000000000..f97550bbf4f --- /dev/null +++ b/db/migrate/20120622200242_create_notifications.rb @@ -0,0 +1,13 @@ +class CreateNotifications < ActiveRecord::Migration + def change + create_table :notifications do |t| + t.integer :notification_type, null: false + t.references :user, null: false + t.string :data, null: false + t.boolean :read, default: false, null: false + t.timestamps + end + + add_index :notifications, [:user_id, :created_at] + end +end diff --git a/db/migrate/20120625145714_add_seen_notification_id_to_users.rb b/db/migrate/20120625145714_add_seen_notification_id_to_users.rb new file mode 100644 index 00000000000..b3aff9c5511 --- /dev/null +++ b/db/migrate/20120625145714_add_seen_notification_id_to_users.rb @@ -0,0 +1,10 @@ +class AddSeenNotificationIdToUsers < ActiveRecord::Migration + def change + + execute "TRUNCATE TABLE notifications" + + add_column :users, :seen_notificaiton_id, :integer, default: 0, null: false + add_column :notifications, :forum_thread_id, :integer, null: true + add_column :notifications, :post_number, :integer, null: true + end +end diff --git a/db/migrate/20120625162318_add_deleted_at_to_posts.rb b/db/migrate/20120625162318_add_deleted_at_to_posts.rb new file mode 100644 index 00000000000..ff9b313f5f3 --- /dev/null +++ b/db/migrate/20120625162318_add_deleted_at_to_posts.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToPosts < ActiveRecord::Migration + def change + add_column :posts, :deleted_at, :datetime + end +end diff --git a/db/migrate/20120625174544_add_highest_post_number_to_forum_threads.rb b/db/migrate/20120625174544_add_highest_post_number_to_forum_threads.rb new file mode 100644 index 00000000000..33ffb5478e4 --- /dev/null +++ b/db/migrate/20120625174544_add_highest_post_number_to_forum_threads.rb @@ -0,0 +1,7 @@ +class AddHighestPostNumberToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :highest_post_number, :integer, default: 0, null: false + + execute "UPDATE forum_threads SET highest_post_number = (SELECT MAX(post_number) FROM posts WHERE posts.forum_thread_id = forum_threads.id)" + end +end diff --git a/db/migrate/20120625195326_add_image_url_to_forum_threads.rb b/db/migrate/20120625195326_add_image_url_to_forum_threads.rb new file mode 100644 index 00000000000..4b37240bcd0 --- /dev/null +++ b/db/migrate/20120625195326_add_image_url_to_forum_threads.rb @@ -0,0 +1,5 @@ +class AddImageUrlToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :image_url, :string + end +end diff --git a/db/migrate/20120629143908_rename_expression_type_id.rb b/db/migrate/20120629143908_rename_expression_type_id.rb new file mode 100644 index 00000000000..ea5ca93d1e7 --- /dev/null +++ b/db/migrate/20120629143908_rename_expression_type_id.rb @@ -0,0 +1,16 @@ +class RenameExpressionTypeId < ActiveRecord::Migration + + def up + add_column :expression_types, :expression_index, :integer + execute "UPDATE expression_types SET expression_index = id" + remove_column :expression_types, :id + + add_index :expression_types, [:site_id, :expression_index], unique: true + end + + def down + add_column :expression_types, :id, :integer + execute "UPDATE expression_types SET id = expression_index" + remove_column :expression_types, :expression_index + end +end diff --git a/db/migrate/20120629150253_denormalize_expressions.rb b/db/migrate/20120629150253_denormalize_expressions.rb new file mode 100644 index 00000000000..1b8a659db4c --- /dev/null +++ b/db/migrate/20120629150253_denormalize_expressions.rb @@ -0,0 +1,25 @@ +class DenormalizeExpressions < ActiveRecord::Migration + def change + + # Denormalizing this makes our queries so, so, so much nicer + + add_column :posts, :expression1_count, :integer, null: false, default: 0 + add_column :posts, :expression2_count, :integer, null: false, default: 0 + add_column :posts, :expression3_count, :integer, null: false, default: 0 + add_column :posts, :expression4_count, :integer, null: false, default: 0 + add_column :posts, :expression5_count, :integer, null: false, default: 0 + + add_column :forum_threads, :expression1_count, :integer, null: false, default: 0 + add_column :forum_threads, :expression2_count, :integer, null: false, default: 0 + add_column :forum_threads, :expression3_count, :integer, null: false, default: 0 + add_column :forum_threads, :expression4_count, :integer, null: false, default: 0 + add_column :forum_threads, :expression5_count, :integer, null: false, default: 0 + + + (1..5).each do |i| + execute "update posts set expression#{i}_count = (select count(*) from expressions where parent_id = posts.id and expression_type_id = #{i})" + execute "update forum_threads set expression#{i}_count = (select sum(expression#{i}_count) from posts where forum_thread_id = forum_threads.id)" + end + end + +end diff --git a/db/migrate/20120629151243_make_expressions_less_generic.rb b/db/migrate/20120629151243_make_expressions_less_generic.rb new file mode 100644 index 00000000000..ffe9e6ee79f --- /dev/null +++ b/db/migrate/20120629151243_make_expressions_less_generic.rb @@ -0,0 +1,16 @@ +class MakeExpressionsLessGeneric < ActiveRecord::Migration + def up + rename_column :expressions, :parent_id, :post_id + rename_column :expressions, :expression_type_id, :expression_index + remove_column :expressions, :parent_type + + add_index :expressions, [:post_id, :expression_index, :user_id], unique: true, name: 'unique_by_user' + end + + def down + rename_column :expressions, :post_id, :parent_id + rename_column :expressions, :expression_index, :expression_type_id + add_column :expressions, :parent_type, :string, null: true + end + +end diff --git a/db/migrate/20120629182637_create_incoming_links.rb b/db/migrate/20120629182637_create_incoming_links.rb new file mode 100644 index 00000000000..4e0c60a4ddd --- /dev/null +++ b/db/migrate/20120629182637_create_incoming_links.rb @@ -0,0 +1,15 @@ +class CreateIncomingLinks < ActiveRecord::Migration + def change + create_table :incoming_links do |t| + t.integer :site_id, null: false + t.string :url, limit: 1000, null: false + t.string :referer, limit: 1000, null: false + t.string :domain, limit: 100, null: false + t.integer :forum_thread_id, null: true + t.integer :post_number, null: true + t.timestamps + end + + add_index :incoming_links, [:site_id, :forum_thread_id, :post_number], name: 'incoming_index' + end +end diff --git a/db/migrate/20120702211427_create_replies.rb b/db/migrate/20120702211427_create_replies.rb new file mode 100644 index 00000000000..cf8e80e3d0f --- /dev/null +++ b/db/migrate/20120702211427_create_replies.rb @@ -0,0 +1,17 @@ +class CreateReplies < ActiveRecord::Migration + def change + create_table :post_replies, id: false do |t| + t.references :post + t.integer :reply_id + t.timestamps + end + + add_index :post_replies, [:post_id, :reply_id], unique: true + + execute "INSERT INTO post_replies (post_id, reply_id, created_at, updated_at) + SELECT p2.id, p.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM posts AS p + INNER JOIN posts AS p2 on p2.post_number = p.reply_to_post_number AND p2.forum_thread_id = P.forum_thread_id + WHERE p.forum_thread_id IS NOT NULL" + end +end diff --git a/db/migrate/20120703184734_add_reflection_to_forum_thread_links.rb b/db/migrate/20120703184734_add_reflection_to_forum_thread_links.rb new file mode 100644 index 00000000000..af6b99cb88e --- /dev/null +++ b/db/migrate/20120703184734_add_reflection_to_forum_thread_links.rb @@ -0,0 +1,6 @@ +class AddReflectionToForumThreadLinks < ActiveRecord::Migration + def change + add_column :forum_thread_links, :reflection, :boolean, default: false + change_column :forum_thread_links, :post_id, :integer, null: true + end +end diff --git a/db/migrate/20120703201312_add_incoming_link_count_to_posts.rb b/db/migrate/20120703201312_add_incoming_link_count_to_posts.rb new file mode 100644 index 00000000000..16c4e37eaad --- /dev/null +++ b/db/migrate/20120703201312_add_incoming_link_count_to_posts.rb @@ -0,0 +1,5 @@ +class AddIncomingLinkCountToPosts < ActiveRecord::Migration + def change + add_column :posts, :incoming_link_count, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20120703203623_add_incoming_link_count_to_forum_threads.rb b/db/migrate/20120703203623_add_incoming_link_count_to_forum_threads.rb new file mode 100644 index 00000000000..15eb799751d --- /dev/null +++ b/db/migrate/20120703203623_add_incoming_link_count_to_forum_threads.rb @@ -0,0 +1,5 @@ +class AddIncomingLinkCountToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :incoming_link_count, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20120703210004_add_bookmark_count_to_posts.rb b/db/migrate/20120703210004_add_bookmark_count_to_posts.rb new file mode 100644 index 00000000000..9c8efde22f3 --- /dev/null +++ b/db/migrate/20120703210004_add_bookmark_count_to_posts.rb @@ -0,0 +1,19 @@ +class AddBookmarkCountToPosts < ActiveRecord::Migration + def change + add_column :posts, :bookmark_count, :integer, default: 0, null: false + add_column :forum_threads, :bookmark_count, :integer, default: 0, null: false + add_column :forum_threads, :star_count, :integer, default: 0, null: false + + execute "UPDATE posts SET bookmark_count = (SELECT COUNT(*) + FROM bookmarks + WHERE post_number = posts.post_number AND forum_thread_id = posts.forum_thread_id)" + + execute "UPDATE forum_threads SET bookmark_count = (SELECT COUNT(*) + FROM bookmarks + WHERE forum_thread_id = forum_threads.id)" + + execute "UPDATE forum_threads SET star_count = (SELECT COUNT(*) + FROM forum_thread_users + WHERE forum_thread_id = forum_threads.id AND starred = true)" + end +end diff --git a/db/migrate/20120704160659_add_avg_time_to_posts.rb b/db/migrate/20120704160659_add_avg_time_to_posts.rb new file mode 100644 index 00000000000..33fbd39f149 --- /dev/null +++ b/db/migrate/20120704160659_add_avg_time_to_posts.rb @@ -0,0 +1,6 @@ +class AddAvgTimeToPosts < ActiveRecord::Migration + def change + add_column :posts, :avg_time, :integer, null: true + add_column :posts, :score, :float, null: true + end +end diff --git a/db/migrate/20120704201743_add_view_count_to_posts.rb b/db/migrate/20120704201743_add_view_count_to_posts.rb new file mode 100644 index 00000000000..2c4f983dd04 --- /dev/null +++ b/db/migrate/20120704201743_add_view_count_to_posts.rb @@ -0,0 +1,8 @@ +class AddViewCountToPosts < ActiveRecord::Migration + def change + add_column :posts, :views, :integer, default: 0, null: false + + execute "UPDATE posts SET views = + (SELECT COUNT(*) FROM post_timings WHERE forum_thread_id = posts.forum_thread_id AND post_number = posts.post_number)" + end +end diff --git a/db/migrate/20120705181724_add_user_to_versions.rb b/db/migrate/20120705181724_add_user_to_versions.rb new file mode 100644 index 00000000000..cdafd3472f0 --- /dev/null +++ b/db/migrate/20120705181724_add_user_to_versions.rb @@ -0,0 +1,7 @@ +class AddUserToVersions < ActiveRecord::Migration + def change + execute "UPDATE versions SET user_type = 'User', user_id = posts.user_id + FROM posts + WHERE posts.id = versions.versioned_id" + end +end diff --git a/db/migrate/20120708210305_add_last_posted_at_to_users.rb b/db/migrate/20120708210305_add_last_posted_at_to_users.rb new file mode 100644 index 00000000000..6e176698ba3 --- /dev/null +++ b/db/migrate/20120708210305_add_last_posted_at_to_users.rb @@ -0,0 +1,11 @@ +class AddLastPostedAtToUsers < ActiveRecord::Migration + def change + add_column :users, :last_posted_at, :datetime, null: true + add_index :users, :last_posted_at + + execute "UPDATE users + SET last_posted_at = (SELECT MAX(posts.created_at) + FROM posts + WHERE posts.user_id = users.id)" + end +end diff --git a/db/migrate/20120712150500_create_categories.rb b/db/migrate/20120712150500_create_categories.rb new file mode 100644 index 00000000000..83431deb7a3 --- /dev/null +++ b/db/migrate/20120712150500_create_categories.rb @@ -0,0 +1,28 @@ +class CreateCategories < ActiveRecord::Migration + def up + create_table :categories do |t| + t.string :name, limit: 50, null: false + t.string :color, limit: 6, null: false, default: 'AB9364' + t.integer :forum_thread_id, null: true + t.integer :top1_forum_thread_id, null: true + t.integer :top2_forum_thread_id, null: true + t.integer :top1_user_id, null: true + t.integer :top2_user_id, null: true + t.integer :forum_thread_count, null: false, default: 0 + t.timestamps + end + + add_index :categories, :name, unique: true + add_index :categories, :forum_thread_count + + execute "INSERT INTO categories (name, forum_thread_count, created_at, updated_At) + SELECT tag, count(*), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP from forum_threads + WHERE tag IS NOT NULL AND tag <> 'null' + GROUP BY tag" + end + + def down + drop_table :categories + end + +end diff --git a/db/migrate/20120712151934_add_category_id_to_forum_threads.rb b/db/migrate/20120712151934_add_category_id_to_forum_threads.rb new file mode 100644 index 00000000000..2606ba57596 --- /dev/null +++ b/db/migrate/20120712151934_add_category_id_to_forum_threads.rb @@ -0,0 +1,18 @@ +class AddCategoryIdToForumThreads < ActiveRecord::Migration + def up + add_column :forum_threads, :category_id, :integer + + execute "UPDATE forum_threads SET category_id = + (SELECT id + FROM categories + WHERE name = forum_threads.tag)" + + remove_column :forum_threads, :tag + end + + def down + remove_column :forum_threads, :category_id + add_column :forum_threads, :tag, :string, limit: 20 + end + +end diff --git a/db/migrate/20120713201324_create_category_featured_threads.rb b/db/migrate/20120713201324_create_category_featured_threads.rb new file mode 100644 index 00000000000..170ff49b81e --- /dev/null +++ b/db/migrate/20120713201324_create_category_featured_threads.rb @@ -0,0 +1,11 @@ +class CreateCategoryFeaturedThreads < ActiveRecord::Migration + def change + create_table :category_featured_threads, id: false do |t| + t.references :category, null: false + t.references :forum_thread, null: false + t.timestamps + end + + add_index :category_featured_threads, [:category_id, :forum_thread_id], unique: true, name: 'cat_featured_threads' + end +end diff --git a/db/migrate/20120716020835_create_site_settings.rb b/db/migrate/20120716020835_create_site_settings.rb new file mode 100644 index 00000000000..b860754624f --- /dev/null +++ b/db/migrate/20120716020835_create_site_settings.rb @@ -0,0 +1,12 @@ +class CreateSiteSettings < ActiveRecord::Migration + def change + create_table :site_settings do |t| + t.string :name, :null => false + t.text :description, :null => false + t.integer :data_type, :null => false + t.text :value + + t.timestamps + end + end +end diff --git a/db/migrate/20120716173544_add_stats_to_categories.rb b/db/migrate/20120716173544_add_stats_to_categories.rb new file mode 100644 index 00000000000..e9cea0050a0 --- /dev/null +++ b/db/migrate/20120716173544_add_stats_to_categories.rb @@ -0,0 +1,7 @@ +class AddStatsToCategories < ActiveRecord::Migration + def change + add_column :categories, :posts_year, :integer + add_column :categories, :posts_month, :integer + add_column :categories, :posts_week, :integer + end +end diff --git a/db/migrate/20120718044955_create_user_open_ids.rb b/db/migrate/20120718044955_create_user_open_ids.rb new file mode 100644 index 00000000000..62cb7ff36c4 --- /dev/null +++ b/db/migrate/20120718044955_create_user_open_ids.rb @@ -0,0 +1,13 @@ +class CreateUserOpenIds < ActiveRecord::Migration + def change + create_table :user_open_ids do |t| + t.integer :user_id + t.string :email + t.string :url + t.timestamps + end + + add_index :user_open_ids, [:url] + + end +end diff --git a/db/migrate/20120719004636_add_email_hashed_password_name_salt_to_users.rb b/db/migrate/20120719004636_add_email_hashed_password_name_salt_to_users.rb new file mode 100644 index 00000000000..65fc6a611dd --- /dev/null +++ b/db/migrate/20120719004636_add_email_hashed_password_name_salt_to_users.rb @@ -0,0 +1,30 @@ +class AddEmailHashedPasswordNameSaltToUsers < ActiveRecord::Migration + def up + add_column :users, :email, :string, limit: 256 + + execute "update users set email= md5(random()::text) || 'domain.com'" + + change_column :users, :email, :string, limit:256, null: false + add_index :users, [:email], unique: true + + rename_column :users, :display_username, :name + + add_column :users, :password_hash, :string, limit: 64 + add_column :users, :salt, :string, limit: 32 + add_column :users, :active, :boolean + add_column :users, :activation_key,:string, limit: 32 + + add_column :user_open_ids, :active, :boolean, null: false + + end + + def down + remove_column :users, :email + remove_column :users, :password_hash + remove_column :users, :salt + rename_column :users, :name, :display_username + remove_column :users, :active + remove_column :users, :activation_key + remove_column :user_open_ids, :active + end +end diff --git a/db/migrate/20120720013733_add_username_lower_to_users.rb b/db/migrate/20120720013733_add_username_lower_to_users.rb new file mode 100644 index 00000000000..b744d174057 --- /dev/null +++ b/db/migrate/20120720013733_add_username_lower_to_users.rb @@ -0,0 +1,11 @@ +class AddUsernameLowerToUsers < ActiveRecord::Migration + def up + add_column :users, :username_lower, :string, limit: 20 + execute "update users set username_lower = lower(username)" + add_index :users, [:username_lower], :unique => true + change_column :users, :username_lower, :string, limit: 20, null:false + end + def down + remove_column :users, :username_lower + end +end diff --git a/db/migrate/20120720044246_add_auth_token_to_users.rb b/db/migrate/20120720044246_add_auth_token_to_users.rb new file mode 100644 index 00000000000..3d6ec19efaf --- /dev/null +++ b/db/migrate/20120720044246_add_auth_token_to_users.rb @@ -0,0 +1,6 @@ +class AddAuthTokenToUsers < ActiveRecord::Migration + def change + add_column :users, :auth_token, :string, limit: 32 + add_index :users, [:auth_token] + end +end diff --git a/db/migrate/20120720162422_add_forum_id_to_categories.rb b/db/migrate/20120720162422_add_forum_id_to_categories.rb new file mode 100644 index 00000000000..39b734c73f7 --- /dev/null +++ b/db/migrate/20120720162422_add_forum_id_to_categories.rb @@ -0,0 +1,12 @@ +class AddForumIdToCategories < ActiveRecord::Migration + def up + add_column :categories, :forum_id, :integer + execute "UPDATE categories SET forum_id = (SELECT MIN(id) FROM forums)" + change_column :categories, :forum_id, :integer, null: false + end + + def down + remove_column :categories, :forum_id + end + +end diff --git a/db/migrate/20120723051512_add_not_nulls_to_user_open_ids.rb b/db/migrate/20120723051512_add_not_nulls_to_user_open_ids.rb new file mode 100644 index 00000000000..44572b23d5f --- /dev/null +++ b/db/migrate/20120723051512_add_not_nulls_to_user_open_ids.rb @@ -0,0 +1,7 @@ +class AddNotNullsToUserOpenIds < ActiveRecord::Migration + def change + change_column :user_open_ids, :user_id, :integer, null: false + change_column :user_open_ids, :email, :string, null: false + change_column :user_open_ids, :url, :string, null: false + end +end diff --git a/db/migrate/20120724234502_add_last_seen_at_to_users.rb b/db/migrate/20120724234502_add_last_seen_at_to_users.rb new file mode 100644 index 00000000000..7281d99bfb8 --- /dev/null +++ b/db/migrate/20120724234502_add_last_seen_at_to_users.rb @@ -0,0 +1,5 @@ +class AddLastSeenAtToUsers < ActiveRecord::Migration + def change + add_column :users, :last_seen_at, :datetime + end +end diff --git a/db/migrate/20120724234711_add_website_to_users.rb b/db/migrate/20120724234711_add_website_to_users.rb new file mode 100644 index 00000000000..8687ceca6aa --- /dev/null +++ b/db/migrate/20120724234711_add_website_to_users.rb @@ -0,0 +1,5 @@ +class AddWebsiteToUsers < ActiveRecord::Migration + def change + add_column :users, :website, :string + end +end diff --git a/db/migrate/20120725183347_add_excerpt_to_categories.rb b/db/migrate/20120725183347_add_excerpt_to_categories.rb new file mode 100644 index 00000000000..ecbdca1790f --- /dev/null +++ b/db/migrate/20120725183347_add_excerpt_to_categories.rb @@ -0,0 +1,5 @@ +class AddExcerptToCategories < ActiveRecord::Migration + def change + add_column :categories, :excerpt, :string, limit: 250 + end +end diff --git a/db/migrate/20120726201830_add_invisible_to_forum_thread.rb b/db/migrate/20120726201830_add_invisible_to_forum_thread.rb new file mode 100644 index 00000000000..1c79c9a4674 --- /dev/null +++ b/db/migrate/20120726201830_add_invisible_to_forum_thread.rb @@ -0,0 +1,12 @@ +class AddInvisibleToForumThread < ActiveRecord::Migration + def up + add_column :forum_threads, :invisible, :boolean, default: false, null: false + change_column :categories, :excerpt, :text, null: true + end + + def down + remove_column :forum_threads, :invisible + change_column :categories, :excerpt, :string, limit: 250, null: true + end + +end diff --git a/db/migrate/20120726235129_add_user_id_to_categories.rb b/db/migrate/20120726235129_add_user_id_to_categories.rb new file mode 100644 index 00000000000..26f2ff32b09 --- /dev/null +++ b/db/migrate/20120726235129_add_user_id_to_categories.rb @@ -0,0 +1,7 @@ +class AddUserIdToCategories < ActiveRecord::Migration + def change + add_column :categories, :user_id, :integer + execute "UPDATE categories SET user_id = 1186" + change_column :categories, :user_id, :integer, null: false + end +end diff --git a/db/migrate/20120727005556_remove_excerpt_from_categories.rb b/db/migrate/20120727005556_remove_excerpt_from_categories.rb new file mode 100644 index 00000000000..aa3f8faf7f0 --- /dev/null +++ b/db/migrate/20120727005556_remove_excerpt_from_categories.rb @@ -0,0 +1,9 @@ +class RemoveExcerptFromCategories < ActiveRecord::Migration + def up + remove_column :categories, :excerpt + end + + def down + add_column :categories, :excerpt, :string, limit: 250 + end +end diff --git a/db/migrate/20120727150428_rename_invisible.rb b/db/migrate/20120727150428_rename_invisible.rb new file mode 100644 index 00000000000..04dc3aab1cf --- /dev/null +++ b/db/migrate/20120727150428_rename_invisible.rb @@ -0,0 +1,9 @@ +class RenameInvisible < ActiveRecord::Migration + def change + + add_column :forum_threads, :visible, :boolean, default: true, null: false + execute "UPDATE forum_threads SET visible = CASE WHEN invisible THEN false ELSE true END" + remove_column :forum_threads, :invisible + + end +end diff --git a/db/migrate/20120727213543_add_thread_counts_to_categories.rb b/db/migrate/20120727213543_add_thread_counts_to_categories.rb new file mode 100644 index 00000000000..36add724c18 --- /dev/null +++ b/db/migrate/20120727213543_add_thread_counts_to_categories.rb @@ -0,0 +1,11 @@ +class AddThreadCountsToCategories < ActiveRecord::Migration + def change + add_column :categories, :threads_year, :integer + add_column :categories, :threads_month, :integer + add_column :categories, :threads_week, :integer + + remove_column :categories, :posts_year + remove_column :categories, :posts_month + remove_column :categories, :posts_week + end +end diff --git a/db/migrate/20120802151210_add_icon_to_expression_types.rb b/db/migrate/20120802151210_add_icon_to_expression_types.rb new file mode 100644 index 00000000000..289ae28b8aa --- /dev/null +++ b/db/migrate/20120802151210_add_icon_to_expression_types.rb @@ -0,0 +1,7 @@ +class AddIconToExpressionTypes < ActiveRecord::Migration + def change + add_column :expression_types, :icon, :string, limit: 20 + + execute "UPDATE expression_types SET icon = 'heart' WHERE expression_index = 1" + end +end diff --git a/db/migrate/20120803191426_add_admin_flag_to_users.rb b/db/migrate/20120803191426_add_admin_flag_to_users.rb new file mode 100644 index 00000000000..9d7abddfec0 --- /dev/null +++ b/db/migrate/20120803191426_add_admin_flag_to_users.rb @@ -0,0 +1,9 @@ +class AddAdminFlagToUsers < ActiveRecord::Migration + def change + add_column :users, :admin, :boolean, default: false, null: false + add_column :users, :moderator, :boolean, default: false, null: false + + # Make all of us admins + execute "UPDATE users SET admin = TRUE where lower(username) in ('eviltrout', 'codinghorror', 'sam', 'hanzo')" + end +end diff --git a/db/migrate/20120806030641_add_new_password_new_salt_email_token_to_users.rb b/db/migrate/20120806030641_add_new_password_new_salt_email_token_to_users.rb new file mode 100644 index 00000000000..ccf40ba9aa6 --- /dev/null +++ b/db/migrate/20120806030641_add_new_password_new_salt_email_token_to_users.rb @@ -0,0 +1,9 @@ +class AddNewPasswordNewSaltEmailTokenToUsers < ActiveRecord::Migration + def change + add_column :users, :new_salt, :string, :limit => 32 + add_column :users, :new_password_hash, :string, :limit => 64 + # email token is more flexible, can be used for both intial activation AND password change confirmation + add_column :users, :email_token, :string, :limit => 32 + remove_column :users, :activation_key + end +end diff --git a/db/migrate/20120806062617_remove_new_password_stuff_from_user.rb b/db/migrate/20120806062617_remove_new_password_stuff_from_user.rb new file mode 100644 index 00000000000..9d8df3e7e44 --- /dev/null +++ b/db/migrate/20120806062617_remove_new_password_stuff_from_user.rb @@ -0,0 +1,6 @@ +class RemoveNewPasswordStuffFromUser < ActiveRecord::Migration + def change + remove_column :users, :new_password_hash + remove_column :users, :new_salt + end +end diff --git a/db/migrate/20120807223020_create_actions.rb b/db/migrate/20120807223020_create_actions.rb new file mode 100644 index 00000000000..15ab85623e3 --- /dev/null +++ b/db/migrate/20120807223020_create_actions.rb @@ -0,0 +1,25 @@ +class CreateActions < ActiveRecord::Migration + def change + create_table :actions do |t| + + # I elected for multiple ids as opposed to using :as cause it makes the table + # thinner, and the joining semantics much simpler (a simple multiple left join will do) + # + # There is a notificiation table as well that covers much of this, + # but this table is wider and is intended for non-notifying actions as well + + + t.integer :action_type, :null => false + t.integer :user_id, :null => false + t.integer :target_forum_thread_id + t.integer :target_post_id + t.integer :target_user_id + t.integer :acting_user_id + + t.timestamps + end + + add_index :actions, [:user_id, :action_type] + add_index :actions, [:acting_user_id] + end +end diff --git a/db/migrate/20120809020415_remove_site_id.rb b/db/migrate/20120809020415_remove_site_id.rb new file mode 100644 index 00000000000..7b9125c2b47 --- /dev/null +++ b/db/migrate/20120809020415_remove_site_id.rb @@ -0,0 +1,22 @@ +class RemoveSiteId < ActiveRecord::Migration + def up + drop_table 'sites' + remove_index 'incoming_links', :name => "incoming_index" + add_index "incoming_links", ["forum_thread_id", "post_number"], :name => "incoming_index" + remove_column 'incoming_links', 'site_id' + remove_index 'users', :name => 'index_users_on_site_id' + remove_column 'users', 'site_id' + + remove_index 'expression_types', :name => 'index_expression_types_on_site_id_and_expression_index' + remove_index 'expression_types', :name => 'index_expression_types_on_site_id_and_name' + remove_column 'expression_types','site_id' + add_index "expression_types", ["expression_index"], :unique => true + add_index "expression_types", ["name"], :unique => true + + drop_table 'forums' + end + + def down + raise 'not reversable' + end +end diff --git a/db/migrate/20120809030647_remove_forum_id.rb b/db/migrate/20120809030647_remove_forum_id.rb new file mode 100644 index 00000000000..412f9e3cc70 --- /dev/null +++ b/db/migrate/20120809030647_remove_forum_id.rb @@ -0,0 +1,10 @@ +class RemoveForumId < ActiveRecord::Migration + def up + remove_column 'forum_threads', 'forum_id' + remove_column 'categories', 'forum_id' + end + + def down + raise 'not reversible' + end +end diff --git a/db/migrate/20120809053414_correct_indexing_on_posts.rb b/db/migrate/20120809053414_correct_indexing_on_posts.rb new file mode 100644 index 00000000000..9a654aee244 --- /dev/null +++ b/db/migrate/20120809053414_correct_indexing_on_posts.rb @@ -0,0 +1,23 @@ +class CorrectIndexingOnPosts < ActiveRecord::Migration + def up + execute "update posts pp +set post_number = c.real_number +from +( + select p1.id, count(*) real_number from posts p1 + join posts p2 on p1.forum_thread_id = p2.forum_thread_id + where p2.id <= p1.id and p1.forum_thread_id = p2.forum_thread_id + group by p1.id +) as c +where pp.id = c.id and pp.post_number <> c.real_number" + + remove_index "posts", ["forum_thread_id","post_number"] + + # this needs to be unique if it is not we can not use post_number to identify a post + add_index "posts", ["forum_thread_id","post_number"], :unique => true + + end + + def down + end +end diff --git a/db/migrate/20120809154750_remove_index_for_now.rb b/db/migrate/20120809154750_remove_index_for_now.rb new file mode 100644 index 00000000000..afd90d83b95 --- /dev/null +++ b/db/migrate/20120809154750_remove_index_for_now.rb @@ -0,0 +1,11 @@ +class RemoveIndexForNow < ActiveRecord::Migration + def up + remove_index "posts", ["forum_thread_id","post_number"] + add_index "posts", ["forum_thread_id","post_number"], unique: false + end + + def down + remove_index "posts", ["forum_thread_id","post_number"] + add_index "posts", ["forum_thread_id","post_number"], :unique => true + end +end diff --git a/db/migrate/20120809174649_create_post_actions.rb b/db/migrate/20120809174649_create_post_actions.rb new file mode 100644 index 00000000000..c00449c5d83 --- /dev/null +++ b/db/migrate/20120809174649_create_post_actions.rb @@ -0,0 +1,21 @@ +class CreatePostActions < ActiveRecord::Migration + def up + create_table :post_actions do |t| + t.integer :post_id, null: false + t.integer :user_id, null: false + t.integer :post_action_type_id, null:false + t.datetime :deleted_at + t.timestamps + end + + add_index :post_actions, ["post_id"] + + # no support for this till rails 4 + execute 'create unique index idx_unique_actions on + post_actions(user_id, post_action_type_id, post_id) where deleted_at is null' + + end + def down + drop_table :post_actions + end +end diff --git a/db/migrate/20120809175110_create_post_action_types.rb b/db/migrate/20120809175110_create_post_action_types.rb new file mode 100644 index 00000000000..c738031ca91 --- /dev/null +++ b/db/migrate/20120809175110_create_post_action_types.rb @@ -0,0 +1,14 @@ +class CreatePostActionTypes < ActiveRecord::Migration + def change + create_table(:post_action_types, id: false) do |t| + t.integer :id, options: "PRIMARY KEY", null: false + t.string :name, null: false, limit: 50 + t.string :long_form, null: false, limit: 100 + t.boolean :is_flag, null: false, default: false + t.text :description + t.string :icon, limit: 20 + + t.timestamps + end + end +end diff --git a/db/migrate/20120809201855_migrate_bookmarks_to_post_actions.rb b/db/migrate/20120809201855_migrate_bookmarks_to_post_actions.rb new file mode 100644 index 00000000000..06034fff5f6 --- /dev/null +++ b/db/migrate/20120809201855_migrate_bookmarks_to_post_actions.rb @@ -0,0 +1,14 @@ +class MigrateBookmarksToPostActions < ActiveRecord::Migration + def up + execute "insert into post_actions(user_id, post_action_type_id, post_id, created_at, updated_at) + select distinct b.user_id, #{PostActionType.bookmark.id} , p.id, b.created_at, b.updated_at +from bookmarks b +join posts p on p.forum_thread_id = b.forum_thread_id and p.post_number = b.post_number" + drop_table "bookmarks" + end + + def down + # I can reverse this, but not really worth the work + raise ActiveRecord::IrriversableMigration + end +end diff --git a/db/migrate/20120810064839_rename_actions_to_user_actions.rb b/db/migrate/20120810064839_rename_actions_to_user_actions.rb new file mode 100644 index 00000000000..f7a245fdbe1 --- /dev/null +++ b/db/migrate/20120810064839_rename_actions_to_user_actions.rb @@ -0,0 +1,5 @@ +class RenameActionsToUserActions < ActiveRecord::Migration + def change + rename_table 'actions', 'user_actions' + end +end diff --git a/db/migrate/20120812235417_retire_expressions.rb b/db/migrate/20120812235417_retire_expressions.rb new file mode 100644 index 00000000000..1e1f427f25f --- /dev/null +++ b/db/migrate/20120812235417_retire_expressions.rb @@ -0,0 +1,20 @@ +class RetireExpressions < ActiveRecord::Migration + def up + execute 'insert into post_actions (post_action_type_id, user_id, post_id, created_at, updated_at) +select + case + when expression_index=1 then 3 + when expression_index=2 then 4 + when expression_index=3 then 2 + end + + , user_id, post_id, created_at, updated_at from expressions' + + drop_table 'expressions' + drop_table 'expression_types' + end + + def down + raise ActiveRecord::IrriversableMigration + end +end diff --git a/db/migrate/20120813004347_rename_expression_columns_in_forum_thread.rb b/db/migrate/20120813004347_rename_expression_columns_in_forum_thread.rb new file mode 100644 index 00000000000..77efca9ea8f --- /dev/null +++ b/db/migrate/20120813004347_rename_expression_columns_in_forum_thread.rb @@ -0,0 +1,10 @@ +class RenameExpressionColumnsInForumThread < ActiveRecord::Migration + def change + rename_column 'forum_threads', 'expression1_count', 'off_topic_count' + rename_column 'forum_threads', 'expression2_count', 'offensive_count' + rename_column 'forum_threads', 'expression3_count', 'like_count' + remove_column 'forum_threads', 'expression4_count' + remove_column 'forum_threads', 'expression5_count' + + end +end diff --git a/db/migrate/20120813042912_rename_expression_columns_in_posts.rb b/db/migrate/20120813042912_rename_expression_columns_in_posts.rb new file mode 100644 index 00000000000..c57a2507041 --- /dev/null +++ b/db/migrate/20120813042912_rename_expression_columns_in_posts.rb @@ -0,0 +1,9 @@ +class RenameExpressionColumnsInPosts < ActiveRecord::Migration + def change + rename_column 'posts', 'expression1_count', 'off_topic_count' + rename_column 'posts', 'expression2_count', 'offensive_count' + rename_column 'posts', 'expression3_count', 'like_count' + remove_column 'posts', 'expression4_count' + remove_column 'posts', 'expression5_count' + end +end diff --git a/db/migrate/20120813201426_create_forum_thread_link_clicks.rb b/db/migrate/20120813201426_create_forum_thread_link_clicks.rb new file mode 100644 index 00000000000..ec550f85421 --- /dev/null +++ b/db/migrate/20120813201426_create_forum_thread_link_clicks.rb @@ -0,0 +1,13 @@ +class CreateForumThreadLinkClicks < ActiveRecord::Migration + def change + create_table :forum_thread_link_clicks do |t| + t.references :forum_thread_link, null: false + t.references :user, null: true + t.integer :ip, null: false, limit: 8 + t.timestamps + end + + add_column :forum_thread_links, :clicks, :integer, default: 0, null: false + add_index :forum_thread_link_clicks, :forum_thread_link_id, as: :by_link + end +end diff --git a/db/migrate/20120815004411_add_unique_index_to_forum_thread_links.rb b/db/migrate/20120815004411_add_unique_index_to_forum_thread_links.rb new file mode 100644 index 00000000000..3656e36ff67 --- /dev/null +++ b/db/migrate/20120815004411_add_unique_index_to_forum_thread_links.rb @@ -0,0 +1,13 @@ +class AddUniqueIndexToForumThreadLinks < ActiveRecord::Migration + def change + + execute "DELETE FROM forum_thread_links USING forum_thread_links ftl2 + WHERE ftl2.forum_thread_id = forum_thread_links.forum_thread_id + AND ftl2.post_id = forum_thread_links.post_id + AND ftl2.url = forum_thread_links.url + AND ftl2.id < forum_thread_links.id" + + # Add the unique index + add_index :forum_thread_links, [:forum_thread_id, :post_id, :url], unique: true, as: 'unique_post_links' + end +end diff --git a/db/migrate/20120815180106_add_post_type_to_posts.rb b/db/migrate/20120815180106_add_post_type_to_posts.rb new file mode 100644 index 00000000000..768da19725d --- /dev/null +++ b/db/migrate/20120815180106_add_post_type_to_posts.rb @@ -0,0 +1,5 @@ +class AddPostTypeToPosts < ActiveRecord::Migration + def change + add_column :posts, :post_type, :integer, default: 1, null: false + end +end diff --git a/db/migrate/20120815204733_add_moderator_posts_count_to_forum_threads.rb b/db/migrate/20120815204733_add_moderator_posts_count_to_forum_threads.rb new file mode 100644 index 00000000000..cb348f65b6a --- /dev/null +++ b/db/migrate/20120815204733_add_moderator_posts_count_to_forum_threads.rb @@ -0,0 +1,10 @@ +class AddModeratorPostsCountToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :moderator_posts_count, :integer, default: 0, null: false + + execute "UPDATE forum_threads + SET moderator_posts_count = (SELECT COUNT(*) + FROM posts WHERE posts.forum_thread_id = forum_threads.id + AND posts.post_type = 2)" + end +end diff --git a/db/migrate/20120816050526_add_unique_constraint_to_user_actions.rb b/db/migrate/20120816050526_add_unique_constraint_to_user_actions.rb new file mode 100644 index 00000000000..5a8ea4af878 --- /dev/null +++ b/db/migrate/20120816050526_add_unique_constraint_to_user_actions.rb @@ -0,0 +1,5 @@ +class AddUniqueConstraintToUserActions < ActiveRecord::Migration + def change + add_index :user_actions, ['action_type','user_id', 'target_forum_thread_id', 'target_post_id', 'acting_user_id'], name: "idx_unique_rows", unique: true + end +end diff --git a/db/migrate/20120816205537_add_forum_thread_states.rb b/db/migrate/20120816205537_add_forum_thread_states.rb new file mode 100644 index 00000000000..95a6e8b6a89 --- /dev/null +++ b/db/migrate/20120816205537_add_forum_thread_states.rb @@ -0,0 +1,7 @@ +class AddForumThreadStates < ActiveRecord::Migration + def change + add_column :forum_threads, :closed, :boolean, default: false, null: false + add_column :forum_threads, :sticky, :boolean, default: false, null: false + add_column :forum_threads, :archived, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20120816205538_add_starred_at_to_forum_thread_user.rb b/db/migrate/20120816205538_add_starred_at_to_forum_thread_user.rb new file mode 100644 index 00000000000..d49209edfe6 --- /dev/null +++ b/db/migrate/20120816205538_add_starred_at_to_forum_thread_user.rb @@ -0,0 +1,20 @@ +class AddStarredAtToForumThreadUser < ActiveRecord::Migration + def up + add_column :forum_thread_users, :starred_at, :datetime + User.exec_sql 'update forum_thread_users f set starred_at = COALESCE(created_at, ?) + from + ( + select f1.forum_thread_id, f1.user_id, t.created_at from forum_thread_users f1 + left join forum_threads t on f1.forum_thread_id = t.id + ) x + where x.forum_thread_id = f.forum_thread_id and x.user_id = f.user_id', [DateTime.now] + + # probably makes sense to move this out to forum_thread_actions + execute 'alter table forum_thread_users add constraint test_starred_at check(starred = false or starred_at is not null)' + end + + def down + execute 'alter table forum_thread_users drop constraint test_starred_at' + remove_column :forum_thread_users, :starred_at + end +end diff --git a/db/migrate/20120820191804_add_search_indices.rb b/db/migrate/20120820191804_add_search_indices.rb new file mode 100644 index 00000000000..e073be6c16d --- /dev/null +++ b/db/migrate/20120820191804_add_search_indices.rb @@ -0,0 +1,11 @@ +class AddSearchIndices < ActiveRecord::Migration + def up + execute "CREATE INDEX idx_search_user ON users USING GIN(to_tsvector('english', username))" + execute "CREATE INDEX idx_search_thread ON forum_threads USING GIN(to_tsvector('english', title))" + end + + def down + execute "DROP INDEX idx_search_thread" + execute "DROP INDEX idx_search_user" + end +end diff --git a/db/migrate/20120821191616_add_bumped_at_to_forum_threads.rb b/db/migrate/20120821191616_add_bumped_at_to_forum_threads.rb new file mode 100644 index 00000000000..584c62c9fbf --- /dev/null +++ b/db/migrate/20120821191616_add_bumped_at_to_forum_threads.rb @@ -0,0 +1,10 @@ +class AddBumpedAtToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :bumped_at, :datetime + execute "UPDATE forum_threads SET bumped_at = last_posted_at" + change_column :forum_threads, :bumped_at, :datetime, null: false + + remove_index :forum_threads, :last_posted_at + add_index :forum_threads, :bumped_at, order: {bumped_at: :desc} + end +end diff --git a/db/migrate/20120823205956_add_slug_to_categories.rb b/db/migrate/20120823205956_add_slug_to_categories.rb new file mode 100644 index 00000000000..50641848253 --- /dev/null +++ b/db/migrate/20120823205956_add_slug_to_categories.rb @@ -0,0 +1,7 @@ +class AddSlugToCategories < ActiveRecord::Migration + def change + add_column :categories, :slug, :string + execute "UPDATE categories SET slug = REPLACE(LOWER(name), ' ', '-')" + change_column :categories, :slug, :string, null: false + end +end diff --git a/db/migrate/20120824171908_create_category_featured_users.rb b/db/migrate/20120824171908_create_category_featured_users.rb new file mode 100644 index 00000000000..27c3fadd5bc --- /dev/null +++ b/db/migrate/20120824171908_create_category_featured_users.rb @@ -0,0 +1,11 @@ +class CreateCategoryFeaturedUsers < ActiveRecord::Migration + def change + create_table :category_featured_users do |t| + t.references :category + t.references :user + t.timestamps + end + + add_index :category_featured_users, [:category_id, :user_id], unique: true + end +end diff --git a/db/migrate/20120828204209_create_onebox_renders.rb b/db/migrate/20120828204209_create_onebox_renders.rb new file mode 100644 index 00000000000..85b60d7654d --- /dev/null +++ b/db/migrate/20120828204209_create_onebox_renders.rb @@ -0,0 +1,12 @@ +class CreateOneboxRenders < ActiveRecord::Migration + def change + create_table :onebox_renders do |t| + t.string :url, null: false + t.text :cooked, null: false + t.datetime :expires_at, null: false + t.timestamps + end + + add_index :onebox_renders, :url, unique: true + end +end diff --git a/db/migrate/20120828204624_create_post_onebox_renders.rb b/db/migrate/20120828204624_create_post_onebox_renders.rb new file mode 100644 index 00000000000..19c70e14295 --- /dev/null +++ b/db/migrate/20120828204624_create_post_onebox_renders.rb @@ -0,0 +1,10 @@ +class CreatePostOneboxRenders < ActiveRecord::Migration + def change + create_table :post_onebox_renders, id: false do |t| + t.references :post, null: false + t.references :onebox_render, null: false + t.timestamps + end + add_index :post_onebox_renders, [:post_id, :onebox_render_id], unique: true + end +end diff --git a/db/migrate/20120830182736_add_preview_to_onebox_renders.rb b/db/migrate/20120830182736_add_preview_to_onebox_renders.rb new file mode 100644 index 00000000000..79edede11af --- /dev/null +++ b/db/migrate/20120830182736_add_preview_to_onebox_renders.rb @@ -0,0 +1,9 @@ +class AddPreviewToOneboxRenders < ActiveRecord::Migration + def change + add_column :onebox_renders, :preview, :text, null: true + + # Blow away the cache, so we can start saving previews too. + execute "DELETE FROM onebox_renders" + execute "DELETE FROM post_onebox_renders" + end +end diff --git a/db/migrate/20120910171504_remove_description_from_site_settings.rb b/db/migrate/20120910171504_remove_description_from_site_settings.rb new file mode 100644 index 00000000000..a0718456c2d --- /dev/null +++ b/db/migrate/20120910171504_remove_description_from_site_settings.rb @@ -0,0 +1,9 @@ +class RemoveDescriptionFromSiteSettings < ActiveRecord::Migration + def up + remove_column :site_settings, :description + end + + def down + add_column :site_settings, :description, :string + end +end diff --git a/db/migrate/20120918152319_rename_views_to_reads.rb b/db/migrate/20120918152319_rename_views_to_reads.rb new file mode 100644 index 00000000000..4a35838010f --- /dev/null +++ b/db/migrate/20120918152319_rename_views_to_reads.rb @@ -0,0 +1,9 @@ +class RenameViewsToReads < ActiveRecord::Migration + def up + rename_column :posts, :views, :reads + end + + def down + rename_column :posts, :reads, :views + end +end diff --git a/db/migrate/20120918205931_add_sub_tag_to_forum_threads.rb b/db/migrate/20120918205931_add_sub_tag_to_forum_threads.rb new file mode 100644 index 00000000000..649f56f5794 --- /dev/null +++ b/db/migrate/20120918205931_add_sub_tag_to_forum_threads.rb @@ -0,0 +1,14 @@ +class AddSubTagToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :sub_tag, :string + add_index :forum_threads, [:category_id, :sub_tag, :bumped_at] + + ForumThread.where("category_id is not null and title like '%:%'").each do |ft| + if ft.title =~ /^(([a-zA-Z0-9]+)\: )(.*)/ + sub_tag = Regexp.last_match[2].downcase.strip + execute "UPDATE forum_threads SET sub_tag = '#{sub_tag}' WHERE id = #{ft.id}" + end + end + + end +end diff --git a/db/migrate/20120919152846_add_has_best_of_to_forum_threads.rb b/db/migrate/20120919152846_add_has_best_of_to_forum_threads.rb new file mode 100644 index 00000000000..ffb7f85cd9b --- /dev/null +++ b/db/migrate/20120919152846_add_has_best_of_to_forum_threads.rb @@ -0,0 +1,8 @@ +class AddHasBestOfToForumThreads < ActiveRecord::Migration + + def change + add_column :forum_threads, :has_best_of, :boolean, default: false, null: false + change_column :posts, :score, :float + end + +end diff --git a/db/migrate/20120921055428_add_twitter_user_info.rb b/db/migrate/20120921055428_add_twitter_user_info.rb new file mode 100644 index 00000000000..04f46e3041f --- /dev/null +++ b/db/migrate/20120921055428_add_twitter_user_info.rb @@ -0,0 +1,13 @@ +class AddTwitterUserInfo < ActiveRecord::Migration + def change + create_table :twitter_user_infos do |t| + t.integer :user_id, :null => false + t.string :screen_name, :null => false + t.integer :twitter_user_id, :null => false + t.timestamps + end + + add_index :twitter_user_infos, [:twitter_user_id], :unique => true + add_index :twitter_user_infos, [:user_id], :unique => true + end +end diff --git a/db/migrate/20120921155050_create_archetypes.rb b/db/migrate/20120921155050_create_archetypes.rb new file mode 100644 index 00000000000..59e716913c0 --- /dev/null +++ b/db/migrate/20120921155050_create_archetypes.rb @@ -0,0 +1,20 @@ +class CreateArchetypes < ActiveRecord::Migration + def up + create_table :archetypes do |t| + t.string :name_key, null: false + t.timestamps + end + add_index :archetypes, :name_key, unique: true + + execute "INSERT INTO archetypes (name_key, created_at, updated_at) VALUES ('regular', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + execute "INSERT INTO archetypes (name_key, created_at, updated_at) VALUES ('poll', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + + add_column :forum_threads, :archetype_id, :integer, default: 1, null: false + end + + def down + remove_column :forum_threads, :archetype_id + drop_table :archetypes + end + +end diff --git a/db/migrate/20120921162512_add_meta_data_to_forum_threads.rb b/db/migrate/20120921162512_add_meta_data_to_forum_threads.rb new file mode 100644 index 00000000000..c528ce36a5f --- /dev/null +++ b/db/migrate/20120921162512_add_meta_data_to_forum_threads.rb @@ -0,0 +1,5 @@ +class AddMetaDataToForumThreads < ActiveRecord::Migration + def change + add_column :forum_threads, :meta_data, :hstore + end +end diff --git a/db/migrate/20120921163606_create_archetype_options.rb b/db/migrate/20120921163606_create_archetype_options.rb new file mode 100644 index 00000000000..7513921f62d --- /dev/null +++ b/db/migrate/20120921163606_create_archetype_options.rb @@ -0,0 +1,17 @@ +class CreateArchetypeOptions < ActiveRecord::Migration + def change + create_table :archetype_options do |t| + t.references :archetype, null: false + t.string :key, null: false + t.integer :option_type, null: false + t.timestamps + end + + add_index :archetype_options, :archetype_id + + execute "INSERT INTO archetype_options (archetype_id, key, option_type, created_at, updated_at) + VALUES (2, 'private_poll', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + execute "INSERT INTO archetype_options (archetype_id, key, option_type, created_at, updated_at) + VALUES (2, 'single_vote', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" + end +end diff --git a/db/migrate/20120924182000_add_hstore_extension.rb b/db/migrate/20120924182000_add_hstore_extension.rb new file mode 100644 index 00000000000..f3f8d8acba9 --- /dev/null +++ b/db/migrate/20120924182000_add_hstore_extension.rb @@ -0,0 +1,9 @@ +class AddHstoreExtension < ActiveRecord::Migration + def self.up + execute "CREATE EXTENSION IF NOT EXISTS hstore" + end + + def self.down + execute "DROP EXTENSION hstore" + end +end diff --git a/db/migrate/20120924182031_add_vote_count_to_posts.rb b/db/migrate/20120924182031_add_vote_count_to_posts.rb new file mode 100644 index 00000000000..c65852ae77c --- /dev/null +++ b/db/migrate/20120924182031_add_vote_count_to_posts.rb @@ -0,0 +1,6 @@ +class AddVoteCountToPosts < ActiveRecord::Migration + def change + add_column :forum_threads, :vote_count, :integer, default: 0, null: false + add_column :posts, :vote_count, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20120925171620_remove_english_from_post_action_types.rb b/db/migrate/20120925171620_remove_english_from_post_action_types.rb new file mode 100644 index 00000000000..25eb104afb5 --- /dev/null +++ b/db/migrate/20120925171620_remove_english_from_post_action_types.rb @@ -0,0 +1,11 @@ +class RemoveEnglishFromPostActionTypes < ActiveRecord::Migration + def up + rename_column :post_action_types, :name, :name_key + execute "UPDATE post_action_types SET name_key = regexp_replace(lower(name_key), '[^a-z]', '_')" + remove_column :post_action_types, :long_form + remove_column :post_action_types, :description + end + + def down + end +end diff --git a/db/migrate/20120925190802_add_sequence_to_post_action_types.rb b/db/migrate/20120925190802_add_sequence_to_post_action_types.rb new file mode 100644 index 00000000000..d6d14b9589a --- /dev/null +++ b/db/migrate/20120925190802_add_sequence_to_post_action_types.rb @@ -0,0 +1,6 @@ +class AddSequenceToPostActionTypes < ActiveRecord::Migration + def change + remove_column :post_action_types, :id + add_column :post_action_types, :id, :primary_key + end +end diff --git a/db/migrate/20120928170023_add_sort_order_to_posts.rb b/db/migrate/20120928170023_add_sort_order_to_posts.rb new file mode 100644 index 00000000000..63cfe0d2c47 --- /dev/null +++ b/db/migrate/20120928170023_add_sort_order_to_posts.rb @@ -0,0 +1,13 @@ +class AddSortOrderToPosts < ActiveRecord::Migration + def change + add_column :posts, :sort_order, :integer + remove_index :posts, :user_id + execute "UPDATE posts AS p SET sort_order = post_number FROM forum_threads AS ft WHERE ft.id = p.forum_thread_id AND ft.archetype_id = 1" + execute "UPDATE posts AS p SET sort_order = + CASE WHEN post_number = 1 THEN 1 + ELSE 2147483647 - p.vote_count + END + FROM forum_threads AS ft + WHERE ft.id = p.forum_thread_id AND ft.archetype_id = 2" + end +end diff --git a/db/migrate/20121009161116_add_email_stuff_to_users.rb b/db/migrate/20121009161116_add_email_stuff_to_users.rb new file mode 100644 index 00000000000..af0210eef16 --- /dev/null +++ b/db/migrate/20121009161116_add_email_stuff_to_users.rb @@ -0,0 +1,6 @@ +class AddEmailStuffToUsers < ActiveRecord::Migration + def change + add_column :users, :last_emailed_at, :datetime, null: true + add_column :users, :email_digests, :boolean, null: false, default: true + end +end diff --git a/db/migrate/20121011155904_create_email_logs.rb b/db/migrate/20121011155904_create_email_logs.rb new file mode 100644 index 00000000000..2d92e00db64 --- /dev/null +++ b/db/migrate/20121011155904_create_email_logs.rb @@ -0,0 +1,13 @@ +class CreateEmailLogs < ActiveRecord::Migration + def change + create_table :email_logs do |t| + t.string :to_address, null: false + t.string :email_type, null: false + t.integer :user_id, null: true + t.timestamps + end + + add_index :email_logs, :created_at, order: {created_at: :desc} + add_index :email_logs, [:user_id, :created_at], order: {created_at: :desc} + end +end diff --git a/db/migrate/20121017162924_convert_archetypes.rb b/db/migrate/20121017162924_convert_archetypes.rb new file mode 100644 index 00000000000..ba4559da9d8 --- /dev/null +++ b/db/migrate/20121017162924_convert_archetypes.rb @@ -0,0 +1,14 @@ +class ConvertArchetypes < ActiveRecord::Migration + def up + add_column :forum_threads, :archetype, :string, default: 'regular', null: false + execute "UPDATE forum_threads SET archetype = a.name_key FROM archetypes AS a WHERE a.id = forum_threads.archetype_id" + remove_column :forum_threads, :archetype_id + + drop_table :archetypes + drop_table :archetype_options + end + + def down + remove_column :forum_threads, :archetype + end +end diff --git a/db/migrate/20121018103721_rename_forum_thread_tables.rb b/db/migrate/20121018103721_rename_forum_thread_tables.rb new file mode 100644 index 00000000000..c4f69ef8826 --- /dev/null +++ b/db/migrate/20121018103721_rename_forum_thread_tables.rb @@ -0,0 +1,40 @@ +class RenameForumThreadTables < ActiveRecord::Migration + def change + rename_table 'forum_threads', 'topics' + rename_table 'forum_thread_link_clicks', 'topic_link_clicks' + rename_table 'forum_thread_links', 'topic_links' + rename_table 'forum_thread_users', 'topic_users' + rename_table 'category_featured_threads', 'category_featured_topics' + + rename_column 'categories', 'forum_thread_id', 'topic_id' + rename_column 'categories', 'top1_forum_thread_id', 'top1_topic_id' + rename_column 'categories', 'top2_forum_thread_id', 'top2_topic_id' + rename_column 'categories', 'forum_thread_count', 'topic_count' + rename_column 'categories', 'threads_year', 'topics_year' + rename_column 'categories', 'threads_month', 'topics_month' + rename_column 'categories', 'threads_week', 'topics_week' + + + rename_column 'category_featured_topics', 'forum_thread_id', 'topic_id' + + rename_column 'topic_link_clicks', 'forum_thread_link_id', 'topic_link_id' + + rename_column 'topic_links', 'forum_thread_id', 'topic_id' + rename_column 'topic_links', 'link_forum_thread_id', 'link_topic_id' + + + rename_column 'topic_users', 'forum_thread_id', 'topic_id' + + rename_column 'incoming_links', 'forum_thread_id', 'topic_id' + + rename_column 'notifications', 'forum_thread_id', 'topic_id' + + rename_column 'post_timings', 'forum_thread_id', 'topic_id' + + rename_column 'posts', 'forum_thread_id', 'topic_id' + + rename_column 'user_actions', 'target_forum_thread_id', 'target_topic_id' + + rename_column 'uploads', 'forum_thread_id', 'topic_id' + end +end diff --git a/db/migrate/20121018133039_create_topic_allowed_users.rb b/db/migrate/20121018133039_create_topic_allowed_users.rb new file mode 100644 index 00000000000..30d5acf1b77 --- /dev/null +++ b/db/migrate/20121018133039_create_topic_allowed_users.rb @@ -0,0 +1,12 @@ +class CreateTopicAllowedUsers < ActiveRecord::Migration + def change + create_table :topic_allowed_users do |t| + t.integer :user_id, :null => false + t.integer :topic_id, :null => false + t.timestamps + end + + add_index :topic_allowed_users, [:topic_id, :user_id], :unique => true + add_index :topic_allowed_users, [:user_id, :topic_id], :unique => true + end +end diff --git a/db/migrate/20121018182709_fix_notification_data.rb b/db/migrate/20121018182709_fix_notification_data.rb new file mode 100644 index 00000000000..5d261a464f5 --- /dev/null +++ b/db/migrate/20121018182709_fix_notification_data.rb @@ -0,0 +1,8 @@ +class FixNotificationData < ActiveRecord::Migration + def up + execute "UPDATE notifications SET data = replace(data, 'thread_title', 'topic_title')" + end + + def down + end +end diff --git a/db/migrate/20121106015500_drop_avatar_url_from_users.rb b/db/migrate/20121106015500_drop_avatar_url_from_users.rb new file mode 100644 index 00000000000..3194bbbb93d --- /dev/null +++ b/db/migrate/20121106015500_drop_avatar_url_from_users.rb @@ -0,0 +1,15 @@ +# avatar_url does not function properly as it does not properly deal with scaling. +# css based scaling is inefficient and has terrible results in both firefox and ie. canvas based scaling is slow. +# +# for local urls we need to upload an image and have a pointer to the upload, then use the upload id in the user table +# for gravatar we already have the email and can hash it + +class DropAvatarUrlFromUsers < ActiveRecord::Migration + def up + remove_column :users, :avatar_url + end + + def down + add_column :users, :avatar_url, :string, null: false, default: '' + end +end diff --git a/db/migrate/20121108193516_add_post_action_id_to_notifications.rb b/db/migrate/20121108193516_add_post_action_id_to_notifications.rb new file mode 100644 index 00000000000..beeae1131cc --- /dev/null +++ b/db/migrate/20121108193516_add_post_action_id_to_notifications.rb @@ -0,0 +1,6 @@ +class AddPostActionIdToNotifications < ActiveRecord::Migration + def change + add_column :notifications, :post_action_id, :integer, null: true + add_index :notifications, :post_action_id + end +end diff --git a/db/migrate/20121109164630_create_trust_levels.rb b/db/migrate/20121109164630_create_trust_levels.rb new file mode 100644 index 00000000000..16a1e70c161 --- /dev/null +++ b/db/migrate/20121109164630_create_trust_levels.rb @@ -0,0 +1,10 @@ +class CreateTrustLevels < ActiveRecord::Migration + def change + create_table :trust_levels do |t| + t.string :name_key, null: false + t.timestamps + end + + add_column :users, :trust_level_id, :integer, default: 1, null: false + end +end diff --git a/db/migrate/20121113200844_bio_markdown_support.rb b/db/migrate/20121113200844_bio_markdown_support.rb new file mode 100644 index 00000000000..54ad72f71c3 --- /dev/null +++ b/db/migrate/20121113200844_bio_markdown_support.rb @@ -0,0 +1,17 @@ +class BioMarkdownSupport < ActiveRecord::Migration + def up + rename_column :users, :bio, :bio_raw + add_column :users, :bio_cooked, :text, null: true + + User.where("bio_raw is NOT NULL").each do |u| + u.send(:cook) + u.save + end + + end + + def down + rename_column :users, :bio_raw, :bio + remove_column :users, :bio_cooked + end +end diff --git a/db/migrate/20121113200845_create_facebook_user_infos.rb b/db/migrate/20121113200845_create_facebook_user_infos.rb new file mode 100644 index 00000000000..aa9c29099d3 --- /dev/null +++ b/db/migrate/20121113200845_create_facebook_user_infos.rb @@ -0,0 +1,19 @@ +class CreateFacebookUserInfos < ActiveRecord::Migration + def change + create_table :facebook_user_infos do |t| + t.integer :user_id, null: false + t.integer :facebook_user_id, null: false + t.string :username, null: false + t.string :first_name + t.string :last_name + t.string :email + t.string :gender + t.string :name + t.string :link + + t.timestamps + end + add_index :facebook_user_infos, :user_id, unique: true + add_index :facebook_user_infos, :facebook_user_id, unique: true + end +end diff --git a/db/migrate/20121115172544_rename_sticky_to_pinned.rb b/db/migrate/20121115172544_rename_sticky_to_pinned.rb new file mode 100644 index 00000000000..6feb2a720c4 --- /dev/null +++ b/db/migrate/20121115172544_rename_sticky_to_pinned.rb @@ -0,0 +1,9 @@ +class RenameStickyToPinned < ActiveRecord::Migration + def up + rename_column :topics, :sticky, :pinned + end + + def down + rename_column :topics, :pinned, :sticky + end +end diff --git a/db/migrate/20121116212424_add_more_email_settings_to_user.rb b/db/migrate/20121116212424_add_more_email_settings_to_user.rb new file mode 100644 index 00000000000..6a7ac29519b --- /dev/null +++ b/db/migrate/20121116212424_add_more_email_settings_to_user.rb @@ -0,0 +1,6 @@ +class AddMoreEmailSettingsToUser < ActiveRecord::Migration + def change + add_column :users, :email_private_messages, :boolean, default: true + add_column :users, :email_mentions, :boolean, default: true + end +end diff --git a/db/migrate/20121119190529_add_email_settings_to_users.rb b/db/migrate/20121119190529_add_email_settings_to_users.rb new file mode 100644 index 00000000000..b6e7e8a656e --- /dev/null +++ b/db/migrate/20121119190529_add_email_settings_to_users.rb @@ -0,0 +1,6 @@ +class AddEmailSettingsToUsers < ActiveRecord::Migration + def change + add_column :users, :email_replied, :boolean, default: true + add_column :users, :email_quoted, :boolean, default: true + end +end diff --git a/db/migrate/20121119200843_add_email_direct_to_users.rb b/db/migrate/20121119200843_add_email_direct_to_users.rb new file mode 100644 index 00000000000..0091e6e3480 --- /dev/null +++ b/db/migrate/20121119200843_add_email_direct_to_users.rb @@ -0,0 +1,8 @@ +class AddEmailDirectToUsers < ActiveRecord::Migration + def change + add_column :users, :email_direct, :boolean, default: true, null: false + remove_column :users, :email_mentions + remove_column :users, :email_replied + remove_column :users, :email_quoted + end +end diff --git a/db/migrate/20121121202035_create_invites.rb b/db/migrate/20121121202035_create_invites.rb new file mode 100644 index 00000000000..727f4561649 --- /dev/null +++ b/db/migrate/20121121202035_create_invites.rb @@ -0,0 +1,15 @@ +class CreateInvites < ActiveRecord::Migration + def change + create_table :invites do |t| + t.string :invite_key, null: false, limit: 32 + t.string :email, null: false + t.integer :invited_by_id, null: false + t.integer :user_id, null: true + t.timestamp :redeemed_at, null: true + t.timestamps + end + + add_index :invites, :invite_key, unique: true + add_index :invites, [:email, :invited_by_id], unique: true + end +end diff --git a/db/migrate/20121121205215_create_topic_invites.rb b/db/migrate/20121121205215_create_topic_invites.rb new file mode 100644 index 00000000000..75a58b2ee97 --- /dev/null +++ b/db/migrate/20121121205215_create_topic_invites.rb @@ -0,0 +1,12 @@ +class CreateTopicInvites < ActiveRecord::Migration + def change + create_table :topic_invites do |t| + t.references :topic, null: false + t.references :invite, null: false + t.timestamps + end + + add_index :topic_invites, [:topic_id, :invite_id], unique: true + add_index :topic_invites, :invite_id + end +end diff --git a/db/migrate/20121122033316_add_muted_at_to_topic_user.rb b/db/migrate/20121122033316_add_muted_at_to_topic_user.rb new file mode 100644 index 00000000000..0a558f3ee0e --- /dev/null +++ b/db/migrate/20121122033316_add_muted_at_to_topic_user.rb @@ -0,0 +1,7 @@ +class AddMutedAtToTopicUser < ActiveRecord::Migration + def change + add_column :topic_users, :muted_at, :datetime + change_column :topic_users, :last_read_post_number, :integer, :null => true + change_column_default :topic_users, :last_read_post_number, nil + end +end diff --git a/db/migrate/20121123054127_make_post_number_distinct.rb b/db/migrate/20121123054127_make_post_number_distinct.rb new file mode 100644 index 00000000000..f70cbc61d32 --- /dev/null +++ b/db/migrate/20121123054127_make_post_number_distinct.rb @@ -0,0 +1,31 @@ +class MakePostNumberDistinct < ActiveRecord::Migration + def up + + Topic.exec_sql('update posts p +set post_number = calc +from +( + select + id, + post_number, + topic_id, + row_number() over (partition by topic_id order by post_number, created_at) calc + from posts + where topic_id in ( + select topic_id from posts + group by topic_id, post_number + having count(*)>1 + ) + +) as X +where calc <> p.post_number and X.id = p.id') + + remove_index :posts, :forum_thread_id_and_post_number + add_index :posts, [:topic_id, :post_number], :unique => true + end + + def down + # don't want to mess with the index ... its annoying + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20121123063630_create_user_visits.rb b/db/migrate/20121123063630_create_user_visits.rb new file mode 100644 index 00000000000..e68d6dd50f5 --- /dev/null +++ b/db/migrate/20121123063630_create_user_visits.rb @@ -0,0 +1,10 @@ +class CreateUserVisits < ActiveRecord::Migration + def change + create_table :user_visits do |t| + t.integer :user_id, null: false + t.date :visited_at, null: false + end + + add_index :user_visits, [:user_id, :visited_at], unique: true + end +end diff --git a/db/migrate/20121129160035_create_email_tokens.rb b/db/migrate/20121129160035_create_email_tokens.rb new file mode 100644 index 00000000000..7508a05aa9c --- /dev/null +++ b/db/migrate/20121129160035_create_email_tokens.rb @@ -0,0 +1,13 @@ +class CreateEmailTokens < ActiveRecord::Migration + def change + create_table :email_tokens do |t| + t.references :user, null: false + t.string :email, null: false + t.string :token, null: false + t.boolean :confirmed, null: false, default: false + t.boolean :expired, null: false, default: false + t.timestamps + end + add_index :email_tokens, :token, unique: true + end +end diff --git a/db/migrate/20121129184948_remove_email_token_from_users.rb b/db/migrate/20121129184948_remove_email_token_from_users.rb new file mode 100644 index 00000000000..fece82cae4d --- /dev/null +++ b/db/migrate/20121129184948_remove_email_token_from_users.rb @@ -0,0 +1,14 @@ +class RemoveEmailTokenFromUsers < ActiveRecord::Migration + def up + execute "INSERT INTO email_tokens (user_id, email, token, created_at, updated_at) + SELECT id, email, email_token, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM users WHERE email_token IS NOT NULL" + + remove_column :users, :email_token + end + + def down + add_column :users, :email_token, :string + execute "DELETE FROM email_tokens" + end +end diff --git a/db/migrate/20121130010400_create_drafts.rb b/db/migrate/20121130010400_create_drafts.rb new file mode 100644 index 00000000000..085e01ae542 --- /dev/null +++ b/db/migrate/20121130010400_create_drafts.rb @@ -0,0 +1,11 @@ +class CreateDrafts < ActiveRecord::Migration + def change + create_table :drafts do |t| + t.integer :user_id, null: false + t.string :draft_key, null: false + t.text :data, null: false + t.timestamps + end + add_index :drafts, [:user_id, :draft_key] + end +end diff --git a/db/migrate/20121130191818_add_link_post_id_to_topic_links.rb b/db/migrate/20121130191818_add_link_post_id_to_topic_links.rb new file mode 100644 index 00000000000..f8347dc2b7b --- /dev/null +++ b/db/migrate/20121130191818_add_link_post_id_to_topic_links.rb @@ -0,0 +1,5 @@ +class AddLinkPostIdToTopicLinks < ActiveRecord::Migration + def change + add_column :topic_links, :link_post_id, :integer + end +end diff --git a/db/migrate/20121202225421_add_visited_at_to_topic_user.rb b/db/migrate/20121202225421_add_visited_at_to_topic_user.rb new file mode 100644 index 00000000000..4525698b2ca --- /dev/null +++ b/db/migrate/20121202225421_add_visited_at_to_topic_user.rb @@ -0,0 +1,6 @@ +class AddVisitedAtToTopicUser < ActiveRecord::Migration + def change + add_column :topic_users, :last_visited_at, :datetime + add_column :topic_users, :first_visited_at, :datetime + end +end diff --git a/db/migrate/20121203181719_rename_seen_notificaiton_id.rb b/db/migrate/20121203181719_rename_seen_notificaiton_id.rb new file mode 100644 index 00000000000..017f1991216 --- /dev/null +++ b/db/migrate/20121203181719_rename_seen_notificaiton_id.rb @@ -0,0 +1,9 @@ +class RenameSeenNotificaitonId < ActiveRecord::Migration + def up + rename_column :users, :seen_notificaiton_id, :seen_notification_id + end + + def down + rename_column :users, :seen_notification_id, :seen_notificaiton_id + end +end diff --git a/db/migrate/20121204183855_fix_link_post_id.rb b/db/migrate/20121204183855_fix_link_post_id.rb new file mode 100644 index 00000000000..da19ef7bac4 --- /dev/null +++ b/db/migrate/20121204183855_fix_link_post_id.rb @@ -0,0 +1,26 @@ +class FixLinkPostId < ActiveRecord::Migration + def up + to_remove = [] + + TopicLink.where('internal = TRUE AND link_post_id IS NULL').each do |tl| + + begin + parsed = URI.parse(tl.url) + route = Rails.application.routes.recognize_path(parsed.path) + if route[:topic_id].present? + post = Post.where(topic_id: route[:topic_id], post_number: route[:post_number] || 1).first + tl.update_column(:link_post_id, post.id) if post.present? + end + + rescue ActionController::RoutingError + to_remove << tl.id + end + + end + + TopicLink.delete_all ["id in (?)", to_remove] + end + + def down + end +end diff --git a/db/migrate/20121204193747_add_another_featured_user_to_topics.rb b/db/migrate/20121204193747_add_another_featured_user_to_topics.rb new file mode 100644 index 00000000000..7e639e75d4e --- /dev/null +++ b/db/migrate/20121204193747_add_another_featured_user_to_topics.rb @@ -0,0 +1,5 @@ +class AddAnotherFeaturedUserToTopics < ActiveRecord::Migration + def change + add_column :topics, :featured_user4_id, :integer, null: true + end +end diff --git a/db/migrate/20121205162143_add_approved_to_users.rb b/db/migrate/20121205162143_add_approved_to_users.rb new file mode 100644 index 00000000000..fdb474e30eb --- /dev/null +++ b/db/migrate/20121205162143_add_approved_to_users.rb @@ -0,0 +1,7 @@ +class AddApprovedToUsers < ActiveRecord::Migration + def change + add_column :users, :approved, :boolean, null: false, default: false + add_column :users, :approved_by_id, :integer, null: true + add_column :users, :approved_at, :timestamp, null: true + end +end diff --git a/db/migrate/20121207000741_add_notifications_to_topic_users.rb b/db/migrate/20121207000741_add_notifications_to_topic_users.rb new file mode 100644 index 00000000000..5eb82f19a2d --- /dev/null +++ b/db/migrate/20121207000741_add_notifications_to_topic_users.rb @@ -0,0 +1,7 @@ +class AddNotificationsToTopicUsers < ActiveRecord::Migration + def change + add_column :topic_users, :notifications, :integer, default: 2 + add_column :topic_users, :notifications_changed_at, :datetime + add_column :topic_users, :notifications_reason_id, :integer + end +end diff --git a/db/migrate/20121211233131_create_site_customizations.rb b/db/migrate/20121211233131_create_site_customizations.rb new file mode 100644 index 00000000000..0e19a3f3690 --- /dev/null +++ b/db/migrate/20121211233131_create_site_customizations.rb @@ -0,0 +1,16 @@ +class CreateSiteCustomizations < ActiveRecord::Migration + def change + create_table :site_customizations do |t| + t.string :name, null: false + t.text :stylesheet + t.text :header + t.integer :position, null: false + t.integer :user_id, null: false + t.boolean :enabled, null: false + t.string :key, null: false + t.timestamps + end + + add_index :site_customizations, [:key] + end +end diff --git a/db/migrate/20121216230719_add_override_default_style_to_site_customization.rb b/db/migrate/20121216230719_add_override_default_style_to_site_customization.rb new file mode 100644 index 00000000000..786df0f1cce --- /dev/null +++ b/db/migrate/20121216230719_add_override_default_style_to_site_customization.rb @@ -0,0 +1,6 @@ +class AddOverrideDefaultStyleToSiteCustomization < ActiveRecord::Migration + def change + add_column :site_customizations, :override_default_style, :boolean, default: false, null: false + add_column :site_customizations, :stylesheet_baked, :text, default: '', null: false + end +end diff --git a/db/migrate/20121218205642_add_topics_entered_to_users.rb b/db/migrate/20121218205642_add_topics_entered_to_users.rb new file mode 100644 index 00000000000..38cf2a158db --- /dev/null +++ b/db/migrate/20121218205642_add_topics_entered_to_users.rb @@ -0,0 +1,6 @@ +class AddTopicsEnteredToUsers < ActiveRecord::Migration + def change + add_column :users, :topics_entered, :integer, default: 0, null: false + add_column :users, :posts_read_count, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20121224072204_add_last_editor_id_to_posts.rb b/db/migrate/20121224072204_add_last_editor_id_to_posts.rb new file mode 100644 index 00000000000..0c74f2992b4 --- /dev/null +++ b/db/migrate/20121224072204_add_last_editor_id_to_posts.rb @@ -0,0 +1,5 @@ +class AddLastEditorIdToPosts < ActiveRecord::Migration + def change + add_column :posts, :last_editor_id, :integer + end +end diff --git a/db/migrate/20121224095139_create_draft_sequence.rb b/db/migrate/20121224095139_create_draft_sequence.rb new file mode 100644 index 00000000000..a93d444a0bd --- /dev/null +++ b/db/migrate/20121224095139_create_draft_sequence.rb @@ -0,0 +1,10 @@ +class CreateDraftSequence < ActiveRecord::Migration + def change + create_table :draft_sequences do |t| + t.integer :user_id, null: false + t.string :draft_key, null: false + t.integer :sequence, null: false + end + add_index :draft_sequences, [:user_id, :draft_key], unique: true + end +end diff --git a/db/migrate/20121224100650_add_sequence_to_drafts.rb b/db/migrate/20121224100650_add_sequence_to_drafts.rb new file mode 100644 index 00000000000..7edde6492d5 --- /dev/null +++ b/db/migrate/20121224100650_add_sequence_to_drafts.rb @@ -0,0 +1,5 @@ +class AddSequenceToDrafts < ActiveRecord::Migration + def change + add_column :drafts, :sequence, :integer, null: false, default: 0 + end +end diff --git a/db/migrate/20121228192219_add_deleted_at_to_invites.rb b/db/migrate/20121228192219_add_deleted_at_to_invites.rb new file mode 100644 index 00000000000..f95e811ad16 --- /dev/null +++ b/db/migrate/20121228192219_add_deleted_at_to_invites.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToInvites < ActiveRecord::Migration + def change + add_column :invites, :deleted_at, :datetime + end +end diff --git a/db/migrate/20130107165207_add_digest_after_days_to_users.rb b/db/migrate/20130107165207_add_digest_after_days_to_users.rb new file mode 100644 index 00000000000..7964f2c71ae --- /dev/null +++ b/db/migrate/20130107165207_add_digest_after_days_to_users.rb @@ -0,0 +1,5 @@ +class AddDigestAfterDaysToUsers < ActiveRecord::Migration + def change + add_column :users, :digest_after_days, :integer, default: 7, null: false + end +end diff --git a/db/migrate/20130108195847_add_previous_visit_at_to_users.rb b/db/migrate/20130108195847_add_previous_visit_at_to_users.rb new file mode 100644 index 00000000000..d56d116bab7 --- /dev/null +++ b/db/migrate/20130108195847_add_previous_visit_at_to_users.rb @@ -0,0 +1,5 @@ +class AddPreviousVisitAtToUsers < ActiveRecord::Migration + def change + add_column :users, :previous_visit_at, :timestamp + end +end diff --git a/db/migrate/20130115012140_merge_mute_options_on_topic_users.rb b/db/migrate/20130115012140_merge_mute_options_on_topic_users.rb new file mode 100644 index 00000000000..b8072553dd9 --- /dev/null +++ b/db/migrate/20130115012140_merge_mute_options_on_topic_users.rb @@ -0,0 +1,11 @@ +class MergeMuteOptionsOnTopicUsers < ActiveRecord::Migration + def change + execute "update topic_users set notifications = 0 where notifications = 3" + execute "update topic_users set notifications = 1 where notifications = 2" + execute "update topic_users set notifications = 2 where notifications = 1" + + execute "update topic_users set notifications = 0 where muted_at is not null" + rename_column :topic_users, :notifications, :notification_level + remove_column :topic_users, :muted_at + end +end diff --git a/db/migrate/20130115021937_correct_default_on_notification_level.rb b/db/migrate/20130115021937_correct_default_on_notification_level.rb new file mode 100644 index 00000000000..93947c64629 --- /dev/null +++ b/db/migrate/20130115021937_correct_default_on_notification_level.rb @@ -0,0 +1,5 @@ +class CorrectDefaultOnNotificationLevel < ActiveRecord::Migration + def change + change_column :topic_users, :notification_level, :integer, default: 1, null: false + end +end diff --git a/db/migrate/20130115043603_oops_unwatch_a_boat_of_watched_stuff.rb b/db/migrate/20130115043603_oops_unwatch_a_boat_of_watched_stuff.rb new file mode 100644 index 00000000000..e2078fee0f2 --- /dev/null +++ b/db/migrate/20130115043603_oops_unwatch_a_boat_of_watched_stuff.rb @@ -0,0 +1,5 @@ +class OopsUnwatchABoatOfWatchedStuff < ActiveRecord::Migration + def change + execute 'update topic_users set notification_level = 1 where notifications_reason_id is null and notification_level = 2' + end +end diff --git a/db/migrate/20130116151829_remove_sub_tag_from_topics.rb b/db/migrate/20130116151829_remove_sub_tag_from_topics.rb new file mode 100644 index 00000000000..2ff253b2232 --- /dev/null +++ b/db/migrate/20130116151829_remove_sub_tag_from_topics.rb @@ -0,0 +1,9 @@ +class RemoveSubTagFromTopics < ActiveRecord::Migration + def up + remove_column :topics, :sub_tag + end + + def down + add_column :topics, :sub_tag, :string + end +end diff --git a/db/migrate/20130120222728_fix_search.rb b/db/migrate/20130120222728_fix_search.rb new file mode 100644 index 00000000000..2ca0bf7c8a2 --- /dev/null +++ b/db/migrate/20130120222728_fix_search.rb @@ -0,0 +1,18 @@ +class FixSearch < ActiveRecord::Migration + def up + execute 'drop index idx_search_thread' + execute 'drop index idx_search_user' + + execute 'create table posts_search (id integer not null primary key, search_data tsvector)' + execute 'create table users_search (id integer not null primary key, search_data tsvector)' + execute 'create table categories_search (id integer not null primary key, search_data tsvector)' + + execute 'create index idx_search_post on posts_search using gin(search_data) ' + execute 'create index idx_search_user on users_search using gin(search_data) ' + execute 'create index idx_search_category on categories_search using gin(search_data) ' + end + + def down + raise ActiveRecord::IrriversableMigration + end +end diff --git a/db/migrate/20130121231352_add_tracking_to_topic_users.rb b/db/migrate/20130121231352_add_tracking_to_topic_users.rb new file mode 100644 index 00000000000..b52c0d98214 --- /dev/null +++ b/db/migrate/20130121231352_add_tracking_to_topic_users.rb @@ -0,0 +1,8 @@ +class AddTrackingToTopicUsers < ActiveRecord::Migration + def up + execute 'update topic_users set notification_level = 3 where notification_level = 2' + end + def down + execute 'update topic_users set notification_level = 2 where notification_level = 3' + end +end diff --git a/db/migrate/20130122051134_add_auto_track_topics_to_user.rb b/db/migrate/20130122051134_add_auto_track_topics_to_user.rb new file mode 100644 index 00000000000..a995ba0731e --- /dev/null +++ b/db/migrate/20130122051134_add_auto_track_topics_to_user.rb @@ -0,0 +1,5 @@ +class AddAutoTrackTopicsToUser < ActiveRecord::Migration + def change + add_column :users, :auto_track_topics, :boolean, null: false, default: false + end +end diff --git a/db/migrate/20130122232825_add_auto_track_after_seconds_and_banning_and_dob_to_user.rb b/db/migrate/20130122232825_add_auto_track_after_seconds_and_banning_and_dob_to_user.rb new file mode 100644 index 00000000000..95e91b35aa2 --- /dev/null +++ b/db/migrate/20130122232825_add_auto_track_after_seconds_and_banning_and_dob_to_user.rb @@ -0,0 +1,20 @@ +class AddAutoTrackAfterSecondsAndBanningAndDobToUser < ActiveRecord::Migration + def change + add_column :users, :banned_at, :datetime + add_column :users, :banned_till, :datetime + add_column :users, :date_of_birth, :date + add_column :users, :auto_track_topics_after_msecs, :integer + add_column :users, :views, :integer, null: false, default: 0 + + remove_column :users, :auto_track_topics + + add_column :topic_users, :total_msecs_viewed, :integer, null: false, default: 0 + + execute 'update topic_users set total_msecs_viewed = + ( + select coalesce(sum(msecs) ,0) + from post_timings t + where topic_users.topic_id = t.topic_id and topic_users.user_id = t.user_id + )' + end +end diff --git a/db/migrate/20130123070909_auto_track_all_topics_replied_to.rb b/db/migrate/20130123070909_auto_track_all_topics_replied_to.rb new file mode 100644 index 00000000000..83408d4c6ae --- /dev/null +++ b/db/migrate/20130123070909_auto_track_all_topics_replied_to.rb @@ -0,0 +1,15 @@ +class AutoTrackAllTopicsRepliedTo < ActiveRecord::Migration + def up + execute 'update topic_users set notification_level = 2, notifications_reason_id = 4 + from posts p + where + notification_level = 1 and + notifications_reason_id is null and + p.topic_id = topic_users.topic_id and + p.user_id = topic_users.user_id + ' + end + + def down + end +end diff --git a/db/migrate/20130125002652_add_hidden_to_posts.rb b/db/migrate/20130125002652_add_hidden_to_posts.rb new file mode 100644 index 00000000000..ad787fbacc7 --- /dev/null +++ b/db/migrate/20130125002652_add_hidden_to_posts.rb @@ -0,0 +1,6 @@ +class AddHiddenToPosts < ActiveRecord::Migration + def change + add_column :posts, :hidden, :boolean, null: false, default: false + add_column :posts, :hidden_reason_id, :integer + end +end diff --git a/db/migrate/20130125030305_add_fields_to_post_action.rb b/db/migrate/20130125030305_add_fields_to_post_action.rb new file mode 100644 index 00000000000..6e140794408 --- /dev/null +++ b/db/migrate/20130125030305_add_fields_to_post_action.rb @@ -0,0 +1,6 @@ +class AddFieldsToPostAction < ActiveRecord::Migration + def change + add_column :post_actions, :deleted_by, :integer + add_column :post_actions, :message, :text + end +end diff --git a/db/migrate/20130125031122_correct_index_on_post_action.rb b/db/migrate/20130125031122_correct_index_on_post_action.rb new file mode 100644 index 00000000000..6d3d71716c4 --- /dev/null +++ b/db/migrate/20130125031122_correct_index_on_post_action.rb @@ -0,0 +1,6 @@ +class CorrectIndexOnPostAction < ActiveRecord::Migration + def change + remove_index "post_actions", name: "idx_unique_actions" + add_index "post_actions", ["user_id", "post_action_type_id", "post_id", "deleted_at"], name: "idx_unique_actions", unique: true + end +end diff --git a/db/migrate/20130127213646_remove_trust_levels.rb b/db/migrate/20130127213646_remove_trust_levels.rb new file mode 100644 index 00000000000..7ee0e51a4ec --- /dev/null +++ b/db/migrate/20130127213646_remove_trust_levels.rb @@ -0,0 +1,14 @@ +class RemoveTrustLevels < ActiveRecord::Migration + def up + drop_table :trust_levels + change_column_default :users, :trust_level_id, TrustLevel.Levels[:new] + rename_column :users, :trust_level_id, :trust_level + + update "UPDATE users set trust_level = #{TrustLevel.Levels[:regular]}" + update "UPDATE users set trust_level = #{TrustLevel.Levels[:moderator]} where moderator = true" + + remove_column :users, :moderator + add_column :users, :flag_level, :integer, null: false, default: 0 + end + +end diff --git a/db/migrate/20130128182013_trust_level_default_null.rb b/db/migrate/20130128182013_trust_level_default_null.rb new file mode 100644 index 00000000000..f61ce26a3ed --- /dev/null +++ b/db/migrate/20130128182013_trust_level_default_null.rb @@ -0,0 +1,9 @@ +class TrustLevelDefaultNull < ActiveRecord::Migration + def up + change_column_default :users, :trust_level, nil + end + + def down + change_column_default :users, :trust_level, 0 + end +end diff --git a/db/migrate/20130129010625_remove_pm_reflections.rb b/db/migrate/20130129010625_remove_pm_reflections.rb new file mode 100644 index 00000000000..bf82c73b37a --- /dev/null +++ b/db/migrate/20130129010625_remove_pm_reflections.rb @@ -0,0 +1,8 @@ +class RemovePmReflections < ActiveRecord::Migration + def up + execute 'delete from topic_links where link_topic_id in (select id from topics where archetype = \'private_message\') ' + end + + def down + end +end diff --git a/db/migrate/20130129163244_add_time_read_to_users.rb b/db/migrate/20130129163244_add_time_read_to_users.rb new file mode 100644 index 00000000000..bd5c3cf8a0d --- /dev/null +++ b/db/migrate/20130129163244_add_time_read_to_users.rb @@ -0,0 +1,8 @@ +class AddTimeReadToUsers < ActiveRecord::Migration + def change + add_column :users, :time_read, :integer, default: 0, null: false + + # Just an estimate + execute "UPDATE users SET time_read = posts_read_count * 20" + end +end diff --git a/db/migrate/20130129174845_add_days_visited_to_users.rb b/db/migrate/20130129174845_add_days_visited_to_users.rb new file mode 100644 index 00000000000..eff920af0d8 --- /dev/null +++ b/db/migrate/20130129174845_add_days_visited_to_users.rb @@ -0,0 +1,7 @@ +class AddDaysVisitedToUsers < ActiveRecord::Migration + def change + add_column :users, :days_visited, :integer, null: false, default: 0 + + execute "UPDATE users AS u SET days_visited = (SELECT COUNT(*) FROM user_visits AS uv WHERE uv.user_id = u.id)" + end +end diff --git a/db/migrate/20130130154611_remove_index_from_views.rb b/db/migrate/20130130154611_remove_index_from_views.rb new file mode 100644 index 00000000000..a6662c81619 --- /dev/null +++ b/db/migrate/20130130154611_remove_index_from_views.rb @@ -0,0 +1,11 @@ +class RemoveIndexFromViews < ActiveRecord::Migration + def up + remove_index "views", name: "unique_views" + change_column :views, :viewed_at, :date + end + + def down + add_index "views", ["parent_id", "parent_type", "ip", "viewed_at"], name: "unique_views", unique: true + change_column :views, :viewed_at, :timestamp + end +end diff --git a/db/migrate/20130131055710_add_custom_flag_count_to_topics.rb b/db/migrate/20130131055710_add_custom_flag_count_to_topics.rb new file mode 100644 index 00000000000..8574ad22126 --- /dev/null +++ b/db/migrate/20130131055710_add_custom_flag_count_to_topics.rb @@ -0,0 +1,6 @@ +class AddCustomFlagCountToTopics < ActiveRecord::Migration + def change + add_column :topics, :custom_flag_count, :integer, null: false, default: 0 + add_column :posts, :custom_flag_count, :integer, null: false, default: 0 + end +end diff --git a/db/migrate/20130201000828_add_column_summaries_to_posts_and_topics.rb b/db/migrate/20130201000828_add_column_summaries_to_posts_and_topics.rb new file mode 100644 index 00000000000..5c86c11a43c --- /dev/null +++ b/db/migrate/20130201000828_add_column_summaries_to_posts_and_topics.rb @@ -0,0 +1,12 @@ +class AddColumnSummariesToPostsAndTopics < ActiveRecord::Migration + def change + add_column :posts, :spam_count, :integer, default: 0, null: false + add_column :topics, :spam_count, :integer, default: 0, null: false + add_column :posts, :illegal_count, :integer, default: 0, null: false + add_column :topics, :illegal_count, :integer, default: 0, null: false + add_column :posts, :inappropriate_count, :integer, default: 0, null: false + add_column :topics, :inappropriate_count, :integer, default: 0, null: false + remove_column :posts, :offensive_count + remove_column :topics, :offensive_count + end +end diff --git a/db/migrate/20130201023409_add_position_to_post_action_type.rb b/db/migrate/20130201023409_add_position_to_post_action_type.rb new file mode 100644 index 00000000000..80bf29fb1da --- /dev/null +++ b/db/migrate/20130201023409_add_position_to_post_action_type.rb @@ -0,0 +1,5 @@ +class AddPositionToPostActionType < ActiveRecord::Migration + def change + add_column :post_action_types, :position, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20130203204338_add_last_version_at_to_posts.rb b/db/migrate/20130203204338_add_last_version_at_to_posts.rb new file mode 100644 index 00000000000..5ce0f7257b0 --- /dev/null +++ b/db/migrate/20130203204338_add_last_version_at_to_posts.rb @@ -0,0 +1,9 @@ +class AddLastVersionAtToPosts < ActiveRecord::Migration + def change + add_column :posts, :last_version_at, :timestamp + execute "UPDATE posts SET last_version_at = COALESCE((SELECT max(created_at) + FROM versions WHERE versions.versioned_id = posts.id + AND versions.versioned_type = 'Post'), posts.created_at)" + change_column :posts, :last_version_at, :timestamp, null: false + end +end diff --git a/db/migrate/20130204000159_add_ip_address_to_users.rb b/db/migrate/20130204000159_add_ip_address_to_users.rb new file mode 100644 index 00000000000..7537b3d7844 --- /dev/null +++ b/db/migrate/20130204000159_add_ip_address_to_users.rb @@ -0,0 +1,8 @@ +class AddIpAddressToUsers < ActiveRecord::Migration + def up + execute 'alter table users add column ip_address inet' + end + def down + execute 'alter table users drop column ip_address' + end +end diff --git a/db/migrate/20130205021905_alter_facebook_user_id.rb b/db/migrate/20130205021905_alter_facebook_user_id.rb new file mode 100644 index 00000000000..40729954180 --- /dev/null +++ b/db/migrate/20130205021905_alter_facebook_user_id.rb @@ -0,0 +1,9 @@ +class AlterFacebookUserId < ActiveRecord::Migration + def up + change_column :facebook_user_infos, :facebook_user_id, :integer, :limit => 8, null: false + end + + def down + change_column :facebook_user_infos, :facebook_user_id, :integer, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 00000000000..b2c0e0689ca --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,341 @@ +# encoding: UTF-8 +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# Note that this schema.rb definition is the authoritative source for your +# database schema. If you need to create the application database on another +# system, you should be using db:schema:load, not running all the migrations +# from scratch. The latter is a flawed and unsustainable approach (the more migrations +# you'll amass, the slower it'll run and the greater likelihood for issues). +# +# It's strongly recommended to check this file into your version control system. + +ActiveRecord::Schema.define(:version => 20120809201855) do + + create_table "actions", :force => true do |t| + t.integer "action_type", :null => false + t.integer "user_id", :null => false + t.integer "target_forum_thread_id" + t.integer "target_post_id" + t.integer "target_user_id" + t.integer "acting_user_id" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "actions", ["acting_user_id"], :name => "index_actions_on_acting_user_id" + add_index "actions", ["user_id", "action_type"], :name => "index_actions_on_user_id_and_action_type" + + create_table "categories", :force => true do |t| + t.string "name", :limit => 50, :null => false + t.string "color", :limit => 6, :default => "AB9364", :null => false + t.integer "forum_thread_id" + t.integer "top1_forum_thread_id" + t.integer "top2_forum_thread_id" + t.integer "top1_user_id" + t.integer "top2_user_id" + t.integer "forum_thread_count", :default => 0, :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.integer "user_id", :null => false + t.integer "threads_year" + t.integer "threads_month" + t.integer "threads_week" + end + + add_index "categories", ["forum_thread_count"], :name => "index_categories_on_forum_thread_count" + add_index "categories", ["name"], :name => "index_categories_on_name", :unique => true + + create_table "category_featured_threads", :id => false, :force => true do |t| + t.integer "category_id", :null => false + t.integer "forum_thread_id", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "category_featured_threads", ["category_id", "forum_thread_id"], :name => "cat_featured_threads", :unique => true + + create_table "expression_types", :id => false, :force => true do |t| + t.string "name", :limit => 50, :null => false + t.string "long_form", :limit => 100, :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.boolean "flag", :default => false + t.text "description" + t.integer "expression_index" + t.string "icon", :limit => 20 + end + + add_index "expression_types", ["expression_index"], :name => "index_expression_types_on_expression_index", :unique => true + add_index "expression_types", ["name"], :name => "index_expression_types_on_name", :unique => true + + create_table "expressions", :id => false, :force => true do |t| + t.integer "post_id", :null => false + t.integer "expression_index", :null => false + t.integer "user_id", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "expressions", ["post_id", "expression_index", "user_id"], :name => "unique_by_user", :unique => true + + create_table "forum_thread_links", :force => true do |t| + t.integer "forum_thread_id", :null => false + t.integer "post_id" + t.integer "user_id", :null => false + t.string "url", :limit => 500, :null => false + t.string "domain", :limit => 100, :null => false + t.boolean "internal", :default => false, :null => false + t.integer "link_forum_thread_id" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.boolean "reflection", :default => false + end + + add_index "forum_thread_links", ["forum_thread_id"], :name => "index_forum_thread_links_on_forum_thread_id" + + create_table "forum_thread_users", :id => false, :force => true do |t| + t.integer "user_id", :null => false + t.integer "forum_thread_id", :null => false + t.boolean "starred", :default => false, :null => false + t.boolean "posted", :default => false, :null => false + t.integer "last_read_post_number", :default => 1, :null => false + t.integer "seen_post_count" + end + + add_index "forum_thread_users", ["forum_thread_id", "user_id"], :name => "index_forum_thread_users_on_forum_thread_id_and_user_id", :unique => true + + create_table "forum_threads", :force => true do |t| + t.string "title", :null => false + t.datetime "last_posted_at" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.integer "views", :default => 0, :null => false + t.integer "posts_count", :default => 0, :null => false + t.integer "user_id", :null => false + t.integer "last_post_user_id", :null => false + t.integer "reply_count", :default => 0, :null => false + t.integer "featured_user1_id" + t.integer "featured_user2_id" + t.integer "featured_user3_id" + t.integer "avg_time" + t.datetime "deleted_at" + t.integer "highest_post_number", :default => 0, :null => false + t.string "image_url" + t.integer "expression1_count", :default => 0, :null => false + t.integer "expression2_count", :default => 0, :null => false + t.integer "expression3_count", :default => 0, :null => false + t.integer "expression4_count", :default => 0, :null => false + t.integer "expression5_count", :default => 0, :null => false + t.integer "incoming_link_count", :default => 0, :null => false + t.integer "bookmark_count", :default => 0, :null => false + t.integer "star_count", :default => 0, :null => false + t.integer "category_id" + t.boolean "visible", :default => true, :null => false + end + + add_index "forum_threads", ["last_posted_at"], :name => "index_forum_threads_on_last_posted_at" + + create_table "incoming_links", :force => true do |t| + t.string "url", :limit => 1000, :null => false + t.string "referer", :limit => 1000, :null => false + t.string "domain", :limit => 100, :null => false + t.integer "forum_thread_id" + t.integer "post_number" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "incoming_links", ["forum_thread_id", "post_number"], :name => "incoming_index" + + create_table "message_bus", :force => true do |t| + t.string "name" + t.string "context" + t.text "data" + t.datetime "created_at" + end + + add_index "message_bus", ["created_at"], :name => "index_message_bus_on_created_at" + + create_table "notifications", :force => true do |t| + t.integer "notification_type", :null => false + t.integer "user_id", :null => false + t.string "data", :null => false + t.boolean "read", :default => false, :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.integer "forum_thread_id" + t.integer "post_number" + end + + add_index "notifications", ["user_id", "created_at"], :name => "index_notifications_on_user_id_and_created_at" + + create_table "post_action_types", :id => false, :force => true do |t| + t.integer "id", :null => false + t.string "name", :limit => 50, :null => false + t.string "long_form", :limit => 100, :null => false + t.boolean "is_flag", :default => false, :null => false + t.text "description" + t.string "icon", :limit => 20 + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + create_table "post_actions", :force => true do |t| + t.integer "post_id", :null => false + t.integer "user_id", :null => false + t.integer "post_action_type_id", :null => false + t.datetime "deleted_at" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "post_actions", ["post_id"], :name => "index_post_actions_on_post_id" + add_index "post_actions", ["user_id", "post_action_type_id", "post_id"], :name => "idx_unique_actions", :unique => true + + create_table "post_replies", :id => false, :force => true do |t| + t.integer "post_id" + t.integer "reply_id" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "post_replies", ["post_id", "reply_id"], :name => "index_post_replies_on_post_id_and_reply_id", :unique => true + + create_table "post_timings", :id => false, :force => true do |t| + t.integer "forum_thread_id", :null => false + t.integer "post_number", :null => false + t.integer "user_id", :null => false + t.integer "msecs", :null => false + end + + add_index "post_timings", ["forum_thread_id", "post_number", "user_id"], :name => "post_timings_unique", :unique => true + add_index "post_timings", ["forum_thread_id", "post_number"], :name => "post_timings_summary" + + create_table "posts", :force => true do |t| + t.integer "user_id", :null => false + t.integer "forum_thread_id", :null => false + t.integer "post_number", :null => false + t.text "raw", :null => false + t.text "cooked", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.integer "reply_to_post_number" + t.integer "cached_version", :default => 1, :null => false + t.integer "reply_count", :default => 0, :null => false + t.integer "quote_count", :default => 0, :null => false + t.integer "reply_below_post_number" + t.datetime "deleted_at" + t.integer "expression1_count", :default => 0, :null => false + t.integer "expression2_count", :default => 0, :null => false + t.integer "expression3_count", :default => 0, :null => false + t.integer "expression4_count", :default => 0, :null => false + t.integer "expression5_count", :default => 0, :null => false + t.integer "incoming_link_count", :default => 0, :null => false + t.integer "bookmark_count", :default => 0, :null => false + t.integer "avg_time" + t.float "score" + t.integer "views", :default => 0, :null => false + end + + add_index "posts", ["forum_thread_id", "post_number"], :name => "index_posts_on_forum_thread_id_and_post_number" + add_index "posts", ["reply_to_post_number"], :name => "index_posts_on_reply_to_post_number" + add_index "posts", ["user_id"], :name => "index_posts_on_user_id" + + create_table "site_settings", :force => true do |t| + t.string "name", :null => false + t.text "description", :null => false + t.integer "data_type", :null => false + t.text "value" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + create_table "uploads", :force => true do |t| + t.integer "user_id", :null => false + t.integer "forum_thread_id", :null => false + t.string "original_filename", :null => false + t.integer "filesize", :null => false + t.integer "width" + t.integer "height" + t.string "url", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "uploads", ["forum_thread_id"], :name => "index_uploads_on_forum_thread_id" + add_index "uploads", ["user_id"], :name => "index_uploads_on_user_id" + + create_table "user_open_ids", :force => true do |t| + t.integer "user_id", :null => false + t.string "email", :null => false + t.string "url", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.boolean "active", :null => false + end + + add_index "user_open_ids", ["url"], :name => "index_user_open_ids_on_url" + + create_table "users", :force => true do |t| + t.string "username", :limit => 20, :null => false + t.string "avatar_url", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.string "name" + t.text "bio" + t.integer "seen_notificaiton_id", :default => 0, :null => false + t.datetime "last_posted_at" + t.string "email", :limit => 256, :null => false + t.string "password_hash", :limit => 64 + t.string "salt", :limit => 32 + t.boolean "active" + t.string "username_lower", :limit => 20, :null => false + t.string "auth_token", :limit => 32 + t.datetime "last_seen_at" + t.string "website" + t.string "email_token", :limit => 32 + t.boolean "admin", :default => false, :null => false + t.boolean "moderator", :default => false, :null => false + end + + add_index "users", ["auth_token"], :name => "index_users_on_auth_token" + add_index "users", ["email"], :name => "index_users_on_email", :unique => true + add_index "users", ["last_posted_at"], :name => "index_users_on_last_posted_at" + add_index "users", ["username"], :name => "index_users_on_username", :unique => true + add_index "users", ["username_lower"], :name => "index_users_on_username_lower", :unique => true + + create_table "versions", :force => true do |t| + t.integer "versioned_id" + t.string "versioned_type" + t.integer "user_id" + t.string "user_type" + t.string "user_name" + t.text "modifications" + t.integer "number" + t.integer "reverted_from" + t.string "tag" + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + end + + add_index "versions", ["created_at"], :name => "index_versions_on_created_at" + add_index "versions", ["number"], :name => "index_versions_on_number" + add_index "versions", ["tag"], :name => "index_versions_on_tag" + add_index "versions", ["user_id", "user_type"], :name => "index_versions_on_user_id_and_user_type" + add_index "versions", ["user_name"], :name => "index_versions_on_user_name" + add_index "versions", ["versioned_id", "versioned_type"], :name => "index_versions_on_versioned_id_and_versioned_type" + + create_table "views", :id => false, :force => true do |t| + t.integer "parent_id", :null => false + t.string "parent_type", :limit => 50, :null => false + t.integer "ip", :limit => 8, :null => false + t.datetime "viewed_at", :null => false + t.integer "user_id" + end + + add_index "views", ["parent_id", "parent_type", "ip", "viewed_at"], :name => "unique_views", :unique => true + add_index "views", ["parent_id", "parent_type"], :name => "index_views_on_parent_id_and_parent_type" + +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 00000000000..e69de29bb2d diff --git a/db/structure.sql b/db/structure.sql new file mode 100644 index 00000000000..50340548078 --- /dev/null +++ b/db/structure.sql @@ -0,0 +1,2534 @@ +-- +-- PostgreSQL database dump +-- + +SET statement_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET client_min_messages = warning; + +-- +-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; + + +-- +-- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; + + +-- +-- Name: hstore; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS hstore WITH SCHEMA public; + + +-- +-- Name: EXTENSION hstore; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON EXTENSION hstore IS 'data type for storing sets of (key, value) pairs'; + + +SET search_path = public, pg_catalog; + +SET default_tablespace = ''; + +SET default_with_oids = false; + +-- +-- Name: categories; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE categories ( + id integer NOT NULL, + name character varying(50) NOT NULL, + color character varying(6) DEFAULT 'AB9364'::character varying NOT NULL, + topic_id integer, + top1_topic_id integer, + top2_topic_id integer, + top1_user_id integer, + top2_user_id integer, + topic_count integer DEFAULT 0 NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + user_id integer NOT NULL, + topics_year integer, + topics_month integer, + topics_week integer, + slug character varying(255) NOT NULL +); + + +-- +-- Name: categories_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE categories_id_seq + START WITH 2 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: categories_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE categories_id_seq OWNED BY categories.id; + + +-- +-- Name: categories_search; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE categories_search ( + id integer NOT NULL, + search_data tsvector +); + + +-- +-- Name: category_featured_topics; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE category_featured_topics ( + category_id integer NOT NULL, + topic_id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: category_featured_users; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE category_featured_users ( + id integer NOT NULL, + category_id integer, + user_id integer, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: category_featured_users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE category_featured_users_id_seq + START WITH 247 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: category_featured_users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE category_featured_users_id_seq OWNED BY category_featured_users.id; + + +-- +-- Name: draft_sequences; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE draft_sequences ( + id integer NOT NULL, + user_id integer NOT NULL, + draft_key character varying(255) NOT NULL, + sequence integer NOT NULL +); + + +-- +-- Name: draft_sequences_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE draft_sequences_id_seq + START WITH 16 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: draft_sequences_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE draft_sequences_id_seq OWNED BY draft_sequences.id; + + +-- +-- Name: drafts; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE drafts ( + id integer NOT NULL, + user_id integer NOT NULL, + draft_key character varying(255) NOT NULL, + data text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + sequence integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: drafts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE drafts_id_seq + START WITH 2 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: drafts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE drafts_id_seq OWNED BY drafts.id; + + +-- +-- Name: email_logs; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE email_logs ( + id integer NOT NULL, + to_address character varying(255) NOT NULL, + email_type character varying(255) NOT NULL, + user_id integer, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: email_logs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE email_logs_id_seq + START WITH 3 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_logs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE email_logs_id_seq OWNED BY email_logs.id; + + +-- +-- Name: email_tokens; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE email_tokens ( + id integer NOT NULL, + user_id integer NOT NULL, + email character varying(255) NOT NULL, + token character varying(255) NOT NULL, + confirmed boolean DEFAULT false NOT NULL, + expired boolean DEFAULT false NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: email_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE email_tokens_id_seq + START WITH 3 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: email_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE email_tokens_id_seq OWNED BY email_tokens.id; + + +-- +-- Name: facebook_user_infos; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE facebook_user_infos ( + id integer NOT NULL, + user_id integer NOT NULL, + facebook_user_id bigint NOT NULL, + username character varying(255) NOT NULL, + first_name character varying(255), + last_name character varying(255), + email character varying(255), + gender character varying(255), + name character varying(255), + link character varying(255), + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: facebook_user_infos_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE facebook_user_infos_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: facebook_user_infos_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE facebook_user_infos_id_seq OWNED BY facebook_user_infos.id; + + +-- +-- Name: incoming_links; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE incoming_links ( + id integer NOT NULL, + url character varying(1000) NOT NULL, + referer character varying(1000) NOT NULL, + domain character varying(100) NOT NULL, + topic_id integer, + post_number integer, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: incoming_links_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE incoming_links_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: incoming_links_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE incoming_links_id_seq OWNED BY incoming_links.id; + + +-- +-- Name: invites; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE invites ( + id integer NOT NULL, + invite_key character varying(32) NOT NULL, + email character varying(255) NOT NULL, + invited_by_id integer NOT NULL, + user_id integer, + redeemed_at timestamp without time zone, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + deleted_at timestamp without time zone +); + + +-- +-- Name: invites_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE invites_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: invites_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE invites_id_seq OWNED BY invites.id; + + +-- +-- Name: message_bus; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE message_bus ( + id integer NOT NULL, + name character varying(255), + context character varying(255), + data text, + created_at timestamp without time zone +); + + +-- +-- Name: message_bus_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE message_bus_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: message_bus_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE message_bus_id_seq OWNED BY message_bus.id; + + +-- +-- Name: notifications; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE notifications ( + id integer NOT NULL, + notification_type integer NOT NULL, + user_id integer NOT NULL, + data character varying(255) NOT NULL, + read boolean DEFAULT false NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + topic_id integer, + post_number integer, + post_action_id integer +); + + +-- +-- Name: notifications_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE notifications_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: notifications_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE notifications_id_seq OWNED BY notifications.id; + + +-- +-- Name: onebox_renders; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE onebox_renders ( + id integer NOT NULL, + url character varying(255) NOT NULL, + cooked text NOT NULL, + expires_at timestamp without time zone NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + preview text +); + + +-- +-- Name: onebox_renders_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE onebox_renders_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: onebox_renders_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE onebox_renders_id_seq OWNED BY onebox_renders.id; + + +-- +-- Name: post_action_types; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE post_action_types ( + name_key character varying(50) NOT NULL, + is_flag boolean DEFAULT false NOT NULL, + icon character varying(20), + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + id integer NOT NULL, + "position" integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: post_action_types_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE post_action_types_id_seq + START WITH 6 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: post_action_types_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE post_action_types_id_seq OWNED BY post_action_types.id; + + +-- +-- Name: post_actions; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE post_actions ( + id integer NOT NULL, + post_id integer NOT NULL, + user_id integer NOT NULL, + post_action_type_id integer NOT NULL, + deleted_at timestamp without time zone, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + deleted_by integer, + message text +); + + +-- +-- Name: post_actions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE post_actions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: post_actions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE post_actions_id_seq OWNED BY post_actions.id; + + +-- +-- Name: post_onebox_renders; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE post_onebox_renders ( + post_id integer NOT NULL, + onebox_render_id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: post_replies; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE post_replies ( + post_id integer, + reply_id integer, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: post_timings; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE post_timings ( + topic_id integer NOT NULL, + post_number integer NOT NULL, + user_id integer NOT NULL, + msecs integer NOT NULL +); + + +-- +-- Name: posts; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE posts ( + id integer NOT NULL, + user_id integer NOT NULL, + topic_id integer NOT NULL, + post_number integer NOT NULL, + raw text NOT NULL, + cooked text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + reply_to_post_number integer, + cached_version integer DEFAULT 1 NOT NULL, + reply_count integer DEFAULT 0 NOT NULL, + quote_count integer DEFAULT 0 NOT NULL, + reply_below_post_number integer, + deleted_at timestamp without time zone, + off_topic_count integer DEFAULT 0 NOT NULL, + like_count integer DEFAULT 0 NOT NULL, + incoming_link_count integer DEFAULT 0 NOT NULL, + bookmark_count integer DEFAULT 0 NOT NULL, + avg_time integer, + score double precision, + reads integer DEFAULT 0 NOT NULL, + post_type integer DEFAULT 1 NOT NULL, + vote_count integer DEFAULT 0 NOT NULL, + sort_order integer, + last_editor_id integer, + hidden boolean DEFAULT false NOT NULL, + hidden_reason_id integer, + custom_flag_count integer DEFAULT 0 NOT NULL, + spam_count integer DEFAULT 0 NOT NULL, + illegal_count integer DEFAULT 0 NOT NULL, + inappropriate_count integer DEFAULT 0 NOT NULL, + last_version_at timestamp without time zone NOT NULL +); + + +-- +-- Name: posts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE posts_id_seq + START WITH 12 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: posts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE posts_id_seq OWNED BY posts.id; + + +-- +-- Name: posts_search; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE posts_search ( + id integer NOT NULL, + search_data tsvector +); + + +-- +-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE schema_migrations ( + version character varying(255) NOT NULL +); + + +-- +-- Name: site_customizations; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE site_customizations ( + id integer NOT NULL, + name character varying(255) NOT NULL, + stylesheet text, + header text, + "position" integer NOT NULL, + user_id integer NOT NULL, + enabled boolean NOT NULL, + key character varying(255) NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + override_default_style boolean DEFAULT false NOT NULL, + stylesheet_baked text DEFAULT ''::text NOT NULL +); + + +-- +-- Name: site_customizations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE site_customizations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: site_customizations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE site_customizations_id_seq OWNED BY site_customizations.id; + + +-- +-- Name: site_settings; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE site_settings ( + id integer NOT NULL, + name character varying(255) NOT NULL, + data_type integer NOT NULL, + value text, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: site_settings_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE site_settings_id_seq + START WITH 6 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: site_settings_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE site_settings_id_seq OWNED BY site_settings.id; + + +-- +-- Name: topic_allowed_users; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE topic_allowed_users ( + id integer NOT NULL, + user_id integer NOT NULL, + topic_id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: topic_allowed_users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE topic_allowed_users_id_seq + START WITH 3 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: topic_allowed_users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE topic_allowed_users_id_seq OWNED BY topic_allowed_users.id; + + +-- +-- Name: topic_invites; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE topic_invites ( + id integer NOT NULL, + topic_id integer NOT NULL, + invite_id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: topic_invites_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE topic_invites_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: topic_invites_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE topic_invites_id_seq OWNED BY topic_invites.id; + + +-- +-- Name: topic_link_clicks; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE topic_link_clicks ( + id integer NOT NULL, + topic_link_id integer NOT NULL, + user_id integer, + ip bigint NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: topic_link_clicks_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE topic_link_clicks_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: topic_link_clicks_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE topic_link_clicks_id_seq OWNED BY topic_link_clicks.id; + + +-- +-- Name: topic_links; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE topic_links ( + id integer NOT NULL, + topic_id integer NOT NULL, + post_id integer, + user_id integer NOT NULL, + url character varying(500) NOT NULL, + domain character varying(100) NOT NULL, + internal boolean DEFAULT false NOT NULL, + link_topic_id integer, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + reflection boolean DEFAULT false, + clicks integer DEFAULT 0 NOT NULL, + link_post_id integer +); + + +-- +-- Name: topic_links_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE topic_links_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: topic_links_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE topic_links_id_seq OWNED BY topic_links.id; + + +-- +-- Name: topic_users; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE topic_users ( + user_id integer NOT NULL, + topic_id integer NOT NULL, + starred boolean DEFAULT false NOT NULL, + posted boolean DEFAULT false NOT NULL, + last_read_post_number integer, + seen_post_count integer, + starred_at timestamp without time zone, + last_visited_at timestamp without time zone, + first_visited_at timestamp without time zone, + notification_level integer DEFAULT 1 NOT NULL, + notifications_changed_at timestamp without time zone, + notifications_reason_id integer, + total_msecs_viewed integer DEFAULT 0 NOT NULL, + CONSTRAINT test_starred_at CHECK (((starred = false) OR (starred_at IS NOT NULL))) +); + + +-- +-- Name: topics; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE topics ( + id integer NOT NULL, + title character varying(255) NOT NULL, + last_posted_at timestamp without time zone, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + views integer DEFAULT 0 NOT NULL, + posts_count integer DEFAULT 0 NOT NULL, + user_id integer NOT NULL, + last_post_user_id integer NOT NULL, + reply_count integer DEFAULT 0 NOT NULL, + featured_user1_id integer, + featured_user2_id integer, + featured_user3_id integer, + avg_time integer, + deleted_at timestamp without time zone, + highest_post_number integer DEFAULT 0 NOT NULL, + image_url character varying(255), + off_topic_count integer DEFAULT 0 NOT NULL, + like_count integer DEFAULT 0 NOT NULL, + incoming_link_count integer DEFAULT 0 NOT NULL, + bookmark_count integer DEFAULT 0 NOT NULL, + star_count integer DEFAULT 0 NOT NULL, + category_id integer, + visible boolean DEFAULT true NOT NULL, + moderator_posts_count integer DEFAULT 0 NOT NULL, + closed boolean DEFAULT false NOT NULL, + pinned boolean DEFAULT false NOT NULL, + archived boolean DEFAULT false NOT NULL, + bumped_at timestamp without time zone NOT NULL, + has_best_of boolean DEFAULT false NOT NULL, + meta_data hstore, + vote_count integer DEFAULT 0 NOT NULL, + archetype character varying(255) DEFAULT 'regular'::character varying NOT NULL, + featured_user4_id integer, + custom_flag_count integer DEFAULT 0 NOT NULL, + spam_count integer DEFAULT 0 NOT NULL, + illegal_count integer DEFAULT 0 NOT NULL, + inappropriate_count integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: topics_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE topics_id_seq + START WITH 14 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: topics_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE topics_id_seq OWNED BY topics.id; + + +-- +-- Name: twitter_user_infos; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE twitter_user_infos ( + id integer NOT NULL, + user_id integer NOT NULL, + screen_name character varying(255) NOT NULL, + twitter_user_id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: twitter_user_infos_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE twitter_user_infos_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: twitter_user_infos_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE twitter_user_infos_id_seq OWNED BY twitter_user_infos.id; + + +-- +-- Name: uploads; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE uploads ( + id integer NOT NULL, + user_id integer NOT NULL, + topic_id integer NOT NULL, + original_filename character varying(255) NOT NULL, + filesize integer NOT NULL, + width integer, + height integer, + url character varying(255) NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: uploads_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE uploads_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: uploads_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE uploads_id_seq OWNED BY uploads.id; + + +-- +-- Name: user_actions; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE user_actions ( + id integer NOT NULL, + action_type integer NOT NULL, + user_id integer NOT NULL, + target_topic_id integer, + target_post_id integer, + target_user_id integer, + acting_user_id integer, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: user_actions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE user_actions_id_seq + START WITH 27 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_actions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE user_actions_id_seq OWNED BY user_actions.id; + + +-- +-- Name: user_open_ids; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE user_open_ids ( + id integer NOT NULL, + user_id integer NOT NULL, + email character varying(255) NOT NULL, + url character varying(255) NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + active boolean NOT NULL +); + + +-- +-- Name: user_open_ids_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE user_open_ids_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_open_ids_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE user_open_ids_id_seq OWNED BY user_open_ids.id; + + +-- +-- Name: user_visits; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE user_visits ( + id integer NOT NULL, + user_id integer NOT NULL, + visited_at date NOT NULL +); + + +-- +-- Name: user_visits_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE user_visits_id_seq + START WITH 6 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_visits_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE user_visits_id_seq OWNED BY user_visits.id; + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE users ( + id integer NOT NULL, + username character varying(20) NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + name character varying(255), + bio_raw text, + seen_notification_id integer DEFAULT 0 NOT NULL, + last_posted_at timestamp without time zone, + email character varying(256) NOT NULL, + password_hash character varying(64), + salt character varying(32), + active boolean, + username_lower character varying(20) NOT NULL, + auth_token character varying(32), + last_seen_at timestamp without time zone, + website character varying(255), + admin boolean DEFAULT false NOT NULL, + last_emailed_at timestamp without time zone, + email_digests boolean DEFAULT true NOT NULL, + trust_level integer NOT NULL, + bio_cooked text, + email_private_messages boolean DEFAULT true, + email_direct boolean DEFAULT true NOT NULL, + approved boolean DEFAULT false NOT NULL, + approved_by_id integer, + approved_at timestamp without time zone, + topics_entered integer DEFAULT 0 NOT NULL, + posts_read_count integer DEFAULT 0 NOT NULL, + digest_after_days integer DEFAULT 7 NOT NULL, + previous_visit_at timestamp without time zone, + banned_at timestamp without time zone, + banned_till timestamp without time zone, + date_of_birth date, + auto_track_topics_after_msecs integer, + views integer DEFAULT 0 NOT NULL, + flag_level integer DEFAULT 0 NOT NULL, + time_read integer DEFAULT 0 NOT NULL, + days_visited integer DEFAULT 0 NOT NULL, + ip_address inet +); + + +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE users_id_seq + START WITH 3 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE users_id_seq OWNED BY users.id; + + +-- +-- Name: users_search; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE users_search ( + id integer NOT NULL, + search_data tsvector +); + + +-- +-- Name: versions; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE versions ( + id integer NOT NULL, + versioned_id integer, + versioned_type character varying(255), + user_id integer, + user_type character varying(255), + user_name character varying(255), + modifications text, + number integer, + reverted_from integer, + tag character varying(255), + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: versions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE versions_id_seq + START WITH 2 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: versions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE versions_id_seq OWNED BY versions.id; + + +-- +-- Name: views; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE views ( + parent_id integer NOT NULL, + parent_type character varying(50) NOT NULL, + ip bigint NOT NULL, + viewed_at date NOT NULL, + user_id integer +); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY categories ALTER COLUMN id SET DEFAULT nextval('categories_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY category_featured_users ALTER COLUMN id SET DEFAULT nextval('category_featured_users_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY draft_sequences ALTER COLUMN id SET DEFAULT nextval('draft_sequences_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY drafts ALTER COLUMN id SET DEFAULT nextval('drafts_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY email_logs ALTER COLUMN id SET DEFAULT nextval('email_logs_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY email_tokens ALTER COLUMN id SET DEFAULT nextval('email_tokens_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY facebook_user_infos ALTER COLUMN id SET DEFAULT nextval('facebook_user_infos_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY incoming_links ALTER COLUMN id SET DEFAULT nextval('incoming_links_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY invites ALTER COLUMN id SET DEFAULT nextval('invites_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY message_bus ALTER COLUMN id SET DEFAULT nextval('message_bus_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY notifications ALTER COLUMN id SET DEFAULT nextval('notifications_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY onebox_renders ALTER COLUMN id SET DEFAULT nextval('onebox_renders_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY post_action_types ALTER COLUMN id SET DEFAULT nextval('post_action_types_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY post_actions ALTER COLUMN id SET DEFAULT nextval('post_actions_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY posts ALTER COLUMN id SET DEFAULT nextval('posts_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY site_customizations ALTER COLUMN id SET DEFAULT nextval('site_customizations_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY site_settings ALTER COLUMN id SET DEFAULT nextval('site_settings_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY topic_allowed_users ALTER COLUMN id SET DEFAULT nextval('topic_allowed_users_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY topic_invites ALTER COLUMN id SET DEFAULT nextval('topic_invites_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY topic_link_clicks ALTER COLUMN id SET DEFAULT nextval('topic_link_clicks_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY topic_links ALTER COLUMN id SET DEFAULT nextval('topic_links_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY topics ALTER COLUMN id SET DEFAULT nextval('topics_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY twitter_user_infos ALTER COLUMN id SET DEFAULT nextval('twitter_user_infos_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY uploads ALTER COLUMN id SET DEFAULT nextval('uploads_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY user_actions ALTER COLUMN id SET DEFAULT nextval('user_actions_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY user_open_ids ALTER COLUMN id SET DEFAULT nextval('user_open_ids_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY user_visits ALTER COLUMN id SET DEFAULT nextval('user_visits_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY versions ALTER COLUMN id SET DEFAULT nextval('versions_id_seq'::regclass); + + +-- +-- Name: actions_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY user_actions + ADD CONSTRAINT actions_pkey PRIMARY KEY (id); + + +-- +-- Name: categories_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY categories + ADD CONSTRAINT categories_pkey PRIMARY KEY (id); + + +-- +-- Name: categories_search_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY categories_search + ADD CONSTRAINT categories_search_pkey PRIMARY KEY (id); + + +-- +-- Name: category_featured_users_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY category_featured_users + ADD CONSTRAINT category_featured_users_pkey PRIMARY KEY (id); + + +-- +-- Name: draft_sequences_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY draft_sequences + ADD CONSTRAINT draft_sequences_pkey PRIMARY KEY (id); + + +-- +-- Name: drafts_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY drafts + ADD CONSTRAINT drafts_pkey PRIMARY KEY (id); + + +-- +-- Name: email_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY email_logs + ADD CONSTRAINT email_logs_pkey PRIMARY KEY (id); + + +-- +-- Name: email_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY email_tokens + ADD CONSTRAINT email_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: facebook_user_infos_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY facebook_user_infos + ADD CONSTRAINT facebook_user_infos_pkey PRIMARY KEY (id); + + +-- +-- Name: forum_thread_link_clicks_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY topic_link_clicks + ADD CONSTRAINT forum_thread_link_clicks_pkey PRIMARY KEY (id); + + +-- +-- Name: forum_thread_links_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY topic_links + ADD CONSTRAINT forum_thread_links_pkey PRIMARY KEY (id); + + +-- +-- Name: forum_threads_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY topics + ADD CONSTRAINT forum_threads_pkey PRIMARY KEY (id); + + +-- +-- Name: incoming_links_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY incoming_links + ADD CONSTRAINT incoming_links_pkey PRIMARY KEY (id); + + +-- +-- Name: invites_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY invites + ADD CONSTRAINT invites_pkey PRIMARY KEY (id); + + +-- +-- Name: message_bus_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY message_bus + ADD CONSTRAINT message_bus_pkey PRIMARY KEY (id); + + +-- +-- Name: notifications_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY notifications + ADD CONSTRAINT notifications_pkey PRIMARY KEY (id); + + +-- +-- Name: onebox_renders_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY onebox_renders + ADD CONSTRAINT onebox_renders_pkey PRIMARY KEY (id); + + +-- +-- Name: post_action_types_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY post_action_types + ADD CONSTRAINT post_action_types_pkey PRIMARY KEY (id); + + +-- +-- Name: post_actions_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY post_actions + ADD CONSTRAINT post_actions_pkey PRIMARY KEY (id); + + +-- +-- Name: posts_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY posts + ADD CONSTRAINT posts_pkey PRIMARY KEY (id); + + +-- +-- Name: posts_search_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY posts_search + ADD CONSTRAINT posts_search_pkey PRIMARY KEY (id); + + +-- +-- Name: site_customizations_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY site_customizations + ADD CONSTRAINT site_customizations_pkey PRIMARY KEY (id); + + +-- +-- Name: site_settings_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY site_settings + ADD CONSTRAINT site_settings_pkey PRIMARY KEY (id); + + +-- +-- Name: topic_allowed_users_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY topic_allowed_users + ADD CONSTRAINT topic_allowed_users_pkey PRIMARY KEY (id); + + +-- +-- Name: topic_invites_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY topic_invites + ADD CONSTRAINT topic_invites_pkey PRIMARY KEY (id); + + +-- +-- Name: twitter_user_infos_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY twitter_user_infos + ADD CONSTRAINT twitter_user_infos_pkey PRIMARY KEY (id); + + +-- +-- Name: uploads_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY uploads + ADD CONSTRAINT uploads_pkey PRIMARY KEY (id); + + +-- +-- Name: user_open_ids_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY user_open_ids + ADD CONSTRAINT user_open_ids_pkey PRIMARY KEY (id); + + +-- +-- Name: user_visits_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY user_visits + ADD CONSTRAINT user_visits_pkey PRIMARY KEY (id); + + +-- +-- Name: users_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: users_search_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY users_search + ADD CONSTRAINT users_search_pkey PRIMARY KEY (id); + + +-- +-- Name: versions_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY versions + ADD CONSTRAINT versions_pkey PRIMARY KEY (id); + + +-- +-- Name: cat_featured_threads; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX cat_featured_threads ON category_featured_topics USING btree (category_id, topic_id); + + +-- +-- Name: idx_search_category; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_search_category ON categories_search USING gin (search_data); + + +-- +-- Name: idx_search_post; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_search_post ON posts_search USING gin (search_data); + + +-- +-- Name: idx_search_user; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_search_user ON users_search USING gin (search_data); + + +-- +-- Name: idx_unique_actions; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX idx_unique_actions ON post_actions USING btree (user_id, post_action_type_id, post_id, deleted_at); + + +-- +-- Name: idx_unique_rows; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX idx_unique_rows ON user_actions USING btree (action_type, user_id, target_topic_id, target_post_id, acting_user_id); + + +-- +-- Name: incoming_index; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX incoming_index ON incoming_links USING btree (topic_id, post_number); + + +-- +-- Name: index_actions_on_acting_user_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_actions_on_acting_user_id ON user_actions USING btree (acting_user_id); + + +-- +-- Name: index_actions_on_user_id_and_action_type; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_actions_on_user_id_and_action_type ON user_actions USING btree (user_id, action_type); + + +-- +-- Name: index_categories_on_forum_thread_count; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_categories_on_forum_thread_count ON categories USING btree (topic_count); + + +-- +-- Name: index_categories_on_name; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_categories_on_name ON categories USING btree (name); + + +-- +-- Name: index_category_featured_users_on_category_id_and_user_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_category_featured_users_on_category_id_and_user_id ON category_featured_users USING btree (category_id, user_id); + + +-- +-- Name: index_draft_sequences_on_user_id_and_draft_key; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_draft_sequences_on_user_id_and_draft_key ON draft_sequences USING btree (user_id, draft_key); + + +-- +-- Name: index_drafts_on_user_id_and_draft_key; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_drafts_on_user_id_and_draft_key ON drafts USING btree (user_id, draft_key); + + +-- +-- Name: index_email_logs_on_created_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_email_logs_on_created_at ON email_logs USING btree (created_at DESC); + + +-- +-- Name: index_email_logs_on_user_id_and_created_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_email_logs_on_user_id_and_created_at ON email_logs USING btree (user_id, created_at DESC); + + +-- +-- Name: index_email_tokens_on_token; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_email_tokens_on_token ON email_tokens USING btree (token); + + +-- +-- Name: index_facebook_user_infos_on_facebook_user_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_facebook_user_infos_on_facebook_user_id ON facebook_user_infos USING btree (facebook_user_id); + + +-- +-- Name: index_facebook_user_infos_on_user_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_facebook_user_infos_on_user_id ON facebook_user_infos USING btree (user_id); + + +-- +-- Name: index_forum_thread_link_clicks_on_forum_thread_link_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_forum_thread_link_clicks_on_forum_thread_link_id ON topic_link_clicks USING btree (topic_link_id); + + +-- +-- Name: index_forum_thread_links_on_forum_thread_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_forum_thread_links_on_forum_thread_id ON topic_links USING btree (topic_id); + + +-- +-- Name: index_forum_thread_links_on_forum_thread_id_and_post_id_and_url; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_forum_thread_links_on_forum_thread_id_and_post_id_and_url ON topic_links USING btree (topic_id, post_id, url); + + +-- +-- Name: index_forum_thread_users_on_forum_thread_id_and_user_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_forum_thread_users_on_forum_thread_id_and_user_id ON topic_users USING btree (topic_id, user_id); + + +-- +-- Name: index_forum_threads_on_bumped_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_forum_threads_on_bumped_at ON topics USING btree (bumped_at DESC); + + +-- +-- Name: index_invites_on_email_and_invited_by_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_invites_on_email_and_invited_by_id ON invites USING btree (email, invited_by_id); + + +-- +-- Name: index_invites_on_invite_key; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_invites_on_invite_key ON invites USING btree (invite_key); + + +-- +-- Name: index_message_bus_on_created_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_message_bus_on_created_at ON message_bus USING btree (created_at); + + +-- +-- Name: index_notifications_on_post_action_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_notifications_on_post_action_id ON notifications USING btree (post_action_id); + + +-- +-- Name: index_notifications_on_user_id_and_created_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_notifications_on_user_id_and_created_at ON notifications USING btree (user_id, created_at); + + +-- +-- Name: index_onebox_renders_on_url; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_onebox_renders_on_url ON onebox_renders USING btree (url); + + +-- +-- Name: index_post_actions_on_post_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_post_actions_on_post_id ON post_actions USING btree (post_id); + + +-- +-- Name: index_post_onebox_renders_on_post_id_and_onebox_render_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_post_onebox_renders_on_post_id_and_onebox_render_id ON post_onebox_renders USING btree (post_id, onebox_render_id); + + +-- +-- Name: index_post_replies_on_post_id_and_reply_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_post_replies_on_post_id_and_reply_id ON post_replies USING btree (post_id, reply_id); + + +-- +-- Name: index_posts_on_reply_to_post_number; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_posts_on_reply_to_post_number ON posts USING btree (reply_to_post_number); + + +-- +-- Name: index_posts_on_topic_id_and_post_number; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_posts_on_topic_id_and_post_number ON posts USING btree (topic_id, post_number); + + +-- +-- Name: index_site_customizations_on_key; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_site_customizations_on_key ON site_customizations USING btree (key); + + +-- +-- Name: index_topic_allowed_users_on_topic_id_and_user_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_topic_allowed_users_on_topic_id_and_user_id ON topic_allowed_users USING btree (topic_id, user_id); + + +-- +-- Name: index_topic_allowed_users_on_user_id_and_topic_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_topic_allowed_users_on_user_id_and_topic_id ON topic_allowed_users USING btree (user_id, topic_id); + + +-- +-- Name: index_topic_invites_on_invite_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_topic_invites_on_invite_id ON topic_invites USING btree (invite_id); + + +-- +-- Name: index_topic_invites_on_topic_id_and_invite_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_topic_invites_on_topic_id_and_invite_id ON topic_invites USING btree (topic_id, invite_id); + + +-- +-- Name: index_twitter_user_infos_on_twitter_user_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_twitter_user_infos_on_twitter_user_id ON twitter_user_infos USING btree (twitter_user_id); + + +-- +-- Name: index_twitter_user_infos_on_user_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_twitter_user_infos_on_user_id ON twitter_user_infos USING btree (user_id); + + +-- +-- Name: index_uploads_on_forum_thread_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_uploads_on_forum_thread_id ON uploads USING btree (topic_id); + + +-- +-- Name: index_uploads_on_user_id; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_uploads_on_user_id ON uploads USING btree (user_id); + + +-- +-- Name: index_user_open_ids_on_url; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_user_open_ids_on_url ON user_open_ids USING btree (url); + + +-- +-- Name: index_user_visits_on_user_id_and_visited_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_user_visits_on_user_id_and_visited_at ON user_visits USING btree (user_id, visited_at); + + +-- +-- Name: index_users_on_auth_token; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_users_on_auth_token ON users USING btree (auth_token); + + +-- +-- Name: index_users_on_email; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_users_on_email ON users USING btree (email); + + +-- +-- Name: index_users_on_last_posted_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_users_on_last_posted_at ON users USING btree (last_posted_at); + + +-- +-- Name: index_users_on_username; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_users_on_username ON users USING btree (username); + + +-- +-- Name: index_users_on_username_lower; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX index_users_on_username_lower ON users USING btree (username_lower); + + +-- +-- Name: index_versions_on_created_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_versions_on_created_at ON versions USING btree (created_at); + + +-- +-- Name: index_versions_on_number; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_versions_on_number ON versions USING btree (number); + + +-- +-- Name: index_versions_on_tag; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_versions_on_tag ON versions USING btree (tag); + + +-- +-- Name: index_versions_on_user_id_and_user_type; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_versions_on_user_id_and_user_type ON versions USING btree (user_id, user_type); + + +-- +-- Name: index_versions_on_user_name; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_versions_on_user_name ON versions USING btree (user_name); + + +-- +-- Name: index_versions_on_versioned_id_and_versioned_type; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_versions_on_versioned_id_and_versioned_type ON versions USING btree (versioned_id, versioned_type); + + +-- +-- Name: index_views_on_parent_id_and_parent_type; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX index_views_on_parent_id_and_parent_type ON views USING btree (parent_id, parent_type); + + +-- +-- Name: post_timings_summary; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX post_timings_summary ON post_timings USING btree (topic_id, post_number); + + +-- +-- Name: post_timings_unique; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX post_timings_unique ON post_timings USING btree (topic_id, post_number, user_id); + + +-- +-- Name: unique_schema_migrations; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX unique_schema_migrations ON schema_migrations USING btree (version); + + +-- +-- PostgreSQL database dump complete +-- + +INSERT INTO schema_migrations (version) VALUES ('20120311163914'); + +INSERT INTO schema_migrations (version) VALUES ('20120311164326'); + +INSERT INTO schema_migrations (version) VALUES ('20120311170118'); + +INSERT INTO schema_migrations (version) VALUES ('20120311201341'); + +INSERT INTO schema_migrations (version) VALUES ('20120311210245'); + +INSERT INTO schema_migrations (version) VALUES ('20120416201606'); + +INSERT INTO schema_migrations (version) VALUES ('20120420183447'); + +INSERT INTO schema_migrations (version) VALUES ('20120423140906'); + +INSERT INTO schema_migrations (version) VALUES ('20120423142820'); + +INSERT INTO schema_migrations (version) VALUES ('20120423151548'); + +INSERT INTO schema_migrations (version) VALUES ('20120425145456'); + +INSERT INTO schema_migrations (version) VALUES ('20120427150624'); + +INSERT INTO schema_migrations (version) VALUES ('20120427151452'); + +INSERT INTO schema_migrations (version) VALUES ('20120427154330'); + +INSERT INTO schema_migrations (version) VALUES ('20120427172031'); + +INSERT INTO schema_migrations (version) VALUES ('20120502183240'); + +INSERT INTO schema_migrations (version) VALUES ('20120502192121'); + +INSERT INTO schema_migrations (version) VALUES ('20120503205521'); + +INSERT INTO schema_migrations (version) VALUES ('20120507144132'); + +INSERT INTO schema_migrations (version) VALUES ('20120507144222'); + +INSERT INTO schema_migrations (version) VALUES ('20120514144549'); + +INSERT INTO schema_migrations (version) VALUES ('20120514173920'); + +INSERT INTO schema_migrations (version) VALUES ('20120514204934'); + +INSERT INTO schema_migrations (version) VALUES ('20120517200130'); + +INSERT INTO schema_migrations (version) VALUES ('20120518200115'); + +INSERT INTO schema_migrations (version) VALUES ('20120519182212'); + +INSERT INTO schema_migrations (version) VALUES ('20120523180723'); + +INSERT INTO schema_migrations (version) VALUES ('20120523184307'); + +INSERT INTO schema_migrations (version) VALUES ('20120523201329'); + +INSERT INTO schema_migrations (version) VALUES ('20120525194845'); + +INSERT INTO schema_migrations (version) VALUES ('20120529175956'); + +INSERT INTO schema_migrations (version) VALUES ('20120529202707'); + +INSERT INTO schema_migrations (version) VALUES ('20120530150726'); + +INSERT INTO schema_migrations (version) VALUES ('20120530160745'); + +INSERT INTO schema_migrations (version) VALUES ('20120530200724'); + +INSERT INTO schema_migrations (version) VALUES ('20120530212912'); + +INSERT INTO schema_migrations (version) VALUES ('20120614190726'); + +INSERT INTO schema_migrations (version) VALUES ('20120614202024'); + +INSERT INTO schema_migrations (version) VALUES ('20120615180517'); + +INSERT INTO schema_migrations (version) VALUES ('20120618152946'); + +INSERT INTO schema_migrations (version) VALUES ('20120618212349'); + +INSERT INTO schema_migrations (version) VALUES ('20120618214856'); + +INSERT INTO schema_migrations (version) VALUES ('20120619150807'); + +INSERT INTO schema_migrations (version) VALUES ('20120619153349'); + +INSERT INTO schema_migrations (version) VALUES ('20120619172714'); + +INSERT INTO schema_migrations (version) VALUES ('20120621155351'); + +INSERT INTO schema_migrations (version) VALUES ('20120621190310'); + +INSERT INTO schema_migrations (version) VALUES ('20120622200242'); + +INSERT INTO schema_migrations (version) VALUES ('20120625145714'); + +INSERT INTO schema_migrations (version) VALUES ('20120625162318'); + +INSERT INTO schema_migrations (version) VALUES ('20120625174544'); + +INSERT INTO schema_migrations (version) VALUES ('20120625195326'); + +INSERT INTO schema_migrations (version) VALUES ('20120629143908'); + +INSERT INTO schema_migrations (version) VALUES ('20120629150253'); + +INSERT INTO schema_migrations (version) VALUES ('20120629151243'); + +INSERT INTO schema_migrations (version) VALUES ('20120629182637'); + +INSERT INTO schema_migrations (version) VALUES ('20120702211427'); + +INSERT INTO schema_migrations (version) VALUES ('20120703184734'); + +INSERT INTO schema_migrations (version) VALUES ('20120703201312'); + +INSERT INTO schema_migrations (version) VALUES ('20120703203623'); + +INSERT INTO schema_migrations (version) VALUES ('20120703210004'); + +INSERT INTO schema_migrations (version) VALUES ('20120704160659'); + +INSERT INTO schema_migrations (version) VALUES ('20120704201743'); + +INSERT INTO schema_migrations (version) VALUES ('20120705181724'); + +INSERT INTO schema_migrations (version) VALUES ('20120708210305'); + +INSERT INTO schema_migrations (version) VALUES ('20120712150500'); + +INSERT INTO schema_migrations (version) VALUES ('20120712151934'); + +INSERT INTO schema_migrations (version) VALUES ('20120713201324'); + +INSERT INTO schema_migrations (version) VALUES ('20120716020835'); + +INSERT INTO schema_migrations (version) VALUES ('20120716173544'); + +INSERT INTO schema_migrations (version) VALUES ('20120718044955'); + +INSERT INTO schema_migrations (version) VALUES ('20120719004636'); + +INSERT INTO schema_migrations (version) VALUES ('20120720013733'); + +INSERT INTO schema_migrations (version) VALUES ('20120720044246'); + +INSERT INTO schema_migrations (version) VALUES ('20120720162422'); + +INSERT INTO schema_migrations (version) VALUES ('20120723051512'); + +INSERT INTO schema_migrations (version) VALUES ('20120724234502'); + +INSERT INTO schema_migrations (version) VALUES ('20120724234711'); + +INSERT INTO schema_migrations (version) VALUES ('20120725183347'); + +INSERT INTO schema_migrations (version) VALUES ('20120726201830'); + +INSERT INTO schema_migrations (version) VALUES ('20120726235129'); + +INSERT INTO schema_migrations (version) VALUES ('20120727005556'); + +INSERT INTO schema_migrations (version) VALUES ('20120727150428'); + +INSERT INTO schema_migrations (version) VALUES ('20120727213543'); + +INSERT INTO schema_migrations (version) VALUES ('20120802151210'); + +INSERT INTO schema_migrations (version) VALUES ('20120803191426'); + +INSERT INTO schema_migrations (version) VALUES ('20120806030641'); + +INSERT INTO schema_migrations (version) VALUES ('20120806062617'); + +INSERT INTO schema_migrations (version) VALUES ('20120807223020'); + +INSERT INTO schema_migrations (version) VALUES ('20120809020415'); + +INSERT INTO schema_migrations (version) VALUES ('20120809030647'); + +INSERT INTO schema_migrations (version) VALUES ('20120809053414'); + +INSERT INTO schema_migrations (version) VALUES ('20120809154750'); + +INSERT INTO schema_migrations (version) VALUES ('20120809174649'); + +INSERT INTO schema_migrations (version) VALUES ('20120809175110'); + +INSERT INTO schema_migrations (version) VALUES ('20120809201855'); + +INSERT INTO schema_migrations (version) VALUES ('20120810064839'); + +INSERT INTO schema_migrations (version) VALUES ('20120812235417'); + +INSERT INTO schema_migrations (version) VALUES ('20120813004347'); + +INSERT INTO schema_migrations (version) VALUES ('20120813042912'); + +INSERT INTO schema_migrations (version) VALUES ('20120813201426'); + +INSERT INTO schema_migrations (version) VALUES ('20120815004411'); + +INSERT INTO schema_migrations (version) VALUES ('20120815180106'); + +INSERT INTO schema_migrations (version) VALUES ('20120815204733'); + +INSERT INTO schema_migrations (version) VALUES ('20120816050526'); + +INSERT INTO schema_migrations (version) VALUES ('20120816205537'); + +INSERT INTO schema_migrations (version) VALUES ('20120816205538'); + +INSERT INTO schema_migrations (version) VALUES ('20120820191804'); + +INSERT INTO schema_migrations (version) VALUES ('20120821191616'); + +INSERT INTO schema_migrations (version) VALUES ('20120823205956'); + +INSERT INTO schema_migrations (version) VALUES ('20120824171908'); + +INSERT INTO schema_migrations (version) VALUES ('20120828204209'); + +INSERT INTO schema_migrations (version) VALUES ('20120828204624'); + +INSERT INTO schema_migrations (version) VALUES ('20120830182736'); + +INSERT INTO schema_migrations (version) VALUES ('20120910171504'); + +INSERT INTO schema_migrations (version) VALUES ('20120918152319'); + +INSERT INTO schema_migrations (version) VALUES ('20120918205931'); + +INSERT INTO schema_migrations (version) VALUES ('20120919152846'); + +INSERT INTO schema_migrations (version) VALUES ('20120921055428'); + +INSERT INTO schema_migrations (version) VALUES ('20120921155050'); + +INSERT INTO schema_migrations (version) VALUES ('20120921162512'); + +INSERT INTO schema_migrations (version) VALUES ('20120921163606'); + +INSERT INTO schema_migrations (version) VALUES ('20120924182000'); + +INSERT INTO schema_migrations (version) VALUES ('20120924182031'); + +INSERT INTO schema_migrations (version) VALUES ('20120925171620'); + +INSERT INTO schema_migrations (version) VALUES ('20120925190802'); + +INSERT INTO schema_migrations (version) VALUES ('20120928170023'); + +INSERT INTO schema_migrations (version) VALUES ('20121009161116'); + +INSERT INTO schema_migrations (version) VALUES ('20121011155904'); + +INSERT INTO schema_migrations (version) VALUES ('20121017162924'); + +INSERT INTO schema_migrations (version) VALUES ('20121018103721'); + +INSERT INTO schema_migrations (version) VALUES ('20121018133039'); + +INSERT INTO schema_migrations (version) VALUES ('20121018182709'); + +INSERT INTO schema_migrations (version) VALUES ('20121106015500'); + +INSERT INTO schema_migrations (version) VALUES ('20121108193516'); + +INSERT INTO schema_migrations (version) VALUES ('20121109164630'); + +INSERT INTO schema_migrations (version) VALUES ('20121113200844'); + +INSERT INTO schema_migrations (version) VALUES ('20121113200845'); + +INSERT INTO schema_migrations (version) VALUES ('20121115172544'); + +INSERT INTO schema_migrations (version) VALUES ('20121116212424'); + +INSERT INTO schema_migrations (version) VALUES ('20121119190529'); + +INSERT INTO schema_migrations (version) VALUES ('20121119200843'); + +INSERT INTO schema_migrations (version) VALUES ('20121121202035'); + +INSERT INTO schema_migrations (version) VALUES ('20121121205215'); + +INSERT INTO schema_migrations (version) VALUES ('20121122033316'); + +INSERT INTO schema_migrations (version) VALUES ('20121123054127'); + +INSERT INTO schema_migrations (version) VALUES ('20121123063630'); + +INSERT INTO schema_migrations (version) VALUES ('20121129160035'); + +INSERT INTO schema_migrations (version) VALUES ('20121129184948'); + +INSERT INTO schema_migrations (version) VALUES ('20121130010400'); + +INSERT INTO schema_migrations (version) VALUES ('20121130191818'); + +INSERT INTO schema_migrations (version) VALUES ('20121202225421'); + +INSERT INTO schema_migrations (version) VALUES ('20121203181719'); + +INSERT INTO schema_migrations (version) VALUES ('20121204183855'); + +INSERT INTO schema_migrations (version) VALUES ('20121204193747'); + +INSERT INTO schema_migrations (version) VALUES ('20121205162143'); + +INSERT INTO schema_migrations (version) VALUES ('20121207000741'); + +INSERT INTO schema_migrations (version) VALUES ('20121211233131'); + +INSERT INTO schema_migrations (version) VALUES ('20121216230719'); + +INSERT INTO schema_migrations (version) VALUES ('20121218205642'); + +INSERT INTO schema_migrations (version) VALUES ('20121224072204'); + +INSERT INTO schema_migrations (version) VALUES ('20121224095139'); + +INSERT INTO schema_migrations (version) VALUES ('20121224100650'); + +INSERT INTO schema_migrations (version) VALUES ('20121228192219'); + +INSERT INTO schema_migrations (version) VALUES ('20130107165207'); + +INSERT INTO schema_migrations (version) VALUES ('20130108195847'); + +INSERT INTO schema_migrations (version) VALUES ('20130115012140'); + +INSERT INTO schema_migrations (version) VALUES ('20130115021937'); + +INSERT INTO schema_migrations (version) VALUES ('20130115043603'); + +INSERT INTO schema_migrations (version) VALUES ('20130116151829'); + +INSERT INTO schema_migrations (version) VALUES ('20130120222728'); + +INSERT INTO schema_migrations (version) VALUES ('20130121231352'); + +INSERT INTO schema_migrations (version) VALUES ('20130122051134'); + +INSERT INTO schema_migrations (version) VALUES ('20130122232825'); + +INSERT INTO schema_migrations (version) VALUES ('20130123070909'); + +INSERT INTO schema_migrations (version) VALUES ('20130125002652'); + +INSERT INTO schema_migrations (version) VALUES ('20130125030305'); + +INSERT INTO schema_migrations (version) VALUES ('20130125031122'); + +INSERT INTO schema_migrations (version) VALUES ('20130127213646'); + +INSERT INTO schema_migrations (version) VALUES ('20130128182013'); + +INSERT INTO schema_migrations (version) VALUES ('20130129010625'); + +INSERT INTO schema_migrations (version) VALUES ('20130129163244'); + +INSERT INTO schema_migrations (version) VALUES ('20130129174845'); + +INSERT INTO schema_migrations (version) VALUES ('20130130154611'); + +INSERT INTO schema_migrations (version) VALUES ('20130131055710'); + +INSERT INTO schema_migrations (version) VALUES ('20130201000828'); + +INSERT INTO schema_migrations (version) VALUES ('20130201023409'); + +INSERT INTO schema_migrations (version) VALUES ('20130203204338'); + +INSERT INTO schema_migrations (version) VALUES ('20130204000159'); + +INSERT INTO schema_migrations (version) VALUES ('20130205021905'); \ No newline at end of file diff --git a/dbs/.gitignore b/dbs/.gitignore new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dbs/export/empty.tar.gz b/dbs/export/empty.tar.gz new file mode 100644 index 00000000000..e654e8f5668 Binary files /dev/null and b/dbs/export/empty.tar.gz differ diff --git a/dbs/export/try.tar.gz b/dbs/export/try.tar.gz new file mode 100644 index 00000000000..41fffdadfaf Binary files /dev/null and b/dbs/export/try.tar.gz differ diff --git a/dbs/sql/empty.sql.gz b/dbs/sql/empty.sql.gz new file mode 100644 index 00000000000..a9c36e96852 Binary files /dev/null and b/dbs/sql/empty.sql.gz differ diff --git a/images/discourse.png b/images/discourse.png new file mode 100644 index 00000000000..e67461e6cb8 Binary files /dev/null and b/images/discourse.png differ diff --git a/jsapp b/jsapp new file mode 120000 index 00000000000..378481d4bcb --- /dev/null +++ b/jsapp @@ -0,0 +1 @@ +app/assets/javascripts/discourse \ No newline at end of file diff --git a/lib/admin_constraint.rb b/lib/admin_constraint.rb new file mode 100644 index 00000000000..125d1d6787a --- /dev/null +++ b/lib/admin_constraint.rb @@ -0,0 +1,10 @@ +require_dependency 'current_user' + +class AdminConstraint + + def matches?(request) + return false unless request.session[:current_user_id].present? + User.where(id: request.session[:current_user_id].to_i).where(admin: true).exists? + end + +end \ No newline at end of file diff --git a/lib/age_words.rb b/lib/age_words.rb new file mode 100644 index 00000000000..1d9bf6fda50 --- /dev/null +++ b/lib/age_words.rb @@ -0,0 +1,20 @@ +module AgeWords + + def self.age_words(secs) + return "—" if secs.blank? + + mins = (secs / 60.0) + hours = (mins / 60.0) + days = (hours / 24.0) + months = (days / 30.0) + years = (months / 12.0) + + return "#{years.floor}y" if years > 1 + return "#{months.floor}mo" if months > 1 + return "#{days.floor}d" if days > 1 + return "#{hours.floor}h" if hours > 1 + return "< 1m" if mins < 1 + return "#{mins.floor}m" + end + +end \ No newline at end of file diff --git a/lib/archetype.rb b/lib/archetype.rb new file mode 100644 index 00000000000..22cdba0afff --- /dev/null +++ b/lib/archetype.rb @@ -0,0 +1,43 @@ +class Archetype + include ActiveModel::Serialization + + attr_accessor :id, :options + + def initialize(id, options) + @id = id + @options = options + end + + def attributes + {'id' => @id, + 'options' => @options} + end + + def self.default + 'regular' + end + + def self.poll + 'poll' + end + + def self.private_message + 'private_message' + end + + def self.list + return [] unless @archetypes.present? + @archetypes.values + end + + def self.register(name, options={}) + @archetypes ||= {} + @archetypes[name] = Archetype.new(name, options) + end + + + # By default we have a regular archetype and a private message + register 'regular' + register 'private_message' + +end diff --git a/lib/assets/.gitkeep b/lib/assets/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/assets/quote_email.js.shbrs b/lib/assets/quote_email.js.shbrs new file mode 100644 index 00000000000..fc3eb126384 --- /dev/null +++ b/lib/assets/quote_email.js.shbrs @@ -0,0 +1,8 @@ + + + + + + + +
            {{{avatarImg}}} {{username}} said:
            {{{quote}}}
            diff --git a/lib/avatar_lookup.rb b/lib/avatar_lookup.rb new file mode 100644 index 00000000000..3f3edfcbca4 --- /dev/null +++ b/lib/avatar_lookup.rb @@ -0,0 +1,35 @@ +class AvatarLookup + + def initialize(user_ids) + @user_ids = user_ids + + @user_ids.flatten! + @user_ids.compact! if @user_ids.present? + @user_ids.uniq! if @user_ids.present? + + @loaded = false + end + + # Lookup a user by id + def [](user_id) + ensure_loaded! + @users_hashed[user_id] + end + + + protected + + def ensure_loaded! + return if @loaded + + @users_hashed = {} + # need email for hash + User.where(id: @user_ids).select([:id, :email, :email, :username]).each do |u| + @users_hashed[u.id] = u + end + + @loaded = true + end + + +end diff --git a/lib/content_buffer.rb b/lib/content_buffer.rb new file mode 100644 index 00000000000..d7f525dacbe --- /dev/null +++ b/lib/content_buffer.rb @@ -0,0 +1,64 @@ +# this class is used to track changes to an arbirary buffer + +class ContentBuffer + + def initialize(initial_content) + @initial_content = initial_content + @lines = @initial_content.split("\n") + end + + def apply_transform!(transform) + start_row = transform[:start][:row] + start_col = transform[:start][:col] + finish_row = transform[:finish][:row] if transform[:finish] + finish_col = transform[:finish][:col] if transform[:finish] + text = transform[:text] + + if transform[:operation] == :delete + + # fix first line + + l = @lines[start_row] + l = l[0...start_col] + + if (finish_row == start_row) + l << @lines[start_row][finish_col..-1] + @lines[start_row] = l + return + end + + @lines[start_row] = l + + # remove middle lines + (finish_row - start_row).times do + l = @lines.delete_at start_row + 1 + end + + # fix last line + @lines[start_row] << @lines[finish_row][finish_col-1..-1] + end + + if transform[:operation] == :insert + + @lines[start_row].insert(start_col, text) + + split = @lines[start_row].split("\n") + + if split.length > 1 + @lines[start_row] = split[0] + i = 1 + split[1..-2].each do |line| + @lines.insert(start_row + i, line) + i += 1 + end + @lines.insert(i, "") unless @lines.length > i + @lines[i] = split[-1] + @lines[i] + end + + end + end + + def to_s + @lines.join("\n") + end +end diff --git a/lib/cooked_post_processor.rb b/lib/cooked_post_processor.rb new file mode 100644 index 00000000000..0a2027eba92 --- /dev/null +++ b/lib/cooked_post_processor.rb @@ -0,0 +1,93 @@ +# Post processing that we can do after a post has already been cooked. For +# example, inserting the onebox content, or image sizes. + +require_dependency 'oneboxer' + +class CookedPostProcessor + + def initialize(post, opts={}) + @dirty = false + @opts = opts + @post = post + @doc = Hpricot(post.cooked) + end + + def dirty? + @dirty + end + + # Bake onebox content into the post + def post_process_oneboxes + args = {post_id: @post.id} + args[:invalidate_oneboxes] = true if @opts[:invalidate_oneboxes] + + Oneboxer.each_onebox_link(@doc) do |url, element| + onebox = Oneboxer.onebox(url, args) + if onebox + element.swap onebox + @dirty = true + end + end + end + + # First let's consider the images + def post_process_images + images = @doc.search("img") + if images.present? + + # Extract the first image from the first post and use it as the 'topic image' + if @post.post_number == 1 + img = images.first + @post.topic.update_column :image_url, img['src'] if img['src'].present? + end + + images.each do |img| + if img['src'].present? + + # If we provided some image sizes, look those up first + if @opts[:image_sizes].present? + if dim = @opts[:image_sizes][img['src']] + w, h = ImageSizer.resize(dim['width'], dim['height']) + img.set_attribute 'width', w.to_s + img.set_attribute 'height', h.to_s + @dirty = true + end + end + + # If the image has no width or height, figure them out. + if img['width'].blank? or img['height'].blank? + dim = CookedPostProcessor.image_dimensions(img['src']) + if dim.present? + img.set_attribute 'width', dim[0].to_s + img.set_attribute 'height', dim[1].to_s + @dirty = true + end + end + + end + end + end + end + + + def post_process + return unless @doc.present? + post_process_images + post_process_oneboxes + end + + def html + @doc.try(:to_html) + end + + # Retrieve the image dimensions for a url + def self.image_dimensions(url) + return nil unless SiteSetting.crawl_images? + uri = URI.parse(url) + return nil unless %w(http https).include?(uri.scheme) + w, h = FastImage.size(url) + + ImageSizer.resize(w, h) + end + +end diff --git a/lib/current_user.rb b/lib/current_user.rb new file mode 100644 index 00000000000..aa73a963aff --- /dev/null +++ b/lib/current_user.rb @@ -0,0 +1,32 @@ +module CurrentUser + + def current_user + return @current_user if @current_user || @not_logged_in + + if session[:current_user_id].blank? + # maybe we have a cookie? + auth_token = cookies[:_t] + if auth_token && auth_token.length == 32 + @current_user = User.where(auth_token: auth_token).first + session[:current_user_id] = @current_user.id if @current_user + end + else + @current_user ||= User.where(id: session[:current_user_id]).first + end + + if @current_user && @current_user.is_banned? + @current_user = nil + end + + @not_logged_in = session[:current_user_id].blank? + if @current_user + @current_user.update_last_seen! + if @current_user.ip_address != request.remote_ip + @current_user.ip_address = request.remote_ip + User.exec_sql('update users set ip_address = ? where id = ?', request.remote_ip, @current_user.id) + end + end + @current_user + end + +end diff --git a/lib/custom_renderer.rb b/lib/custom_renderer.rb new file mode 100644 index 00000000000..ab10ca7eeb3 --- /dev/null +++ b/lib/custom_renderer.rb @@ -0,0 +1,31 @@ +class CustomRenderer < AbstractController::Base + include ActiveSupport::Configurable + include AbstractController::Rendering + include AbstractController::Helpers + include AbstractController::Translation + include AbstractController::AssetPaths + include Rails.application.routes.url_helpers + helper ApplicationHelper + self.view_paths = "app/views" + include CurrentUser + + def action_name + "" + end + + def controller_name + "" + end + + def cookies + @parent.send(:cookies) + end + + def session + @parent.send(:session) + end + + def initialize(parent) + @parent = parent + end +end diff --git a/lib/discourse.rb b/lib/discourse.rb new file mode 100644 index 00000000000..cc25ccc2bcb --- /dev/null +++ b/lib/discourse.rb @@ -0,0 +1,49 @@ +module Discourse + + # When they try to do something they should be logged in for + class NotLoggedIn < Exception; end + + # When the input is somehow bad + class InvalidParameters < Exception; end + + # When they don't have permission to do something + class InvalidAccess < Exception; end + + # When something they want is not found + class NotFound < Exception; end + + + # Get the current base URL for the current site + def self.current_hostname + RailsMultisite::ConnectionManagement.current_hostname + end + + def self.base_url + protocol = "http" + protocol = "https" if SiteSetting.use_ssl? + result = "#{protocol}://#{current_hostname}" + result << ":#{SiteSetting.port}" if SiteSetting.port.present? + result + end + + def self.enable_maintenance_mode + $redis.set maintenance_mode_key, 1 + true + end + + def self.disable_maintenance_mode + $redis.del maintenance_mode_key + true + end + + def self.maintenance_mode? + !!$redis.get( maintenance_mode_key ) + end + + +private + + def self.maintenance_mode_key + 'maintenance_mode' + end +end diff --git a/lib/discourse_observer.rb b/lib/discourse_observer.rb new file mode 100644 index 00000000000..eea9b03995d --- /dev/null +++ b/lib/discourse_observer.rb @@ -0,0 +1,48 @@ +# +# Support delegating after_create to an appropriate helper for that class name. +# For example, an observer on post will call after_create_post if that method +# is defined. +# +# It does this after_commit by default, and contains a hack to make this work +# even in test mode. +# +class DiscourseObserver < ActiveRecord::Observer + + def after_create_delegator(model) + observer_method = :"after_create_#{model.class.name.underscore}" + send(observer_method, model) if respond_to?(observer_method) + end + + def after_destroy_delegator(model) + observer_method = :"after_destroy_#{model.class.name.underscore}" + send(observer_method, model) if respond_to?(observer_method) + end + +end + +if Rails.env.test? + + # In test mode, call the delegator right away + class DiscourseObserver < ActiveRecord::Observer + alias_method :after_create, :after_create_delegator + alias_method :after_destroy, :after_destroy_delegator + end + +else + + # Outside of test mode, use after_commit + class DiscourseObserver < ActiveRecord::Observer + def after_commit(model) + if model.send(:transaction_include_action?, :create) + after_create_delegator(model) + end + + if model.send(:transaction_include_action?, :destroy) + after_destroy_delegator(model) + end + + end + end + +end + diff --git a/lib/discourse_plugin_registry.rb b/lib/discourse_plugin_registry.rb new file mode 100644 index 00000000000..a56862a67e9 --- /dev/null +++ b/lib/discourse_plugin_registry.rb @@ -0,0 +1,57 @@ +# +# A class that handles interaction between a plugin and the Discourse App. +# +class DiscoursePluginRegistry + + class << self + attr_accessor :javascripts + attr_accessor :server_side_javascripts + attr_accessor :stylesheets + end + + def register_js(filename, options={}) + self.class.javascripts ||= Set.new + self.class.server_side_javascripts ||= Set.new + + # If we have a server side option, add that too. + self.class.server_side_javascripts << options[:server_side] if options[:server_side].present? + + self.class.javascripts << filename + end + + def register_css(filename) + self.class.stylesheets ||= Set.new + self.class.stylesheets << filename + end + + def stylesheets + self.class.stylesheets || Set.new + end + + def register_archetype(name, options={}) + Archetype.register(name, options) + end + + def server_side_javascripts + self.class.javascripts || Set.new + end + + def javascripts + self.class.javascripts || Set.new + end + + def self.clear + self.stylesheets = Set.new + self.server_side_javascripts = Set.new + self.javascripts = Set.new + end + + def self.setup(plugin_class) + registry = DiscoursePluginRegistry.new + plugin = plugin_class.new(registry) + plugin.setup + end + + + +end diff --git a/lib/discourse_redis.rb b/lib/discourse_redis.rb new file mode 100644 index 00000000000..4c8df3fe3f7 --- /dev/null +++ b/lib/discourse_redis.rb @@ -0,0 +1,44 @@ +# +# A wrapper around redis that namespaces keys with the current site id +# +class DiscourseRedis + + def initialize + @config = YAML::load(File.open("#{Rails.root}/config/redis.yml"))[Rails.env] + redis_opts = {:host => @config['host'], :port => @config['port'], :db => @config['db']} + @redis = Redis.new(redis_opts) + end + + # prefix the key with the namespace + def method_missing(meth, *args, &block) + if @redis.respond_to?(meth) + @redis.send(meth, *args, &block) + else + super + end + end + + # Proxy key methods through, but prefix the keys with the namespace + %w(append blpop brpop brpoplpush decr decrby del exists expire expireat get getbit getrange getset hdel + hexists hget hgetall hincrby hincrbyfloat hkeys hlen hmget hmset hset hsetnx hvals incr incrby incrbyfloat + lindex linsert llen lpop lpush lpushx lrange lrem lset ltrim mget move mset msetnx persist pexpire pexpireat psetex + pttl rename renamenx rpop rpoplpush rpush rpushx sadd scard sdiff set setbit setex setnx setrange sinter + sismember smembers sort spop srandmember srem strlen sunion ttl type watch zadd zcard zcount zincrby + zrange zrangebyscore zrank zrem zremrangebyrank zremrangebyscore zrevrange zrevrangebyscore zrevrank zrangebyscore).each do |m| + class_eval %{ + def #{m}(*args) + args[0] = "\#\{DiscourseRedis.namespace\}:\#\{args[0]\}" + @redis.#{m}(*args) + end + } + end + + def self.namespace + RailsMultisite::ConnectionManagement.current_db + end + + def url + "redis://#{@config['host']}:#{@config['port']}/#{@config['db']}" + end + +end diff --git a/lib/distributed_hash.rb b/lib/distributed_hash.rb new file mode 100644 index 00000000000..b4938d5f51a --- /dev/null +++ b/lib/distributed_hash.rb @@ -0,0 +1,36 @@ +# Like a hash, just does its best to stay in sync accross the farm +# +# Redis backed with an allowance for a certain amount of latency + + +class DistributedHash + + @lock = Mutex.new + + def self.ensure_subscribed + @lock.synchronize do + unless @subscribed + + end + @subscribed = true + end + end + + + def initialize(key, options={}) + @key = key + end + + def []=(k,v) + end + + def [](k) + end + + def delete(k) + end + + def clear + end + +end diff --git a/lib/email.rb b/lib/email.rb new file mode 100644 index 00000000000..08c12a42f6d --- /dev/null +++ b/lib/email.rb @@ -0,0 +1,15 @@ +require 'mail' +module Email + + def self.is_valid?(email) + + parser = Mail::RFC2822Parser.new + parser.root = :addr_spec + result = parser.parse(email) + + # Don't allow for a TLD by itself list (sam@localhost) + # The Grammar is: (local_part "@" domain) / local_part ... need to discard latter + result && result.respond_to?(:domain) && result.domain.dot_atom_text.elements.size > 1 + end + +end diff --git a/lib/email_builder.rb b/lib/email_builder.rb new file mode 100644 index 00000000000..660adc7d0a1 --- /dev/null +++ b/lib/email_builder.rb @@ -0,0 +1,20 @@ +# Help us build an email +module EmailBuilder + + def build_email(to, email_key, params={}) + params[:site_name] = SiteSetting.title + params[:base_url] = Discourse.base_url + params[:user_preferences_url] = "#{Discourse.base_url}/user_preferences" + + body = I18n.t("#{email_key}.text_body_template", params) + + # Are we appending an unsubscribe link? + if params[:add_unsubscribe_link] + body << "\n" + body << I18n.t("unsubscribe_link", params) + end + + mail to: to, subject: I18n.t("#{email_key}.subject_template", params), body: body + end + +end \ No newline at end of file diff --git a/lib/email_sender.rb b/lib/email_sender.rb new file mode 100644 index 00000000000..b24bcbf2fdf --- /dev/null +++ b/lib/email_sender.rb @@ -0,0 +1,37 @@ +# +# A helper class to send an email. It will also handle a nil message, which it considers +# to be "do nothing". This is because some Mailers will decide not to do work for some +# reason. For example, emailing a user too frequently. A nil to address is also considered +# "do nothing" +# +# It also adds an HTML part for the plain text body using markdown +# +class EmailSender + + def initialize(message, email_type, user=nil) + @message = message + @email_type = email_type + @user = user + end + + def send + return if @message.blank? + return if @message.to.blank? + return if @message.body.blank? + + plain_body = @message.body.to_s + + @message.html_part = Mail::Part.new do + content_type 'text/html; charset=UTF-8' + body PrettyText.cook(plain_body, environment: 'email') + end + + @message.deliver + + to_address = @message.to + to_address = to_address.first if to_address.is_a?(Array) + + EmailLog.create!(email_type: @email_type, to_address: to_address, user_id: @user.try(:id)) + end + +end diff --git a/lib/export/export.rb b/lib/export/export.rb new file mode 100644 index 00000000000..f857bd45ff3 --- /dev/null +++ b/lib/export/export.rb @@ -0,0 +1,35 @@ +module Export + + class UnsupportedExportSource < RuntimeError; end + class FormatInvalidError < RuntimeError; end + class FilenameMissingError < RuntimeError; end + class ExportInProgressError < RuntimeError; end + + def self.current_schema_version + ActiveRecord::Migrator.current_version.to_s + end + + def self.models_included_in_export + @models_included_in_export ||= begin + Rails.application.eager_load! # So that all models get loaded now + ActiveRecord::Base.descendants + end + end + + def self.export_running_key + 'exporter_is_running' + end + + def self.is_export_running? + $redis.get(export_running_key) == '1' + end + + def self.set_export_started + $redis.set export_running_key, '1' + end + + def self.set_export_is_not_running + $redis.del export_running_key + end + +end \ No newline at end of file diff --git a/lib/export/json_encoder.rb b/lib/export/json_encoder.rb new file mode 100644 index 00000000000..dbf61cee435 --- /dev/null +++ b/lib/export/json_encoder.rb @@ -0,0 +1,75 @@ +module Export + + class SchemaArgumentsError < RuntimeError; end + + # TODO: Use yajl-ruby for performance. + # https://github.com/brianmario/yajl-ruby + + class JsonEncoder + + def initialize + @table_data = {} + end + + def tmp_directory + @tmp_directory ||= begin + f = File.join( Rails.root, 'tmp', Time.now.strftime('export%Y%m%d%H%M%S') ) + Dir.mkdir(f) unless Dir[f].present? + f + end + end + + def json_output_stream + @json_output_stream ||= File.new( File.join( tmp_directory, 'tables.json' ), 'w+b' ) + end + + def write_schema_info(args) + raise SchemaArgumentsError unless args[:source].present? and args[:version].present? + + @schema_data = { + schema: { + source: args[:source], + version: args[:version] + } + } + end + + def write_table(table_name, columns) + @table_data[table_name] ||= {} + @table_data[table_name][:fields] = columns.map(&:name) + @table_data[table_name][:rows] ||= [] + + row_count = 0 + begin + rows = yield(row_count) + if rows + row_count += rows.size + @table_data[table_name][:rows] << rows + end + + # TODO: write to multiple files as needed. + # one file per table? multiple files per table? + + end while rows and rows.size > 0 + + @table_data[table_name][:rows].flatten!(1) + @table_data[table_name][:row_count] = @table_data[table_name][:rows].size + end + + def finish + @schema_data[:schema][:table_count] = @table_data.keys.count + json_output_stream.write( @schema_data.merge(@table_data).to_json ) + json_output_stream.close + + @filenames = [File.join( tmp_directory, 'tables.json' )] + end + + def filenames + @filenames ||= [] + end + + def cleanup_temp + FileUtils.rm_rf(tmp_directory) if Dir[tmp_directory].present? + end + end +end \ No newline at end of file diff --git a/lib/freedom_patches/active_record_base.rb b/lib/freedom_patches/active_record_base.rb new file mode 100644 index 00000000000..110e2b56610 --- /dev/null +++ b/lib/freedom_patches/active_record_base.rb @@ -0,0 +1,24 @@ +class ActiveRecord::Base + + # Execute SQL manually + def self.exec_sql(*args) + conn = ActiveRecord::Base.connection + sql = ActiveRecord::Base.send(:sanitize_sql_array, args) + conn.execute(sql) + end + + def self.exec_sql_row_count(*args) + exec_sql(*args).cmd_tuples + end + + def exec_sql(*args) + ActiveRecord::Base.exec_sql(*args) + end + + # Support for psql. If we want to support multiple RDBMs in the future we can + # split this. + def exec_sql_row_count(*args) + exec_sql(*args).cmd_tuples + end + +end \ No newline at end of file diff --git a/lib/freedom_patches/rails4.rb b/lib/freedom_patches/rails4.rb new file mode 100644 index 00000000000..23bba3fe196 --- /dev/null +++ b/lib/freedom_patches/rails4.rb @@ -0,0 +1,69 @@ +# this file can be deleted when we port to rails4 +module FreedomPatches + module Rails4 + + def self.distance_of_time_in_words(from_time, to_time = 0, include_seconds = false, options = {}) + options = { + :scope => :'datetime.distance_in_words', + }.merge!(options) + + from_time = from_time.to_time if from_time.respond_to?(:to_time) + to_time = to_time.to_time if to_time.respond_to?(:to_time) + distance = (to_time.to_f - from_time.to_f).abs + distance_in_minutes = (distance / 60.0).round + distance_in_seconds = distance.round + + I18n.with_options :locale => options[:locale], :scope => options[:scope] do |locale| + case distance_in_minutes + when 0..1 + return distance_in_minutes == 0 ? + locale.t(:less_than_x_minutes, :count => 1) : + locale.t(:x_minutes, :count => distance_in_minutes) unless include_seconds + + case distance_in_seconds + when 0..4 then locale.t :less_than_x_seconds, :count => 5 + when 5..9 then locale.t :less_than_x_seconds, :count => 10 + when 10..19 then locale.t :less_than_x_seconds, :count => 20 + when 20..39 then locale.t :half_a_minute + when 40..59 then locale.t :less_than_x_minutes, :count => 1 + else locale.t :x_minutes, :count => 1 + end + + when 2..44 then locale.t :x_minutes, :count => distance_in_minutes + when 45..89 then locale.t :about_x_hours, :count => 1 + when 90..1439 then locale.t :about_x_hours, :count => (distance_in_minutes.to_f / 60.0).round + when 1440..2519 then locale.t :x_days, :count => 1 + when 2520..43199 then locale.t :x_days, :count => (distance_in_minutes.to_f / 1440.0).round + when 43200..86399 then locale.t :about_x_months, :count => 1 + when 86400..525599 then locale.t :x_months, :count => (distance_in_minutes.to_f / 43200.0).round + else + fyear = from_time.year + fyear += 1 if from_time.month >= 3 + tyear = to_time.year + tyear -= 1 if to_time.month < 3 + leap_years = (fyear > tyear) ? 0 : (fyear..tyear).count{|x| Date.leap?(x)} + minute_offset_for_leap_year = leap_years * 1440 + # Discount the leap year days when calculating year distance. + # e.g. if there are 20 leap year days between 2 dates having the same day + # and month then the based on 365 days calculation + # the distance in years will come out to over 80 years when in written + # english it would read better as about 80 years. + minutes_with_offset = distance_in_minutes - minute_offset_for_leap_year + remainder = (minutes_with_offset % 525600) + distance_in_years = (minutes_with_offset / 525600) + if remainder < 131400 + locale.t(:about_x_years, :count => distance_in_years) + elsif remainder < 394200 + locale.t(:over_x_years, :count => distance_in_years) + else + locale.t(:almost_x_years, :count => distance_in_years + 1) + end + end + end + end + + def self.time_ago_in_words(from_time, include_seconds = false, options = {}) + distance_of_time_in_words(from_time, Time.now, include_seconds, options) + end + end +end diff --git a/lib/guardian.rb b/lib/guardian.rb new file mode 100644 index 00000000000..d5d406f65b0 --- /dev/null +++ b/lib/guardian.rb @@ -0,0 +1,315 @@ +# The guardian is responsible for confirming access to various site resources and opreations +class Guardian + + attr_reader :user + + def initialize(user=nil) + @user = user + end + + def current_user + @user + end + + def is_admin? + !@user.nil? && @user.admin? + end + + # Can the user see the object? + def can_see?(obj) + return false if obj.blank? + + see_method = :"can_see_#{obj.class.name.underscore}?" + return send(see_method, obj) if respond_to?(see_method) + + return true + end + + # Can the user edit the obj + def can_edit?(obj) + return false if obj.blank? + return false if @user.blank? + + edit_method = :"can_edit_#{obj.class.name.underscore}?" + return send(edit_method, obj) if respond_to?(edit_method) + + true + end + + # Can we delete the object + def can_delete?(obj) + return false if obj.blank? + return false if @user.blank? + + delete_method = :"can_delete_#{obj.class.name.underscore}?" + return send(delete_method, obj) if respond_to?(delete_method) + + true + end + + def can_moderate?(obj) + return false if obj.blank? + return false if @user.blank? + @user.has_trust_level?(:moderator) + end + alias :can_move_posts? :can_moderate? + alias :can_see_flags? :can_moderate? + + # Can the user create a topic in the forum + def can_create?(klass, parent=nil) + return false if klass.blank? + return false if @user.blank? + + # If no parent is provided, we look for a can_i_create_klass? + # custom method. + # + # If a parent is provided, we look for a method called + # can_i_create_klass_on_parent? + target = klass.name.underscore + if parent.present? + return false unless can_see?(parent) + target << "_on_#{parent.class.name.underscore}" + end + create_method = :"can_create_#{target}?" + + return send(create_method, parent) if respond_to?(create_method) + + true + end + + # Can we impersonate this user? + def can_impersonate?(target) + return false if target.blank? + return false if @user.blank? + + # You must be an admin to impersonate + return false unless @user.admin? + + # You may not impersonate other admins + return false if target.admin? + + # You may not impersonate yourself + return false if @user == target + + true + end + + # Can we approve it? + def can_approve?(target) + return false if target.blank? + return false if @user.blank? + return false if target.approved? + @user.has_trust_level?(:moderator) + end + + def can_ban?(user) + return false if user.blank? + return false unless @user.try(:admin?) + return false if user.admin? + true + end + + def can_revoke_admin?(admin) + return false unless @user.try(:admin?) + return false if admin.blank? + return false if @user.id == admin.id + return false unless admin.admin? + true + end + + def can_grant_admin?(user) + return false unless @user.try(:admin?) + return false if user.blank? + return false if @user.id == user.id + return false if user.admin? + true + end + + # Can we see who acted on a post in a particular way? + def can_see_post_actors?(topic, post_action_type_id) + return false unless topic.present? + + type_symbol = PostActionType.Types.invert[post_action_type_id] + return false if type_symbol == :bookmark + return can_see_flags?(topic) if PostActionType.is_flag?(type_symbol) + + if type_symbol == :vote + # We can see votes if the topic allows for public voting + return false if topic.has_meta_data_boolean?(:private_poll) + end + + true + end + + def can_see_pending_invites_from?(user) + return false if user.blank? + return false if @user.blank? + return user == @user + end + + # For now, can_invite_to is basically can_see? + def can_invite_to?(object) + return false if @user.blank? + return false unless can_see?(object) + return false if SiteSetting.must_approve_users? + @user.has_trust_level?(:moderator) + end + + + def can_see_deleted_posts? + return true if is_admin? + false + end + + def can_see_private_messages?(user_id) + return true if is_admin? + return false if @user.blank? + @user.id == user_id + end + + # Support for ensure_{blah}! methods. + def method_missing(method, *args, &block) + if method.to_s =~ /^ensure_(.*)\!$/ + can_method = :"#{Regexp.last_match[1]}?" + + if respond_to?(can_method) + raise Discourse::InvalidAccess.new("#{can_method} failed") unless send(can_method, *args, &block) + return + end + end + + super.method_missing(method, *args, &block) + end + + # Make sure we can see the object. Will raise a NotFound if it's nil + def ensure_can_see!(obj) + raise Discourse::InvalidAccess.new("Can't see #{obj}") unless can_see?(obj) + end + + # Creating Methods + def can_create_category?(parent) + @user.has_trust_level?(:moderator) + end + + def can_create_post_on_topic?(topic) + return true if @user.has_trust_level?(:moderator) + return false if topic.closed? + return false if topic.archived? + true + end + + # Editing Methods + def can_edit_category?(category) + @user.has_trust_level?(:moderator) + end + + def can_edit_post?(post) + return true if @user.has_trust_level?(:moderator) + return false if post.topic.archived? + (post.user == @user) + end + + def can_edit_user?(user) + return true if user == @user + @user.admin? + end + + def can_edit_topic?(topic) + return true if @user.has_trust_level?(:moderator) + return true if topic.user == @user + false + end + + # Deleting Methods + def can_delete_post?(post) + # Can't delete the first post + return false if post.post_number == 1 + + @user.has_trust_level?(:moderator) + end + + def can_delete_category?(category) + return false unless @user.has_trust_level?(:moderator) + return category.topic_count == 0 + end + + def can_delete_topic?(topic) + return false unless @user.has_trust_level?(:moderator) + return false if Category.exists?(topic_id: topic.id) + true + end + + def can_delete_post_action?(post_action) + + # You can only undo your own actions + return false unless post_action.user == @user + + # Make sure they want to delete it within the window + return post_action.created_at > SiteSetting.post_undo_action_window_mins.minutes.ago + end + + def can_send_private_message?(target_user) + return false unless User === target_user + return false if @user.blank? + + # Can't send message to yourself + return false if @user.id == target_user.id + + # Have to be a basic level at least + return false unless @user.has_trust_level?(:basic) + + SiteSetting.enable_private_messages + end + + def can_reply_as_new_topic?(topic) + return false if @user.blank? + return false if topic.blank? + return false if topic.private_message? + + @user.has_trust_level?(:basic) + end + + def can_see_topic?(topic) + if topic.private_message? + return false if @user.blank? + return true if topic.allowed_users.include?(@user) + return is_admin? + end + true + end + + def can_vote?(post, opts={}) + post_can_act?(post,:vote, opts) + end + + # Can the user act on the post in a particular way. + # taken_actions = the list of actions the user has already taken + def post_can_act?(post, action_key, opts={}) + return false if @user.blank? + return false if post.blank? + return false if post.topic.archived? + + taken = opts[:taken_actions] + taken = taken.keys if taken + + if PostActionType.is_flag?(action_key) + return false unless @user.has_trust_level?(:basic) + + if taken + return false unless (taken & PostActionType.FlagTypes).empty? + end + else + return false if taken && taken.include?(PostActionType.Types[action_key]) + end + + case action_key + when :like + return false if post.user == @user + when :vote then + return false if opts[:voted_in_topic] and post.topic.has_meta_data_boolean?(:single_vote) + end + + return true + end + +end diff --git a/lib/headless-ember.js b/lib/headless-ember.js new file mode 100644 index 00000000000..9bdf72d75e2 --- /dev/null +++ b/lib/headless-ember.js @@ -0,0 +1,29 @@ +// DOM +var Element = {}; +Element.firstChild = function () { return Element; }; +Element.innerHTML = function () { return Element; }; + +var document = { createRange: false, createElement: function() { return Element; } }; +var window = this; +this.document = document; + +// Console +var console = window.console = {}; +console.log = console.info = console.warn = console.error = function(){}; + +// jQuery +var jQuery = window.jQuery = function() { return jQuery; }; +jQuery.ready = function() { return jQuery; }; +jQuery.inArray = function() { return jQuery; }; +jQuery.event = { + fixHooks: function() { + } +}; + +jQuery.jquery = "1.7.2"; +var $ = jQuery; + +// Ember +function precompileEmberHandlebars(string) { + return Ember.Handlebars.precompile(string).toString(); +} \ No newline at end of file diff --git a/lib/image_sizer.rb b/lib/image_sizer.rb new file mode 100644 index 00000000000..9063ae48463 --- /dev/null +++ b/lib/image_sizer.rb @@ -0,0 +1,17 @@ +module ImageSizer + + # Resize an image to the aspect ratio we want + def self.resize(width, height) + max_width = SiteSetting.max_image_width.to_f + return nil if width.blank? or height.blank? + + w = width.to_f + h = height.to_f + + return [w.floor, h.floor] if w < max_width + + # Using the maximum width, resize the heigh retaining the aspect ratio + [max_width.floor, (h * (max_width / w)).floor] + end + +end diff --git a/lib/imgur.rb b/lib/imgur.rb new file mode 100644 index 00000000000..5de9e8cd736 --- /dev/null +++ b/lib/imgur.rb @@ -0,0 +1,24 @@ +require 'rest_client' +require 'image_size' + +module Imgur + + def self.upload_file(file) + + blob = file.read + response = RestClient.post(SiteSetting.imgur_endpoint, key: SiteSetting.imgur_api_key, image: Base64.encode64(blob)) + + json = JSON.parse(response.body)['upload'] rescue nil + + return nil if json.blank? + + # Resize the image + json['image']['width'], json['image']['height'] = ImageSizer.resize(json['image']['width'], json['image']['height']) + + {url: json['links']['original'], + filesize: json['image']['size'], + width: json['image']['width'], + height: json['image']['height']} + end + +end diff --git a/lib/import/adapter/base.rb b/lib/import/adapter/base.rb new file mode 100644 index 00000000000..762485ec880 --- /dev/null +++ b/lib/import/adapter/base.rb @@ -0,0 +1,31 @@ +module Import + module Adapter + class Base + + def self.register(opts={}) + Import.add_import_adapter self, opts[:version], opts[:tables] + @table_names = opts[:tables] + end + + def apply_to_column_names(table_name, column_names) + up_column_names(table_name, column_names) + end + + def apply_to_row(table_name, row) + up_row(table_name, row) + end + + + # Implement the following methods in subclasses: + + def up_column_names(table_name, column_names) + column_names + end + + def up_row(table_name, row) + row + end + + end + end +end \ No newline at end of file diff --git a/lib/import/adapter/merge_mute_options_on_topic_users.rb b/lib/import/adapter/merge_mute_options_on_topic_users.rb new file mode 100644 index 00000000000..dd160fa3d7e --- /dev/null +++ b/lib/import/adapter/merge_mute_options_on_topic_users.rb @@ -0,0 +1,28 @@ +module Import + module Adapter + class MergeMuteOptionsOnTopicUsers < Base + + register version: '20130115012140', tables: [:topic_users] + + def up_column_names(table_name, column_names) + # rename_column :topic_users, :notifications, :notification_level + # remove_column :topic_users, :muted_at + if table_name.to_sym == :topic_users + column_names.map {|col| col == 'notifications' ? 'notification_level' : col}.reject {|col| col == 'muted_at'} + else + column_names + end + end + + def up_row(table_name, row) + # remove_column :topic_users, :muted_at + if table_name.to_sym == :topic_users + row[0..6] + row[8..-1] + else + row + end + end + + end + end +end \ No newline at end of file diff --git a/lib/import/adapter/remove_sub_tag_from_topics.rb b/lib/import/adapter/remove_sub_tag_from_topics.rb new file mode 100644 index 00000000000..51c2695c7e0 --- /dev/null +++ b/lib/import/adapter/remove_sub_tag_from_topics.rb @@ -0,0 +1,27 @@ +module Import + module Adapter + class RemoveSubTagFromTopics < Base + + register version: '20130116151829', tables: [:topics] + + def up_column_names(table_name, column_names) + # remove_column :topics, :sub_tag + if table_name.to_sym == :topics + column_names.reject {|col| col == 'sub_tag'} + else + column_names + end + end + + def up_row(table_name, row) + # remove_column :topics, :sub_tag + if table_name.to_sym == :topics + row[0..29] + row[31..-1] + else + row + end + end + + end + end +end \ No newline at end of file diff --git a/lib/import/import.rb b/lib/import/import.rb new file mode 100644 index 00000000000..b9b81151067 --- /dev/null +++ b/lib/import/import.rb @@ -0,0 +1,54 @@ +require_dependency 'import/adapter/base' + +module Import + + class UnsupportedExportSource < RuntimeError; end + class FormatInvalidError < RuntimeError; end + class FilenameMissingError < RuntimeError; end + class ImportInProgressError < RuntimeError; end + class ImportDisabledError < RuntimeError; end + class UnsupportedSchemaVersion < RuntimeError; end + class WrongTableCountError < RuntimeError; end + class WrongFieldCountError < RuntimeError; end + + def self.import_running_key + 'importer_is_running' + end + + def self.is_import_running? + $redis.get(import_running_key) == '1' + end + + def self.set_import_started + $redis.set import_running_key, '1' + end + + def self.set_import_is_not_running + $redis.del import_running_key + end + + + def self.clear_adapters + @adapters = {} + @adapter_instances = {} + end + + def self.add_import_adapter(klass, version, tables) + @adapters ||= {} + @adapter_instances ||= {} + unless @adapter_instances[klass] + @adapter_instances[klass] = klass.new + tables.each do |table| + @adapters[table.to_s] ||= [] + @adapters[table.to_s] << [version, @adapter_instances[klass]] + end + end + end + + def self.adapters_for_version(version) + a = Hash.new([]) + @adapters.each {|table_name,adapters| a[table_name] = adapters.reject {|i| i[0].to_i <= version.to_i}.map {|j| j[1]} } if defined?(@adapters) + a + end + +end \ No newline at end of file diff --git a/lib/import/json_decoder.rb b/lib/import/json_decoder.rb new file mode 100644 index 00000000000..e1b876878a1 --- /dev/null +++ b/lib/import/json_decoder.rb @@ -0,0 +1,27 @@ +module Import + + class JsonDecoder + + def initialize(input_filename) + @input_filename = input_filename + end + + + def input_stream + @input_stream ||= begin + File.open( @input_filename, 'rb' ) + end + end + + def start( opts ) + @json = JSON.parse(input_stream.read) + opts[:callbacks][:schema_info].call( source: @json['schema']['source'], version: @json['schema']['version'], table_count: @json.keys.size - 1) + @json.each do |key, val| + next if key == 'schema' + opts[:callbacks][:table_data].call( key, val['fields'], val['rows'], val['row_count'] ) + end + end + + end + +end \ No newline at end of file diff --git a/lib/jobs.rb b/lib/jobs.rb new file mode 100644 index 00000000000..f9a44ef9285 --- /dev/null +++ b/lib/jobs.rb @@ -0,0 +1,83 @@ +module Jobs + + class Base + include Sidekiq::Worker + + def self.delayed_perform(opts={}) + self.new.perform(opts) + end + + def self.mutex + @mutex ||= Mutex.new + end + + def execute(opts={}) + raise "Overwrite me!" + end + + def perform(opts={}) + opts = opts.with_indifferent_access + + if opts.delete(:sync_exec) + if opts.has_key?(:current_site_id) and opts[:current_site_id] != RailsMultisite::ConnectionManagement.current_db + raise ArgumentError.new("You can't connect to another database when executing a job synchronously.") + else + return execute(opts) + end + end + + + dbs = + if opts[:current_site_id] + [opts[:current_site_id]] + else + RailsMultisite::ConnectionManagement.all_dbs + end + + dbs.each do |db| + begin + Jobs::Base.mutex.synchronize do + RailsMultisite::ConnectionManagement.establish_connection(:db => db) + execute(opts) + end + ensure + ActiveRecord::Base.connection_handler.clear_active_connections! + end + end + end + end + + def self.enqueue(job_name, opts={}) + + klass_name = "Jobs::#{job_name.to_s.camelcase}" + klass = klass_name.constantize + + # Unless we want to work on all sites + unless opts.delete(:all_sites) + opts[:current_site_id] ||= RailsMultisite::ConnectionManagement.current_db + end + + # If we are able to queue a job, do it + if SiteSetting.queue_jobs? + if opts[:delay_for].present? + klass.delay_for(opts.delete(:delay_for)).delayed_perform(opts) + else + Sidekiq::Client.enqueue(klass_name.constantize, opts) + end + else + # Otherwise execute the job right away + opts.delete(:delay_for) + opts[:sync_exec] = true + klass.new.perform(opts) + end + + end + + def self.enqueue_in(secs, job_name, opts={}) + enqueue(job_name, opts.merge!(delay_for: secs)) + end + +end + +# Require all jobs +Dir["#{Rails.root}/lib/jobs/*"].each {|file| require_dependency file } diff --git a/lib/jobs/calculate_avg_time.rb b/lib/jobs/calculate_avg_time.rb new file mode 100644 index 00000000000..10c9918c7c7 --- /dev/null +++ b/lib/jobs/calculate_avg_time.rb @@ -0,0 +1,12 @@ +module Jobs + + class CalculateAvgTime < Jobs::Base + + def execute(args) + Post.calculate_avg_time + Topic.calculate_avg_time + end + + end + +end \ No newline at end of file diff --git a/lib/jobs/calculate_score.rb b/lib/jobs/calculate_score.rb new file mode 100644 index 00000000000..1b7d827dc0e --- /dev/null +++ b/lib/jobs/calculate_score.rb @@ -0,0 +1,13 @@ +require_dependency 'score_calculator' + +module Jobs + + class CalculateScore < Jobs::Base + + def execute(args) + ScoreCalculator.new.calculate + end + + end + +end \ No newline at end of file diff --git a/lib/jobs/calculate_view_counts.rb b/lib/jobs/calculate_view_counts.rb new file mode 100644 index 00000000000..84d93394860 --- /dev/null +++ b/lib/jobs/calculate_view_counts.rb @@ -0,0 +1,13 @@ +require_dependency 'score_calculator' + +module Jobs + + class CalculateViewCounts < Jobs::Base + + def execute(args) + User.update_view_counts + end + + end + +end \ No newline at end of file diff --git a/lib/jobs/category_stats.rb b/lib/jobs/category_stats.rb new file mode 100644 index 00000000000..7409517b452 --- /dev/null +++ b/lib/jobs/category_stats.rb @@ -0,0 +1,11 @@ +module Jobs + + class CategoryStats < Jobs::Base + + def execute(args) + Category.update_stats + end + + end + +end \ No newline at end of file diff --git a/lib/jobs/enqueue_digest_emails.rb b/lib/jobs/enqueue_digest_emails.rb new file mode 100644 index 00000000000..2c263616063 --- /dev/null +++ b/lib/jobs/enqueue_digest_emails.rb @@ -0,0 +1,23 @@ +module Jobs + + # A daily job that will enqueue digest emails to be sent to users + class EnqueueDigestEmails < Jobs::Base + + def execute(args) + target_users.each do |u| + Jobs.enqueue(:user_email, type: :digest, user_id: u.id) + end + end + + def target_users + # Users who want to receive emails and haven't been emailed int he last day + User + .select(:id) + .where(email_digests: true) + .where("COALESCE(last_emailed_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * digest_after_days)") + .where("COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * digest_after_days)") + end + + end + +end \ No newline at end of file diff --git a/lib/jobs/exporter.rb b/lib/jobs/exporter.rb new file mode 100644 index 00000000000..3620fb9b5cd --- /dev/null +++ b/lib/jobs/exporter.rb @@ -0,0 +1,119 @@ +require_dependency 'export/json_encoder' +require_dependency 'export/export' +require_dependency 'import/import' + +module Jobs + + class Exporter < Jobs::Base + + sidekiq_options :retry => false + + def execute(args) + raise Import::ImportInProgressError if Import::is_import_running? + raise Export::ExportInProgressError if Export::is_export_running? + + @format = args[:format] || :json + + @output_base_filename = File.absolute_path( args[:filename] || File.join( Rails.root, 'tmp', "export-#{Time.now.strftime('%Y-%m-%d-%H%M%S')}" ) ) + @output_base_filename = @output_base_filename[0...-3] if @output_base_filename[-3..-1] == '.gz' + @output_base_filename = @output_base_filename[0...-4] if @output_base_filename[-4..-1] == '.tar' + + @user = args[:user_id] ? User.where(id: args[:user_id].to_i).first : nil + + start_export + @encoder.write_schema_info( source: 'discourse', version: Export.current_schema_version ) + ordered_models_for_export.each do |model| + log " #{model.table_name}" + column_info = model.columns + order_col = column_info.map(&:name).find {|x| x == 'id'} || order_columns_for(model) + @encoder.write_table(model.table_name, column_info) do |num_rows_written| + if order_col + model.connection.select_rows("select * from #{model.table_name} order by #{order_col} limit #{batch_size} offset #{num_rows_written}") + else + # Take the rows in the order the database returns them + log "WARNING: no order by clause is being used for #{model.name} (#{model.table_name}). Please update Jobs::Exporter order_columns_for for #{model.name}." + model.connection.select_rows("select * from #{model.table_name} limit #{batch_size} offset #{num_rows_written}") + end + end + end + "#{@output_base_filename}.tar.gz" + ensure + finish_export + end + + def ordered_models_for_export + Export.models_included_in_export + end + + def order_columns_for(model) + @order_columns_for_hash ||= { + 'CategoryFeaturedTopic' => 'category_id, topic_id', + 'PostOneboxRender' => 'post_id, onebox_render_id', + 'PostReply' => 'post_id, reply_id', + 'PostTiming' => 'topic_id, post_number, user_id', + 'TopicUser' => 'topic_id, user_id', + 'View' => 'parent_id, parent_type, ip, viewed_at' + } + @order_columns_for_hash[model.name] + end + + def batch_size + 1000 + end + + def start_export + if @format == :json + @encoder = Export::JsonEncoder.new + else + raise Export::FormatInvalidError + end + Export.set_export_started + Discourse.enable_maintenance_mode + end + + def finish_export + if @encoder + @encoder.finish + create_tar_file + @encoder.cleanup_temp + end + ensure + Export.set_export_is_not_running + Discourse.disable_maintenance_mode + send_notification + end + + def create_tar_file + filenames = @encoder.filenames + + FileUtils.cd( File.dirname(filenames.first) ) do + `tar cvf #{@output_base_filename}.tar #{File.basename(filenames.first)}` + end + + FileUtils.cd( File.join(Rails.root, 'public') ) do + Upload.find_each do |upload| + `tar rvf #{@output_base_filename}.tar #{upload.url[1..-1]}` unless upload.url[0,4] == 'http' + end + end + + `gzip #{@output_base_filename}.tar` + + true + end + + def send_notification + SystemMessage.new(@user).create('export_succeeded') if @user + true + end + + def log(*args) + puts args + args.each do |arg| + Rails.logger.info "#{Time.now.to_formatted_s(:db)}: [EXPORTER] #{arg}" + end + true + end + + end + +end diff --git a/lib/jobs/feature_threads.rb b/lib/jobs/feature_threads.rb new file mode 100644 index 00000000000..08977dded2a --- /dev/null +++ b/lib/jobs/feature_threads.rb @@ -0,0 +1,11 @@ +module Jobs + + class FeatureTopics < Jobs::Base + + def execute(args) + CategoryFeaturedTopic.feature_topics + end + + end + +end diff --git a/lib/jobs/feature_topic_users.rb b/lib/jobs/feature_topic_users.rb new file mode 100644 index 00000000000..aea4504343a --- /dev/null +++ b/lib/jobs/feature_topic_users.rb @@ -0,0 +1,35 @@ +module Jobs + + class FeatureTopicUsers < Jobs::Base + + def execute(args) + topic = Topic.where(id: args[:topic_id]).first + raise Discourse::InvalidParameters.new(:topic_id) unless topic.present? + + to_feature = topic.posts + + # Don't include the OP or the last poster + to_feature = to_feature.where('user_id <> ?', topic.user_id) + to_feature = to_feature.where('user_id <> ?', topic.last_post_user_id) + + # Exclude a given post if supplied (in the case of deletes) + to_feature = to_feature.where("id <> ?", args[:except_post_id]) if args[:except_post_id].present? + + + # Clear the featured users by default + Topic::FEATURED_USERS.times do |i| + topic.send("featured_user#{i+1}_id=", nil) + end + + # Assign the featured_user{x} columns + to_feature = to_feature.group(:user_id).order('count_all desc').limit(Topic::FEATURED_USERS) + to_feature.count.keys.each_with_index do |user_id, i| + topic.send("featured_user#{i+1}_id=", user_id) + end + + topic.save + end + + end + +end diff --git a/lib/jobs/importer.rb b/lib/jobs/importer.rb new file mode 100644 index 00000000000..708eb0c8ed7 --- /dev/null +++ b/lib/jobs/importer.rb @@ -0,0 +1,289 @@ +require_dependency 'import/json_decoder' +require_dependency 'import/import' +require_dependency 'import/adapter/base' + +(Dir.entries(File.join( Rails.root, 'lib', 'import', 'adapter' )) - ['.', '..', 'base.rb']).each do |f| + require_dependency "import/adapter/#{f}" +end + +module Jobs + + class Importer < Jobs::Base + + sidekiq_options :retry => false + + BACKUP_SCHEMA = 'backup' + + def initialize + @index_definitions = {} + @format = :json + @warnings = [] + end + + def execute(args) + ordered_models_for_import.each { |model| model.primary_key } # a HACK to workaround cache problems + + raise Import::ImportDisabledError unless SiteSetting.allow_import? + raise Import::ImportInProgressError if Import::is_import_running? + raise Export::ExportInProgressError if Export::is_export_running? + + # Disable printing of NOTICE, DETAIL and other unimportant messages from postgresql + User.exec_sql("SET client_min_messages TO WARNING") + + @format = args[:format] || :json + @archive_filename = args[:filename] + if args[:user_id] + # After the import is done, we'll need to reload the user record and make sure it's the same person + # before sending a notification + user = User.where(id: args[:user_id].to_i).first + @user_info = { user_id: user.id, email: user.email } + else + @user_info = nil + end + + start_import + backup_tables + begin + load_data + create_indexes + extract_uploads + rescue + log "Performing a ROLLBACK because something went wrong!" + rollback + raise + end + ensure + finish_import + end + + def ordered_models_for_import + Export.models_included_in_export + end + + def start_import + if @format != :json + raise Import::FormatInvalidError + elsif @archive_filename.nil? + raise Import::FilenameMissingError + else + extract_files + @decoder = Import::JsonDecoder.new( File.join(tmp_directory, 'tables.json') ) + Import.set_import_started + Discourse.enable_maintenance_mode + end + self + end + + def tmp_directory + @tmp_directory ||= begin + f = File.join( Rails.root, 'tmp', Time.now.strftime('import%Y%m%d%H%M%S') ) + Dir.mkdir(f) unless Dir[f].present? + f + end + end + + def extract_files + FileUtils.cd( tmp_directory ) do + `tar xvzf #{@archive_filename} tables.json` + end + end + + def backup_tables + log " Backing up tables" + ActiveRecord::Base.transaction do + create_backup_schema + ordered_models_for_import.each do |model| + backup_and_setup_table( model ) + end + end + self + end + + def create_backup_schema + User.exec_sql("DROP SCHEMA IF EXISTS #{BACKUP_SCHEMA} CASCADE") + User.exec_sql("CREATE SCHEMA #{BACKUP_SCHEMA}") + self + end + + def backup_and_setup_table( model ) + log " #{model.table_name}" + @index_definitions[model.table_name] = model.exec_sql("SELECT indexdef FROM pg_indexes WHERE tablename = '#{model.table_name}' and schemaname = 'public';").map { |x| x['indexdef'] } + model.exec_sql("ALTER TABLE #{model.table_name} SET SCHEMA #{BACKUP_SCHEMA}") + model.exec_sql("CREATE TABLE #{model.table_name} (LIKE #{BACKUP_SCHEMA}.#{model.table_name} INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING COMMENTS INCLUDING STORAGE);") + self + end + + def load_data + log " Importing data" + @decoder.start( + callbacks: { + schema_info: method(:set_schema_info), + table_data: method(:load_table) + } + ) + self + end + + def set_schema_info(arg) + if arg[:source] and arg[:source].downcase == 'discourse' + if arg[:version] and arg[:version] <= Export.current_schema_version + @export_schema_version = arg[:version] + if arg[:table_count] == ordered_models_for_import.size + true + else + raise Import::WrongTableCountError.new("Expected to find #{ordered_models_for_import.size} tables, but export file has #{arg[:table_count]} tables!") + end + elsif arg[:version].nil? + raise ArgumentError.new("The schema version must be provided.") + else + raise Import::UnsupportedSchemaVersion.new("Export file is from a newer version of Discourse. Upgrade and run migrations to import this file.") + end + else + raise Import::UnsupportedExportSource + end + end + + def load_table(table_name, fields_arg, row_data, row_count) + fields = fields_arg.dup + model = Export::models_included_in_export.find { |m| m.table_name == table_name } + if model + + @adapters ||= Import.adapters_for_version( @export_schema_version ) + + log " #{table_name}: #{row_count} rows" + + if @adapters[table_name] + @adapters[table_name].each do |adapter| + fields = adapter.apply_to_column_names(table_name, fields) + end + end + + if fields.size > model.columns.size + raise Import::WrongFieldCountError.new("Table #{table_name} is expected to have #{model.columns.size} fields, but got #{fields.size}! Maybe your Discourse server is older than the server that this export file comes from?") + end + + # If there are fewer fields in the data than the model has, then insert only those fields and + # hope that the table uses default values or allows null for the missing columns. + # If the table doesn't have defaults or is not nullable, then a migration adapter should have been created + # along with the migration. + + column_info = model.columns + + col_num = -1 + rows = row_data.map do |row| + if @adapters[table_name] + @adapters[table_name].each do |adapter| + row = adapter.apply_to_row(table_name, row) + end + end + row + end.transpose.map do |col_values| + col_num += 1 + case column_info[col_num].type + when :boolean + col_values.map { |v| v.nil? ? nil : (v == 'f' ? false : true) } + else + col_values + end + end.transpose + + parameter_markers = fields.map {|x| "?"}.join(',') + sql_stmt = "INSERT INTO #{table_name} (#{fields.join(',')}) VALUES (#{parameter_markers})" + + rows.each do |row| + User.exec_sql(sql_stmt, *row) + end + + true + else + add_warning "Export file contained an unrecognized table named: #{table_name}! It was ignored." + end + end + + def create_indexes + log " Creating indexes" + ordered_models_for_import.each do |model| + log " #{model.table_name}" + @index_definitions[model.table_name].each do |indexdef| + model.exec_sql( indexdef ) + end + + # The indexdef statements don't create the primary keys, so we need to find the primary key and do it ourselves. + pkey_index_def = @index_definitions[model.table_name].find { |ixdef| ixdef =~ / ([\S]{1,}_pkey) / } + if pkey_index_def and pkey_index_name = / ([\S]{1,}_pkey) /.match(pkey_index_def)[1] + model.exec_sql( "ALTER TABLE ONLY #{model.table_name} ADD PRIMARY KEY USING INDEX #{pkey_index_name}" ) + end + + if model.columns.map(&:name).include?('id') + max_id = model.exec_sql("SELECT MAX(id) AS max FROM #{model.table_name}")[0]['max'].to_i + 1 + seq_name = "#{model.table_name}_id_seq" + model.exec_sql("CREATE SEQUENCE #{seq_name} START WITH #{max_id} INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1") + model.exec_sql("ALTER SEQUENCE #{seq_name} OWNED BY #{model.table_name}.id") + model.exec_sql("ALTER TABLE #{model.table_name} ALTER COLUMN id SET DEFAULT nextval('#{seq_name}')") + end + end + self + end + + def extract_uploads + if `tar tf #{@archive_filename} | grep "uploads/"`.present? + FileUtils.cd( File.join(Rails.root, 'public') ) do + `tar -xz --keep-newer-files -f #{@archive_filename} uploads/` + end + end + end + + def rollback + ordered_models_for_import.each do |model| + log " #{model.table_name}" + model.exec_sql("DROP TABLE IF EXISTS #{model.table_name}") rescue nil + begin + model.exec_sql("ALTER TABLE #{BACKUP_SCHEMA}.#{model.table_name} SET SCHEMA public") + rescue => e + log " Failed to restore. #{e.class.name}: #{e.message}" + end + end + end + + def finish_import + Import.set_import_is_not_running + Discourse.disable_maintenance_mode + FileUtils.rm_rf(tmp_directory) if Dir[tmp_directory].present? + + if @warnings.size > 0 + log "WARNINGS:" + @warnings.each do |message| + log " #{message}" + end + end + + # send_notification + end + + def send_notification + # Doesn't work. "WARNING: Can't mass-assign protected attributes: created_at" + # Still a problem with the activerecord schema_cache I think. + # if @user_info and @user_info[:user_id] + # user = User.where(id: @user_info[:user_id]).first + # if user and user.email == @user_info[:email] + # SystemMessage.new(user).create('import_succeeded') + # end + # end + true + end + + def add_warning(message) + @warnings << message + end + + def log(*args) + puts args + args.each do |arg| + Rails.logger.info "#{Time.now.to_formatted_s(:db)}: [IMPORTER] #{arg}" + end + true + end + + end + +end \ No newline at end of file diff --git a/lib/jobs/invite_email.rb b/lib/jobs/invite_email.rb new file mode 100644 index 00000000000..22b9f0fb40a --- /dev/null +++ b/lib/jobs/invite_email.rb @@ -0,0 +1,18 @@ +require_dependency 'email_sender' + +module Jobs + + # Asynchronously send an email + class InviteEmail < Jobs::Base + + def execute(args) + raise Discourse::InvalidParameters.new(:invite_id) unless args[:invite_id].present? + + invite = Invite.where(id: args[:invite_id]).first + message = InviteMailer.send_invite(invite) + EmailSender.new(message, :invite).send + end + + end + +end diff --git a/lib/jobs/notify_moved_posts.rb b/lib/jobs/notify_moved_posts.rb new file mode 100644 index 00000000000..a16f3e3cb64 --- /dev/null +++ b/lib/jobs/notify_moved_posts.rb @@ -0,0 +1,31 @@ +module Jobs + + class NotifyMovedPosts < Jobs::Base + + def execute(args) + raise Discourse::InvalidParameters.new(:post_ids) if args[:post_ids].blank? + raise Discourse::InvalidParameters.new(:moved_by_id) if args[:moved_by_id].blank? + + # Make sure we don't notify the same user twice (in case multiple posts were moved at once.) + users_notified = Set.new + posts = Post.where(id: args[:post_ids]).where('user_id <> ?', args[:moved_by_id]).includes(:user, :topic) + if posts.present? + moved_by = User.where(id: args[:moved_by_id]).first + + posts.each do |p| + unless users_notified.include?(p.user_id) + p.user.notifications.create(notification_type: Notification.Types[:moved_post], + topic_id: p.topic_id, + post_number: p.post_number, + data: {topic_title: p.topic.title, + display_username: moved_by.username}.to_json) + users_notified << p.user_id + end + end + end + + end + + end + +end diff --git a/lib/jobs/process_post.rb b/lib/jobs/process_post.rb new file mode 100644 index 00000000000..ee056ddf45e --- /dev/null +++ b/lib/jobs/process_post.rb @@ -0,0 +1,25 @@ +require 'image_sizer' +require_dependency 'cooked_post_processor' + +module Jobs + + class ProcessPost < Jobs::Base + + def execute(args) + post = Post.where(id: args[:post_id]).first + return unless post.present? + + if args[:cook].present? + post.update_column(:cooked, post.cook(post.raw, topic_id: post.topic_id)) + end + + cp = CookedPostProcessor.new(post, args) + cp.post_process + + # If we changed the document, save it + post.update_column(:cooked, cp.html) if cp.dirty? + end + + end + +end diff --git a/lib/jobs/send_system_message.rb b/lib/jobs/send_system_message.rb new file mode 100644 index 00000000000..f0f96eb9074 --- /dev/null +++ b/lib/jobs/send_system_message.rb @@ -0,0 +1,21 @@ +require 'image_sizer' +require_dependency 'system_message' + +module Jobs + + class SendSystemMessage < Jobs::Base + + def execute(args) + raise Discourse::InvalidParameters.new(:user_id) unless args[:user_id].present? + raise Discourse::InvalidParameters.new(:message_type) unless args[:message_type].present? + + user = User.where(id: args[:user_id]).first + return if user.blank? + + system_message = SystemMessage.new(user) + system_message.create(args[:message_type]) + end + + end + +end diff --git a/lib/jobs/test_email.rb b/lib/jobs/test_email.rb new file mode 100644 index 00000000000..75c7a75e473 --- /dev/null +++ b/lib/jobs/test_email.rb @@ -0,0 +1,18 @@ +require_dependency 'email_sender' + +module Jobs + + # Asynchronously send an email + class TestEmail < Jobs::Base + + def execute(args) + + raise Discourse::InvalidParameters.new(:to_address) unless args[:to_address].present? + + message = TestMailer.send_test(args[:to_address]) + EmailSender.new(message, :test_message).send + end + + end + +end diff --git a/lib/jobs/user_email.rb b/lib/jobs/user_email.rb new file mode 100644 index 00000000000..c795c541537 --- /dev/null +++ b/lib/jobs/user_email.rb @@ -0,0 +1,71 @@ +require_dependency 'email_sender' + +module Jobs + + # Asynchronously send an email to a user + class UserEmail < Jobs::Base + + def execute(args) + + # Required parameters + raise Discourse::InvalidParameters.new(:user_id) unless args[:user_id].present? + raise Discourse::InvalidParameters.new(:type) unless args[:type].present? + + # Find the user + user = User.where(id: args[:user_id]).first + return unless user.present? + + seen_recently = (user.last_seen_at.present? and user.last_seen_at > SiteSetting.email_time_window_mins.minutes.ago) + + email_args = {} + + if args[:post_id] + + # Don't email a user about a post when we've seen them recently. + return if seen_recently + + post = Post.where(id: args[:post_id]).first + return unless post.present? + + # Don't send the email if the user has read the post + return if PostTiming.where(topic_id: post.topic_id, post_number: post.post_number, user_id: user.id).present? + + email_args[:post] = post + end + + email_args[:email_token] = args[:email_token] if args[:email_token].present? + + + notification = nil + notification = Notification.where(id: args[:notification_id]).first if args[:notification_id].present? + + if notification.present? + + # Don't email a user about a post when we've seen them recently. + return if seen_recently + + # Load the post if present + email_args[:post] ||= notification.post if notification.post.present? + email_args[:notification] = notification + + # Don't send email if the notification this email is about has already been read + return if notification.read? + end + + # Make sure that mailer exists + raise Discourse::InvalidParameters.new(:type) unless UserNotifications.respond_to?(args[:type]) + + message = UserNotifications.send(args[:type], user, email_args) + + # Update the to address if we have a custom one + if args[:to_address].present? + message.to = [args[:to_address]] + end + + EmailSender.new(message, args[:type], user).send + + end + + end + +end diff --git a/lib/markdown_linker.rb b/lib/markdown_linker.rb new file mode 100644 index 00000000000..68e79444d64 --- /dev/null +++ b/lib/markdown_linker.rb @@ -0,0 +1,27 @@ +# Helps create links using markdown (where references are at the bottom) +class MarkdownLinker + + def initialize(base_url) + @base_url = base_url + @index = 1 + @markdown_links = {} + @rendered = 1 + end + + def create(title, url) + @markdown_links[@index] = "#{@base_url}#{url}" + result = "[#{title}][#{@index}]" + @index += 1 + result + end + + def references + result = "" + (@rendered..@index-1).each do |i| + result << " [#{i}]: #{@markdown_links[i]}\n" + end + @rendered = @index + result + end + +end diff --git a/lib/mothership.rb b/lib/mothership.rb new file mode 100644 index 00000000000..709508d4a19 --- /dev/null +++ b/lib/mothership.rb @@ -0,0 +1,58 @@ +require_dependency 'rest_client' + +module Mothership + + class NicknameUnavailable < RuntimeError; end + + def self.nickname_available?(nickname) + response = get('/users/nickname_available', {nickname: nickname}) + [response['available'], response['suggestion']] + end + + def self.nickname_match?(nickname, email) + response = get('/users/nickname_match', {nickname: nickname, email: email}) + [response['match'], response['available'] || false, response['suggestion']] + end + + def self.register_nickname(nickname, email) + json = post('/users', {nickname: nickname, email: email}) + if json.has_key?('success') + true + else + raise NicknameUnavailable # json['failed'] == -200 + end + end + + def self.current_discourse_version + get('current_version')['version'] + end + + + private + + def self.get(rel_url, params={}) + response = RestClient.get( "#{mothership_base_url}#{rel_url}", {params: {access_token: access_token}.merge(params), accept: accepts } ) + JSON.parse(response) + end + + def self.post(rel_url, params={}) + response = RestClient.post( "#{mothership_base_url}#{rel_url}", {access_token: access_token}.merge(params), content_type: :json, accept: accepts ) + JSON.parse(response) + end + + def self.mothership_base_url + if Rails.env == 'production' + 'http://api.discourse.org/api' + else + 'http://local.mothership:3000/api' + end + end + + def self.access_token + @access_token ||= SiteSetting.discourse_org_access_key + end + + def self.accepts + [:json, 'application/vnd.discoursehub.v1'] + end +end \ No newline at end of file diff --git a/lib/oneboxer.rb b/lib/oneboxer.rb new file mode 100644 index 00000000000..d2070ff89b3 --- /dev/null +++ b/lib/oneboxer.rb @@ -0,0 +1,140 @@ +require 'open-uri' + +require_dependency 'oneboxer/base' +require_dependency 'oneboxer/whitelist' +Dir["#{Rails.root}/lib/oneboxer/*_onebox.rb"].each {|f| + require_dependency(f.split('/')[-2..-1].join('/')) +} + +module Oneboxer + extend Oneboxer::Base + + Dir["#{Rails.root}/lib/oneboxer/*_onebox.rb"].each do |f| + add_onebox "Oneboxer::#{Pathname.new(f).basename.to_s.gsub(/\.rb$/, '').classify}".constantize + end + + def self.default_expiry + 1.month + end + + # Return a oneboxer for a given URL + def self.onebox_for_url(url) + matchers.each do |regexp, oneboxer| + return oneboxer.new(url) if url =~ regexp + end + nil + end + + # Retrieve the onebox for a url without caching + def self.onebox_nocache(url) + oneboxer = onebox_for_url(url) + return oneboxer.onebox if oneboxer.present? + + if Whitelist.allowed?(url) + page_html = open(url).read + if page_html.present? + doc = Hpricot(page_html) + + # See if if it has an oembed thing we can use + (doc/"link[@type='application/json+oembed']").each do |oembed| + return OembedOnebox.new(oembed[:href]).onebox + end + (doc/"link[@type='text/json+oembed']").each do |oembed| + return OembedOnebox.new(oembed[:href]).onebox + end + + # Check for opengraph + open_graph = Oneboxer.parse_open_graph(doc) + return OpenGraphOnebox.new(url, open_graph).onebox if open_graph.present? + end + end + + nil + end + + # Parse URLs out of HTML, returning the document when finished. + def self.each_onebox_link(string_or_doc) + doc = string_or_doc + doc = Hpricot(doc) if doc.is_a?(String) + + onebox_links = doc.search("a.onebox") + if onebox_links.present? + onebox_links.each do |link| + if link['href'].present? + yield link['href'], link + end + end + end + + doc + end + + def self.create_post_reference(result, args={}) + result.post_onebox_renders.create(post_id: args[:post_id]) if args[:post_id].present? + rescue ActiveRecord::RecordNotUnique + end + + def self.render_from_cache(url, args={}) + result = OneboxRender.where(url: url).first + + # Return the result but also create a reference to it + if result.present? + create_post_reference(result, args) + return result + end + nil + end + + # Cache results from a onebox call + def self.fetch_and_cache(url, args) + cooked, preview = onebox_nocache(url) + return nil if cooked.blank? + + # Store a cooked version in the database + OneboxRender.transaction do + begin + render = OneboxRender.create(url: url, preview: preview, cooked: cooked, expires_at: Oneboxer.default_expiry.from_now) + create_post_reference(render, args) + rescue ActiveRecord::RecordNotUnique + end + end + + [cooked, preview] + end + + # Retrieve a preview of a onebox, caching the result for performance + def self.preview(url, args={}) + cached = render_from_cache(url, args) unless args[:no_cache].present? + + # If we have a preview stored, return that. Otherwise return cooked content. + if cached.present? + return cached.preview if cached.preview.present? + return cached.cooked + end + cooked, preview = fetch_and_cache(url, args) + + return preview if preview.present? + cooked + end + + def self.invalidate(url) + OneboxRender.destroy_all(url: url) + end + + # Return the cooked content for a url, caching the result for performance + def self.onebox(url, args={}) + + if args[:invalidate_oneboxes].present? + # Remove the onebox from the cache + Oneboxer.invalidate(url) + else + cached = render_from_cache(url, args) unless args[:no_cache].present? + return cached.cooked if cached.present? + end + + + cooked, preview = fetch_and_cache(url, args) + cooked + end + +end diff --git a/lib/oneboxer/amazon_onebox.rb b/lib/oneboxer/amazon_onebox.rb new file mode 100644 index 00000000000..abab32cbe19 --- /dev/null +++ b/lib/oneboxer/amazon_onebox.rb @@ -0,0 +1,44 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class AmazonOnebox < HandlebarsOnebox + + matcher /^https?:\/\/(?:www\.)?amazon.(com|ca)\/.*$/ + favicon 'amazon.png' + + def template + template_path("simple_onebox") + end + + # Use the mobile version of the site + def translate_url + + # If we're already mobile don't translate the url + return @url if @url =~ /https?:\/\/www\.amazon\.com\/gp\/aw\/d\// + + m = @url.match(/(?:d|g)p\/(?:product\/)?(?[^\/]+)(?:\/|$)/mi) + return "http://www.amazon.com/gp/aw/d/" + URI::encode(m[:id]) if m.present? + @url + end + + def parse(data) + hp = Hpricot(data) + + result = {} + result[:title] = hp.at("h1") + result[:title] = result[:title].inner_html if result[:title].present? + + image = hp.at(".main-image img") + result[:image] = image['src'] if image + + result[:by_info] = hp.at("#by-line") + result[:by_info] = BaseOnebox.remove_whitespace(result[:by_info].inner_html) if result[:by_info].present? + + summary = hp.at("#description-and-details-content") + result[:text] = summary.inner_html if summary.present? + + result + end + + end +end diff --git a/lib/oneboxer/android_app_store_onebox.rb b/lib/oneboxer/android_app_store_onebox.rb new file mode 100644 index 00000000000..63afa632182 --- /dev/null +++ b/lib/oneboxer/android_app_store_onebox.rb @@ -0,0 +1,35 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class AndroidAppStoreOnebox < HandlebarsOnebox + + matcher /^https?:\/\/play\.google\.com\/.+$/ + favicon 'google_play.png' + + def template + template_path('simple_onebox') + end + + def parse(data) + + hp = Hpricot(data) + + result = {} + + m = hp.at("h1.doc-banner-title") + result[:title] = m.inner_text if m + + m = hp.at("div#doc-original-text") + if m + result[:text] = BaseOnebox.replace_tags_with_spaces(m.inner_html) + result[:text] = result[:text][0..MAX_TEXT] + end + + m = hp.at("div.doc-banner-icon img") + result[:image] = m['src'] if m + + result + end + + end +end diff --git a/lib/oneboxer/apple_app_onebox.rb b/lib/oneboxer/apple_app_onebox.rb new file mode 100644 index 00000000000..dec524bb849 --- /dev/null +++ b/lib/oneboxer/apple_app_onebox.rb @@ -0,0 +1,37 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class AppleAppOnebox < HandlebarsOnebox + + matcher /^https?:\/\/itunes\.apple\.com\/.+$/ + favicon 'apple.png' + + # Don't masquerade as mobile + def http_params + {} + end + + def template + template_path('simple_onebox') + end + + def parse(data) + + hp = Hpricot(data) + + result = {} + + m = hp.at("h1") + result[:title] = m.inner_text if m + + m = hp.at("h4 ~ p") + result[:text] = m.inner_text[0..MAX_TEXT] if m + + m = hp.at(".product img.artwork") + result[:image] = m['src'] if m + + result + end + + end +end diff --git a/lib/oneboxer/base.rb b/lib/oneboxer/base.rb new file mode 100644 index 00000000000..e35e33d3c13 --- /dev/null +++ b/lib/oneboxer/base.rb @@ -0,0 +1,45 @@ +module Oneboxer + + class << self + def parse_open_graph(doc) + result = {} + + %w(title type image url description).each do |prop| + node = doc.at("/html/head/meta[@property='og:#{prop}']") + result[prop] = (node['content'] || node['value']) if node + end + + # If there's no description, try and get one from the meta tags + if result['description'].blank? + node = doc.at("/html/head/meta[@name='description']") + result['description'] = node['content'] if node + end + if result['description'].blank? + node = doc.at("/html/head/meta[@name='Description']") + result['description'] = node['content'] if node + end + + + result + end + end + + module Base + + def matchers + @matchers ||= {} + @matchers + end + + # Add a matcher + def add_matcher(regexp, klass) + matchers[regexp] = klass + end + + def add_onebox(klass) + matchers[klass.regexp] = klass + end + + end + +end diff --git a/lib/oneboxer/base_onebox.rb b/lib/oneboxer/base_onebox.rb new file mode 100644 index 00000000000..5f5f7acea77 --- /dev/null +++ b/lib/oneboxer/base_onebox.rb @@ -0,0 +1,48 @@ +require 'open-uri' + +module Oneboxer + + class BaseOnebox + + class << self + attr_accessor :regexp + attr_accessor :favicon_file + + def matcher(regexp) + self.regexp = regexp + end + + def favicon(favicon_file) + self.favicon_file = "favicons/#{favicon_file}" + end + + def remove_whitespace(s) + s.gsub /\n/, '' + end + + def image_html(url, title, page_url) + "#{title}" + end + + def replace_tags_with_spaces(s) + s.gsub /<[^>]+>/, ' ' + end + + def uriencode(val) + return URI.escape(val, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]")) + end + + end + + def initialize(url, opts={}) + @url = url + @opts = opts + end + + def translate_url + @url + end + + end + +end diff --git a/lib/oneboxer/bliptv_onebox.rb b/lib/oneboxer/bliptv_onebox.rb new file mode 100644 index 00000000000..e10d9a5d21f --- /dev/null +++ b/lib/oneboxer/bliptv_onebox.rb @@ -0,0 +1,13 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class BliptvOnebox < OembedOnebox + + matcher /^https?\:\/\/blip\.tv\/.+$/ + + def oembed_endpoint + "http://blip.tv/oembed/?url=#{BaseOnebox.uriencode(@url)}&width=300" + end + + end +end diff --git a/lib/oneboxer/clikthrough_onebox.rb b/lib/oneboxer/clikthrough_onebox.rb new file mode 100644 index 00000000000..8561c655591 --- /dev/null +++ b/lib/oneboxer/clikthrough_onebox.rb @@ -0,0 +1,14 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class ClikthroughOnebox < OembedOnebox + + matcher /clikthrough\.com\/theater\/video\/\d+$/ + + def oembed_endpoint + "http://clikthrough.com/services/oembed?url=#{BaseOnebox.uriencode(@url)}" + end + + + end +end diff --git a/lib/oneboxer/college_humor_onebox.rb b/lib/oneboxer/college_humor_onebox.rb new file mode 100644 index 00000000000..5f5761fb85a --- /dev/null +++ b/lib/oneboxer/college_humor_onebox.rb @@ -0,0 +1,14 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class CollegeHumorOnebox < OembedOnebox + + matcher /^https?\:\/\/www\.collegehumor\.com\/video\/.*$/ + + def oembed_endpoint + "http://www.collegehumor.com/oembed.json?url=#{BaseOnebox.uriencode(@url)}" + end + + + end +end diff --git a/lib/oneboxer/dailymotion_onebox.rb b/lib/oneboxer/dailymotion_onebox.rb new file mode 100644 index 00000000000..83ae8647ca9 --- /dev/null +++ b/lib/oneboxer/dailymotion_onebox.rb @@ -0,0 +1,14 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class DailymotionOnebox < OembedOnebox + + matcher /dailymotion\.com\/.+$/ + + def oembed_endpoint + "http://www.dailymotion.com/api/oembed/?url=#{BaseOnebox.uriencode(@url)}" + end + + + end +end diff --git a/lib/oneboxer/discourse_onebox.rb b/lib/oneboxer/discourse_onebox.rb new file mode 100644 index 00000000000..0c9fe34f484 --- /dev/null +++ b/lib/oneboxer/discourse_onebox.rb @@ -0,0 +1,88 @@ +require_dependency 'oneboxer/oembed_onebox' +require_dependency 'freedom_patches/rails4' + +module Oneboxer + class DiscourseOnebox < BaseOnebox + include ActionView::Helpers::DateHelper + + # TODO: we need to remove these hardcoded urls ASAP + matcher /^https?\:\/\/(dev.discourse.org|localhost\:3000|l.discourse|discuss.emberjs.com)\/.*$/ + + def onebox + uri = URI::parse(@url) + route = Rails.application.routes.recognize_path(uri.path) + + args = {original_url: @url} + + # Figure out what kind of onebox to show based on the URL + case route[:controller] + when 'users' + user = User.where(username_lower: route[:username].downcase).first + Guardian.new.ensure_can_see!(user) + + args.merge! avatar: PrettyText.avatar_img(user.username, 'tiny'), username: user.username + args[:bio] = user.bio_cooked if user.bio_cooked.present? + + @template = 'user' + when 'topics' + if route[:post_number].present? and route[:post_number].to_i > 1 + # Post Link + post = Post.where(topic_id: route[:topic_id], post_number: route[:post_number].to_i).first + Guardian.new.ensure_can_see!(post) + + topic = post.topic + slug = Slug.for(topic.title) + + excerpt = post.excerpt(SiteSetting.post_onebox_maxlength) + excerpt.gsub!("\n"," ") + # hack to make it render for now + excerpt.gsub!("[/quote]", "[quote]") + quote = "[quote=\"#{post.user.username}, topic:#{topic.id}, slug:#{slug}, post:#{post.post_number}\"]#{excerpt}[/quote]" + + cooked = PrettyText.cook(quote) + return cooked + + else + # Topic Link + topic = Topic.where(id: route[:topic_id].to_i).includes(:user).first + post = topic.posts.first + Guardian.new(nil).ensure_can_see!(topic) + + posters = topic.posters_summary.map do |p| + {username: p[:user][:username], + avatar: PrettyText.avatar_img(p[:user][:username], 'tiny'), + description: p[:description], + extras: p[:extras]} + end + + category = topic.category + if category + category = "#{category.name}" + + end + + quote = post.excerpt(SiteSetting.post_onebox_maxlength) + args.merge! title: topic.title, + avatar: PrettyText.avatar_img(topic.user.username, 'tiny'), + posts_count: topic.posts_count, + last_post: FreedomPatches::Rails4.time_ago_in_words(topic.last_posted_at, false, scope: :'datetime.distance_in_words_verbose'), + age: FreedomPatches::Rails4.time_ago_in_words(topic.created_at, false, scope: :'datetime.distance_in_words_verbose'), + views: topic.views, + posters: posters, + quote: quote, + category: category, + topic: topic.id + + @template = 'topic' + end + + end + + return nil unless @template + Mustache.render(File.read("#{Rails.root}/lib/oneboxer/templates/discourse_#{@template}_onebox.hbrs"), args) + rescue ActionController::RoutingError + nil + end + + end +end diff --git a/lib/oneboxer/dotsub_onebox.rb b/lib/oneboxer/dotsub_onebox.rb new file mode 100644 index 00000000000..2d524e9372c --- /dev/null +++ b/lib/oneboxer/dotsub_onebox.rb @@ -0,0 +1,14 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class DotsubOnebox < OembedOnebox + + matcher /dotsub\.com\/.+$/ + + def oembed_endpoint + "http://dotsub.com/services/oembed?url=#{BaseOnebox.uriencode(@url)}" + end + + + end +end diff --git a/lib/oneboxer/flickr_onebox.rb b/lib/oneboxer/flickr_onebox.rb new file mode 100644 index 00000000000..8b0f845178c --- /dev/null +++ b/lib/oneboxer/flickr_onebox.rb @@ -0,0 +1,24 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class FlickrOnebox < BaseOnebox + + matcher /^https?\:\/\/.*\.flickr\.com\/.*$/ + + def onebox + + page_html = open(@url).read + return nil if page_html.blank? + doc = Hpricot(page_html) + + # Flikrs oembed just stopped returning images for no reason. Let's use opengraph instead. + open_graph = Oneboxer.parse_open_graph(doc) + + # A site is supposed to supply all the basic og attributes, but some don't (like deviant art) + # If it just has image and no title, embed it as an image. + return BaseOnebox.image_html(open_graph['image'], nil, @url) if open_graph['image'].present? + nil + end + + end +end diff --git a/lib/oneboxer/funny_or_die_onebox.rb b/lib/oneboxer/funny_or_die_onebox.rb new file mode 100644 index 00000000000..3ee9cc132a2 --- /dev/null +++ b/lib/oneboxer/funny_or_die_onebox.rb @@ -0,0 +1,10 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class FunnyOrDieOnebox < OembedOnebox + matcher /^https?\:\/\/(www\.)?funnyordie\.com\/videos\/.*$/ + def oembed_endpoint + "http://www.funnyordie.com/oembed.json?url=#{BaseOnebox.uriencode(@url)}" + end + end +end diff --git a/lib/oneboxer/gist_onebox.rb b/lib/oneboxer/gist_onebox.rb new file mode 100644 index 00000000000..d64f05474ab --- /dev/null +++ b/lib/oneboxer/gist_onebox.rb @@ -0,0 +1,30 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class GistOnebox < HandlebarsOnebox + + matcher /^https?:\/\/gist\.github\.com/ + favicon 'github.png' + + def translate_url + m = @url.match(/gist\.github\.com\/(?[0-9a-f]+)/mi) + return "https://api.github.com/gists/#{m[:id]}" if m + @url + end + + def parse(data) + + parsed = JSON.parse(data) + + result = {files: [], title: parsed['description']} + + parsed['files'].each do |filename, attrs| + result[:files] << {filename: filename}.merge!(attrs) + end + + + result + end + + end +end diff --git a/lib/oneboxer/github_blob_onebox.rb b/lib/oneboxer/github_blob_onebox.rb new file mode 100644 index 00000000000..59fb282dcaf --- /dev/null +++ b/lib/oneboxer/github_blob_onebox.rb @@ -0,0 +1,49 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class GithubBlobOnebox < HandlebarsOnebox + + matcher /github\.com\/[^\/]+\/[^\/]+\/blob\/.*/ + favicon 'github.png' + + def translate_url + m = @url.match(/github\.com\/(?[^\/]+)\/(?[^\/]+)\/blob\/(?[^\/]+)\/(?[^#]+)(#(L(?[^-]*)(-L(?.*))?))?/mi) + if m + @from = (m[:from] || -1).to_i + @to = (m[:to] || -1).to_i + @file = m[:file] + return "https://raw.github.com/#{m[:user]}/#{m[:repo]}/#{m[:sha1]}/#{m[:file]}" + end + nil + end + + def parse(data) + + if @from > 0 + if @to < 0 + @from = @from - 10 + @to = @from + 20 + end + if @to > @from + data = data.split("\n")[@from..@to].join("\n") + end + end + + extension = @file.split(".")[-1] + @lang = case extension + when "rb" then "ruby" + when "js" then "javascript" + else extension + end + + truncated = false + if data.length > SiteSetting.onebox_max_chars + data = data[0..SiteSetting.onebox_max_chars-1] + truncated = true + end + + {content: data, truncated: truncated} + end + + end +end diff --git a/lib/oneboxer/handlebars_onebox.rb b/lib/oneboxer/handlebars_onebox.rb new file mode 100644 index 00000000000..7741d95a80a --- /dev/null +++ b/lib/oneboxer/handlebars_onebox.rb @@ -0,0 +1,51 @@ +require 'open-uri' +require_dependency 'oneboxer/base_onebox' + +module Oneboxer + + class HandlebarsOnebox < BaseOnebox + + MAX_TEXT = 500 + + def template_path(template_name) + "#{Rails.root}/lib/oneboxer/templates/#{template_name}.hbrs" + end + + def template + template_name = self.class.name.underscore + template_name.gsub!(/oneboxer\//, '') + template_path(template_name) + end + + def default_url + "#{@url}" + end + + def http_params + {'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 5_0_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9A405 Safari/7534.48.3'} + end + + def onebox + html = open(translate_url, http_params).read + args = parse(html) + return default_url unless args.present? + args[:original_url] = @url + args[:lang] = @lang || "" + args[:favicon] = ActionController::Base.helpers.image_path(self.class.favicon_file) if self.class.favicon_file.present? + begin + parsed = URI.parse(@url) + args[:host] = parsed.host.split('.').last(2).join('.') + rescue URI::InvalidURIError + # In case there is a problem with the URL, we just won't set the host + end + + Mustache.render(File.read(template), args) + rescue => ex + # If there's an exception, just embed the link + raise ex if Rails.env.development? + default_url + end + + end + +end diff --git a/lib/oneboxer/hulu_onebox.rb b/lib/oneboxer/hulu_onebox.rb new file mode 100644 index 00000000000..f300e0f420d --- /dev/null +++ b/lib/oneboxer/hulu_onebox.rb @@ -0,0 +1,13 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class HuluOnebox < OembedOnebox + + matcher /^https?\:\/\/www\.hulu\.com\/watch\/.*$/ + + def oembed_endpoint + "http://www.hulu.com/api/oembed.json?url=#{BaseOnebox.uriencode(@url)}" + end + + end +end diff --git a/lib/oneboxer/image_onebox.rb b/lib/oneboxer/image_onebox.rb new file mode 100644 index 00000000000..2a041f15d74 --- /dev/null +++ b/lib/oneboxer/image_onebox.rb @@ -0,0 +1,13 @@ +require_dependency 'oneboxer/base_onebox' + +module Oneboxer + class ImageOnebox < BaseOnebox + + matcher /^https?:\/\/.*\.(jpg|png|gif|jpeg)$/ + + def onebox + "" + end + + end +end diff --git a/lib/oneboxer/imgur_onebox.rb b/lib/oneboxer/imgur_onebox.rb new file mode 100644 index 00000000000..6eec8c2a122 --- /dev/null +++ b/lib/oneboxer/imgur_onebox.rb @@ -0,0 +1,29 @@ +require 'open-uri' +require_dependency 'oneboxer/base_onebox' + +module Oneboxer + class ImgurOnebox < BaseOnebox + + matcher /^https?\:\/\/imgur\.com\/.*$/ + + def translate_url + m = @url.match(/\/gallery\/(?[^\/]+)/mi) + return "http://api.imgur.com/2/image/#{URI::encode(m[:hash])}.json" if m.present? + + m = @url.match(/imgur\.com\/(?[^\/]+)/mi) + return "http://api.imgur.com/2/image/#{URI::encode(m[:hash])}.json" if m.present? + + nil + end + + def onebox + url = translate_url + return @url if url.blank? + + parsed = JSON.parse(open(translate_url).read) + image = parsed['image'] + BaseOnebox.image_html(image['links']['original'], image['image']['caption'], image['links']['imgur_page']) + end + + end +end diff --git a/lib/oneboxer/kinomap_onebox.rb b/lib/oneboxer/kinomap_onebox.rb new file mode 100644 index 00000000000..1e1af58dea2 --- /dev/null +++ b/lib/oneboxer/kinomap_onebox.rb @@ -0,0 +1,14 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class KinomapOnebox < OembedOnebox + + matcher /kinomap\.com/ + + def oembed_endpoint + "http://www.kinomap.com/oembed?url=#{BaseOnebox.uriencode(@url)}&format=json" + end + + + end +end diff --git a/lib/oneboxer/nfb_onebox.rb b/lib/oneboxer/nfb_onebox.rb new file mode 100644 index 00000000000..f8677f0d42f --- /dev/null +++ b/lib/oneboxer/nfb_onebox.rb @@ -0,0 +1,14 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class NfbOnebox < OembedOnebox + + matcher /nfb\.ca\/film\/[-\w]+\/?/ + + def oembed_endpoint + "http://www.nfb.ca/remote/services/oembed/?url=#{BaseOnebox.uriencode(@url)}&format=json" + end + + + end +end diff --git a/lib/oneboxer/oembed_onebox.rb b/lib/oneboxer/oembed_onebox.rb new file mode 100644 index 00000000000..c9aa50c04b5 --- /dev/null +++ b/lib/oneboxer/oembed_onebox.rb @@ -0,0 +1,52 @@ +require 'open-uri' +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + + class OembedOnebox < HandlebarsOnebox + + MAX_TEXT = 500 + + def oembed_endpoint + @url + end + + def template + template_path('oembed_onebox') + end + + def onebox + + parsed = JSON.parse(open(oembed_endpoint).read) + + # If it's a video, just embed the iframe + if %w(video rich).include?(parsed['type']) + + # Return a preview of the thumbnail url, since iframes don't do well on previews + preview = nil + preview = "" if parsed['thumbnail_url'].present? + return [parsed['html'], preview] + end + + if %w(image photo).include?(parsed['type']) + return BaseOnebox.image_html(parsed['url'] || parsed['thumbnail_url'], parsed['title'], parsed['web_page'] || @url) + end + + parsed['html'] ||= parsed['abstract'] + + begin + parsed_uri = URI.parse(@url) + parsed['host'] = parsed_uri.host.split('.').last(2).join('.') + rescue URI::InvalidURIError + # In case there is a problem with the URL, we just won't set the host + end + + + Mustache.render(File.read(template), parsed) + rescue OpenURI::HTTPError + nil + end + + end + +end diff --git a/lib/oneboxer/open_graph_onebox.rb b/lib/oneboxer/open_graph_onebox.rb new file mode 100644 index 00000000000..427310ccdaf --- /dev/null +++ b/lib/oneboxer/open_graph_onebox.rb @@ -0,0 +1,35 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class OpenGraphOnebox < HandlebarsOnebox + + def template + template_path('simple_onebox') + end + + def onebox + # We expect to have the options we need already + return nil unless @opts.present? + + # A site is supposed to supply all the basic og attributes, but some don't (like deviant art) + # If it just has image and no title, embed it as an image. + return BaseOnebox.image_html(@opts['image'], nil, @url) if @opts['image'].present? and @opts['title'].blank? + + @opts['title'] ||= @opts['description'] + return nil if @opts['title'].blank? + + @opts[:original_url] = @url + @opts[:text] = @opts['description'] + + begin + parsed = URI.parse(@url) + @opts[:host] = parsed.host.split('.').last(2).join('.') + rescue URI::InvalidURIError + # In case there is a problem with the URL, we just won't set the host + end + + Mustache.render(File.read(template), @opts) + end + + end +end diff --git a/lib/oneboxer/qik_onebox.rb b/lib/oneboxer/qik_onebox.rb new file mode 100644 index 00000000000..86c0e617e26 --- /dev/null +++ b/lib/oneboxer/qik_onebox.rb @@ -0,0 +1,13 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class QikOnebox < OembedOnebox + + matcher /^https?\:\/\/qik\.com\/video\/.*$/ + + def oembed_endpoint + "http://qik.com/api/oembed.json?url=#{BaseOnebox.uriencode(@url)}" + end + + end +end diff --git a/lib/oneboxer/revision_onebox.rb b/lib/oneboxer/revision_onebox.rb new file mode 100644 index 00000000000..e521cb2a273 --- /dev/null +++ b/lib/oneboxer/revision_onebox.rb @@ -0,0 +1,13 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class RevisionOnebox < OembedOnebox + + matcher /^http\:\/\/(.*\.)?revision3\.com\/.*$/ + + def oembed_endpoint + "http://revision3.com/api/oembed/?url=#{BaseOnebox.uriencode(@url)}&format=json" + end + + end +end diff --git a/lib/oneboxer/smugmug_onebox.rb b/lib/oneboxer/smugmug_onebox.rb new file mode 100644 index 00000000000..6dc2d2e6178 --- /dev/null +++ b/lib/oneboxer/smugmug_onebox.rb @@ -0,0 +1,13 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class SmugmugOnebox < OembedOnebox + + matcher /^https?\:\/\/.*\.smugmug\.com\/.*$/ + + def oembed_endpoint + "http://api.smugmug.com/services/oembed/?url=#{BaseOnebox.uriencode(@url)}&format=json" + end + + end +end diff --git a/lib/oneboxer/ted_onebox.rb b/lib/oneboxer/ted_onebox.rb new file mode 100644 index 00000000000..ee496b0ba89 --- /dev/null +++ b/lib/oneboxer/ted_onebox.rb @@ -0,0 +1,10 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class TedOnebox < OembedOnebox + matcher /^https?\:\/\/(www\.)?ted\.com\/talks\/.*$/ + def oembed_endpoint + "http://www.ted.com/talks/oembed.json?url=#{BaseOnebox.uriencode(@url)}" + end + end +end diff --git a/lib/oneboxer/templates/discourse_post_onebox.hbrs b/lib/oneboxer/templates/discourse_post_onebox.hbrs new file mode 100644 index 00000000000..e69de29bb2d diff --git a/lib/oneboxer/templates/discourse_topic_onebox.hbrs b/lib/oneboxer/templates/discourse_topic_onebox.hbrs new file mode 100644 index 00000000000..bcced049435 --- /dev/null +++ b/lib/oneboxer/templates/discourse_topic_onebox.hbrs @@ -0,0 +1,23 @@ + + + + diff --git a/lib/oneboxer/templates/discourse_user_onebox.hbrs b/lib/oneboxer/templates/discourse_user_onebox.hbrs new file mode 100644 index 00000000000..02dfc2d7cee --- /dev/null +++ b/lib/oneboxer/templates/discourse_user_onebox.hbrs @@ -0,0 +1,8 @@ +
            + {{{avatar}}} +

            {{username}}

            + + {{#bio}}

            {{bio}}

            {{/bio}} + +
            +
            diff --git a/lib/oneboxer/templates/gist_onebox.hbrs b/lib/oneboxer/templates/gist_onebox.hbrs new file mode 100644 index 00000000000..4717c38e59e --- /dev/null +++ b/lib/oneboxer/templates/gist_onebox.hbrs @@ -0,0 +1,16 @@ +
            + {{#host}} + + {{#favicon}} {{/favicon}}{{host}} + + {{/host}} +
            + {{#title}} +

            {{title}}

            + {{/title}} + {{#files}} +

            {{filename}}

            +
            {{content}}
            + {{/files}} +
            +
            \ No newline at end of file diff --git a/lib/oneboxer/templates/github_blob_onebox.hbrs b/lib/oneboxer/templates/github_blob_onebox.hbrs new file mode 100644 index 00000000000..d27d5eb633e --- /dev/null +++ b/lib/oneboxer/templates/github_blob_onebox.hbrs @@ -0,0 +1,15 @@ +
            + {{#host}} + + {{#favicon}} {{/favicon}}{{host}} + + {{/host}} +
            +

            {{original_url}}

            +
            {{content}}
            + + {{#truncated}} + This file has been truncated. show original + {{/truncated}} +
            +
            diff --git a/lib/oneboxer/templates/oembed_onebox.hbrs b/lib/oneboxer/templates/oembed_onebox.hbrs new file mode 100644 index 00000000000..ac4da55f036 --- /dev/null +++ b/lib/oneboxer/templates/oembed_onebox.hbrs @@ -0,0 +1,17 @@ +
            + {{#host}} + + {{/host}} +
            +

            {{title}}

            + {{#author_info}}

            {{author_info}}

            {{/author_info}} + {{{html}}} +
            +
            +
            diff --git a/lib/oneboxer/templates/simple_onebox.hbrs b/lib/oneboxer/templates/simple_onebox.hbrs new file mode 100644 index 00000000000..340a89fb526 --- /dev/null +++ b/lib/oneboxer/templates/simple_onebox.hbrs @@ -0,0 +1,18 @@ +
            + {{#host}} + + {{/host}} +
            + {{#image}}{{/image}} +

            {{title}}

            + {{#by_info}}

            {{by_info}}

            {{/by_info}} + {{{text}}} +
            +
            +
            diff --git a/lib/oneboxer/templates/twitter_onebox.hbrs b/lib/oneboxer/templates/twitter_onebox.hbrs new file mode 100644 index 00000000000..e0c6b1c4c92 --- /dev/null +++ b/lib/oneboxer/templates/twitter_onebox.hbrs @@ -0,0 +1,24 @@ +
            + {{#host}} + + {{/host}} +
            + {{#user.profile_image_url}}{{/user.profile_image_url}} +

            @{{user.screen_name}}

            + + {{{text}}} + + +
            + +
            + +
            diff --git a/lib/oneboxer/twitter_onebox.rb b/lib/oneboxer/twitter_onebox.rb new file mode 100644 index 00000000000..9d6976cc694 --- /dev/null +++ b/lib/oneboxer/twitter_onebox.rb @@ -0,0 +1,30 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class TwitterOnebox < HandlebarsOnebox + + matcher /^https?:\/\/(?:www\.)?twitter.com\/.*$/ + favicon 'twitter.png' + + def translate_url + m = @url.match(/\/(?[^\/]+)\/status\/(?\d+)/mi) + return "http://api.twitter.com/1/statuses/show/#{URI::encode(m[:id])}.json" if m.present? + @url + end + + def parse(data) + + result = JSON.parse(data) + + result["created_at"] = Time.parse(result["created_at"]).strftime("%I:%M%p - %d %b %y") + + # Hyperlink URLs + URI.extract(result['text'], %w(http https)).each do |url| + result['text'].gsub!(url, "#{url}") + end + + result + end + + end +end diff --git a/lib/oneboxer/viddler_onebox.rb b/lib/oneboxer/viddler_onebox.rb new file mode 100644 index 00000000000..987e05cd143 --- /dev/null +++ b/lib/oneboxer/viddler_onebox.rb @@ -0,0 +1,13 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class ViddlerOnebox < OembedOnebox + + matcher /viddler\.com\/.+$/ + + def oembed_endpoint + "http://lab.viddler.com/services/oembed/?url=#{BaseOnebox.uriencode(@url)}" + end + + end +end diff --git a/lib/oneboxer/vimeo_onebox.rb b/lib/oneboxer/vimeo_onebox.rb new file mode 100644 index 00000000000..9cd520df735 --- /dev/null +++ b/lib/oneboxer/vimeo_onebox.rb @@ -0,0 +1,13 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class VimeoOnebox < OembedOnebox + + matcher /^https?\:\/\/vimeo\.com\/.*$/ + + def oembed_endpoint + "http://vimeo.com/api/oembed.json?url=#{BaseOnebox.uriencode(@url)}&width=600" + end + + end +end diff --git a/lib/oneboxer/whitelist.rb b/lib/oneboxer/whitelist.rb new file mode 100644 index 00000000000..38f27760622 --- /dev/null +++ b/lib/oneboxer/whitelist.rb @@ -0,0 +1,86 @@ +module Oneboxer + + module Whitelist + def self.entries + [/cnn\.com\/.+/, + /washingtonpost\.com\/.+/, + /\/\d{4}\/\d{2}\/\d{2}\//, # wordpress + /funnyordie\.com\/.+/, + /youtube\.com\/.+/, + /youtu\.be\/.+/, + /500px\.com\/.+/, + /scribd\.com\/.+/, + /photobucket\.com\/.+/, + /ebay\.(com|ca|co\.uk)\/.+/, + /nytimes\.com\/.+/, + /tumblr\.com\/.+/, + /pinterest\.com\/.+/, + /imdb\.com\/.+/, + /bbc\.co\.uk\/.+/, + /ask\.com\/.+/, + /huffingtonpost\.com\/.+/, + /aol\.(com|ca)\/.+/, + /espn\.go\.com\/.+/, + /about\.com\/.+/, + /cnet\.com\/.+/, + /ehow\.com\/.+/, + /dailymail\.co\.uk\/.+/, + /indiatimes\.com\/.+/, + /answers\.com\/.+/, + /instagr\.am\/.+/, + /battle\.net\/.+/, + /sourceforge\.net\/.+/, + /myspace\.com\/.+/, + /wikia\.com\/.+/, + /etsy\.com\/.+/, + /walmart\.com\/.+/, + /reference\.com\/.+/, + /yelp\.com\/.+/, + /foxnews\.com\/.+/, + /guardian\.co\.uk\/.+/, + /digg\.com\/.+/, + /squidoo\.com\/.+/, + /wsj\.com\/.+/, + /archive\.org\/.+/, + /nba\.com\/.+/, + /samsung\.com\/.+/, + /mashable\.com\/.+/, + /forbes\.com\/.+/, + /soundcloud\.com\/.+/, + /thefreedictionary\.com\/.+/, + /groupon\.com\/.+/, + /ikea\.com\/.+/, + /dell\.com\/.+/, + /mlb\.com\/.+/, + /bestbuy\.(com|ca)\/.+/, + /bloomberg\.com\/.+/, + /ign\.com\/.+/, + /twitpic\.com\/.+/, + /techcrunch\.com\/.+/, + /usatoday\.com\/.+/, + /go\.com\/.+/, + /businessinsider\.com\/.+/, + /zillow\.com\/.+/, + /tmz\.com\/.+/, + /thesun\.co\.uk\/.+/, + /thestar\.(com|ca)\/.+/, + /theglobeandmail\.com\/.+/, + /torontosun\.com\/.+/, + /kickstarter\.com\/.+/, + /wired\.com\/.+/, + /time\.com\/.+/, + /npr\.org\/.+/, + /cracked\.com\/.+/, + /deadline\.com\/.+/ + ] + end + + def self.allowed?(url) + #return true + entries.each {|e| return true if url =~ e } + false + end + + end + +end diff --git a/lib/oneboxer/wikipedia_onebox.rb b/lib/oneboxer/wikipedia_onebox.rb new file mode 100644 index 00000000000..694d3951543 --- /dev/null +++ b/lib/oneboxer/wikipedia_onebox.rb @@ -0,0 +1,59 @@ +require_dependency 'oneboxer/handlebars_onebox' + +module Oneboxer + class WikipediaOnebox < HandlebarsOnebox + + matcher /^https?:\/\/.*wikipedia.(com|org)\/.*$/ + favicon 'wikipedia.png' + + def template + template_path('simple_onebox') + end + + def translate_url + m = @url.match(/wiki\/(?[^#\/]+)/mi) + + article_id = CGI::unescape(m[:identifier]) + return "http://en.m.wikipedia.org/w/index.php?title=#{URI::encode(article_id)}" + @url + end + + def parse(data) + + hp = Hpricot(data) + + result = {} + + title = hp.at('title').inner_html + result[:title] = title.gsub!(/ - Wikipedia, the free encyclopedia/, '') if title.present? + + # get the first image > 150 pix high + images = hp.search("img").select { |img| img['height'].to_i > 150 } + + result[:image] = "http:#{images[0]["src"]}" unless images.empty? + + # remove the table from mobile layout, as it can contain paras in some rare cases + hp.search("table").remove + + # get all the paras + paras = hp.search("p") + text = "" + + unless paras.empty? + cnt = 0 + while text.length < MAX_TEXT and cnt <= 3 + text << " " unless cnt == 0 + paragraph = paras[cnt].inner_text[0..MAX_TEXT] + paragraph.gsub!(/\[\d+\]/mi, "") + text << paragraph + cnt += 1 + end + end + + text = "#{text[0..MAX_TEXT]}..." if text.length > MAX_TEXT + result[:text] = text + result + end + + end +end diff --git a/lib/oneboxer/yfrog_onebox.rb b/lib/oneboxer/yfrog_onebox.rb new file mode 100644 index 00000000000..6090930d9cc --- /dev/null +++ b/lib/oneboxer/yfrog_onebox.rb @@ -0,0 +1,13 @@ +require_dependency 'oneboxer/oembed_onebox' + +module Oneboxer + class YfrogOnebox < OembedOnebox + + matcher /yfrog\.(com|ru|com\.tr|it|fr|co\.il|co\.uk|com\.pl|pl|eu|us)\/[a-zA-Z0-9]+/ + + def oembed_endpoint + "http://www.yfrog.com/api/oembed/?url=#{BaseOnebox.uriencode(@url)}&format=json" + end + + end +end diff --git a/lib/post_creator.rb b/lib/post_creator.rb new file mode 100644 index 00000000000..f73cc1c85d0 --- /dev/null +++ b/lib/post_creator.rb @@ -0,0 +1,98 @@ +# Responsible for creating posts and topics +# +require_dependency 'rate_limiter' + +class PostCreator + + # Errors when creating the post + attr_reader :errors + + # Acceptable options: + # + # raw - raw text of post + # image_sizes - We can pass a list of the sizes of images in the post as a shortcut. + # + # When replying to a topic: + # topic_id - topic we're replying to + # reply_to_post_number - post number we're replying to + # + # When creating a topic: + # title - New topic title + # archetype - Topic archetype + # category - Category to assign to topic + # target_usernames - comma delimited list of usernames for membership (private message) + # meta_data - Topic meta data hash + def initialize(user, opts) + @user = user + @opts = opts + raise Discourse::InvalidParameters.new(:raw) if @opts[:raw].blank? + end + + def guardian + @guardian ||= Guardian.new(@user) + end + + def create + topic = nil + post = nil + + Post.transaction do + if @opts[:topic_id].blank? + topic_params = {title: @opts[:title], user_id: @user.id, last_post_user_id: @user.id} + topic_params[:archetype] = @opts[:archetype] if @opts[:archetype].present? + + guardian.ensure_can_create!(Topic) + + category = Category.where(name: @opts[:category]).first + topic_params[:category_id] = category.id if category.present? + topic_params[:meta_data] = @opts[:meta_data] if @opts[:meta_data].present? + + topic = Topic.new(topic_params) + + if @opts[:archetype] == Archetype.private_message + + usernames = @opts[:target_usernames].split(',') + User.where(:username => usernames).each do |u| + + unless guardian.can_send_private_message?(u) + topic.errors.add(:archetype, :cant_send_pm) + @errors = topic.errors + raise ActiveRecord::Rollback.new + end + + topic.topic_allowed_users.build(user_id: u.id) + end + topic.topic_allowed_users.build(user_id: @user.id) + end + + unless topic.save + @errors = topic.errors + raise ActiveRecord::Rollback.new + end + else + topic = Topic.where(id: @opts[:topic_id]).first + guardian.ensure_can_create!(Post, topic) + end + + post = topic.posts.new(raw: @opts[:raw], + user: @user, + reply_to_post_number: @opts[:reply_to_post_number]) + post.image_sizes = @opts[:image_sizes] if @opts[:image_sizes].present? + unless post.save + @errors = post.errors + raise ActiveRecord::Rollback.new + end + + # Extract links + TopicLink.extract_from(post) + end + + post + end + + # Shortcut + def self.create(user, opts) + PostCreator.new(user, opts).create + end + +end diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb new file mode 100644 index 00000000000..4842cd81712 --- /dev/null +++ b/lib/pretty_text.rb @@ -0,0 +1,266 @@ +require 'coffee_script' +require 'v8' +require 'nokogiri' + +module PrettyText + + def self.whitelist + { + :elements => %w[ + a abbr aside b bdo blockquote br caption cite code col colgroup dd div del dfn dl + dt em hr figcaption figure h1 h2 h3 h4 h5 h6 hgroup i img ins kbd li mark + ol p pre q rp rt ruby s samp small span strike strong sub sup table tbody td + tfoot th thead time tr u ul var wbr + ], + + :attributes => { + :all => ['dir', 'lang', 'title', 'class'], + 'aside' => ['data-post', 'data-full', 'data-topic'], + 'a' => ['href'], + 'blockquote' => ['cite'], + 'col' => ['span', 'width'], + 'colgroup' => ['span', 'width'], + 'del' => ['cite', 'datetime'], + 'img' => ['align', 'alt', 'height', 'src', 'width'], + 'ins' => ['cite', 'datetime'], + 'ol' => ['start', 'reversed', 'type'], + 'q' => ['cite'], + 'span' => ['style'], + 'table' => ['summary', 'width', 'style', 'cellpadding', 'cellspacing'], + 'td' => ['abbr', 'axis', 'colspan', 'rowspan', 'width', 'style'], + 'th' => ['abbr', 'axis', 'colspan', 'rowspan', 'scope', 'width', 'style'], + 'time' => ['datetime', 'pubdate'], + 'ul' => ['type'] + }, + + :protocols => { + 'a' => {'href' => ['ftp', 'http', 'https', 'mailto', :relative]}, + 'blockquote' => {'cite' => ['http', 'https', :relative]}, + 'del' => {'cite' => ['http', 'https', :relative]}, + 'img' => {'src' => ['http', 'https', :relative]}, + 'ins' => {'cite' => ['http', 'https', :relative]}, + 'q' => {'cite' => ['http', 'https', :relative]} + } + } + end + + + class Helpers + # function here are available to v8 + def avatar_template(username) + return "" unless username + + user = User.where(username_lower: username.downcase).first + if user + user.avatar_template + end + end + + def is_username_valid(username) + return false unless username + username = username.downcase + return User.exec_sql('select 1 from users where username_lower = ?', username).values.length == 1 + end + end + + @mutex = Mutex.new + + def self.mention_matcher + /(\@[a-zA-Z0-9\-]+)/ + end + + def self.app_root + Rails.root + end + + def self.v8 + return @ctx unless @ctx.nil? + + @ctx = V8::Context.new + + @ctx["helpers"] = Helpers.new + + @ctx.load(app_root + "app/assets/javascripts/external/Markdown.Converter.js") + @ctx.load(app_root + "app/assets/javascripts/external/twitter-text-1.5.0.js") + @ctx.load(app_root + "lib/headless-ember.js") + @ctx.load(app_root + "app/assets/javascripts/external/rsvp.js") + @ctx.load(Rails.configuration.ember.handlebars_location) + #@ctx.load(Rails.configuration.ember.ember_location) + + @ctx.load(app_root + "app/assets/javascripts/external/sugar-1.3.5.js") + @ctx.eval("var Discourse = {}; Discourse.SiteSettings = #{SiteSetting.client_settings_json};") + @ctx.eval("var window = {}; window.devicePixelRatio = 2;") # hack to make code think stuff is retina + + @ctx.eval(CoffeeScript.compile(File.read(app_root + "app/assets/javascripts/discourse/components/bbcode.js.coffee"))) + @ctx.eval(CoffeeScript.compile(File.read(app_root + "app/assets/javascripts/discourse/components/utilities.coffee"))) + + # Load server side javascripts + if DiscoursePluginRegistry.server_side_javascripts.present? + DiscoursePluginRegistry.server_side_javascripts.each do |ssjs| + @ctx.load(ssjs) + end + end + + @ctx['quoteTemplate'] = File.open(app_root + 'app/assets/javascripts/discourse/templates/quote.js.shbrs') {|f| f.read} + @ctx['quoteEmailTemplate'] = File.open(app_root + 'lib/assets/quote_email.js.shbrs') {|f| f.read} + @ctx.eval("HANDLEBARS_TEMPLATES = { + 'quote': Handlebars.compile(quoteTemplate), + 'quote_email': Handlebars.compile(quoteEmailTemplate), + };") + @ctx + end + + def self.markdown(text, opts=nil) + # we use the exact same markdown converter as the client + # TODO: use the same extensions on both client and server (in particular the template for mentions) + + baked = nil + + @mutex.synchronize do + # we need to do this to work in a multi site environment, many sites, many settings + v8.eval("Discourse.SiteSettings = #{SiteSetting.client_settings_json};") + v8.eval("Discourse.BaseUrl = 'http://#{RailsMultisite::ConnectionManagement.current_hostname}';") + v8['opts'] = opts || {} + v8['raw'] = text + v8.eval('opts["mentionLookup"] = function(u){return helpers.is_username_valid(u);}') + v8.eval('opts["lookupAvatar"] = function(p){return Discourse.Utilities.avatarImg({username: p, size: "tiny", avatarTemplate: helpers.avatar_template(p)});}') + baked = v8.eval('Discourse.Utilities.markdownConverter(opts).makeHtml(raw)') + end + + # we need some minimal server side stuff, apply CDN and TODO filter disallowed markup + baked = apply_cdn(baked, Rails.configuration.action_controller.asset_host) + baked + end + + # leaving this here, cause it invokes v8, don't want to implement twice + def self.avatar_img(username, size) + r = nil + @mutex.synchronize do + v8['username'] = username + v8['size'] = size + v8.eval("Discourse.SiteSettings = #{SiteSetting.client_settings_json};") + v8.eval("Discourse.CDN = '#{Rails.configuration.action_controller.asset_host}';") + v8.eval("Discourse.BaseUrl = '#{RailsMultisite::ConnectionManagement.current_hostname}';") + r = v8.eval("Discourse.Utilities.avatarImg({ username: username, size: size });") + end + r + end + + def self.apply_cdn(html, url) + return html unless url + + image = /\.(jpg|jpeg|gif|png|tiff|tif)$/ + + doc = Nokogiri::HTML.fragment(html) + doc.css("a").each do |l| + href = l.attributes["href"].to_s + if href[0] == '/' && href =~ image + l["href"] = url + href + end + end + doc.css("img").each do |l| + src = l.attributes["src"].to_s + if src[0] == '/' + l["src"] = url + src + end + end + + doc.to_s + end + + def self.cook(text, opts={}) + cloned = opts.dup + # we have a minor inconsistency + cloned[:topicId] = opts[:topic_id] + Sanitize.clean(markdown(text.dup, cloned), PrettyText.whitelist) + end + + def self.extract_links(html) + doc = Nokogiri::HTML.fragment(html) + links = [] + doc.css("a").each do |l| + links << l.attributes["href"].to_s + end + links + end + + class ExcerptParser < Nokogiri::XML::SAX::Document + + class DoneException < StandardError; end + + attr_reader :excerpt + + def initialize(length) + @length = length + @excerpt = "" + @current_length = 0 + end + + def self.get_excerpt(html, length) + me = self.new(length) + parser = Nokogiri::HTML::SAX::Parser.new(me) + begin + copy = "
            " + copy << html unless html.nil? + copy << "
            " + parser.parse(html) unless html.nil? + rescue DoneException + # we are done + end + me.excerpt + end + + def start_element(name, attributes=[]) + case name + when "img" + attributes = Hash[*attributes.flatten] + if attributes["alt"] + characters("[#{attributes["alt"]}]") + elsif attributes["title"] + characters("[#{attributes["title"]}]") + else + characters("[image]") + end + when "a" + c = "" + characters(c, false, false, false) + @in_a = true + when "aside" + @in_quote = true + end + end + + def end_element(name) + case name + when "a" + characters("",false, false, false) + @in_a = false + when "p", "br" + characters(" ") + when "aside" + @in_quote = false + end + end + + def characters(string, truncate = true, count_it = true, encode = true) + return if @in_quote + encode = encode ? lambda{|s| ERB::Util.html_escape(s)} : lambda {|s| s} + if @current_length + string.length > @length && count_it + @excerpt << encode.call(string[0..(@length-@current_length)-1]) if truncate + @excerpt << "…" + @excerpt << "" if @in_a + raise DoneException.new + end + @excerpt << encode.call(string) + @current_length += string.length if count_it + end + end + + def self.excerpt(html, length) + ExcerptParser.get_excerpt(html, length) + end + +end + diff --git a/lib/promotion.rb b/lib/promotion.rb new file mode 100644 index 00000000000..5fd793f72cb --- /dev/null +++ b/lib/promotion.rb @@ -0,0 +1,35 @@ +# +# Check whether a user is ready for a new trust level. +# +class Promotion + + def initialize(user) + @user = user + end + + # Review a user for a promotion. Delegates work to a review_#{trust_level} method. + # Returns true if the user was promoted, false otherwise. + def review + # nil users are never promoted + return false if @user.blank? + + trust_key = TrustLevel.Levels.invert[@user.trust_level] + + review_method = :"review_#{trust_key.to_s}" + return send(review_method) if respond_to?(review_method) + + false + end + + def review_new + return false if @user.topics_entered < SiteSetting.basic_requires_topics_entered + return false if @user.posts_read_count < SiteSetting.basic_requires_read_posts + return false if (@user.time_read / 60) < SiteSetting.basic_requires_time_spent_mins + + @user.trust_level = TrustLevel.Levels[:basic] + @user.save + + true + end + +end diff --git a/lib/rate_limiter.rb b/lib/rate_limiter.rb new file mode 100644 index 00000000000..74c99881290 --- /dev/null +++ b/lib/rate_limiter.rb @@ -0,0 +1,53 @@ +require_dependency 'rate_limiter/limit_exceeded' +require_dependency 'rate_limiter/on_create_record' + +# A redis backed rate limiter. +class RateLimiter + + # We don't observe rate limits in test mode + def self.disabled? + Rails.env.test? + end + + def initialize(user, key, max, secs) + @user = user + @key = "rate-limit:#{@user.id}:#{key}" + @max = max + @secs = secs + end + + def clear! + $redis.del(@key) + end + + def can_perform? + return true if RateLimiter.disabled? + return true if @user.has_trust_level?(:moderator) + + result = $redis.get(@key) + return true if result.blank? + return true if result.to_i < @max + false + end + + def performed! + return if RateLimiter.disabled? + return if @user.has_trust_level?(:moderator) + + result = $redis.incr(@key).to_i + $redis.expire(@key, @secs) if result == 1 + if result > @max + + # In case we go over, clamp it to the maximum + $redis.decr(@key) + + raise LimitExceeded.new($redis.ttl(@key)) + end + end + + def rollback! + return if RateLimiter.disabled? + $redis.decr(@key) + end + +end diff --git a/lib/rate_limiter/limit_exceeded.rb b/lib/rate_limiter/limit_exceeded.rb new file mode 100644 index 00000000000..aa66b721d7f --- /dev/null +++ b/lib/rate_limiter/limit_exceeded.rb @@ -0,0 +1,11 @@ +class RateLimiter + + # A rate limit has been exceeded. + class LimitExceeded < Exception + attr_accessor :available_in + def initialize(available_in) + @available_in = available_in + end + end + +end \ No newline at end of file diff --git a/lib/rate_limiter/on_create_record.rb b/lib/rate_limiter/on_create_record.rb new file mode 100644 index 00000000000..2c0987594f6 --- /dev/null +++ b/lib/rate_limiter/on_create_record.rb @@ -0,0 +1,61 @@ +class RateLimiter + + # A mixin we can use on ActiveRecord Models to automatically rate limit them + # based on a SiteSetting. + # + # It expects a SiteSetting called `rate_limit_create_{model_name}` where + # `model_name` is the class name of your model, underscored. + # + module OnCreateRecord + + # Over write to define your own rate limiter + def default_rate_limiter + return @rate_limiter if @rate_limiter.present? + + limit_key = "create_#{self.class.name.underscore}" + max_setting = SiteSetting.send("rate_limit_#{limit_key}") + @rate_limiter = RateLimiter.new(user, limit_key, 1, max_setting) + end + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def rate_limit(limiter_method=nil) + + limiter_method = limiter_method || :default_rate_limiter + + self.after_create do + + rate_limiter = send(limiter_method) + return unless rate_limiter.present? + + rate_limiter.performed! + @performed ||= {} + @performed[limiter_method] = true + end + + self.after_destroy do + rate_limiter = send(limiter_method) + return unless rate_limiter.present? + + rate_limiter.rollback! + end + + self.after_rollback do + rate_limiter = send(limiter_method) + return unless rate_limiter.present? + + if @performed.present? and @performed[limiter_method] + rate_limiter.rollback! + @performed[limiter_method] = false + end + end + + end + end + + end + +end \ No newline at end of file diff --git a/lib/remote_ip_improved.rb b/lib/remote_ip_improved.rb new file mode 100644 index 00000000000..8d1b7157988 --- /dev/null +++ b/lib/remote_ip_improved.rb @@ -0,0 +1,129 @@ +# https://github.com/rails/rails/pull/7234 + +class RemoteIpImproved + class IpSpoofAttackError < StandardError ; end + + # IP addresses that are "trusted proxies" that can be stripped from + # the comma-delimited list in the X-Forwarded-For header. See also: + # http://en.wikipedia.org/wiki/Private_network#Private_IPv4_address_spaces + # http://en.wikipedia.org/wiki/Private_network#Private_IPv6_addresses. + TRUSTED_PROXIES = %r{ + ^127\.0\.0\.1$ | # localhost + ^::1$ | + ^(10 | # private IP 10.x.x.x + 172\.(1[6-9]|2[0-9]|3[0-1]) | # private IP in the range 172.16.0.0 .. 172.31.255.255 + 192\.168 | # private IP 192.168.x.x + fc00:: # private IP fc00 + )\. + }x + + attr_reader :check_ip, :proxies + + def initialize(app, check_ip_spoofing = true, custom_proxies = nil) + @app = app + @check_ip = check_ip_spoofing + @proxies = case custom_proxies + when Regexp + custom_proxies + when nil + TRUSTED_PROXIES + else + Regexp.union(TRUSTED_PROXIES, custom_proxies) + end + end + + def call(env) + env["action_dispatch.remote_ip"] = GetIp.new(env, self) + @app.call(env) + end + + class GetIp + + # IP v4 and v6 (with compression) validation regexp + # https://gist.github.com/1289635 + VALID_IP = %r{ + (^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[0-9]{1,2})(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[0-9]{1,2})){3}$) | # ip v4 + (^( + (([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}) | # ip v6 not abbreviated + (([0-9A-Fa-f]{1,4}:){6}:[0-9A-Fa-f]{1,4}) | # ip v6 with double colon in the end + (([0-9A-Fa-f]{1,4}:){5}:([0-9A-Fa-f]{1,4}:)?[0-9A-Fa-f]{1,4}) | # - ip addresses v6 + (([0-9A-Fa-f]{1,4}:){4}:([0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4}) | # - with + (([0-9A-Fa-f]{1,4}:){3}:([0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4}) | # - double colon + (([0-9A-Fa-f]{1,4}:){2}:([0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4}) | # - in the middle + (([0-9A-Fa-f]{1,4}:){6} ((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3} (\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){1,5}:((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){1}:([0-9A-Fa-f]{1,4}:){0,4}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){0,2}:([0-9A-Fa-f]{1,4}:){0,3}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){0,3}:([0-9A-Fa-f]{1,4}:){0,2}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (([0-9A-Fa-f]{1,4}:){0,4}:([0-9A-Fa-f]{1,4}:){1}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + (::([0-9A-Fa-f]{1,4}:){0,5}((\b((25[0-5])|(1\d{2})|(2[0-4]\d) |(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)) | # ip v6 with compatible to v4 + ([0-9A-Fa-f]{1,4}::([0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4}) | # ip v6 with compatible to v4 + (::([0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4}) | # ip v6 with double colon at the begining + (([0-9A-Fa-f]{1,4}:){1,7}:) # ip v6 without ending + )$) + }x + + def initialize(env, middleware) + @env = env + @middleware = middleware + @calculated_ip = false + end + + # Determines originating IP address. REMOTE_ADDR is the standard + # but will be wrong if the user is behind a proxy. Proxies will set + # HTTP_CLIENT_IP and/or HTTP_X_FORWARDED_FOR, so we prioritize those. + # HTTP_X_FORWARDED_FOR may be a comma-delimited list in the case of + # multiple chained proxies. The first address which is in this list + # if it's not a known proxy will be the originating IP. + # Format of HTTP_X_FORWARDED_FOR: + # client_ip, proxy_ip1, proxy_ip2... + # http://en.wikipedia.org/wiki/X-Forwarded-For + def calculate_ip + client_ip = @env['HTTP_CLIENT_IP'] + forwarded_ip = ips_from('HTTP_X_FORWARDED_FOR').first + remote_addrs = ips_from('REMOTE_ADDR') + + check_ip = client_ip && @middleware.check_ip + if check_ip && forwarded_ip != client_ip + # We don't know which came from the proxy, and which from the user + raise IpSpoofAttackError, "IP spoofing attack?!" \ + "HTTP_CLIENT_IP=#{@env['HTTP_CLIENT_IP'].inspect}" \ + "HTTP_X_FORWARDED_FOR=#{@env['HTTP_X_FORWARDED_FOR'].inspect}" + end + + client_ips = remove_proxies [client_ip, forwarded_ip, remote_addrs].flatten + if client_ips.present? + client_ips.first + else + # If there is no client ip we can return first valid proxy ip from REMOTE_ADDR etc + [remote_addrs, client_ip, forwarded_ip].flatten.find { |ip| valid_ip? ip } + end + end + + def to_s + return @ip if @calculated_ip + @calculated_ip = true + @ip = calculate_ip + end + + private + + def ips_from(header) + @env[header] ? @env[header].strip.split(/[,\s]+/) : [] + end + + def valid_ip?(ip) + ip =~ VALID_IP + end + + def not_a_proxy?(ip) + ip !~ @middleware.proxies + end + + def remove_proxies(ips) + ips.select { |ip| valid_ip?(ip) && not_a_proxy?(ip) } + end + + end + +end diff --git a/lib/score_calculator.rb b/lib/score_calculator.rb new file mode 100644 index 00000000000..456bec7531e --- /dev/null +++ b/lib/score_calculator.rb @@ -0,0 +1,58 @@ +class ScoreCalculator + + def self.default_score_weights + { + reply_count: 5, + like_count: 15, + incoming_link_count: 5, + bookmark_count: 2, + avg_time: 0.05, + reads: 0.2 + } + end + + def initialize(weightings=nil) + @weightings = weightings || ScoreCalculator.default_score_weights + end + + # Calculate the score for all posts based on the weightings + def calculate + + # First update the scores of the posts + exec_sql(post_score_sql, @weightings) + + # Update the best of flag + exec_sql " + UPDATE topics SET has_best_of = + CASE + WHEN like_count >= :likes_required AND + posts_count >= :posts_required AND + EXISTS(SELECT * FROM posts AS p + WHERE p.topic_id = topics.id + AND p.score >= :score_required) THEN true + ELSE false + END", + likes_required: SiteSetting.best_of_likes_required, + posts_required: SiteSetting.best_of_posts_required, + score_required: SiteSetting.best_of_score_threshold + + end + + + private + + def exec_sql(sql, params) + ActiveRecord::Base.exec_sql(sql, params) + end + + # Generate a SQL statement to update the scores of all posts + def post_score_sql + "UPDATE posts SET score = ".tap do |sql| + components = [] + @weightings.keys.each do |k| + components << "COALESCE(#{k.to_s}, 0) * :#{k.to_s}" + end + sql << components.join(" + ") + end + end +end diff --git a/lib/search.rb b/lib/search.rb new file mode 100644 index 00000000000..ab186e4eebc --- /dev/null +++ b/lib/search.rb @@ -0,0 +1,168 @@ +module Search + + def self.min_search_term_length + 3 + end + + def self.per_facet + 5 + end + + def self.facets + %w(topic category user) + end + + def self.user_query_sql + "SELECT 'user' AS type, + u.username_lower AS id, + '/users/' || u.username_lower AS url, + u.username AS title, + u.email, + NULL AS color + FROM users AS u + JOIN users_search s on s.id = u.id + WHERE s.search_data @@ TO_TSQUERY(:query) + ORDER BY last_posted_at desc + " + end + + def self.topic_query_sql + "SELECT 'topic' AS type, + CAST(ft.id AS VARCHAR), + '/t/slug/' || ft.id AS url, + ft.title, + NULL AS email, + NULL AS color + FROM topics AS ft + JOIN posts AS p ON p.topic_id = ft.id AND p.post_number = 1 + JOIN posts_search s on s.id = p.id + WHERE s.search_data @@ TO_TSQUERY(:query) + AND ft.deleted_at IS NULL + AND ft.visible + AND ft.archetype <> '#{Archetype.private_message}' + ORDER BY + TS_RANK_CD(TO_TSVECTOR('english', ft.title), TO_TSQUERY(:query)) desc, + TS_RANK_CD(search_data, TO_TSQUERY(:query)) desc, + bumped_at desc" + end + + + def self.post_query_sql + "SELECT cast('topic' as varchar) AS type, + CAST(ft.id AS VARCHAR), + '/t/slug/' || ft.id || '/' || p.post_number AS url, + ft.title, + NULL AS email, + NULL AS color + FROM topics AS ft + JOIN posts AS p ON p.topic_id = ft.id AND p.post_number <> 1 + JOIN posts_search s on s.id = p.id + WHERE s.search_data @@ TO_TSQUERY(:query) + AND ft.deleted_at IS NULL and p.deleted_at IS NULL + AND ft.visible + AND ft.archetype <> '#{Archetype.private_message}' + ORDER BY + TS_RANK_CD(TO_TSVECTOR('english', ft.title), TO_TSQUERY(:query)) desc, + TS_RANK_CD(search_data, TO_TSQUERY(:query)) desc, + bumped_at desc" + end + + def self.category_query_sql + "SELECT 'category' AS type, + c.name AS id, + '/category/' || c.slug AS url, + c.name AS title, + NULL AS email, + c.color + FROM categories AS c + JOIN categories_search s on s.id = c.id + WHERE s.search_data @@ TO_TSQUERY(:query) + ORDER BY topics_month desc + " + end + + def self.query(term, type_filter=nil) + + return nil if term.blank? + sanitized_term = term.gsub(/[^0-9a-zA-Z_ ]/, '') + + # really short terms are totally pointless + return nil if sanitized_term.blank? || sanitized_term.length < self.min_search_term_length + + terms = sanitized_term.split + terms.map! {|t| "#{t}:*"} + + if type_filter.present? + raise Discourse::InvalidAccess.new("invalid type filter") unless Search.facets.include?(type_filter) + sql = Search.send("#{type_filter}_query_sql") << " LIMIT #{Search.per_facet * Search.facets.size}" + db_result = ActiveRecord::Base.exec_sql(sql , query: terms.join(" & ")) + else + + db_result = [] + [user_query_sql, category_query_sql, topic_query_sql].each do |sql| + sql << " limit " << Search.per_facet.to_s + db_result += ActiveRecord::Base.exec_sql(sql , query: terms.join(" & ")).to_a + end + end + + db_result = db_result.to_a + + expected_topics = 0 + expected_topics = Search.facets.size unless type_filter.present? + expected_topics = Search.per_facet * Search.facets.size if type_filter == 'topic' + if expected_topics > 0 + db_result.each do |row| + expected_topics -= 1 if row['type'] == 'topic' + end + end + if expected_topics > 0 + tmp = ActiveRecord::Base.exec_sql "#{post_query_sql} limit :per_facet", + query: terms.join(" & "), per_facet: expected_topics * 3 + + topic_ids = Set.new db_result.map{|r| r["id"]} + + tmp = tmp.to_a + tmp = tmp.reject{ |i| + if topic_ids.include? i["id"] + true + else + topic_ids << i["id"] + false + end + } + + db_result += tmp[0..expected_topics-1] + end + + + # Group the results by type + grouped = {} + db_result.each do |row| + + type = row.delete('type') + + # Add the slug for topics + row['url'].gsub!('slug', Slug.for(row['title'])) if type == 'topic' + + # Remove attributes when we know they don't matter + row.delete('id') + if type == 'user' + row['avatar_template'] = User.avatar_template(row['email']) + end + row.delete('email') + row.delete('color') unless type == 'category' + + grouped[type] ||= [] + grouped[type] << row + end + + result = grouped.map do |type, results| + {type: type, + name: I18n.t("search.types.#{type}"), + more: type_filter.blank? && (results.size == Search.per_facet), + results: results} + end + result + end + +end diff --git a/lib/site_setting_extension.rb b/lib/site_setting_extension.rb new file mode 100644 index 00000000000..8a1bd859997 --- /dev/null +++ b/lib/site_setting_extension.rb @@ -0,0 +1,230 @@ +module SiteSettingExtension + + module Types + String = 1 + Time = 2 + Fixnum = 3 + Float = 4 + Bool = 5 + Null = 6 + end + + def mutex + @mutex ||= Mutex.new + end + + def current + @@containers ||= {} + @@containers[RailsMultisite::ConnectionManagement.current_db] ||= {} + end + + def defaults + @defaults ||= {} + end + + def setting(name, default = nil, type = nil) + mutex.synchronize do + self.defaults[name] = default + current_value = current.has_key?(name) ? current[name] : default + setup_methods(name, current_value) + end + end + + # just like a setting, except that it is available in javascript via DiscourseSession + def client_setting(name, defualt = nil, type = nil) + setting(name,defualt,type) + @@client_settings ||= [] + @@client_settings << name + end + + def client_settings + @@client_settings + end + + + def client_settings_json + Rails.cache.fetch(SiteSettingExtension.client_settings_cache_key, expires_in: 30.minutes) do + MultiJson.dump(Hash[*@@client_settings.map{|n| [n, self.send(n)]}.flatten]) + end + end + + # Retrieve all settings + def all_settings + @defaults.map do |s, v| + {setting: s, + description: description(s), + default: v, + value: send(s).to_s} + end + end + + def description(setting) + I18n.t("site_settings.#{setting}") + end + + # table is not in the db yet, initial migration, etc + def table_exists? + @table_exists = ActiveRecord::Base.connection.table_exists? 'site_settings' if @table_exists == nil + @table_exists + end + + def self.client_settings_cache_key + "client_settings_json" + end + + # refresh all the site settings + def refresh! + return unless table_exists? + mutex.synchronize do + ensure_listen_for_changes + old = current + changes = [] + deletions = [] + + all_settings = SiteSetting.select([:name,:value,:data_type]) + new_hash = Hash[*(all_settings.map{|s| [s.name.intern, convert(s.value,s.data_type)]}.to_a.flatten)] + + # add defaults + new_hash = defaults.merge(new_hash) + + new_hash.each do |name, value| + changes << [name,value] if !old.has_key?(name) || old[name] != value + end + + old.each do |name,value| + deletions << [name,value] unless new_hash.has_key?(name) + end + + if deletions.length > 0 || changes.length > 0 + @current = new_hash + changes.each do |name, val| + setup_methods name, val + end + deletions.each do |name,val| + setup_methods name, defaults[name] + end + end + + $redis.del(SiteSettingExtension.client_settings_cache_key) + end + end + + def ensure_listen_for_changes + unless @subscribed + pid = process_id + MessageBus.subscribe("/site_settings") do |msg| + message = msg.data + if message["process"] != pid + begin + # picks a db + MessageBus.on_connect.call(msg.site_id) + SiteSetting.refresh! + ensure + MessageBus.on_disconnect.call(msg.site_id) + end + end + end + @subscribed = true + end + end + + def process_id + @@process_id ||= SecureRandom.uuid + end + + def remove_override!(name) + return unless table_exists? + SiteSetting.where(:name => name).destroy_all + end + + def add_override!(name,val) + return unless table_exists? + + setting = SiteSetting.where(:name => name).first + type = get_data_type(defaults[name]) + + if type == Types::Bool && val != true && val != false + val = (val == "t" || val == "true") + end + + if type == Types::Fixnum && !(Fixnum === val) + val = val.to_i + end + + if setting + setting.value = val + setting.data_type = type + setting.save + else + SiteSetting.create!(:name => name, :value => val, :data_type => type) + end + + MessageBus.publish('/site_settings', {process: process_id}) + end + + + protected + + def get_data_type(val) + return Types::Null if val.nil? + + if String === val + Types::String + elsif Fixnum === val + Types::Fixnum + elsif TrueClass === val || FalseClass === val + Types::Bool + else + raise ArgumentError.new :val + end + end + + def convert(value, type) + case type + when Types::Fixnum + value.to_i + when Types::String + value + when Types::Bool + value == "t" + when Types::Null + nil + end + end + + + def setup_methods(name, current_value) + + # trivial multi db support, we can optimize this later + db = RailsMultisite::ConnectionManagement.current_db + + @@containers ||= {} + @@containers[db] ||= {} + @@containers[db][name] = current_value + + setter = ("#{name}=").sub("?","") + + eval "define_singleton_method :#{name} do + c = @@containers[RailsMultisite::ConnectionManagement.current_db] + c = c[name] if c + c + end + + define_singleton_method :#{setter} do |val| + add_override!(:#{name}, val) + refresh! + end + " + end + + def method_missing(method, *args, &block) + as_question = method.to_s.gsub(/\?$/, '') + if respond_to?(as_question) + return send(as_question, *args, &block) + end + super(method, *args, &block) + end + + +end + diff --git a/lib/slug.rb b/lib/slug.rb new file mode 100644 index 00000000000..9678130ed24 --- /dev/null +++ b/lib/slug.rb @@ -0,0 +1,30 @@ +# encoding: utf-8 + +# Generates a slug. This is annoying beacuse it's duplicating what the javascript +# does, but on the other hand slugs are never matched so it's okay if they differ +# a little. +module Slug + + def self.for(string) + + str = string.dup + str.gsub!(/^\s+|\s+$/, '') + str.downcase! + + from = "àáäâèéëêìíïîòóöôùúüûñç·/_,:;." + to = "aaaaeeeeiiiioooouuuunc-------" + + idx = 0 + from.each_char do |c| + str.gsub!(c, to[idx]) + idx += 1 + end + + str.gsub!(/[^a-z0-9 -]/, '') + str.gsub!(/\s+/, '-') + str.gsub!(/\-+/, '-') + + str + end + +end diff --git a/lib/sql_builder.rb b/lib/sql_builder.rb new file mode 100644 index 00000000000..5bf295ed6c4 --- /dev/null +++ b/lib/sql_builder.rb @@ -0,0 +1,48 @@ +class SqlBuilder + + def initialize(template) + @args = {} + @sql = template + @sections = {} + end + + [:set, :where2,:where,:order_by,:limit,:left_join,:join,:offset].each do |k| + define_method k do |data, args = {}| + @args.merge!(args) + @sections[k] ||= [] + @sections[k] << data + self + end + end + + def exec(args = {}) + sql = @sql.dup + @args.merge!(args) + + @sections.each do |k,v| + joined = nil + case k + when :where, :where2 + joined = "WHERE " << v.join(" AND ") + when :join + joined = v.map{|v| "JOIN " << v }.join("\n") + when :left_join + joined = v.map{|v| "LEFT JOIN " << v }.join("\n") + when :limit + joined = "LIMIT " << v.last.to_s + when :offset + joined = "OFFSET " << v.last.to_s + when :order_by + joined = "ORDER BY " << v.join(" , ") + when :set + joined = "SET " << v.join(" , ") + end + + sql.sub!("/*#{k}*/", joined) + end + + ActiveRecord::Base.exec_sql(sql,@args) + end + + +end diff --git a/lib/system_message.rb b/lib/system_message.rb new file mode 100644 index 00000000000..92aaf399031 --- /dev/null +++ b/lib/system_message.rb @@ -0,0 +1,47 @@ +# Handle sending a message to a user from the system. +require_dependency 'post_creator' + +class SystemMessage + + def self.create(recipient, type, params = {}) + self.new(recipient).create(type, params) + end + + def initialize(recipient) + @recipient = recipient + end + + def create(type, params = {}) + + defaults = {site_name: SiteSetting.title, + username: @recipient.username, + user_preferences_url: "#{Discourse.base_url}/users/#{@recipient.username_lower}/preferences", + new_user_tips: I18n.t("system_messages.usage_tips.text_body_template"), + site_password: "", + base_url: Discourse.base_url} + + params = defaults.merge(params) + + if SiteSetting.restrict_access? + params[:site_password] = I18n.t('system_messages.site_password', access_password: SiteSetting.access_password) + end + + title = I18n.t("system_messages.#{type}.subject_template", params) + raw_body = I18n.t("system_messages.#{type}.text_body_template", params) + + PostCreator.create(SystemMessage.system_user, + raw: raw_body, + title: title, + archetype: Archetype.private_message, + target_usernames: @recipient.username) + end + + + # Either returns the system_username user or the first admin. + def self.system_user + user = User.where(username_lower: SiteSetting.system_username).first if SiteSetting.system_username.present? + user = User.where(admin: true).order(:id).first if user.blank? + user + end + +end diff --git a/lib/tasks/add_topic_to_quotes.rake b/lib/tasks/add_topic_to_quotes.rake new file mode 100644 index 00000000000..037e37cd9a7 --- /dev/null +++ b/lib/tasks/add_topic_to_quotes.rake @@ -0,0 +1,9 @@ +desc "Add the topic to quotes" +task "add_topic_to_quotes" => :environment do + Post.where("raw like '%topic:%'").each do |p| + new_raw = p.raw.gsub(/topic:(\d+)\]/, "topic:#{p.topic_id}\"]") + new_cooked = p.cook(new_raw, topic_id: p.topic_id) + Post.update_all ["raw = ?, cooked = ?", new_raw, new_cooked], ["id = ?", p.id] + end +end + diff --git a/lib/tasks/build_test_topic.rake b/lib/tasks/build_test_topic.rake new file mode 100644 index 00000000000..333257dda03 --- /dev/null +++ b/lib/tasks/build_test_topic.rake @@ -0,0 +1,50 @@ +# Build a test topic full of links to test our replaceState/pushState functionality. + +desc 'create pushstate/replacestate test topic' +task 'build_test_topic' => :environment do + puts 'Creating topic' + + + # Acceptable options: + # + # raw - raw text of post + # image_sizes - We can pass a list of the sizes of images in the post as a shortcut. + # + # When replying to a topic: + # topic_id - topic we're replying to + # reply_to_post_number - post number we're replying to + # + # When creating a topic: + # title - New topic title + # archetype - Topic archetype + # category - Category to assign to topic + # target_usernames - comma delimited list of usernames for membership (private message) + # meta_data - Topic meta data hash + evil_trout = User.find_by_username('EvilTrout') + + first_post = PostCreator.new(evil_trout, raw: "This is the original post.", title: "pushState/replaceState test topic").create + topic = first_post.topic + + topic_url = "#{Discourse.base_url}/t/#{Slug.for(topic.title)}/#{topic.id}" + + 99.times do |i| + post_number = (i + 2) + + links = [] + [-30, -10, 10, 30].each do |offset| + where = (post_number + offset) + if where >= 1 and where <= 100 + links << "Link to ##{where}: #{topic_url}/#{where}" + end + end + + raw = < :environment do |t| + require "net/https" + require "uri" + + config = YAML::load(File.open("#{Rails.root}/config/cdn.yml")) + + # pre-stage css/js only for now + a = Dir.glob("#{Rails.root}/public/assets/*").map do |f| + if f =~ /[a-f0-9]{16}\.(css|js)$/ + "/assets/#{f.split('/')[-1]}" + end + end.compact + + puts "pre staging: #{a.join(' ')}" + start = Time.now + + uri = URI.parse("https://client.cdn77.com/api/prefetch") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + + request = Net::HTTP::Post.new(uri.request_uri) + request.set_form_data( + "id" => config["id"], + "login" => config["login"], + "passwd" => config["password"], + "json" => {"prefetch_paths" => a.join("\n")}.to_json + ) + + response = http.request(request) + json = JSON.parse(response.body) + if json["status"] != "ok" + raise "Failed to pre-stage" + end + puts "Done (took: #{((Time.now - start) * 1000.0).to_i}ms)" + +end diff --git a/lib/tasks/export.rake b/lib/tasks/export.rake new file mode 100644 index 00000000000..b19c8acf865 --- /dev/null +++ b/lib/tasks/export.rake @@ -0,0 +1,49 @@ +desc 'export the database' +task 'export', [:output_filename] => :environment do |t, args| + puts 'Starting export...' + output_filename = Jobs::Exporter.new.execute( format: :json, filename: args.output_filename ) + puts 'Export done.' + puts "Output file is in: #{output_filename}", '' +end + +desc 'import from an export file and replace the contents of the current database' +task 'import', [:input_filename] => :environment do |t, args| + puts 'Starting import...' + begin + Jobs::Importer.new.execute( format: :json, filename: args.input_filename ) + puts 'Import done.' + rescue Import::FilenameMissingError + puts '', 'The filename argument was missing.', '', 'Usage:', '' + puts ' rake import[/path/to/export.json.gz]', '' + rescue Import::ImportDisabledError + puts '', 'Imports are not allowed.', 'An admin needs to set allow_import to true in the site settings before imports can be run.', '' + puts 'Import cancelled.', '' + end +end + +desc 'After a successful import, restore the backup tables' +task 'import:rollback' => :environment do |t| + num_backup_tables = User.exec_sql("select count(*) as count from information_schema.tables where table_schema = 'backup'")[0]['count'].to_i + + if User.exec_sql("select count(*) as count from information_schema.schemata where schema_name = 'backup'")[0]['count'].to_i <= 0 + puts "Backup tables don't exist! An import was never performed or the backup tables were dropped.", "Rollback cancelled." + elsif num_backup_tables != Export.models_included_in_export.size + puts "Expected #{Export.models_included_in_export.size} backup tables, but there are #{num_backup_tables}!", "Rollback cancelled." + else + puts 'Starting rollback..' + Jobs::Importer.new.rollback + puts 'Rollback done.' + end +end + +desc 'Allow imports' +task 'import:enable' => :environment do |t| + SiteSetting.allow_import = true + puts 'Imports are now permitted. Disable them with rake import:disable' +end + +desc 'Forbid imports' +task 'import:disable' => :environment do |t| + SiteSetting.allow_import = false + puts 'Imports are now forbidden.' +end \ No newline at end of file diff --git a/lib/tasks/images.rake b/lib/tasks/images.rake new file mode 100644 index 00000000000..8c41681842e --- /dev/null +++ b/lib/tasks/images.rake @@ -0,0 +1,12 @@ +task "images:compress" => :environment do + io = ImageOptim.new + images = Dir.glob("#{Rails.root}/app/**/*.png") + image_sizes = Hash[*images.map{|i| [i,File.size(i)]}.to_a.flatten] + io.optimize_images!(images) do |name, optimized| + if optimized + new_size = File.size(name) + puts "#{name} => from: #{image_sizes[name.to_s]} to: #{new_size}" + end + end +end + diff --git a/lib/tasks/posts.rake b/lib/tasks/posts.rake new file mode 100644 index 00000000000..4499dbf0abd --- /dev/null +++ b/lib/tasks/posts.rake @@ -0,0 +1,23 @@ +desc "walk all posts updating cooked with latest markdown" +task "posts:rebake" => :environment do + RailsMultisite::ConnectionManagement.each_connection do |db| + puts "Re baking post markdown for #{db} , changes are denoted with # , no change with ." + i = 0 + Post.select([:id, :cooked, :raw, :topic_id]).each do |p| + i += 1 + cooked = p.cook(p.raw, topic_id: p.topic_id) + if cooked != p.cooked + Post.exec_sql('update posts set cooked = ? where id = ?', cooked, p.id) + putc "#" + else + putc "." + end + end + puts + puts + puts "#{i} posts done!" + puts "-----------------------------------------------" + puts + + end +end diff --git a/lib/tasks/search.rake b/lib/tasks/search.rake new file mode 100644 index 00000000000..c990e7d7692 --- /dev/null +++ b/lib/tasks/search.rake @@ -0,0 +1,42 @@ +task "search:reindex" => :environment do + RailsMultisite::ConnectionManagement.each_connection do |db| + puts "Reindexing #{db}" + puts "" + puts "Posts:" + Post.exec_sql("select p.id, p.cooked, c.name category, t.title from + posts p + join topics t on t.id = p.topic_id + left join categories c on c.id = t.category_id + ").each do |p| + post_id = p["id"] + cooked = p["cooked"] + title = p["title"] + category = p["cat"] + SearchObserver.update_posts_index(post_id, cooked, title, category) + + putc "." + end + + puts + puts "Users:" + User.exec_sql("select id, name, username from users").each do |u| + id = u["id"] + name = u["name"] + username = u["username"] + SearchObserver.update_users_index(id, username, name) + + putc "." + end + + puts + puts "Categories" + + Category.exec_sql("select id, name from categories").each do |c| + id = c["id"] + name = c["name"] + SearchObserver.update_categories_index(id, name) + end + + puts + end +end diff --git a/lib/tasks/user_actions.rake b/lib/tasks/user_actions.rake new file mode 100644 index 00000000000..39168c911fb --- /dev/null +++ b/lib/tasks/user_actions.rake @@ -0,0 +1,13 @@ +desc "rebuild the user_actions table" +task "user_actions:rebuild" => :environment do + o = UserActionObserver.send :new + MessageBus.off + UserAction.delete_all + PostAction.all.each{|i| o.after_save(i)} + Topic.all.each {|i| o.after_save(i)} + Post.all.each {|i| o.after_save(i)} + Notification.all.each {|i| o.after_save(i)} + # not really needed but who knows + MessageBus.on +end + diff --git a/lib/topic_query.rb b/lib/topic_query.rb new file mode 100644 index 00000000000..591607907a0 --- /dev/null +++ b/lib/topic_query.rb @@ -0,0 +1,156 @@ +# +# Helps us find topics. Returns a TopicList object containing the topics +# found. +# +require_dependency 'topic_list' + +class TopicQuery + + def initialize(user=nil, opts={}) + @user = user + + # Cast to int to avoid sql injection + @user_id = user.id.to_i if @user.present? + + @opts = opts + end + + # Return a list of suggested topics for a topic + def list_suggested_for(topic) + + exclude_topic_ids = [topic.id] + + # If not logged in, return some random results, preferably in this category + if @user.blank? + return TopicList.new(@user, random_suggested_results_for(topic, SiteSetting.suggested_topics, exclude_topic_ids)) + end + + results = unread_results(per_page: SiteSetting.suggested_topics).where('topics.id NOT IN (?)', exclude_topic_ids).all + results_left = SiteSetting.suggested_topics - results.size + + # If we don't have enough results, go to new posts + if results_left > 0 + exclude_topic_ids << results.map {|t| t.id} + exclude_topic_ids.flatten! + + results << new_results(per_page: results_left).where('topics.id NOT IN (?)', exclude_topic_ids).all + results.flatten! + + results_left = SiteSetting.suggested_topics - results.size + + # If we STILL don't have enough results, find random topics + if results_left > 0 + exclude_topic_ids << results.map {|t| t.id} + exclude_topic_ids.flatten! + + results << random_suggested_results_for(topic, results_left, exclude_topic_ids).all + results.flatten! + end + end + + TopicList.new(@user, results) + end + + # The popular view of topics + def list_popular + return_list(unordered: true) do |list| + list.order('CASE WHEN topics.category_id IS NULL and topics.pinned THEN 0 ELSE 1 END, topics.bumped_at DESC') + end + end + + # The favorited topics + def list_favorited + return_list do |list| + list.joins("INNER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.starred AND tu.user_id = #{@user_id})") + end + end + + def list_read + return_list(unordered: true) do |list| + list + .joins("INNER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{@user_id})") + .order('COALESCE(tu.last_visited_at, topics.bumped_at) DESC') + end + end + + def list_new + TopicList.new(@user, new_results) + end + + def list_unread + TopicList.new(@user, unread_results) + end + + def list_posted + return_list do |list| + list.joins("INNER JOIN topic_users AS tu ON (tu.topic_id = topics.id AND tu.posted AND tu.user_id = #{@user_id})") + end + end + + def list_uncategorized + return_list {|l| l.where(category_id: nil).order('topics.pinned desc')} + end + + def list_category(category) + return_list {|l| l.where(category_id: category.id).order('topics.pinned desc')} + end + + def unread_count + unread_results(limit: false).count + end + + def new_count + new_results(limit: false).count + end + + protected + + def return_list(list_opts={}) + TopicList.new(@user, yield(default_list(list_opts))) + end + + # Create a list based on a bunch of detault options + def default_list(list_opts={}) + + query_opts = @opts.merge(list_opts) + page_size = query_opts[:per_page] || SiteSetting.topics_per_page + + result = Topic + result = result.topic_list_order unless query_opts[:unordered] + result = result.listable_topics.includes(:category) + result = result.where('categories.name is null or categories.name <> ?', query_opts[:exclude_category]) if query_opts[:exclude_category] + result = result.where('categories.name = ?', query_opts[:only_category]) if query_opts[:only_category] + result = result.limit(page_size) unless query_opts[:limit] == false + result = result.visible if @user.blank? or @user.regular? + result = result.where('topics.id <> ?', query_opts[:except_topic_id]) if query_opts[:except_topic_id].present? + result = result.offset(query_opts[:page].to_i * page_size) if query_opts[:page].present? + result + end + + def new_results(list_opts={}) + date = @user.previous_visit_at + date = @user.created_at unless date + + default_list(list_opts) + .joins("LEFT OUTER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{@user_id})") + .where("topics.created_at >= :created_at", created_at: date) + .where("tu.last_read_post_number IS NULL") + .where("COALESCE(tu.notification_level, :tracking) >= :tracking", tracking: TopicUser::NotificationLevel::TRACKING) + end + + def unread_results(list_opts={}) + default_list(list_opts) + .joins("INNER JOIN topic_users AS tu ON (topics.id = tu.topic_id AND tu.user_id = #{@user_id} AND tu.last_read_post_number < topics.highest_post_number)") + .where("COALESCE(tu.notification_level, :regular) >= :tracking", regular: TopicUser::NotificationLevel::REGULAR, tracking: TopicUser::NotificationLevel::TRACKING) + end + + def random_suggested_results_for(topic, count, exclude_topic_ids) + results = default_list(unordered: true, per_page: count) + .where('topics.id NOT IN (?)', exclude_topic_ids) + .order('RANDOM()') + + results = results.where('category_id = ?', topic.category_id) if topic.category_id.present? + results + end + +end diff --git a/lib/topic_view.rb b/lib/topic_view.rb new file mode 100644 index 00000000000..cd4cbea2165 --- /dev/null +++ b/lib/topic_view.rb @@ -0,0 +1,206 @@ +require_dependency 'guardian' +require_dependency 'topic_query' + +class TopicView + + attr_accessor :topic, :min, :max, :draft, :draft_key, :draft_sequence + + def initialize(topic_id, user=nil, options={}) + @topic = Topic.where(id: topic_id).includes(:category).first + raise Discourse::NotFound if @topic.blank? + + # Special case: If the topic is private and the user isn't logged in, ask them + # to log in! + if @topic.present? and @topic.private_message? and user.blank? + raise Discourse::NotLoggedIn.new + end + + Guardian.new(user).ensure_can_see!(@topic) + @min, @max = 1, SiteSetting.posts_per_page + @posts = @topic.posts + + + @posts = @posts.with_deleted if user.try(:admin?) + @posts = @posts.best_of if options[:best_of].present? + + if options[:username_filters].present? + usernames = options[:username_filters].map{|u| u.downcase} + @posts = @posts.where('post_number = 1 or user_id in (select u.id from users u where username_lower in (?))', usernames) + end + + @user = user + @initial_load = true + + end + + # Filter to all posts near a particular post number + def filter_posts_near(post_number) + @min, @max = post_range(post_number) + filter_posts_in_range(@min, @max) + end + + def filter_posts_in_range(min, max) + @min, @max = min, max + @posts = @posts.where("post_number between ? and ?", @min, @max).includes(:user).regular_order + end + + + def post_numbers + @post_numbers ||= @posts.order(:post_number).pluck(:post_number) + end + + def filter_posts_paged(page) + page ||= 0 + min = (SiteSetting.posts_per_page * page) + max = min + SiteSetting.posts_per_page + + max_val = (post_numbers.length - 1) + + # If we're off the charts, return nil + return nil if min > max_val + + # Pin max to the last post + max = max_val if max > max_val + + filter_posts_in_range(post_numbers[min], post_numbers[max]) + end + + # Filter to all posts before a particular post number + def filter_posts_before(post_number) + @initial_load = false + @max = post_number - 1 + + @posts = @posts.reverse_order.where("post_number < ?", post_number) + @posts = @posts.includes(:topic).joins(:user).limit(SiteSetting.posts_per_page) + @min = @max - @posts.size + @min = 1 if @min < 1 + end + + # Filter to all posts after a particular post number + def filter_posts_after(post_number) + @initial_load = false + @min = post_number + @posts = @posts.regular_order.where("post_number > ?", post_number) + @posts = @posts.includes(:topic).joins(:user).limit(SiteSetting.posts_per_page) + @max = @min + @posts.size + end + + def posts + @posts + end + + def read?(post_number) + read_posts_set.include?(post_number) + end + + def topic_user + @topic_user ||= begin + return nil if @user.blank? + @topic.topic_users.where(user_id: @user.id).first + end + end + + def posts_count + @posts_count ||= Post.where(topic_id: @topic.id).group(:user_id).order('count_all desc').limit(24).count + end + + def participants + @participants ||= begin + participants = {} + User.where(id: posts_count.map {|k,v| k}).each {|u| participants[u.id] = u} + participants + end + end + + def all_post_actions + @all_post_actions ||= PostAction.counts_for(posts, @user) + end + + def voted_in_topic? + return false + + # all post_actions is not the way to do this, cut down on the query, roll it up into topic if we need it + + @voted_in_topic ||= begin + return false unless all_post_actions.present? + all_post_actions.values.flatten.map {|ac| ac.keys}.flatten.include?(PostActionType.Types[:vote]) + end + end + + def post_action_visibility + @post_action_visibility ||= begin + result = [] + PostActionType.Types.each do |k, v| + result << v if Guardian.new(@user).can_see_post_actors?(@topic, v) + end + result + end + end + + def links + @links ||= @topic.links_grouped + end + + def link_counts + @link_counts ||= TopicLinkClick.counts_for(@topic, posts) + end + + # Binary search for closest value + def self.closest(array, target, min, max) + return min if max <= min + return max if (max - min) == 1 + + middle_idx = ((min + max) / 2).floor + middle_val = array[middle_idx] + + return middle_idx if target == middle_val + return closest(array, target, min, middle_idx) if middle_val > target + return closest(array, target, middle_idx, max) + end + + # Find a range of posts, allowing for gaps by deleted posts. + def post_range(post_number) + closest_index = TopicView.closest(post_numbers, post_number, 0, post_numbers.size - 1) + + min_idx = closest_index - (SiteSetting.posts_per_page.to_f / 4).floor + min_idx = 0 if min_idx < 0 + max_idx = min_idx + (SiteSetting.posts_per_page - 1) + if max_idx > (post_numbers.length - 1) + max_idx = post_numbers.length - 1 + min_idx = max_idx - SiteSetting.posts_per_page + min_idx = 0 if min_idx < 0 + end + + [post_numbers[min_idx], post_numbers[max_idx]] + end + + # Are we the initial page load? If so, we can return extra information like + # user post counts, etc. + def initial_load? + @initial_load + end + + def suggested_topics + return nil if topic.private_message? + @suggested_topics ||= TopicQuery.new(@user).list_suggested_for(topic) + end + + protected + + def read_posts_set + @read_posts_set ||= begin + result = Set.new + return result unless @user.present? + return result unless topic_user.present? + + posts_max = @max > (topic_user.last_read_post_number || 1 ) ? (topic_user.last_read_post_number || 1) : @max + + PostTiming.select(:post_number) + .where("topic_id = ? AND user_id = ? AND post_number BETWEEN ? AND ?", + @topic.id, @user.id, @min, posts_max) + .each {|t| result << t.post_number} + result + end + end + +end diff --git a/lib/trust_level.rb b/lib/trust_level.rb new file mode 100644 index 00000000000..586d4a2471d --- /dev/null +++ b/lib/trust_level.rb @@ -0,0 +1,28 @@ +class TrustLevel + + attr_reader :id, :name + + def self.Levels + {:new => 0, + :basic => 1, + :regular => 2, + :experienced => 3, + :advanced => 4, + :moderator => 5} + end + + def self.all + self.Levels.map do |name_key, id| + TrustLevel.new(name_key, id) + end + end + + def initialize(name_key, id) + @name = I18n.t("trust_levels.#{name_key}.title") + @id = id + end + + def serializable_hash + {id: @id, name: @name} + end +end diff --git a/lib/unread.rb b/lib/unread.rb new file mode 100644 index 00000000000..6ac8f273eda --- /dev/null +++ b/lib/unread.rb @@ -0,0 +1,33 @@ +class Unread + + # This module helps us calculate unread and new post counts + + def initialize(topic, topic_user) + @topic = topic + @topic_user = topic_user + end + + + def unread_posts + return 0 if do_not_notify?(@topic_user.notification_level) + result = ((@topic_user.seen_post_count||0) - (@topic_user.last_read_post_number||0)) + result = 0 if result < 0 + result + end + + def new_posts + return 0 if @topic_user.seen_post_count.blank? + return 0 if do_not_notify?(@topic_user.notification_level) + + new_posts = (@topic.highest_post_number - @topic_user.seen_post_count) + new_posts = 0 if new_posts < 0 + return new_posts + end + + protected + + def do_not_notify?(notification_level) + [TopicUser::NotificationLevel::MUTED, TopicUser::NotificationLevel::REGULAR].include?(notification_level) + end + +end diff --git a/lib/version.rb b/lib/version.rb new file mode 100644 index 00000000000..9019d804cb0 --- /dev/null +++ b/lib/version.rb @@ -0,0 +1,10 @@ +module Discourse + module VERSION #:nodoc: + MAJOR = 0 + MINOR = 8 + TINY = 0 + PRE = nil + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') + end +end \ No newline at end of file diff --git a/log/.gitkeep b/log/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/public/403.html b/public/403.html new file mode 100644 index 00000000000..ba61372267f --- /dev/null +++ b/public/403.html @@ -0,0 +1,27 @@ + + + + You can't do that (403) + + + + +
            +

            403

            +

            You can't view that resource!

            + +

            This will be replaced by a custom Discourse 403 page.

            +
            + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 00000000000..4a8c7246b00 --- /dev/null +++ b/public/404.html @@ -0,0 +1,25 @@ + + + + The resource you wanted can't be found (404) + + + + + +
            +

            The resource you want can't be found.

            +
            + + diff --git a/public/404.json b/public/404.json new file mode 100644 index 00000000000..79eb0b9aa1f --- /dev/null +++ b/public/404.json @@ -0,0 +1 @@ +{"failed": true, "status_code": 404} \ No newline at end of file diff --git a/public/422.html b/public/422.html new file mode 100644 index 00000000000..83660ab1878 --- /dev/null +++ b/public/422.html @@ -0,0 +1,26 @@ + + + + The change you wanted was rejected (422) + + + + + +
            +

            The change you wanted was rejected.

            +

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

            +
            + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 00000000000..10a046c3223 --- /dev/null +++ b/public/500.html @@ -0,0 +1,12 @@ + + + + Oops - Error 500 + + +

            Oops

            +

            The software powering this discussion forum encountered an unexpected problem. We apologize for the inconvenience.

            +

            Detailed information about the error was logged, and an automatic notification. We'll take a look at it.

            +

            No further action is necessary. Howeever, if the error condition persists, you can provide additional detail, including steps to reproduce the error, by posting a discussion topic in the meta category.

            + + diff --git a/public/503.html b/public/503.html new file mode 100644 index 00000000000..739380597e0 --- /dev/null +++ b/public/503.html @@ -0,0 +1,11 @@ + + + + Site Is Undergoing Maintenance - Discourse.org + + +

            We are currently down for planned site maintenance

            +

            Please check back in a few minutes.

            +

            Sorry for the inconvenience!

            + + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000000..25dd82c5c1c Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/javascripts/highlight-handlebars.pack.js b/public/javascripts/highlight-handlebars.pack.js new file mode 100644 index 00000000000..7bd9fac6f50 --- /dev/null +++ b/public/javascripts/highlight-handlebars.pack.js @@ -0,0 +1 @@ +var hljs=new function(){function l(o){return o.replace(/&/gm,"&").replace(//gm,">")}function b(p){for(var o=p.firstChild;o;o=o.nextSibling){if(o.nodeName=="CODE"){return o}if(!(o.nodeType==3&&o.nodeValue.match(/\s+/))){break}}}function h(p,o){return Array.prototype.map.call(p.childNodes,function(q){if(q.nodeType==3){return o?q.nodeValue.replace(/\n/g,""):q.nodeValue}if(q.nodeName=="BR"){return"\n"}return h(q,o)}).join("")}function a(q){var p=(q.className+" "+q.parentNode.className).split(/\s+/);p=p.map(function(r){return r.replace(/^language-/,"")});for(var o=0;o"}while(x.length||v.length){var u=t().splice(0,1)[0];y+=l(w.substr(p,u.offset-p));p=u.offset;if(u.event=="start"){y+=s(u.node);r.push(u.node)}else{if(u.event=="stop"){var o,q=r.length;do{q--;o=r[q];y+=("")}while(o!=u.node);r.splice(q,1);while(q'+L[0]+""}else{r+=L[0]}N=A.lR.lastIndex;L=A.lR.exec(K)}return r+K.substr(N)}function z(){if(A.sL&&!e[A.sL]){return l(w)}var r=A.sL?d(A.sL,w):g(w);if(A.r>0){v+=r.keyword_count;B+=r.r}return''+r.value+""}function J(){return A.sL!==undefined?z():G()}function I(L,r){var K=L.cN?'':"";if(L.rB){x+=K;w=""}else{if(L.eB){x+=l(r)+K;w=""}else{x+=K;w=r}}A=Object.create(L,{parent:{value:A}});B+=L.r}function C(K,r){w+=K;if(r===undefined){x+=J();return 0}var L=o(r,A);if(L){x+=J();I(L,r);return L.rB?0:r.length}var M=s(A,r);if(M){if(!(M.rE||M.eE)){w+=r}x+=J();do{if(A.cN){x+=""}A=A.parent}while(A!=M.parent);if(M.eE){x+=l(r)}w="";if(M.starts){I(M.starts,"")}return M.rE?0:r.length}if(t(r,A)){throw"Illegal"}w+=r;return r.length||1}var F=e[D];f(F);var A=F;var w="";var B=0;var v=0;var x="";try{var u,q,p=0;while(true){A.t.lastIndex=p;u=A.t.exec(E);if(!u){break}q=C(E.substr(p,u.index-p),u[0]);p=u.index+q}C(E.substr(p));return{r:B,keyword_count:v,value:x,language:D}}catch(H){if(H=="Illegal"){return{r:0,keyword_count:0,value:l(E)}}else{throw H}}}function g(s){var o={keyword_count:0,r:0,value:l(s)};var q=o;for(var p in e){if(!e.hasOwnProperty(p)){continue}var r=d(p,s);r.language=p;if(r.keyword_count+r.r>q.keyword_count+q.r){q=r}if(r.keyword_count+r.r>o.keyword_count+o.r){q=o;o=r}}if(q.language){o.second_best=q}return o}function i(q,p,o){if(p){q=q.replace(/^((<[^>]+>|\t)+)/gm,function(r,v,u,t){return v.replace(/\t/g,p)})}if(o){q=q.replace(/\n/g,"
            ")}return q}function m(r,u,p){var v=h(r,p);var t=a(r);if(t=="no-highlight"){return}var w=t?d(t,v):g(v);t=w.language;var o=c(r);if(o.length){var q=document.createElement("pre");q.innerHTML=w.value;w.value=j(o,c(q),v)}w.value=i(w.value,u,p);var s=r.className;if(!s.match("(\\s|^)(language-)?"+t+"(\\s|$)")){s=s?(s+" "+t):t}r.innerHTML=w.value;r.className=s;r.result={language:t,kw:w.keyword_count,re:w.r};if(w.second_best){r.second_best={language:w.second_best.language,kw:w.second_best.keyword_count,re:w.second_best.r}}}function n(){if(n.called){return}n.called=true;Array.prototype.map.call(document.getElementsByTagName("pre"),b).filter(Boolean).forEach(function(o){m(o,hljs.tabReplace)})}function k(){window.addEventListener("DOMContentLoaded",n,false);window.addEventListener("load",n,false)}var e={};this.LANGUAGES=e;this.highlight=d;this.highlightAuto=g;this.fixMarkup=i;this.highlightBlock=m;this.initHighlighting=n;this.initHighlightingOnLoad=k;this.IR="[a-zA-Z][a-zA-Z0-9_]*";this.UIR="[a-zA-Z_][a-zA-Z0-9_]*";this.NR="\\b\\d+(\\.\\d+)?";this.CNR="(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";this.BNR="\\b(0b[01]+)";this.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|\\.|-|-=|/|/=|:|;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";this.BE={b:"\\\\[\\s\\S]",r:0};this.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[this.BE],r:0};this.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[this.BE],r:0};this.CLCM={cN:"comment",b:"//",e:"$"};this.CBLCLM={cN:"comment",b:"/\\*",e:"\\*/"};this.HCM={cN:"comment",b:"#",e:"$"};this.NM={cN:"number",b:this.NR,r:0};this.CNM={cN:"number",b:this.CNR,r:0};this.BNM={cN:"number",b:this.BNR,r:0};this.inherit=function(q,r){var o={};for(var p in q){o[p]=q[p]}if(r){for(var p in r){o[p]=r[p]}}return o}}();hljs.LANGUAGES["1c"]=function(b){var f="[a-zA-Zа-яА-Я][a-zA-Z0-9_а-яА-Я]*";var c="возврат дата для если и или иначе иначеесли исключение конецесли конецпопытки конецпроцедуры конецфункции конеццикла константа не перейти перем перечисление по пока попытка прервать продолжить процедура строка тогда фс функция цикл число экспорт";var e="ansitooem oemtoansi ввестивидсубконто ввестидату ввестизначение ввестиперечисление ввестипериод ввестиплансчетов ввестистроку ввестичисло вопрос восстановитьзначение врег выбранныйплансчетов вызватьисключение датагод датамесяц датачисло добавитьмесяц завершитьработусистемы заголовоксистемы записьжурналарегистрации запуститьприложение зафиксироватьтранзакцию значениевстроку значениевстрокувнутр значениевфайл значениеизстроки значениеизстрокивнутр значениеизфайла имякомпьютера имяпользователя каталогвременныхфайлов каталогиб каталогпользователя каталогпрограммы кодсимв командасистемы конгода конецпериодаби конецрассчитанногопериодаби конецстандартногоинтервала конквартала конмесяца коннедели лев лог лог10 макс максимальноеколичествосубконто мин монопольныйрежим названиеинтерфейса названиенабораправ назначитьвид назначитьсчет найти найтипомеченныенаудаление найтиссылки началопериодаби началостандартногоинтервала начатьтранзакцию начгода начквартала начмесяца начнедели номерднягода номерднянедели номернеделигода нрег обработкаожидания окр описаниеошибки основнойжурналрасчетов основнойплансчетов основнойязык открытьформу открытьформумодально отменитьтранзакцию очиститьокносообщений периодстр полноеимяпользователя получитьвремята получитьдатута получитьдокументта получитьзначенияотбора получитьпозициюта получитьпустоезначение получитьта прав праводоступа предупреждение префиксавтонумерации пустаястрока пустоезначение рабочаядаттьпустоезначение рабочаядата разделительстраниц разделительстрок разм разобратьпозициюдокумента рассчитатьрегистрына рассчитатьрегистрыпо сигнал симв символтабуляции создатьобъект сокрл сокрлп сокрп сообщить состояние сохранитьзначение сред статусвозврата стрдлина стрзаменить стрколичествострок стрполучитьстроку стрчисловхождений сформироватьпозициюдокумента счетпокоду текущаядата текущеевремя типзначения типзначениястр удалитьобъекты установитьтана установитьтапо фиксшаблон формат цел шаблон";var a={cN:"dquote",b:'""'};var d={cN:"string",b:'"',e:'"|$',c:[a],r:0};var g={cN:"string",b:"\\|",e:'"|$',c:[a]};return{cI:true,l:f,k:{keyword:c,built_in:e},c:[b.CLCM,b.NM,d,g,{cN:"function",b:"(процедура|функция)",e:"$",l:f,k:"процедура функция",c:[{cN:"title",b:f},{cN:"tail",eW:true,c:[{cN:"params",b:"\\(",e:"\\)",l:f,k:"знач",c:[d,g]},{cN:"export",b:"экспорт",eW:true,l:f,k:"экспорт",c:[b.CLCM]}]},b.CLCM]},{cN:"preprocessor",b:"#",e:"$"},{cN:"date",b:"'\\d{2}\\.\\d{2}\\.(\\d{2}|\\d{4})'"}]}}(hljs);hljs.LANGUAGES.actionscript=function(a){var d="[a-zA-Z_$][a-zA-Z0-9_$]*";var c="([*]|[a-zA-Z_$][a-zA-Z0-9_$]*)";var e={cN:"rest_arg",b:"[.]{3}",e:d,r:10};var b={cN:"title",b:d};return{k:{keyword:"as break case catch class const continue default delete do dynamic each else extends final finally for function get if implements import in include instanceof interface internal is namespace native new override package private protected public return set static super switch this throw try typeof use var void while with",literal:"true false null undefined"},c:[a.ASM,a.QSM,a.CLCM,a.CBLCLM,a.CNM,{cN:"package",bWK:true,e:"{",k:"package",c:[b]},{cN:"class",bWK:true,e:"{",k:"class interface",c:[{bWK:true,k:"extends implements"},b]},{cN:"preprocessor",bWK:true,e:";",k:"import include"},{cN:"function",bWK:true,e:"[{;]",k:"function",i:"\\S",c:[b,{cN:"params",b:"\\(",e:"\\)",c:[a.ASM,a.QSM,a.CLCM,a.CBLCLM,e]},{cN:"type",b:":",e:c,r:10}]}]}}(hljs);hljs.LANGUAGES.apache=function(a){var b={cN:"number",b:"[\\$%]\\d+"};return{cI:true,k:{keyword:"acceptfilter acceptmutex acceptpathinfo accessfilename action addalt addaltbyencoding addaltbytype addcharset adddefaultcharset adddescription addencoding addhandler addicon addiconbyencoding addiconbytype addinputfilter addlanguage addmoduleinfo addoutputfilter addoutputfilterbytype addtype alias aliasmatch allow allowconnect allowencodedslashes allowoverride anonymous anonymous_logemail anonymous_mustgiveemail anonymous_nouserid anonymous_verifyemail authbasicauthoritative authbasicprovider authdbduserpwquery authdbduserrealmquery authdbmgroupfile authdbmtype authdbmuserfile authdefaultauthoritative authdigestalgorithm authdigestdomain authdigestnccheck authdigestnonceformat authdigestnoncelifetime authdigestprovider authdigestqop authdigestshmemsize authgroupfile authldapbinddn authldapbindpassword authldapcharsetconfig authldapcomparednonserver authldapdereferencealiases authldapgroupattribute authldapgroupattributeisdn authldapremoteuserattribute authldapremoteuserisdn authldapurl authname authnprovideralias authtype authuserfile authzdbmauthoritative authzdbmtype authzdefaultauthoritative authzgroupfileauthoritative authzldapauthoritative authzownerauthoritative authzuserauthoritative balancermember browsermatch browsermatchnocase bufferedlogs cachedefaultexpire cachedirlength cachedirlevels cachedisable cacheenable cachefile cacheignorecachecontrol cacheignoreheaders cacheignorenolastmod cacheignorequerystring cachelastmodifiedfactor cachemaxexpire cachemaxfilesize cacheminfilesize cachenegotiateddocs cacheroot cachestorenostore cachestoreprivate cgimapextension charsetdefault charsetoptions charsetsourceenc checkcaseonly checkspelling chrootdir contentdigest cookiedomain cookieexpires cookielog cookiename cookiestyle cookietracking coredumpdirectory customlog dav davdepthinfinity davgenericlockdb davlockdb davmintimeout dbdexptime dbdkeep dbdmax dbdmin dbdparams dbdpersist dbdpreparesql dbdriver defaulticon defaultlanguage defaulttype deflatebuffersize deflatecompressionlevel deflatefilternote deflatememlevel deflatewindowsize deny directoryindex directorymatch directoryslash documentroot dumpioinput dumpiologlevel dumpiooutput enableexceptionhook enablemmap enablesendfile errordocument errorlog example expiresactive expiresbytype expiresdefault extendedstatus extfilterdefine extfilteroptions fileetag filterchain filterdeclare filterprotocol filterprovider filtertrace forcelanguagepriority forcetype forensiclog gracefulshutdowntimeout group header headername hostnamelookups identitycheck identitychecktimeout imapbase imapdefault imapmenu include indexheadinsert indexignore indexoptions indexorderdefault indexstylesheet isapiappendlogtoerrors isapiappendlogtoquery isapicachefile isapifakeasync isapilognotsupported isapireadaheadbuffer keepalive keepalivetimeout languagepriority ldapcacheentries ldapcachettl ldapconnectiontimeout ldapopcacheentries ldapopcachettl ldapsharedcachefile ldapsharedcachesize ldaptrustedclientcert ldaptrustedglobalcert ldaptrustedmode ldapverifyservercert limitinternalrecursion limitrequestbody limitrequestfields limitrequestfieldsize limitrequestline limitxmlrequestbody listen listenbacklog loadfile loadmodule lockfile logformat loglevel maxclients maxkeepaliverequests maxmemfree maxrequestsperchild maxrequestsperthread maxspareservers maxsparethreads maxthreads mcachemaxobjectcount mcachemaxobjectsize mcachemaxstreamingbuffer mcacheminobjectsize mcacheremovalalgorithm mcachesize metadir metafiles metasuffix mimemagicfile minspareservers minsparethreads mmapfile mod_gzip_on mod_gzip_add_header_count mod_gzip_keep_workfiles mod_gzip_dechunk mod_gzip_min_http mod_gzip_minimum_file_size mod_gzip_maximum_file_size mod_gzip_maximum_inmem_size mod_gzip_temp_dir mod_gzip_item_include mod_gzip_item_exclude mod_gzip_command_version mod_gzip_can_negotiate mod_gzip_handle_methods mod_gzip_static_suffix mod_gzip_send_vary mod_gzip_update_static modmimeusepathinfo multiviewsmatch namevirtualhost noproxy nwssltrustedcerts nwsslupgradeable options order passenv pidfile protocolecho proxybadheader proxyblock proxydomain proxyerroroverride proxyftpdircharset proxyiobuffersize proxymaxforwards proxypass proxypassinterpolateenv proxypassmatch proxypassreverse proxypassreversecookiedomain proxypassreversecookiepath proxypreservehost proxyreceivebuffersize proxyremote proxyremotematch proxyrequests proxyset proxystatus proxytimeout proxyvia readmename receivebuffersize redirect redirectmatch redirectpermanent redirecttemp removecharset removeencoding removehandler removeinputfilter removelanguage removeoutputfilter removetype requestheader require rewritebase rewritecond rewriteengine rewritelock rewritelog rewriteloglevel rewritemap rewriteoptions rewriterule rlimitcpu rlimitmem rlimitnproc satisfy scoreboardfile script scriptalias scriptaliasmatch scriptinterpretersource scriptlog scriptlogbuffer scriptloglength scriptsock securelisten seerequesttail sendbuffersize serveradmin serveralias serverlimit servername serverpath serverroot serversignature servertokens setenv setenvif setenvifnocase sethandler setinputfilter setoutputfilter ssienableaccess ssiendtag ssierrormsg ssistarttag ssitimeformat ssiundefinedecho sslcacertificatefile sslcacertificatepath sslcadnrequestfile sslcadnrequestpath sslcarevocationfile sslcarevocationpath sslcertificatechainfile sslcertificatefile sslcertificatekeyfile sslciphersuite sslcryptodevice sslengine sslhonorciperorder sslmutex ssloptions sslpassphrasedialog sslprotocol sslproxycacertificatefile sslproxycacertificatepath sslproxycarevocationfile sslproxycarevocationpath sslproxyciphersuite sslproxyengine sslproxymachinecertificatefile sslproxymachinecertificatepath sslproxyprotocol sslproxyverify sslproxyverifydepth sslrandomseed sslrequire sslrequiressl sslsessioncache sslsessioncachetimeout sslusername sslverifyclient sslverifydepth startservers startthreads substitute suexecusergroup threadlimit threadsperchild threadstacksize timeout traceenable transferlog typesconfig unsetenv usecanonicalname usecanonicalphysicalport user userdir virtualdocumentroot virtualdocumentrootip virtualscriptalias virtualscriptaliasip win32disableacceptex xbithack",literal:"on off"},c:[a.HCM,{cN:"sqbracket",b:"\\s\\[",e:"\\]$"},{cN:"cbracket",b:"[\\$%]\\{",e:"\\}",c:["self",b]},b,{cN:"tag",b:""},a.QSM]}}(hljs);hljs.LANGUAGES.applescript=function(a){var b=a.inherit(a.QSM,{i:""});var e={cN:"title",b:a.UIR};var d={cN:"params",b:"\\(",e:"\\)",c:["self",a.CNM,b]};var c=[{cN:"comment",b:"--",e:"$",},{cN:"comment",b:"\\(\\*",e:"\\*\\)",c:["self",{b:"--",e:"$"}]},a.HCM];return{k:{keyword:"about above after against and around as at back before beginning behind below beneath beside between but by considering contain contains continue copy div does eighth else end equal equals error every exit fifth first for fourth from front get given global if ignoring in into is it its last local me middle mod my ninth not of on onto or over prop property put ref reference repeat returning script second set seventh since sixth some tell tenth that the then third through thru timeout times to transaction try until where while whose with without",constant:"AppleScript false linefeed return pi quote result space tab true",type:"alias application boolean class constant date file integer list number real record string text",command:"activate beep count delay launch log offset read round run say summarize write",property:"character characters contents day frontmost id item length month name paragraph paragraphs rest reverse running time version weekday word words year"},c:[b,a.CNM,{cN:"type",b:"\\bPOSIX file\\b"},{cN:"command",b:"\\b(clipboard info|the clipboard|info for|list (disks|folder)|mount volume|path to|(close|open for) access|(get|set) eof|current date|do shell script|get volume settings|random number|set volume|system attribute|system info|time to GMT|(load|run|store) script|scripting components|ASCII (character|number)|localized string|choose (application|color|file|file name|folder|from list|remote application|URL)|display (alert|dialog))\\b|^\\s*return\\b"},{cN:"constant",b:"\\b(text item delimiters|current application|missing value)\\b"},{cN:"keyword",b:"\\b(apart from|aside from|instead of|out of|greater than|isn't|(doesn't|does not) (equal|come before|come after|contain)|(greater|less) than( or equal)?|(starts?|ends|begins?) with|contained by|comes (before|after)|a (ref|reference))\\b"},{cN:"property",b:"\\b(POSIX path|(date|time) string|quoted form)\\b"},{cN:"function_start",bWK:true,k:"on",i:"[${=;\\n]",c:[e,d]}].concat(c)}}(hljs);hljs.LANGUAGES.avrasm=function(a){return{cI:true,k:{keyword:"adc add adiw and andi asr bclr bld brbc brbs brcc brcs break breq brge brhc brhs brid brie brlo brlt brmi brne brpl brsh brtc brts brvc brvs bset bst call cbi cbr clc clh cli cln clr cls clt clv clz com cp cpc cpi cpse dec eicall eijmp elpm eor fmul fmuls fmulsu icall ijmp in inc jmp ld ldd ldi lds lpm lsl lsr mov movw mul muls mulsu neg nop or ori out pop push rcall ret reti rjmp rol ror sbc sbr sbrc sbrs sec seh sbi sbci sbic sbis sbiw sei sen ser ses set sev sez sleep spm st std sts sub subi swap tst wdr",built_in:"r0 r1 r2 r3 r4 r5 r6 r7 r8 r9 r10 r11 r12 r13 r14 r15 r16 r17 r18 r19 r20 r21 r22 r23 r24 r25 r26 r27 r28 r29 r30 r31 x|0 xh xl y|0 yh yl z|0 zh zl ucsr1c udr1 ucsr1a ucsr1b ubrr1l ubrr1h ucsr0c ubrr0h tccr3c tccr3a tccr3b tcnt3h tcnt3l ocr3ah ocr3al ocr3bh ocr3bl ocr3ch ocr3cl icr3h icr3l etimsk etifr tccr1c ocr1ch ocr1cl twcr twdr twar twsr twbr osccal xmcra xmcrb eicra spmcsr spmcr portg ddrg ping portf ddrf sreg sph spl xdiv rampz eicrb eimsk gimsk gicr eifr gifr timsk tifr mcucr mcucsr tccr0 tcnt0 ocr0 assr tccr1a tccr1b tcnt1h tcnt1l ocr1ah ocr1al ocr1bh ocr1bl icr1h icr1l tccr2 tcnt2 ocr2 ocdr wdtcr sfior eearh eearl eedr eecr porta ddra pina portb ddrb pinb portc ddrc pinc portd ddrd pind spdr spsr spcr udr0 ucsr0a ucsr0b ubrr0l acsr admux adcsr adch adcl porte ddre pine pinf"},c:[a.CBLCLM,{cN:"comment",b:";",e:"$"},a.CNM,a.BNM,{cN:"number",b:"\\b(\\$[a-zA-Z0-9]+|0o[0-7]+)"},a.QSM,{cN:"string",b:"'",e:"[^\\\\]'",i:"[^\\\\][^']"},{cN:"label",b:"^[A-Za-z0-9_.$]+:"},{cN:"preprocessor",b:"#",e:"$"},{cN:"preprocessor",b:"\\.[a-zA-Z]+"},{cN:"localvars",b:"@[0-9]+"}]}}(hljs);hljs.LANGUAGES.axapta=function(a){return{k:"false int abstract private char interface boolean static null if for true while long throw finally protected extends final implements return void enum else break new catch byte super class case short default double public try this switch continue reverse firstfast firstonly forupdate nofetch sum avg minof maxof count order group by asc desc index hint like dispaly edit client server ttsbegin ttscommit str real date container anytype common div mod",c:[a.CLCM,a.CBLCLM,a.ASM,a.QSM,a.CNM,{cN:"preprocessor",b:"#",e:"$"},{cN:"class",bWK:true,e:"{",i:":",k:"class interface",c:[{cN:"inheritance",bWK:true,k:"extends implements",r:10},{cN:"title",b:a.UIR}]}]}}(hljs);hljs.LANGUAGES.bash=function(a){var g="true false";var e="if then else elif fi for break continue while in do done echo exit return set declare";var c={cN:"variable",b:"\\$[a-zA-Z0-9_#]+"};var b={cN:"variable",b:"\\${([^}]|\\\\})+}"};var h={cN:"string",b:'"',e:'"',i:"\\n",c:[a.BE,c,b],r:0};var d={cN:"string",b:"'",e:"'",c:[{b:"''"}],r:0};var f={cN:"test_condition",b:"",e:"",c:[h,d,c,b],k:{literal:g},r:0};return{k:{keyword:e,literal:g},c:[{cN:"shebang",b:"(#!\\/bin\\/bash)|(#!\\/bin\\/sh)",r:10},c,b,a.HCM,h,d,a.inherit(f,{b:"\\[ ",e:" \\]",r:0}),a.inherit(f,{b:"\\[\\[ ",e:" \\]\\]"})]}}(hljs);hljs.LANGUAGES.brainfuck=function(a){return{c:[{cN:"comment",b:"[^\\[\\]\\.,\\+\\-<> \r\n]",eE:true,e:"[\\[\\]\\.,\\+\\-<> \r\n]",r:0},{cN:"title",b:"[\\[\\]]",r:0},{cN:"string",b:"[\\.,]"},{cN:"literal",b:"[\\+\\-]"}]}}(hljs);hljs.LANGUAGES.clojure=function(l){var e={built_in:"def cond apply if-not if-let if not not= = < < > <= <= >= == + / * - rem quot neg? pos? delay? symbol? keyword? true? false? integer? empty? coll? list? set? ifn? fn? associative? sequential? sorted? counted? reversible? number? decimal? class? distinct? isa? float? rational? reduced? ratio? odd? even? char? seq? vector? string? map? nil? contains? zero? instance? not-every? not-any? libspec? -> ->> .. . inc compare do dotimes mapcat take remove take-while drop letfn drop-last take-last drop-while while intern condp case reduced cycle split-at split-with repeat replicate iterate range merge zipmap declare line-seq sort comparator sort-by dorun doall nthnext nthrest partition eval doseq await await-for let agent atom send send-off release-pending-sends add-watch mapv filterv remove-watch agent-error restart-agent set-error-handler error-handler set-error-mode! error-mode shutdown-agents quote var fn loop recur throw try monitor-enter monitor-exit defmacro defn defn- macroexpand macroexpand-1 for doseq dosync dotimes and or when when-not when-let comp juxt partial sequence memoize constantly complement identity assert peek pop doto proxy defstruct first rest cons defprotocol cast coll deftype defrecord last butlast sigs reify second ffirst fnext nfirst nnext defmulti defmethod meta with-meta ns in-ns create-ns import intern refer keys select-keys vals key val rseq name namespace promise into transient persistent! conj! assoc! dissoc! pop! disj! import use class type num float double short byte boolean bigint biginteger bigdec print-method print-dup throw-if throw printf format load compile get-in update-in pr pr-on newline flush read slurp read-line subvec with-open memfn time ns assert re-find re-groups rand-int rand mod locking assert-valid-fdecl alias namespace resolve ref deref refset swap! reset! set-validator! compare-and-set! alter-meta! reset-meta! commute get-validator alter ref-set ref-history-count ref-min-history ref-max-history ensure sync io! new next conj set! memfn to-array future future-call into-array aset gen-class reduce merge map filter find empty hash-map hash-set sorted-map sorted-map-by sorted-set sorted-set-by vec vector seq flatten reverse assoc dissoc list disj get union difference intersection extend extend-type extend-protocol int nth delay count concat chunk chunk-buffer chunk-append chunk-first chunk-rest max min dec unchecked-inc-int unchecked-inc unchecked-dec-inc unchecked-dec unchecked-negate unchecked-add-int unchecked-add unchecked-subtract-int unchecked-subtract chunk-next chunk-cons chunked-seq? prn vary-meta lazy-seq spread list* str find-keyword keyword symbol gensym force rationalize"};var f="[a-zA-Z_0-9\\!\\.\\?\\-\\+\\*\\/\\<\\=\\>\\&\\#\\$';]+";var a="[\\s:\\(\\{]+\\d+(\\.\\d+)?";var d={cN:"number",b:a,r:0};var j={cN:"string",b:'"',e:'"',c:[l.BE],r:0};var o={cN:"comment",b:";",e:"$",r:0};var n={cN:"collection",b:"[\\[\\{]",e:"[\\]\\}]"};var c={cN:"comment",b:"\\^"+f};var b={cN:"comment",b:"\\^\\{",e:"\\}"};var h={cN:"attribute",b:"[:]"+f};var m={cN:"list",b:"\\(",e:"\\)",r:0};var g={eW:true,eE:true,k:{literal:"true false nil"},r:0};var i={k:e,l:f,cN:"title",b:f,starts:g};m.c=[{cN:"comment",b:"comment"},i];g.c=[m,j,c,b,o,h,n,d];n.c=[m,j,c,o,h,n,d];return{i:"\\S",c:[o,m]}}(hljs);hljs.LANGUAGES.cmake=function(a){return{cI:true,k:"add_custom_command add_custom_target add_definitions add_dependencies add_executable add_library add_subdirectory add_test aux_source_directory break build_command cmake_minimum_required cmake_policy configure_file create_test_sourcelist define_property else elseif enable_language enable_testing endforeach endfunction endif endmacro endwhile execute_process export find_file find_library find_package find_path find_program fltk_wrap_ui foreach function get_cmake_property get_directory_property get_filename_component get_property get_source_file_property get_target_property get_test_property if include include_directories include_external_msproject include_regular_expression install link_directories load_cache load_command macro mark_as_advanced message option output_required_files project qt_wrap_cpp qt_wrap_ui remove_definitions return separate_arguments set set_directory_properties set_property set_source_files_properties set_target_properties set_tests_properties site_name source_group string target_link_libraries try_compile try_run unset variable_watch while build_name exec_program export_library_dependencies install_files install_programs install_targets link_libraries make_directory remove subdir_depends subdirs use_mangled_mesa utility_source variable_requires write_file",c:[{cN:"envvar",b:"\\${",e:"}"},a.HCM,a.QSM,a.NM]}}(hljs);hljs.LANGUAGES.coffeescript=function(c){var b={keyword:"in if for while finally new do return else break catch instanceof throw try this switch continue typeof delete debugger super then unless until loop of by when and or is isnt not",literal:"true false null undefined yes no on off ",reserved:"case default function var void with const let enum export import native __hasProp __extends __slice __bind __indexOf"};var a="[A-Za-z$_][0-9A-Za-z$_]*";var e={cN:"title",b:a};var d={cN:"subst",b:"#\\{",e:"}",k:b,c:[c.BNM,c.CNM]};return{k:b,c:[c.BNM,c.CNM,c.ASM,{cN:"string",b:'"""',e:'"""',c:[c.BE,d]},{cN:"string",b:'"',e:'"',c:[c.BE,d],r:0},{cN:"comment",b:"###",e:"###"},c.HCM,{cN:"regexp",b:"///",e:"///",c:[c.HCM]},{cN:"regexp",b:"//[gim]*"},{cN:"regexp",b:"/\\S(\\\\.|[^\\n])*/[gim]*"},{b:"`",e:"`",eB:true,eE:true,sL:"javascript"},{cN:"function",b:a+"\\s*=\\s*(\\(.+\\))?\\s*[-=]>",rB:true,c:[e,{cN:"params",b:"\\(",e:"\\)"}]},{cN:"class",bWK:true,k:"class",e:"$",i:":",c:[{bWK:true,k:"extends",eW:true,i:":",c:[e]},e]},{cN:"property",b:"@"+a}]}}(hljs);hljs.LANGUAGES.cpp=function(a){var b={keyword:"false int float while private char catch export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const struct for static_cast|10 union namespace unsigned long throw volatile static protected bool template mutable if public friend do return goto auto void enum else break new extern using true class asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue wchar_t inline delete alignof char16_t char32_t constexpr decltype noexcept nullptr static_assert thread_local restrict _Bool complex",built_in:"std string cin cout cerr clog stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap array shared_ptr"};return{k:b,i:"",k:b,r:10,c:["self"]}]}}(hljs);hljs.LANGUAGES.cs=function(a){return{k:"abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual volatile void while ascending descending from get group into join let orderby partial select set value var where yield",c:[{cN:"comment",b:"///",e:"$",rB:true,c:[{cN:"xmlDocTag",b:"///|"},{cN:"xmlDocTag",b:""}]},a.CLCM,a.CBLCLM,{cN:"preprocessor",b:"#",e:"$",k:"if else elif endif define undef warning error line region endregion pragma checksum"},{cN:"string",b:'@"',e:'"',c:[{b:'""'}]},a.ASM,a.QSM,a.CNM]}}(hljs);hljs.LANGUAGES.css=function(a){var b={cN:"function",b:a.IR+"\\(",e:"\\)",c:[a.NM,a.ASM,a.QSM]};return{cI:true,i:"[=/|']",c:[a.CBLCLM,{cN:"id",b:"\\#[A-Za-z0-9_-]+"},{cN:"class",b:"\\.[A-Za-z0-9_-]+",r:0},{cN:"attr_selector",b:"\\[",e:"\\]",i:"$"},{cN:"pseudo",b:":(:)?[a-zA-Z0-9\\_\\-\\+\\(\\)\\\"\\']+"},{cN:"at_rule",b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{cN:"at_rule",b:"@",e:"[{;]",eE:true,k:"import page media charset",c:[b,a.ASM,a.QSM,a.NM]},{cN:"tag",b:a.IR,r:0},{cN:"rules",b:"{",e:"}",i:"[^\\s]",r:0,c:[a.CBLCLM,{cN:"rule",b:"[^\\s]",rB:true,e:";",eW:true,c:[{cN:"attribute",b:"[A-Z\\_\\.\\-]+",e:":",eE:true,i:"[^\\s]",starts:{cN:"value",eW:true,eE:true,c:[b,a.NM,a.QSM,a.ASM,a.CBLCLM,{cN:"hexcolor",b:"\\#[0-9A-F]+"},{cN:"important",b:"!important"}]}}]}]}]}}(hljs);hljs.LANGUAGES.d=function(x){var b={keyword:"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__",built_in:"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring",literal:"false null true"};var c="(0|[1-9][\\d_]*)",q="(0|[1-9][\\d_]*|\\d[\\d_]*|[\\d_]+?\\d)",h="0[bB][01_]+",v="([\\da-fA-F][\\da-fA-F_]*|_[\\da-fA-F][\\da-fA-F_]*)",y="0[xX]"+v,p="([eE][+-]?"+q+")",o="("+q+"(\\.\\d*|"+p+")|\\d+\\."+q+q+"|\\."+c+p+"?)",k="(0[xX]("+v+"\\."+v+"|\\.?"+v+")[pP][+-]?"+q+")",l="("+c+"|"+h+"|"+y+")",n="("+k+"|"+o+")";var z="\\\\(['\"\\?\\\\abfnrtv]|u[\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\dA-Fa-f]{2}|U[\\dA-Fa-f]{8})|&[a-zA-Z\\d]{2,};";var m={cN:"number",b:"\\b"+l+"(L|u|U|Lu|LU|uL|UL)?",r:0};var j={cN:"number",b:"\\b("+n+"([fF]|L|i|[fF]i|Li)?|"+l+"(i|[fF]i|Li))",r:0};var s={cN:"string",b:"'("+z+"|.)",e:"'",i:"."};var r={b:z,r:0};var w={cN:"string",b:'"',c:[r],e:'"[cwd]?',r:0};var f={cN:"string",b:'[rq]"',e:'"[cwd]?',r:5};var u={cN:"string",b:"`",e:"`[cwd]?"};var i={cN:"string",b:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',r:10};var t={cN:"string",b:'q"\\{',e:'\\}"'};var e={cN:"shebang",b:"^#!",e:"$",r:5};var g={cN:"preprocessor",b:"#(line)",e:"$",r:5};var d={cN:"keyword",b:"@[a-zA-Z_][a-zA-Z_\\d]*"};var a={cN:"comment",b:"\\/\\+",c:["self"],e:"\\+\\/",r:10};return{l:x.UIR,k:b,c:[x.CLCM,x.CBLCLM,a,i,w,f,u,t,j,m,s,e,g,d]}}(hljs);hljs.LANGUAGES.delphi=function(b){var f="and safecall cdecl then string exports library not pascal set virtual file in array label packed end. index while const raise for to implementation with except overload destructor downto finally program exit unit inherited override if type until function do begin repeat goto nil far initialization object else var uses external resourcestring interface end finalization class asm mod case on shr shl of register xorwrite threadvar try record near stored constructor stdcall inline div out or procedure";var e="safecall stdcall pascal stored const implementation finalization except to finally program inherited override then exports string read not mod shr try div shl set library message packed index for near overload label downto exit public goto interface asm on of constructor or private array unit raise destructor var type until function else external with case default record while protected property procedure published and cdecl do threadvar file in if end virtual write far out begin repeat nil initialization object uses resourcestring class register xorwrite inline static";var a={cN:"comment",b:"{",e:"}",r:0};var g={cN:"comment",b:"\\(\\*",e:"\\*\\)",r:10};var c={cN:"string",b:"'",e:"'",c:[{b:"''"}],r:0};var d={cN:"string",b:"(#\\d+)+"};var h={cN:"function",bWK:true,e:"[:;]",k:"function constructor|10 destructor|10 procedure|10",c:[{cN:"title",b:b.IR},{cN:"params",b:"\\(",e:"\\)",k:f,c:[c,d]},a,g]};return{cI:true,k:f,i:'("|\\$[G-Zg-z]|\\/\\*|]+"}]}]};return{cI:true,c:[{cN:"pi",b:"<\\?",e:"\\?>",r:10},{cN:"doctype",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},{cN:"comment",b:"",r:10},{cN:"cdata",b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"|$)",e:">",k:{title:"style"},c:[b],starts:{e:"",rE:true,sL:"css"}},{cN:"tag",b:"|$)",e:">",k:{title:"script"},c:[b],starts:{e:"<\/script>",rE:true,sL:"javascript"}},{b:"<%",e:"%>",sL:"vbscript"},{cN:"tag",b:"",c:[{cN:"title",b:"[^ />]+"},b]}]}}(hljs);hljs.LANGUAGES.django=function(c){function e(h,g){return(g==undefined||(!h.cN&&g.cN=="tag")||h.cN=="value")}function f(l,k){var g={};for(var j in l){if(j!="contains"){g[j]=l[j]}var m=[];for(var h=0;l.c&&h ",r:10},{cN:"comment",b:"%",e:"$"},{cN:"number",b:"\\b(\\d+#[a-fA-F0-9]+|\\d+(\\.\\d+)?([eE][-+]?\\d+)?)",r:0},a.ASM,a.QSM,{cN:"constant",b:"\\?(::)?([A-Z]\\w*(::)?)+"},{cN:"arrow",b:"->"},{cN:"ok",b:"ok"},{cN:"exclamation_mark",b:"!"},{cN:"function_or_atom",b:"(\\b[a-z'][a-zA-Z0-9_']*:[a-z'][a-zA-Z0-9_']*)|(\\b[a-z'][a-zA-Z0-9_']*)",r:0},{cN:"variable",b:"[A-Z][a-zA-Z0-9_']*",r:0}]}}(hljs);hljs.LANGUAGES.erlang=function(i){var c="[a-z'][a-zA-Z0-9_']*";var o="("+c+":"+c+"|"+c+")";var f={keyword:"after and andalso|10 band begin bnot bor bsl bzr bxor case catch cond div end fun let not of orelse|10 query receive rem try when xor",literal:"false true"};var l={cN:"comment",b:"%",e:"$",r:0};var e={cN:"number",b:"\\b(\\d+#[a-fA-F0-9]+|\\d+(\\.\\d+)?([eE][-+]?\\d+)?)",r:0};var g={b:"fun\\s+"+c+"/\\d+"};var n={b:o+"\\(",e:"\\)",rB:true,r:0,c:[{cN:"function_name",b:o,r:0},{b:"\\(",e:"\\)",eW:true,rE:true,r:0}]};var h={cN:"tuple",b:"{",e:"}",r:0};var a={cN:"variable",b:"\\b_([A-Z][A-Za-z0-9_]*)?",r:0};var m={cN:"variable",b:"[A-Z][a-zA-Z0-9_]*",r:0};var b={b:"#",e:"}",i:".",r:0,rB:true,c:[{cN:"record_name",b:"#"+i.UIR,r:0},{b:"{",eW:true,r:0}]};var k={k:f,b:"(fun|receive|if|try|case)",e:"end"};k.c=[l,g,i.inherit(i.ASM,{cN:""}),k,n,i.QSM,e,h,a,m,b];var j=[l,g,k,n,i.QSM,e,h,a,m,b];n.c[1].c=j;h.c=j;b.c[1].c=j;var d={cN:"params",b:"\\(",e:"\\)",c:j};return{k:f,i:"(",rB:true,i:"\\(|#|//|/\\*|\\\\|:",c:[d,{cN:"title",b:c}],starts:{e:";|\\.",k:f,c:j}},l,{cN:"pp",b:"^-",e:"\\.",r:0,eE:true,rB:true,l:"-"+i.IR,k:"-module -record -undef -export -ifdef -ifndef -author -copyright -doc -vsn -import -include -include_lib -compile -define -else -endif -file -behaviour -behavior",c:[d]},e,i.QSM,b,a,m,h]}}(hljs);hljs.LANGUAGES.glsl=function(a){return{k:{keyword:"atomic_uint attribute bool break bvec2 bvec3 bvec4 case centroid coherent const continue default discard dmat2 dmat2x2 dmat2x3 dmat2x4 dmat3 dmat3x2 dmat3x3 dmat3x4 dmat4 dmat4x2 dmat4x3 dmat4x4 do double dvec2 dvec3 dvec4 else flat float for highp if iimage1D iimage1DArray iimage2D iimage2DArray iimage2DMS iimage2DMSArray iimage2DRect iimage3D iimageBuffer iimageCube iimageCubeArray image1D image1DArray image2D image2DArray image2DMS image2DMSArray image2DRect image3D imageBuffer imageCube imageCubeArray in inout int invariant isampler1D isampler1DArray isampler2D isampler2DArray isampler2DMS isampler2DMSArray isampler2DRect isampler3D isamplerBuffer isamplerCube isamplerCubeArray ivec2 ivec3 ivec4 layout lowp mat2 mat2x2 mat2x3 mat2x4 mat3 mat3x2 mat3x3 mat3x4 mat4 mat4x2 mat4x3 mat4x4 mediump noperspective out patch precision readonly restrict return sample sampler1D sampler1DArray sampler1DArrayShadow sampler1DShadow sampler2D sampler2DArray sampler2DArrayShadow sampler2DMS sampler2DMSArray sampler2DRect sampler2DRectShadow sampler2DShadow sampler3D samplerBuffer samplerCube samplerCubeArray samplerCubeArrayShadow samplerCubeShadow smooth struct subroutine switch uimage1D uimage1DArray uimage2D uimage2DArray uimage2DMS uimage2DMSArray uimage2DRect uimage3D uimageBuffer uimageCube uimageCubeArray uint uniform usampler1D usampler1DArray usampler2D usampler2DArray usampler2DMS usampler2DMSArray usampler2DRect usampler3D usamplerBuffer usamplerCube usamplerCubeArray uvec2 uvec3 uvec4 varying vec2 vec3 vec4 void volatile while writeonly",built_in:"gl_BackColor gl_BackLightModelProduct gl_BackLightProduct gl_BackMaterial gl_BackSecondaryColor gl_ClipDistance gl_ClipPlane gl_ClipVertex gl_Color gl_DepthRange gl_EyePlaneQ gl_EyePlaneR gl_EyePlaneS gl_EyePlaneT gl_Fog gl_FogCoord gl_FogFragCoord gl_FragColor gl_FragCoord gl_FragData gl_FragDepth gl_FrontColor gl_FrontFacing gl_FrontLightModelProduct gl_FrontLightProduct gl_FrontMaterial gl_FrontSecondaryColor gl_InstanceID gl_InvocationID gl_Layer gl_LightModel gl_LightSource gl_MaxAtomicCounterBindings gl_MaxAtomicCounterBufferSize gl_MaxClipDistances gl_MaxClipPlanes gl_MaxCombinedAtomicCounterBuffers gl_MaxCombinedAtomicCounters gl_MaxCombinedImageUniforms gl_MaxCombinedImageUnitsAndFragmentOutputs gl_MaxCombinedTextureImageUnits gl_MaxDrawBuffers gl_MaxFragmentAtomicCounterBuffers gl_MaxFragmentAtomicCounters gl_MaxFragmentImageUniforms gl_MaxFragmentInputComponents gl_MaxFragmentUniformComponents gl_MaxFragmentUniformVectors gl_MaxGeometryAtomicCounterBuffers gl_MaxGeometryAtomicCounters gl_MaxGeometryImageUniforms gl_MaxGeometryInputComponents gl_MaxGeometryOutputComponents gl_MaxGeometryOutputVertices gl_MaxGeometryTextureImageUnits gl_MaxGeometryTotalOutputComponents gl_MaxGeometryUniformComponents gl_MaxGeometryVaryingComponents gl_MaxImageSamples gl_MaxImageUnits gl_MaxLights gl_MaxPatchVertices gl_MaxProgramTexelOffset gl_MaxTessControlAtomicCounterBuffers gl_MaxTessControlAtomicCounters gl_MaxTessControlImageUniforms gl_MaxTessControlInputComponents gl_MaxTessControlOutputComponents gl_MaxTessControlTextureImageUnits gl_MaxTessControlTotalOutputComponents gl_MaxTessControlUniformComponents gl_MaxTessEvaluationAtomicCounterBuffers gl_MaxTessEvaluationAtomicCounters gl_MaxTessEvaluationImageUniforms gl_MaxTessEvaluationInputComponents gl_MaxTessEvaluationOutputComponents gl_MaxTessEvaluationTextureImageUnits gl_MaxTessEvaluationUniformComponents gl_MaxTessGenLevel gl_MaxTessPatchComponents gl_MaxTextureCoords gl_MaxTextureImageUnits gl_MaxTextureUnits gl_MaxVaryingComponents gl_MaxVaryingFloats gl_MaxVaryingVectors gl_MaxVertexAtomicCounterBuffers gl_MaxVertexAtomicCounters gl_MaxVertexAttribs gl_MaxVertexImageUniforms gl_MaxVertexOutputComponents gl_MaxVertexTextureImageUnits gl_MaxVertexUniformComponents gl_MaxVertexUniformVectors gl_MaxViewports gl_MinProgramTexelOffsetgl_ModelViewMatrix gl_ModelViewMatrixInverse gl_ModelViewMatrixInverseTranspose gl_ModelViewMatrixTranspose gl_ModelViewProjectionMatrix gl_ModelViewProjectionMatrixInverse gl_ModelViewProjectionMatrixInverseTranspose gl_ModelViewProjectionMatrixTranspose gl_MultiTexCoord0 gl_MultiTexCoord1 gl_MultiTexCoord2 gl_MultiTexCoord3 gl_MultiTexCoord4 gl_MultiTexCoord5 gl_MultiTexCoord6 gl_MultiTexCoord7 gl_Normal gl_NormalMatrix gl_NormalScale gl_ObjectPlaneQ gl_ObjectPlaneR gl_ObjectPlaneS gl_ObjectPlaneT gl_PatchVerticesIn gl_PerVertex gl_Point gl_PointCoord gl_PointSize gl_Position gl_PrimitiveID gl_PrimitiveIDIn gl_ProjectionMatrix gl_ProjectionMatrixInverse gl_ProjectionMatrixInverseTranspose gl_ProjectionMatrixTranspose gl_SampleID gl_SampleMask gl_SampleMaskIn gl_SamplePosition gl_SecondaryColor gl_TessCoord gl_TessLevelInner gl_TessLevelOuter gl_TexCoord gl_TextureEnvColor gl_TextureMatrixInverseTranspose gl_TextureMatrixTranspose gl_Vertex gl_VertexID gl_ViewportIndex gl_in gl_out EmitStreamVertex EmitVertex EndPrimitive EndStreamPrimitive abs acos acosh all any asin asinh atan atanh atomicCounter atomicCounterDecrement atomicCounterIncrement barrier bitCount bitfieldExtract bitfieldInsert bitfieldReverse ceil clamp cos cosh cross dFdx dFdy degrees determinant distance dot equal exp exp2 faceforward findLSB findMSB floatBitsToInt floatBitsToUint floor fma fract frexp ftransform fwidth greaterThan greaterThanEqual imageAtomicAdd imageAtomicAnd imageAtomicCompSwap imageAtomicExchange imageAtomicMax imageAtomicMin imageAtomicOr imageAtomicXor imageLoad imageStore imulExtended intBitsToFloat interpolateAtCentroid interpolateAtOffset interpolateAtSample inverse inversesqrt isinf isnan ldexp length lessThan lessThanEqual log log2 matrixCompMult max memoryBarrier min mix mod modf noise1 noise2 noise3 noise4 normalize not notEqual outerProduct packDouble2x32 packHalf2x16 packSnorm2x16 packSnorm4x8 packUnorm2x16 packUnorm4x8 pow radians reflect refract round roundEven shadow1D shadow1DLod shadow1DProj shadow1DProjLod shadow2D shadow2DLod shadow2DProj shadow2DProjLod sign sin sinh smoothstep sqrt step tan tanh texelFetch texelFetchOffset texture texture1D texture1DLod texture1DProj texture1DProjLod texture2D texture2DLod texture2DProj texture2DProjLod texture3D texture3DLod texture3DProj texture3DProjLod textureCube textureCubeLod textureGather textureGatherOffset textureGatherOffsets textureGrad textureGradOffset textureLod textureLodOffset textureOffset textureProj textureProjGrad textureProjGradOffset textureProjLod textureProjLodOffset textureProjOffset textureQueryLod textureSize transpose trunc uaddCarry uintBitsToFloat umulExtended unpackDouble2x32 unpackHalf2x16 unpackSnorm2x16 unpackSnorm4x8 unpackUnorm2x16 unpackUnorm4x8 usubBorrow gl_TextureMatrix gl_TextureMatrixInverse",literal:"true false"},i:'"',c:[a.CLCM,a.CBLCLM,a.CNM,{cN:"preprocessor",b:"#",e:"$"}]}}(hljs);hljs.LANGUAGES.go=function(a){var b={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer",constant:"true false iota nil",typename:"bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{k:b,i:";",sL:"xml"}],r:0},{cN:"function",bWK:true,e:"{",k:"function",c:[{cN:"title",b:"[A-Za-z$_][0-9A-Za-z$_]*"},{cN:"params",b:"\\(",e:"\\)",c:[a.CLCM,a.CBLCLM],i:"[\"'\\(]"}],i:"\\[|%"}]}}(hljs);hljs.LANGUAGES.json=function(a){var e={literal:"true false null"};var d=[a.QSM,a.CNM];var c={cN:"value",e:",",eW:true,eE:true,c:d,k:e};var b={b:"{",e:"}",c:[{cN:"attribute",b:'\\s*"',e:'"\\s*:\\s*',eB:true,eE:true,c:[a.BE],i:"\\n",starts:c}],i:"\\S"};var f={b:"\\[",e:"\\]",c:[a.inherit(c,{cN:null})],i:"\\S"};d.splice(d.length,0,b,f);return{c:d,k:e,i:"\\S"}}(hljs);hljs.LANGUAGES.lisp=function(i){var k="[a-zA-Z_\\-\\+\\*\\/\\<\\=\\>\\&\\#][a-zA-Z0-9_\\-\\+\\*\\/\\<\\=\\>\\&\\#]*";var l="(\\-|\\+)?\\d+(\\.\\d+|\\/\\d+)?((d|e|f|l|s)(\\+|\\-)?\\d+)?";var a={cN:"literal",b:"\\b(t{1}|nil)\\b"};var d=[{cN:"number",b:l},{cN:"number",b:"#b[0-1]+(/[0-1]+)?"},{cN:"number",b:"#o[0-7]+(/[0-7]+)?"},{cN:"number",b:"#x[0-9a-f]+(/[0-9a-f]+)?"},{cN:"number",b:"#c\\("+l+" +"+l,e:"\\)"}];var h={cN:"string",b:'"',e:'"',c:[i.BE],r:0};var m={cN:"comment",b:";",e:"$"};var g={cN:"variable",b:"\\*",e:"\\*"};var n={cN:"keyword",b:"[:&]"+k};var b={b:"\\(",e:"\\)",c:["self",a,h].concat(d)};var e={cN:"quoted",b:"['`]\\(",e:"\\)",c:d.concat([h,g,n,b])};var c={cN:"quoted",b:"\\(quote ",e:"\\)",k:{title:"quote"},c:d.concat([h,g,n,b])};var j={cN:"list",b:"\\(",e:"\\)"};var f={cN:"body",eW:true,eE:true};j.c=[{cN:"title",b:k},f];f.c=[e,c,j,a].concat(d).concat([h,m,g,n]);return{i:"[^\\s]",c:d.concat([a,h,m,e,c,j])}}(hljs);hljs.LANGUAGES.lua=function(b){var a="\\[=*\\[";var e="\\]=*\\]";var c={b:a,e:e,c:["self"]};var d=[{cN:"comment",b:"--(?!"+a+")",e:"$"},{cN:"comment",b:"--"+a,e:e,c:[c],r:10}];return{l:b.UIR,k:{keyword:"and break do else elseif end false for if in local nil not or repeat return then true until while",built_in:"_G _VERSION assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall coroutine debug io math os package string table"},c:d.concat([{cN:"function",bWK:true,e:"\\)",k:"function",c:[{cN:"title",b:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"},{cN:"params",b:"\\(",eW:true,c:d}].concat(d)},b.CNM,b.ASM,b.QSM,{cN:"string",b:a,e:e,c:[c],r:10}])}}(hljs);hljs.LANGUAGES.markdown=function(a){return{c:[{cN:"header",b:"^#{1,3}",e:"$"},{cN:"header",b:"^.+?\\n[=-]{2,}$"},{b:"<",e:">",sL:"xml",r:0},{cN:"bullet",b:"^([*+-]|(\\d+\\.))\\s+"},{cN:"strong",b:"[*_]{2}.+?[*_]{2}"},{cN:"emphasis",b:"\\*.+?\\*"},{cN:"emphasis",b:"_.+?_",r:0},{cN:"blockquote",b:"^>\\s+",e:"$"},{cN:"code",b:"`.+?`"},{cN:"code",b:"^ ",e:"$",r:0},{cN:"horizontal_rule",b:"^-{3,}",e:"$"},{b:"\\[.+?\\]\\(.+?\\)",rB:true,c:[{cN:"link_label",b:"\\[.+\\]"},{cN:"link_url",b:"\\(",e:"\\)",eB:true,eE:true}]}]}}(hljs);hljs.LANGUAGES.matlab=function(a){var b=[a.CNM,{cN:"string",b:"'",e:"'",c:[a.BE,{b:"''"}],r:0}];return{k:{keyword:"break case catch classdef continue else elseif end enumerated events for function global if methods otherwise parfor persistent properties return spmd switch try while",built_in:"sin sind sinh asin asind asinh cos cosd cosh acos acosd acosh tan tand tanh atan atand atan2 atanh sec secd sech asec asecd asech csc cscd csch acsc acscd acsch cot cotd coth acot acotd acoth hypot exp expm1 log log1p log10 log2 pow2 realpow reallog realsqrt sqrt nthroot nextpow2 abs angle complex conj imag real unwrap isreal cplxpair fix floor ceil round mod rem sign airy besselj bessely besselh besseli besselk beta betainc betaln ellipj ellipke erf erfc erfcx erfinv expint gamma gammainc gammaln psi legendre cross dot factor isprime primes gcd lcm rat rats perms nchoosek factorial cart2sph cart2pol pol2cart sph2cart hsv2rgb rgb2hsv zeros ones eye repmat rand randn linspace logspace freqspace meshgrid accumarray size length ndims numel disp isempty isequal isequalwithequalnans cat reshape diag blkdiag tril triu fliplr flipud flipdim rot90 find sub2ind ind2sub bsxfun ndgrid permute ipermute shiftdim circshift squeeze isscalar isvector ans eps realmax realmin pi i inf nan isnan isinf isfinite j why compan gallery hadamard hankel hilb invhilb magic pascal rosser toeplitz vander wilkinson"},i:'(//|"|#|/\\*|\\s+/\\w+)',c:[{cN:"function",bWK:true,e:"$",k:"function",c:[{cN:"title",b:a.UIR},{cN:"params",b:"\\(",e:"\\)"},{cN:"params",b:"\\[",e:"\\]"}]},{cN:"transposed_variable",b:"[a-zA-Z_][a-zA-Z_0-9]*('+[\\.']*|[\\.']+)",e:""},{cN:"matrix",b:"\\[",e:"\\]'*[\\.']*",c:b},{cN:"cell",b:"\\{",e:"\\}'*[\\.']*",c:b},{cN:"comment",b:"\\%",e:"$"}].concat(b)}}(hljs);hljs.LANGUAGES.mel=function(a){return{k:"int float string vector matrix if else switch case default while do for in break continue global proc return about abs addAttr addAttributeEditorNodeHelp addDynamic addNewShelfTab addPP addPanelCategory addPrefixToName advanceToNextDrivenKey affectedNet affects aimConstraint air alias aliasAttr align alignCtx alignCurve alignSurface allViewFit ambientLight angle angleBetween animCone animCurveEditor animDisplay animView annotate appendStringArray applicationName applyAttrPreset applyTake arcLenDimContext arcLengthDimension arclen arrayMapper art3dPaintCtx artAttrCtx artAttrPaintVertexCtx artAttrSkinPaintCtx artAttrTool artBuildPaintMenu artFluidAttrCtx artPuttyCtx artSelectCtx artSetPaintCtx artUserPaintCtx assignCommand assignInputDevice assignViewportFactories attachCurve attachDeviceAttr attachSurface attrColorSliderGrp attrCompatibility attrControlGrp attrEnumOptionMenu attrEnumOptionMenuGrp attrFieldGrp attrFieldSliderGrp attrNavigationControlGrp attrPresetEditWin attributeExists attributeInfo attributeMenu attributeQuery autoKeyframe autoPlace bakeClip bakeFluidShading bakePartialHistory bakeResults bakeSimulation basename basenameEx batchRender bessel bevel bevelPlus binMembership bindSkin blend2 blendShape blendShapeEditor blendShapePanel blendTwoAttr blindDataType boneLattice boundary boxDollyCtx boxZoomCtx bufferCurve buildBookmarkMenu buildKeyframeMenu button buttonManip CBG cacheFile cacheFileCombine cacheFileMerge cacheFileTrack camera cameraView canCreateManip canvas capitalizeString catch catchQuiet ceil changeSubdivComponentDisplayLevel changeSubdivRegion channelBox character characterMap characterOutlineEditor characterize chdir checkBox checkBoxGrp checkDefaultRenderGlobals choice circle circularFillet clamp clear clearCache clip clipEditor clipEditorCurrentTimeCtx clipSchedule clipSchedulerOutliner clipTrimBefore closeCurve closeSurface cluster cmdFileOutput cmdScrollFieldExecuter cmdScrollFieldReporter cmdShell coarsenSubdivSelectionList collision color colorAtPoint colorEditor colorIndex colorIndexSliderGrp colorSliderButtonGrp colorSliderGrp columnLayout commandEcho commandLine commandPort compactHairSystem componentEditor compositingInterop computePolysetVolume condition cone confirmDialog connectAttr connectControl connectDynamic connectJoint connectionInfo constrain constrainValue constructionHistory container containsMultibyte contextInfo control convertFromOldLayers convertIffToPsd convertLightmap convertSolidTx convertTessellation convertUnit copyArray copyFlexor copyKey copySkinWeights cos cpButton cpCache cpClothSet cpCollision cpConstraint cpConvClothToMesh cpForces cpGetSolverAttr cpPanel cpProperty cpRigidCollisionFilter cpSeam cpSetEdit cpSetSolverAttr cpSolver cpSolverTypes cpTool cpUpdateClothUVs createDisplayLayer createDrawCtx createEditor createLayeredPsdFile createMotionField createNewShelf createNode createRenderLayer createSubdivRegion cross crossProduct ctxAbort ctxCompletion ctxEditMode ctxTraverse currentCtx currentTime currentTimeCtx currentUnit currentUnit curve curveAddPtCtx curveCVCtx curveEPCtx curveEditorCtx curveIntersect curveMoveEPCtx curveOnSurface curveSketchCtx cutKey cycleCheck cylinder dagPose date defaultLightListCheckBox defaultNavigation defineDataServer defineVirtualDevice deformer deg_to_rad delete deleteAttr deleteShadingGroupsAndMaterials deleteShelfTab deleteUI deleteUnusedBrushes delrandstr detachCurve detachDeviceAttr detachSurface deviceEditor devicePanel dgInfo dgdirty dgeval dgtimer dimWhen directKeyCtx directionalLight dirmap dirname disable disconnectAttr disconnectJoint diskCache displacementToPoly displayAffected displayColor displayCull displayLevelOfDetail displayPref displayRGBColor displaySmoothness displayStats displayString displaySurface distanceDimContext distanceDimension doBlur dolly dollyCtx dopeSheetEditor dot dotProduct doubleProfileBirailSurface drag dragAttrContext draggerContext dropoffLocator duplicate duplicateCurve duplicateSurface dynCache dynControl dynExport dynExpression dynGlobals dynPaintEditor dynParticleCtx dynPref dynRelEdPanel dynRelEditor dynamicLoad editAttrLimits editDisplayLayerGlobals editDisplayLayerMembers editRenderLayerAdjustment editRenderLayerGlobals editRenderLayerMembers editor editorTemplate effector emit emitter enableDevice encodeString endString endsWith env equivalent equivalentTol erf error eval eval evalDeferred evalEcho event exactWorldBoundingBox exclusiveLightCheckBox exec executeForEachObject exists exp expression expressionEditorListen extendCurve extendSurface extrude fcheck fclose feof fflush fgetline fgetword file fileBrowserDialog fileDialog fileExtension fileInfo filetest filletCurve filter filterCurve filterExpand filterStudioImport findAllIntersections findAnimCurves findKeyframe findMenuItem findRelatedSkinCluster finder firstParentOf fitBspline flexor floatEq floatField floatFieldGrp floatScrollBar floatSlider floatSlider2 floatSliderButtonGrp floatSliderGrp floor flow fluidCacheInfo fluidEmitter fluidVoxelInfo flushUndo fmod fontDialog fopen formLayout format fprint frameLayout fread freeFormFillet frewind fromNativePath fwrite gamma gauss geometryConstraint getApplicationVersionAsFloat getAttr getClassification getDefaultBrush getFileList getFluidAttr getInputDeviceRange getMayaPanelTypes getModifiers getPanel getParticleAttr getPluginResource getenv getpid glRender glRenderEditor globalStitch gmatch goal gotoBindPose grabColor gradientControl gradientControlNoAttr graphDollyCtx graphSelectContext graphTrackCtx gravity grid gridLayout group groupObjectsByName HfAddAttractorToAS HfAssignAS HfBuildEqualMap HfBuildFurFiles HfBuildFurImages HfCancelAFR HfConnectASToHF HfCreateAttractor HfDeleteAS HfEditAS HfPerformCreateAS HfRemoveAttractorFromAS HfSelectAttached HfSelectAttractors HfUnAssignAS hardenPointCurve hardware hardwareRenderPanel headsUpDisplay headsUpMessage help helpLine hermite hide hilite hitTest hotBox hotkey hotkeyCheck hsv_to_rgb hudButton hudSlider hudSliderButton hwReflectionMap hwRender hwRenderLoad hyperGraph hyperPanel hyperShade hypot iconTextButton iconTextCheckBox iconTextRadioButton iconTextRadioCollection iconTextScrollList iconTextStaticLabel ikHandle ikHandleCtx ikHandleDisplayScale ikSolver ikSplineHandleCtx ikSystem ikSystemInfo ikfkDisplayMethod illustratorCurves image imfPlugins inheritTransform insertJoint insertJointCtx insertKeyCtx insertKnotCurve insertKnotSurface instance instanceable instancer intField intFieldGrp intScrollBar intSlider intSliderGrp interToUI internalVar intersect iprEngine isAnimCurve isConnected isDirty isParentOf isSameObject isTrue isValidObjectName isValidString isValidUiName isolateSelect itemFilter itemFilterAttr itemFilterRender itemFilterType joint jointCluster jointCtx jointDisplayScale jointLattice keyTangent keyframe keyframeOutliner keyframeRegionCurrentTimeCtx keyframeRegionDirectKeyCtx keyframeRegionDollyCtx keyframeRegionInsertKeyCtx keyframeRegionMoveKeyCtx keyframeRegionScaleKeyCtx keyframeRegionSelectKeyCtx keyframeRegionSetKeyCtx keyframeRegionTrackCtx keyframeStats lassoContext lattice latticeDeformKeyCtx launch launchImageEditor layerButton layeredShaderPort layeredTexturePort layout layoutDialog lightList lightListEditor lightListPanel lightlink lineIntersection linearPrecision linstep listAnimatable listAttr listCameras listConnections listDeviceAttachments listHistory listInputDeviceAxes listInputDeviceButtons listInputDevices listMenuAnnotation listNodeTypes listPanelCategories listRelatives listSets listTransforms listUnselected listerEditor loadFluid loadNewShelf loadPlugin loadPluginLanguageResources loadPrefObjects localizedPanelLabel lockNode loft log longNameOf lookThru ls lsThroughFilter lsType lsUI Mayatomr mag makeIdentity makeLive makePaintable makeRoll makeSingleSurface makeTubeOn makebot manipMoveContext manipMoveLimitsCtx manipOptions manipRotateContext manipRotateLimitsCtx manipScaleContext manipScaleLimitsCtx marker match max memory menu menuBarLayout menuEditor menuItem menuItemToShelf menuSet menuSetPref messageLine min minimizeApp mirrorJoint modelCurrentTimeCtx modelEditor modelPanel mouse movIn movOut move moveIKtoFK moveKeyCtx moveVertexAlongDirection multiProfileBirailSurface mute nParticle nameCommand nameField namespace namespaceInfo newPanelItems newton nodeCast nodeIconButton nodeOutliner nodePreset nodeType noise nonLinear normalConstraint normalize nurbsBoolean nurbsCopyUVSet nurbsCube nurbsEditUV nurbsPlane nurbsSelect nurbsSquare nurbsToPoly nurbsToPolygonsPref nurbsToSubdiv nurbsToSubdivPref nurbsUVSet nurbsViewDirectionVector objExists objectCenter objectLayer objectType objectTypeUI obsoleteProc oceanNurbsPreviewPlane offsetCurve offsetCurveOnSurface offsetSurface openGLExtension openMayaPref optionMenu optionMenuGrp optionVar orbit orbitCtx orientConstraint outlinerEditor outlinerPanel overrideModifier paintEffectsDisplay pairBlend palettePort paneLayout panel panelConfiguration panelHistory paramDimContext paramDimension paramLocator parent parentConstraint particle particleExists particleInstancer particleRenderInfo partition pasteKey pathAnimation pause pclose percent performanceOptions pfxstrokes pickWalk picture pixelMove planarSrf plane play playbackOptions playblast plugAttr plugNode pluginInfo pluginResourceUtil pointConstraint pointCurveConstraint pointLight pointMatrixMult pointOnCurve pointOnSurface pointPosition poleVectorConstraint polyAppend polyAppendFacetCtx polyAppendVertex polyAutoProjection polyAverageNormal polyAverageVertex polyBevel polyBlendColor polyBlindData polyBoolOp polyBridgeEdge polyCacheMonitor polyCheck polyChipOff polyClipboard polyCloseBorder polyCollapseEdge polyCollapseFacet polyColorBlindData polyColorDel polyColorPerVertex polyColorSet polyCompare polyCone polyCopyUV polyCrease polyCreaseCtx polyCreateFacet polyCreateFacetCtx polyCube polyCut polyCutCtx polyCylinder polyCylindricalProjection polyDelEdge polyDelFacet polyDelVertex polyDuplicateAndConnect polyDuplicateEdge polyEditUV polyEditUVShell polyEvaluate polyExtrudeEdge polyExtrudeFacet polyExtrudeVertex polyFlipEdge polyFlipUV polyForceUV polyGeoSampler polyHelix polyInfo polyInstallAction polyLayoutUV polyListComponentConversion polyMapCut polyMapDel polyMapSew polyMapSewMove polyMergeEdge polyMergeEdgeCtx polyMergeFacet polyMergeFacetCtx polyMergeUV polyMergeVertex polyMirrorFace polyMoveEdge polyMoveFacet polyMoveFacetUV polyMoveUV polyMoveVertex polyNormal polyNormalPerVertex polyNormalizeUV polyOptUvs polyOptions polyOutput polyPipe polyPlanarProjection polyPlane polyPlatonicSolid polyPoke polyPrimitive polyPrism polyProjection polyPyramid polyQuad polyQueryBlindData polyReduce polySelect polySelectConstraint polySelectConstraintMonitor polySelectCtx polySelectEditCtx polySeparate polySetToFaceNormal polySewEdge polyShortestPathCtx polySmooth polySoftEdge polySphere polySphericalProjection polySplit polySplitCtx polySplitEdge polySplitRing polySplitVertex polyStraightenUVBorder polySubdivideEdge polySubdivideFacet polyToSubdiv polyTorus polyTransfer polyTriangulate polyUVSet polyUnite polyWedgeFace popen popupMenu pose pow preloadRefEd print progressBar progressWindow projFileViewer projectCurve projectTangent projectionContext projectionManip promptDialog propModCtx propMove psdChannelOutliner psdEditTextureFile psdExport psdTextureFile putenv pwd python querySubdiv quit rad_to_deg radial radioButton radioButtonGrp radioCollection radioMenuItemCollection rampColorPort rand randomizeFollicles randstate rangeControl readTake rebuildCurve rebuildSurface recordAttr recordDevice redo reference referenceEdit referenceQuery refineSubdivSelectionList refresh refreshAE registerPluginResource rehash reloadImage removeJoint removeMultiInstance removePanelCategory rename renameAttr renameSelectionList renameUI render renderGlobalsNode renderInfo renderLayerButton renderLayerParent renderLayerPostProcess renderLayerUnparent renderManip renderPartition renderQualityNode renderSettings renderThumbnailUpdate renderWindowEditor renderWindowSelectContext renderer reorder reorderDeformers requires reroot resampleFluid resetAE resetPfxToPolyCamera resetTool resolutionNode retarget reverseCurve reverseSurface revolve rgb_to_hsv rigidBody rigidSolver roll rollCtx rootOf rot rotate rotationInterpolation roundConstantRadius rowColumnLayout rowLayout runTimeCommand runup sampleImage saveAllShelves saveAttrPreset saveFluid saveImage saveInitialState saveMenu savePrefObjects savePrefs saveShelf saveToolSettings scale scaleBrushBrightness scaleComponents scaleConstraint scaleKey scaleKeyCtx sceneEditor sceneUIReplacement scmh scriptCtx scriptEditorInfo scriptJob scriptNode scriptTable scriptToShelf scriptedPanel scriptedPanelType scrollField scrollLayout sculpt searchPathArray seed selLoadSettings select selectContext selectCurveCV selectKey selectKeyCtx selectKeyframeRegionCtx selectMode selectPref selectPriority selectType selectedNodes selectionConnection separator setAttr setAttrEnumResource setAttrMapping setAttrNiceNameResource setConstraintRestPosition setDefaultShadingGroup setDrivenKeyframe setDynamic setEditCtx setEditor setFluidAttr setFocus setInfinity setInputDeviceMapping setKeyCtx setKeyPath setKeyframe setKeyframeBlendshapeTargetWts setMenuMode setNodeNiceNameResource setNodeTypeFlag setParent setParticleAttr setPfxToPolyCamera setPluginResource setProject setStampDensity setStartupMessage setState setToolTo setUITemplate setXformManip sets shadingConnection shadingGeometryRelCtx shadingLightRelCtx shadingNetworkCompare shadingNode shapeCompare shelfButton shelfLayout shelfTabLayout shellField shortNameOf showHelp showHidden showManipCtx showSelectionInTitle showShadingGroupAttrEditor showWindow sign simplify sin singleProfileBirailSurface size sizeBytes skinCluster skinPercent smoothCurve smoothTangentSurface smoothstep snap2to2 snapKey snapMode snapTogetherCtx snapshot soft softMod softModCtx sort sound soundControl source spaceLocator sphere sphrand spotLight spotLightPreviewPort spreadSheetEditor spring sqrt squareSurface srtContext stackTrace startString startsWith stitchAndExplodeShell stitchSurface stitchSurfacePoints strcmp stringArrayCatenate stringArrayContains stringArrayCount stringArrayInsertAtIndex stringArrayIntersector stringArrayRemove stringArrayRemoveAtIndex stringArrayRemoveDuplicates stringArrayRemoveExact stringArrayToString stringToStringArray strip stripPrefixFromName stroke subdAutoProjection subdCleanTopology subdCollapse subdDuplicateAndConnect subdEditUV subdListComponentConversion subdMapCut subdMapSewMove subdMatchTopology subdMirror subdToBlind subdToPoly subdTransferUVsToCache subdiv subdivCrease subdivDisplaySmoothness substitute substituteAllString substituteGeometry substring surface surfaceSampler surfaceShaderList swatchDisplayPort switchTable symbolButton symbolCheckBox sysFile system tabLayout tan tangentConstraint texLatticeDeformContext texManipContext texMoveContext texMoveUVShellContext texRotateContext texScaleContext texSelectContext texSelectShortestPathCtx texSmudgeUVContext texWinToolCtx text textCurves textField textFieldButtonGrp textFieldGrp textManip textScrollList textToShelf textureDisplacePlane textureHairColor texturePlacementContext textureWindow threadCount threePointArcCtx timeControl timePort timerX toNativePath toggle toggleAxis toggleWindowVisibility tokenize tokenizeList tolerance tolower toolButton toolCollection toolDropped toolHasOptions toolPropertyWindow torus toupper trace track trackCtx transferAttributes transformCompare transformLimits translator trim trunc truncateFluidCache truncateHairCache tumble tumbleCtx turbulence twoPointArcCtx uiRes uiTemplate unassignInputDevice undo undoInfo ungroup uniform unit unloadPlugin untangleUV untitledFileName untrim upAxis updateAE userCtx uvLink uvSnapshot validateShelfName vectorize view2dToolCtx viewCamera viewClipPlane viewFit viewHeadOn viewLookAt viewManip viewPlace viewSet visor volumeAxis vortex waitCursor warning webBrowser webBrowserPrefs whatIs window windowPref wire wireContext workspace wrinkle wrinkleContext writeTake xbmLangPathList xform",i:"",c:[b.HCM,{cN:"string",b:'"',e:'"',c:[b.BE].concat(c),r:0},{cN:"string",b:"'",e:"'",c:[b.BE].concat(c),r:0},{cN:"url",b:"([a-z]+):/",e:"\\s",eW:true,eE:true},{cN:"regexp",b:"\\s\\^",e:"\\s|{|;",rE:true,c:[b.BE].concat(c)},{cN:"regexp",b:"~\\*?\\s+",e:"\\s|{|;",rE:true,c:[b.BE].concat(c)},{cN:"regexp",b:"\\*(\\.[a-z\\-]+)+",c:[b.BE].concat(c)},{cN:"regexp",b:"([a-z\\-]+\\.)+\\*",c:[b.BE].concat(c)},{cN:"number",b:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{cN:"number",b:"\\b\\d+[kKmMgGdshdwy]*\\b",r:0}].concat(c)};return{c:[b.HCM,{b:b.UIR+"\\s",e:";|{",rB:true,c:[{cN:"title",b:b.UIR,starts:a}]}],i:"[^\\s\\}]"}}(hljs);hljs.LANGUAGES.objectivec=function(a){var b={keyword:"int float while private char catch export sizeof typedef const struct for union unsigned long volatile static protected bool mutable if public do return goto void enum else break extern class asm case short default double throw register explicit signed typename try this switch continue wchar_t inline readonly assign property protocol self synchronized end synthesize id optional required implementation nonatomic interface super unichar finally dynamic IBOutlet IBAction selector strong weak readonly",literal:"false true FALSE TRUE nil YES NO NULL",built_in:"NSString NSDictionary CGRect CGPoint UIButton UILabel UITextView UIWebView MKMapView UISegmentedControl NSObject UITableViewDelegate UITableViewDataSource NSThread UIActivityIndicator UITabbar UIToolBar UIBarButtonItem UIImageView NSAutoreleasePool UITableView BOOL NSInteger CGFloat NSException NSLog NSMutableString NSMutableArray NSMutableDictionary NSURL NSIndexPath CGSize UITableViewCell UIView UIViewController UINavigationBar UINavigationController UITabBarController UIPopoverController UIPopoverControllerDelegate UIImage NSNumber UISearchBar NSFetchedResultsController NSFetchedResultsChangeType UIScrollView UIScrollViewDelegate UIEdgeInsets UIColor UIFont UIApplication NSNotFound NSNotificationCenter NSNotification UILocalNotification NSBundle NSFileManager NSTimeInterval NSDate NSCalendar NSUserDefaults UIWindow NSRange NSArray NSError NSURLRequest NSURLConnection class UIInterfaceOrientation MPMoviePlayerController dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"};return{k:b,i:""}]},{cN:"preprocessor",b:"#",e:"$"},{cN:"class",bWK:true,e:"({|$)",k:"interface class protocol implementation",c:[{cN:"id",b:a.UIR}]},{cN:"variable",b:"\\."+a.UIR}]}}(hljs);hljs.LANGUAGES.parser3=function(a){return{sL:"xml",c:[{cN:"comment",b:"^#",e:"$"},{cN:"comment",b:"\\^rem{",e:"}",r:10,c:[{b:"{",e:"}",c:["self"]}]},{cN:"preprocessor",b:"^@(?:BASE|USE|CLASS|OPTIONS)$",r:10},{cN:"title",b:"@[\\w\\-]+\\[[\\w^;\\-]*\\](?:\\[[\\w^;\\-]*\\])?(?:.*)$"},{cN:"variable",b:"\\$\\{?[\\w\\-\\.\\:]+\\}?"},{cN:"keyword",b:"\\^[\\w\\-\\.\\:]+"},{cN:"number",b:"\\^#[0-9a-fA-F]+"},a.CNM]}}(hljs);hljs.LANGUAGES.perl=function(e){var a="getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when";var d={cN:"subst",b:"[$@]\\{",e:"\\}",k:a,r:10};var b={cN:"variable",b:"\\$\\d"};var i={cN:"variable",b:"[\\$\\%\\@\\*](\\^\\w\\b|#\\w+(\\:\\:\\w+)*|[^\\s\\w{]|{\\w+}|\\w+(\\:\\:\\w*)*)"};var f=[e.BE,d,b,i];var h={b:"->",c:[{b:e.IR},{b:"{",e:"}"}]};var g={cN:"comment",b:"^(__END__|__DATA__)",e:"\\n$",r:5};var c=[b,i,e.HCM,g,{cN:"comment",b:"^\\=\\w",e:"\\=cut",eW:true},h,{cN:"string",b:"q[qwxr]?\\s*\\(",e:"\\)",c:f,r:5},{cN:"string",b:"q[qwxr]?\\s*\\[",e:"\\]",c:f,r:5},{cN:"string",b:"q[qwxr]?\\s*\\{",e:"\\}",c:f,r:5},{cN:"string",b:"q[qwxr]?\\s*\\|",e:"\\|",c:f,r:5},{cN:"string",b:"q[qwxr]?\\s*\\<",e:"\\>",c:f,r:5},{cN:"string",b:"qw\\s+q",e:"q",c:f,r:5},{cN:"string",b:"'",e:"'",c:[e.BE],r:0},{cN:"string",b:'"',e:'"',c:f,r:0},{cN:"string",b:"`",e:"`",c:[e.BE]},{cN:"string",b:"{\\w+}",r:0},{cN:"string",b:"-?\\w+\\s*\\=\\>",r:0},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{b:"("+e.RSR+"|\\b(split|return|print|reverse|grep)\\b)\\s*",k:"split return print reverse grep",r:0,c:[e.HCM,g,{cN:"regexp",b:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",r:10},{cN:"regexp",b:"(m|qr)?/",e:"/[a-z]*",c:[e.BE],r:0}]},{cN:"sub",bWK:true,e:"(\\s*\\(.*?\\))?[;{]",k:"sub",r:5},{cN:"operator",b:"-\\w\\b",r:0}];d.c=c;h.c[1].c=c;return{k:a,c:c}}(hljs);hljs.LANGUAGES.php=function(a){var e={cN:"variable",b:"\\$+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*"};var b=[a.inherit(a.ASM,{i:null}),a.inherit(a.QSM,{i:null}),{cN:"string",b:'b"',e:'"',c:[a.BE]},{cN:"string",b:"b'",e:"'",c:[a.BE]}];var c=[a.BNM,a.CNM];var d={cN:"title",b:a.UIR};return{cI:true,k:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return implements parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception php_user_filter default die require __FUNCTION__ enddeclare final try this switch continue endfor endif declare unset true false namespace trait goto instanceof insteadof __DIR__ __NAMESPACE__ __halt_compiler",c:[a.CLCM,a.HCM,{cN:"comment",b:"/\\*",e:"\\*/",c:[{cN:"phpdoc",b:"\\s@[A-Za-z]+"}]},{cN:"comment",eB:true,b:"__halt_compiler.+?;",eW:true},{cN:"string",b:"<<<['\"]?\\w+['\"]?$",e:"^\\w+;",c:[a.BE]},{cN:"preprocessor",b:"<\\?php",r:10},{cN:"preprocessor",b:"\\?>"},e,{cN:"function",bWK:true,e:"{",k:"function",i:"\\$|\\[|%",c:[d,{cN:"params",b:"\\(",e:"\\)",c:["self",e,a.CBLCLM].concat(b).concat(c)}]},{cN:"class",bWK:true,e:"{",k:"class",i:"[:\\(\\$]",c:[{bWK:true,eW:true,k:"extends",c:[d]},d]},{b:"=>"}].concat(b).concat(c)}}(hljs);hljs.LANGUAGES.profile=function(a){return{c:[a.CNM,{cN:"builtin",b:"{",e:"}$",eB:true,eE:true,c:[a.ASM,a.QSM],r:0},{cN:"filename",b:"[a-zA-Z_][\\da-zA-Z_]+\\.[\\da-zA-Z_]{1,3}",e:":",eE:true},{cN:"header",b:"(ncalls|tottime|cumtime)",e:"$",k:"ncalls tottime|10 cumtime|10 filename",r:10},{cN:"summary",b:"function calls",e:"$",c:[a.CNM],r:10},a.ASM,a.QSM,{cN:"function",b:"\\(",e:"\\)$",c:[{cN:"title",b:a.UIR,r:0}],r:0}]}}(hljs);hljs.LANGUAGES.python=function(a){var f={cN:"prompt",b:"^(>>>|\\.\\.\\.) "};var c=[{cN:"string",b:"(u|b)?r?'''",e:"'''",c:[f],r:10},{cN:"string",b:'(u|b)?r?"""',e:'"""',c:[f],r:10},{cN:"string",b:"(u|r|ur)'",e:"'",c:[a.BE],r:10},{cN:"string",b:'(u|r|ur)"',e:'"',c:[a.BE],r:10},{cN:"string",b:"(b|br)'",e:"'",c:[a.BE]},{cN:"string",b:'(b|br)"',e:'"',c:[a.BE]}].concat([a.ASM,a.QSM]);var e={cN:"title",b:a.UIR};var d={cN:"params",b:"\\(",e:"\\)",c:["self",a.CNM,f].concat(c)};var b={bWK:true,e:":",i:"[${=;\\n]",c:[e,d],r:10};return{k:{keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda nonlocal|10",built_in:"None True False Ellipsis NotImplemented"},i:"(|\\?)",c:c.concat([f,a.HCM,a.inherit(b,{cN:"function",k:"def"}),a.inherit(b,{cN:"class",k:"class"}),a.CNM,{cN:"decorator",b:"@",e:"$"},{b:"\\b(print|exec)\\("}])}}(hljs);hljs.LANGUAGES.r=function(a){var b="([a-zA-Z]|\\.[a-zA-Z.])[a-zA-Z0-9._]*";return{c:[a.HCM,{b:b,l:b,k:{keyword:"function if in break next repeat else for return switch while try tryCatch|10 stop warning require library attach detach source setMethod setGeneric setGroupGeneric setClass ...|10",literal:"NULL NA TRUE FALSE T F Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10"},r:0},{cN:"number",b:"0[xX][0-9a-fA-F]+[Li]?\\b",r:0},{cN:"number",b:"\\d+(?:[eE][+\\-]?\\d*)?L\\b",r:0},{cN:"number",b:"\\d+\\.(?!\\d)(?:i\\b)?",r:0},{cN:"number",b:"\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d*)?i?\\b",r:0},{cN:"number",b:"\\.\\d+(?:[eE][+\\-]?\\d*)?i?\\b",r:0},{b:"`",e:"`",r:0},{cN:"string",b:'"',e:'"',c:[a.BE],r:0},{cN:"string",b:"'",e:"'",c:[a.BE],r:0}]}}(hljs);hljs.LANGUAGES.rib=function(a){return{k:"ArchiveRecord AreaLightSource Atmosphere Attribute AttributeBegin AttributeEnd Basis Begin Blobby Bound Clipping ClippingPlane Color ColorSamples ConcatTransform Cone CoordinateSystem CoordSysTransform CropWindow Curves Cylinder DepthOfField Detail DetailRange Disk Displacement Display End ErrorHandler Exposure Exterior Format FrameAspectRatio FrameBegin FrameEnd GeneralPolygon GeometricApproximation Geometry Hider Hyperboloid Identity Illuminate Imager Interior LightSource MakeCubeFaceEnvironment MakeLatLongEnvironment MakeShadow MakeTexture Matte MotionBegin MotionEnd NuPatch ObjectBegin ObjectEnd ObjectInstance Opacity Option Orientation Paraboloid Patch PatchMesh Perspective PixelFilter PixelSamples PixelVariance Points PointsGeneralPolygons PointsPolygons Polygon Procedural Projection Quantize ReadArchive RelativeDetail ReverseOrientation Rotate Scale ScreenWindow ShadingInterpolation ShadingRate Shutter Sides Skew SolidBegin SolidEnd Sphere SubdivisionMesh Surface TextureCoordinates Torus Transform TransformBegin TransformEnd TransformPoints Translate TrimCurve WorldBegin WorldEnd",i:">|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?";var g={keyword:"and false then defined module in return redo if BEGIN retry end for true self when next until do begin unless END rescue nil else break undef not super class case require yield alias while ensure elsif or include"};var c={cN:"yardoctag",b:"@[A-Za-z]+"};var k=[{cN:"comment",b:"#",e:"$",c:[c]},{cN:"comment",b:"^\\=begin",e:"^\\=end",c:[c],r:10},{cN:"comment",b:"^__END__",e:"\\n$"}];var d={cN:"subst",b:"#\\{",e:"}",l:a,k:g};var i=[e.BE,d];var b=[{cN:"string",b:"'",e:"'",c:i,r:0},{cN:"string",b:'"',e:'"',c:i,r:0},{cN:"string",b:"%[qw]?\\(",e:"\\)",c:i},{cN:"string",b:"%[qw]?\\[",e:"\\]",c:i},{cN:"string",b:"%[qw]?{",e:"}",c:i},{cN:"string",b:"%[qw]?<",e:">",c:i,r:10},{cN:"string",b:"%[qw]?/",e:"/",c:i,r:10},{cN:"string",b:"%[qw]?%",e:"%",c:i,r:10},{cN:"string",b:"%[qw]?-",e:"-",c:i,r:10},{cN:"string",b:"%[qw]?\\|",e:"\\|",c:i,r:10}];var h={cN:"function",bWK:true,e:" |$|;",k:"def",c:[{cN:"title",b:j,l:a,k:g},{cN:"params",b:"\\(",e:"\\)",l:a,k:g}].concat(k)};var f=k.concat(b.concat([{cN:"class",bWK:true,e:"$|;",k:"class module",c:[{cN:"title",b:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?",r:0},{cN:"inheritance",b:"<\\s*",c:[{cN:"parent",b:"("+e.IR+"::)?"+e.IR}]}].concat(k)},h,{cN:"constant",b:"(::)?(\\b[A-Z]\\w*(::)?)+",r:0},{cN:"symbol",b:":",c:b.concat([{b:j}]),r:0},{cN:"symbol",b:a+":",r:0},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{cN:"number",b:"\\?\\w"},{cN:"variable",b:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{b:"("+e.RSR+")\\s*",c:k.concat([{cN:"regexp",b:"/",e:"/[a-z]*",i:"\\n",c:[e.BE,d]}]),r:0}]));d.c=f;h.c[1].c=f;return{l:a,k:g,c:f}}(hljs);hljs.LANGUAGES.rust=function(b){var d={cN:"title",b:b.UIR};var c={cN:"number",b:"\\b(0[xb][A-Za-z0-9_]+|[0-9_]+(\\.[0-9_]+)?([uif](8|16|32|64)?)?)",r:0};var a="alt any as assert be bind block bool break char check claim const cont dir do else enum export f32 f64 fail false float fn for i16 i32 i64 i8 if iface impl import in int let log mod mutable native note of prove pure resource ret self str syntax true type u16 u32 u64 u8 uint unchecked unsafe use vec while";return{k:a,i:" {'http://www.forumwarz.com/images/header/logo.png' => {'width' => 111, 'height' => 222}}) + end + + it "doesn't call image_dimensions because it knows the size" do + CookedPostProcessor.expects(:image_dimensions).never + @cpp.post_process_images + end + + it "adds the width from the image sizes provided" do + @cpp.post_process_images + @cpp.html.should =~ /width=\"111\"/ + end + + end + + context 'with unsized images in the post' do + before do + CookedPostProcessor.expects(:image_dimensions).returns([123, 456]) + @post = Fabricate(:post_with_images) + end + + it "adds a topic image if there's one in the post" do + @post.topic.reload + @post.topic.image_url.should == "/path/to/img.jpg" + end + + it "adds the height and width to images that don't have them" do + @post.reload + @post.cooked.should =~ /width=\"123\" height=\"456\"/ + end + + end + end + + + context 'image_dimensions' do + it "returns unless called with a http or https url" do + CookedPostProcessor.image_dimensions('/tmp/image.jpg').should be_blank + end + + context 'with valid url' do + before do + @url = 'http://www.forumwarz.com/images/header/logo.png' + end + + it "doesn't call fastimage if image crawling is disabled" do + SiteSetting.expects(:crawl_images?).returns(false) + FastImage.expects(:size).never + CookedPostProcessor.image_dimensions(@url) + end + + it "calls fastimage if image crawling is enabled" do + SiteSetting.expects(:crawl_images?).returns(true) + FastImage.expects(:size).with(@url) + CookedPostProcessor.image_dimensions(@url) + end + end + end + +end diff --git a/spec/components/discourse_plugin_registry_spec.rb b/spec/components/discourse_plugin_registry_spec.rb new file mode 100644 index 00000000000..fe064e6bced --- /dev/null +++ b/spec/components/discourse_plugin_registry_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' +require 'discourse_plugin_registry' + +describe DiscoursePluginRegistry do + + let(:registry) { DiscoursePluginRegistry.new } + + context '.register_css' do + before do + registry.register_css('hello.css') + end + + it 'is returned by DiscoursePluginRegistry.stylesheets' do + registry.stylesheets.include?('hello.css').should be_true + end + + it "won't add the same file twice" do + lambda { registry.register_css('hello.css') }.should_not change(registry.stylesheets, :size) + end + end + + context '.register_js' do + before do + registry.register_js('hello.js') + end + + it 'is returned by DiscoursePluginRegistry.javascripts' do + registry.javascripts.include?('hello.js').should be_true + end + + it "won't add the same file twice" do + lambda { registry.register_js('hello.js') }.should_not change(registry.javascripts, :size) + end + end + + context '.register_archetype' do + it "delegates archetypes to the Archetype component" do + Archetype.expects(:register).with('threaded', hello: 123) + registry.register_archetype('threaded', hello: 123) + end + end + +end diff --git a/spec/components/discourse_spec.rb b/spec/components/discourse_spec.rb new file mode 100644 index 00000000000..10cc61f45ab --- /dev/null +++ b/spec/components/discourse_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' +require 'discourse' + +describe Discourse do + + before do + RailsMultisite::ConnectionManagement.stubs(:current_hostname).returns('foo.com') + end + + context 'current_hostname' do + + it 'returns the hostname from the current db connection' do + Discourse.current_hostname.should == 'foo.com' + end + + end + + context 'base_url' do + + context 'when ssl is off' do + before do + SiteSetting.expects(:use_ssl?).returns(false) + end + + it 'has a non-ssl base url' do + Discourse.base_url.should == "http://foo.com" + end + end + + context 'when ssl is on' do + before do + SiteSetting.expects(:use_ssl?).returns(true) + end + + it 'has a non-ssl base url' do + Discourse.base_url.should == "https://foo.com" + end + end + + context 'with a non standard port specified' do + before do + SiteSetting.stubs(:port).returns(3000) + end + + it "returns the non standart port in the base url" do + Discourse.base_url.should == "http://foo.com:3000" + end + + end + + + end + + +end + diff --git a/spec/components/distributed_hash_spec.rb b/spec/components/distributed_hash_spec.rb new file mode 100644 index 00000000000..29f04af21df --- /dev/null +++ b/spec/components/distributed_hash_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' +require 'distributed_hash' + +describe DiscoursePluginRegistry do + # it 'should sync the sets across instances' do + # h1 = DistributedHash.new(:hash) + # h2 = DistributedHash.new(:hash) + + # h1[:hello] = "world" + # h2[:hello].should == "world" + # end +end diff --git a/spec/components/email_sender_spec.rb b/spec/components/email_sender_spec.rb new file mode 100644 index 00000000000..85de8d5674a --- /dev/null +++ b/spec/components/email_sender_spec.rb @@ -0,0 +1,119 @@ +require 'spec_helper' +require 'email_sender' + +describe EmailSender do + + it "doesn't deliver mail when the message is nil" do + Mail::Message.any_instance.expects(:deliver).never + EmailSender.new(nil, :hello).send + end + + it "doesn't deliver when the to address is nil" do + message = Mail::Message.new(body: 'hello') + message.expects(:deliver).never + EmailSender.new(message, :hello).send + end + + it "doesn't deliver when the body is nil" do + message = Mail::Message.new(to: 'eviltrout@test.domain') + message.expects(:deliver).never + EmailSender.new(message, :hello).send + end + + context 'with a valid message' do + + let(:message) do + message = Mail::Message.new to: 'eviltrout@test.domain', + body: '**hello**' + message.stubs(:deliver) + message + end + + let(:email_sender) { EmailSender.new(message, :valid_type) } + + it 'calls deliver' do + message.expects(:deliver).once + email_sender.send + end + + context 'email logs' do + + before do + email_sender.send + @email_log = EmailLog.last + end + + it 'creates an email log' do + @email_log.should be_present + end + + it 'has the correct type' do + @email_log.email_type.should == 'valid_type' + end + + it 'has the correct to_address' do + @email_log.to_address.should == 'eviltrout@test.domain' + end + + it 'has no user_id' do + @email_log.user_id.should be_blank + end + + + end + + context 'html' do + before do + email_sender.send + end + + it 'makes the message multipart' do + message.should be_multipart + end + + it 'has a html part' do + message.parts.detect {|p| p.content_type == "text/html; charset=UTF-8"}.should be_true + end + + context 'html part' do + let(:html_part) { message.parts.detect {|p| p.content_type == "text/html; charset=UTF-8"} } + + it 'has a html part' do + html_part.should be_present + end + + it 'has run markdown on the body' do + html_part.body.to_s.should == "

            hello

            " + end + + end + + + end + + + end + + context 'with a user' do + let(:message) do + message = Mail::Message.new to: 'eviltrout@test.domain', body: 'test body' + message.stubs(:deliver) + message + end + + let(:user) { Fabricate(:user) } + let(:email_sender) { EmailSender.new(message, :valid_type, user) } + + before do + email_sender.send + @email_log = EmailLog.last + end + + it 'should have the current user_id' do + @email_log.user_id.should == user.id + end + + + end + +end diff --git a/spec/components/email_spec.rb b/spec/components/email_spec.rb new file mode 100644 index 00000000000..2676d463c3b --- /dev/null +++ b/spec/components/email_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' +require 'email' + +describe Email do + + + it 'should treat a good email as valid' do + Email.is_valid?('sam@sam.com').should be_true + end + + it 'should treat a bad email as invalid' do + Email.is_valid?('sam@sam').should be_false + end + + it 'should allow museum tld' do + Email.is_valid?('sam@nic.museum').should be_true + end + + it 'should not think a word is an email' do + Email.is_valid?('sam').should be_false + end +end diff --git a/spec/components/export/export_spec.rb b/spec/components/export/export_spec.rb new file mode 100644 index 00000000000..1b5925630df --- /dev/null +++ b/spec/components/export/export_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' +require 'export/export' + +describe Export do + describe '#current_schema_version' do + it "should return the latest migration version" do + Export.current_schema_version.should == User.exec_sql("select max(version) as max from schema_migrations")[0]["max"] + end + end + + describe "models_included_in_export" do + it "should include the user model" do + Export.models_included_in_export.map(&:name).should include('User') + end + + it "should not include the message bus model" do + Export.models_included_in_export.map(&:name).should_not include('MessageBus') + end + end + + describe "is_export_running?" do + it "should return true when an export is in progress" do + $redis.stubs(:get).with(Export.export_running_key).returns('1') + Export.is_export_running?.should be_true + end + + it "should return false when an export is not happening" do + $redis.stubs(:get).with(Export.export_running_key).returns('0') + Export.is_export_running?.should be_false + end + + it "should return false when an export has never been run" do + $redis.stubs(:get).with(Export.export_running_key).returns(nil) + Export.is_export_running?.should be_false + end + end +end \ No newline at end of file diff --git a/spec/components/export/json_encoder_spec.rb b/spec/components/export/json_encoder_spec.rb new file mode 100644 index 00000000000..a2c5f624eca --- /dev/null +++ b/spec/components/export/json_encoder_spec.rb @@ -0,0 +1,146 @@ +require 'spec_helper' +require 'export/json_encoder' + +describe Export::JsonEncoder do + describe "exported data" do + before do + @encoder = Export::JsonEncoder.new + @testIO = StringIO.new + @encoder.stubs(:json_output_stream).returns(@testIO) + @encoder.stubs(:tmp_directory).returns( File.join(Rails.root, 'tmp', 'json_encoder_spec') ) + end + + describe "write_schema_info" do + it "should write a schema section when given valid arguments" do + version = '20121216230719' + @encoder.write_schema_info( source: 'discourse', version: version ) + @encoder.finish + json = JSON.parse( @testIO.string ) + json.should have_key('schema') + json['schema']['source'].should == 'discourse' + json['schema']['version'].should == version + end + + it "should raise an exception when its arguments are invalid" do + expect { + @encoder.write_schema_info({}) + }.to raise_error(Export::SchemaArgumentsError) + end + end + + describe "write_table" do + let(:table_name) { Topic.table_name } + let(:columns) { Topic.columns } + + before do + @encoder.write_schema_info( source: 'discourse', version: '111' ) + end + + it "should yield a row count of 0 to the caller on the first iteration" do + yield_count = 0 + @encoder.write_table(table_name, columns) do |row_count| + row_count.should == 0 + yield_count += 1 + break + end + yield_count.should == 1 + end + + it "should yield the number of rows I sent the first time on the second iteration" do + yield_count = 0 + @encoder.write_table(table_name, columns) do |row_count| + yield_count += 1 + if yield_count == 1 + [[1, 'Hello'], [2, 'Yeah'], [3, 'Great']] + elsif yield_count == 2 + row_count.should == 3 + break + end + end + yield_count.should == 2 + end + + it "should stop yielding when it gets an empty array" do + yield_count = 0 + @encoder.write_table(table_name, columns) do |row_count| + yield_count += 1 + break if yield_count > 1 + [] + end + yield_count.should == 1 + end + + it "should stop yielding when it gets nil" do + yield_count = 0 + @encoder.write_table(table_name, columns) do |row_count| + yield_count += 1 + break if yield_count > 1 + nil + end + yield_count.should == 1 + end + end + + describe "exported data" do + before do + @encoder.write_schema_info( source: 'discourse', version: '20121216230719' ) + end + + it "should have a table count of 0 when no tables were exported" do + @encoder.finish + json = JSON.parse( @testIO.string ) + json['schema']['table_count'].should == 0 + end + + it "should have a table count of 1 when one table was exported" do + @encoder.write_table(Topic.table_name, Topic.columns) { |row_count| [] } + @encoder.finish + json = JSON.parse( @testIO.string ) + json['schema']['table_count'].should == 1 + end + + it "should have a table count of 3 when three tables were exported" do + @encoder.write_table(Topic.table_name, Topic.columns) { |row_count| [] } + @encoder.write_table(User.table_name, User.columns) { |row_count| [] } + @encoder.write_table(Post.table_name, Post.columns) { |row_count| [] } + @encoder.finish + json = JSON.parse( @testIO.string ) + json['schema']['table_count'].should == 3 + end + + it "should have a row count of 0 when no rows were exported" do + @encoder.write_table(Notification.table_name, Notification.columns) { |row_count| [] } + @encoder.finish + json = JSON.parse( @testIO.string ) + json[Notification.table_name]['row_count'].should == 0 + end + + it "should have a row count of 1 when one row was exported" do + @encoder.write_table(Notification.table_name, Notification.columns) do |row_count| + if row_count == 0 + [['1409', '5', '1227', '', 't', '2012-12-07 19:59:56.691592', '2012-12-07 19:59:56.691592', '303', '16', '420']] + else + [] + end + end + @encoder.finish + json = JSON.parse( @testIO.string ) + json[Notification.table_name]['row_count'].should == 1 + end + + it "should have a row count of 2 when two rows were exported" do + @encoder.write_table(Notification.table_name, Notification.columns) do |row_count| + if row_count == 0 + [['1409', '5', '1227', '', 't', '2012-12-07 19:59:56.691592', '2012-12-07 19:59:56.691592', '303', '16', '420'], + ['1408', '4', '1188', '', 'f', '2012-12-07 18:40:30.460404', '2012-12-07 18:40:30.460404', '304', '1', '421']] + else + [] + end + end + @encoder.finish + json = JSON.parse( @testIO.string ) + json[Notification.table_name]['row_count'].should == 2 + end + end + end +end diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb new file mode 100644 index 00000000000..3a1d4773208 --- /dev/null +++ b/spec/components/guardian_spec.rb @@ -0,0 +1,728 @@ +require 'spec_helper' +require 'guardian' + +describe Guardian do + + let(:user) { Fabricate(:user) } + let(:moderator) { Fabricate(:moderator) } + let(:admin) { Fabricate(:admin) } + let(:another_admin) { Fabricate(:another_admin) } + let(:coding_horror) { Fabricate(:coding_horror) } + + let(:topic) { Fabricate(:topic, user: user) } + let(:post) { Fabricate(:post, topic: topic, user: topic.user) } + + it 'can be created without a user (not logged in)' do + lambda { Guardian.new }.should_not raise_error + end + + it 'can be instantiaed with a user instance' do + lambda { Guardian.new(user) }.should_not raise_error + end + + describe 'post_can_act?' do + let(:post) { Fabricate(:post) } + let(:user) { Fabricate(:user) } + + it "returns false when the user is nil" do + Guardian.new(nil).post_can_act?(post, :like).should be_false + end + + it "returns false when the post is nil" do + Guardian.new(user).post_can_act?(nil, :like).should be_false + end + + it "returns false when the topic is archived" do + post.topic.archived = true + Guardian.new(user).post_can_act?(post, :like).should be_false + end + + it "returns false when liking yourself" do + Guardian.new(post.user).post_can_act?(post, :like).should be_false + end + + it "returns false when you've already done it" do + Guardian.new(user).post_can_act?(post, :like, taken_actions: {PostActionType.Types[:like] => 1}).should be_false + end + + it "returns false when you already flagged a post" do + Guardian.new(user).post_can_act?(post, :off_topic, taken_actions: {PostActionType.Types[:spam] => 1}).should be_false + end + + describe "trust levels" do + it "returns true for a new user liking something" do + user.trust_level = TrustLevel.Levels[:new] + Guardian.new(user).post_can_act?(post, :like).should be_true + end + + it "returns false for a new user flagging something as spam" do + user.trust_level = TrustLevel.Levels[:new] + Guardian.new(user).post_can_act?(post, :spam).should be_false + end + + it "returns false for a new user flagging something as off topic" do + user.trust_level = TrustLevel.Levels[:new] + Guardian.new(user).post_can_act?(post, :off_topic).should be_false + end + end + end + + + describe 'can_send_private_message' do + let(:user) { Fabricate(:user) } + let(:another_user) { Fabricate(:user) } + + it "returns false when the user is nil" do + Guardian.new(nil).can_send_private_message?(user).should be_false + end + + it "returns false when the target user is nil" do + Guardian.new(user).can_send_private_message?(nil).should be_false + end + + it "returns false when the target is the same as the user" do + Guardian.new(user).can_send_private_message?(user).should be_false + end + + it "returns false when you are untrusted" do + user.trust_level = TrustLevel.Levels[:new] + Guardian.new(user).can_send_private_message?(another_user).should be_false + end + + it "returns true to another user" do + Guardian.new(user).can_send_private_message?(another_user).should be_true + end + end + + describe 'can_reply_as_new_topic' do + let(:user) { Fabricate(:user) } + let(:topic) { Fabricate(:topic) } + + it "returns false for a non logged in user" do + Guardian.new(nil).can_reply_as_new_topic?(topic).should be_false + end + + it "returns false for a nil topic" do + Guardian.new(user).can_reply_as_new_topic?(nil).should be_false + end + + it "returns false for an untrusted user" do + user.trust_level = TrustLevel.Levels[:new] + Guardian.new(user).can_reply_as_new_topic?(topic).should be_false + end + + it "returns true for a trusted user" do + Guardian.new(user).can_reply_as_new_topic?(topic).should be_true + end + end + + describe 'can_see_post_actors?' do + + let(:topic) { Fabricate(:topic, user: coding_horror)} + + it 'returns false when the post is nil' do + Guardian.new(user).can_see_post_actors?(nil, PostActionType.Types[:like]).should be_false + end + + it 'returns true for likes' do + Guardian.new(user).can_see_post_actors?(topic, PostActionType.Types[:like]).should be_true + end + + it 'returns false for bookmarks' do + Guardian.new(user).can_see_post_actors?(topic, PostActionType.Types[:bookmark]).should be_false + end + + it 'returns false for off-topic flags' do + Guardian.new(user).can_see_post_actors?(topic, PostActionType.Types[:off_topic]).should be_false + end + + it 'returns false for spam flags' do + Guardian.new(user).can_see_post_actors?(topic, PostActionType.Types[:spam]).should be_false + end + + it 'returns true for public votes' do + Guardian.new(user).can_see_post_actors?(topic, PostActionType.Types[:vote]).should be_true + end + + it 'returns false for private votes' do + topic.expects(:has_meta_data_boolean?).with(:private_poll).returns(true) + Guardian.new(user).can_see_post_actors?(topic, PostActionType.Types[:vote]).should be_false + end + + end + + describe 'can_impersonate?' do + it 'returns false when the target is nil' do + Guardian.new(admin).can_impersonate?(nil).should be_false + end + + it 'returns false when the user is nil' do + Guardian.new.can_impersonate?(user).should be_false + end + + it "doesn't allow a non-admin to impersonate someone" do + Guardian.new(coding_horror).can_impersonate?(user).should be_false + end + + it "doesn't allow an admin to impersonate themselves" do + Guardian.new(admin).can_impersonate?(admin).should be_false + end + + it "doesn't allow an admin to impersonate another admin" do + Guardian.new(admin).can_impersonate?(another_admin).should be_false + end + + it "allows an admin to impersonate a regular user" do + Guardian.new(admin).can_impersonate?(user).should be_true + end + + it "allows an admin to impersonate a moderator" do + Guardian.new(admin).can_impersonate?(moderator).should be_true + end + + end + + describe 'can_invite_to?' do + let(:topic) { Fabricate(:topic) } + let(:user) { topic.user } + let(:moderator) { Fabricate(:moderator) } + + it 'returns false with a nil user' do + Guardian.new(nil).can_invite_to?(topic).should be_false + end + + it 'returns false with a nil object' do + Guardian.new(moderator).can_invite_to?(nil).should be_false + end + + it 'returns true for a moderator to invite' do + Guardian.new(moderator).can_invite_to?(topic).should be_true + end + + it 'returns false when the site requires approving users' do + SiteSetting.expects(:must_approve_users?).returns(true) + Guardian.new(moderator).can_invite_to?(topic).should be_false + end + + it 'returns false for a regular user to invite' do + Guardian.new(user).can_invite_to?(topic).should be_false + end + + end + + describe 'can_see?' do + + it 'returns false with a nil object' do + Guardian.new.can_see?(nil).should be_false + end + + describe 'a Topic' do + it 'allows non logged in users to view topics' do + Guardian.new.can_see?(topic).should be_true + end + end + end + + describe 'can_create?' do + + describe 'a Category' do + + it 'returns false when not logged in' do + Guardian.new.can_create?(Category).should be_false + end + + it 'returns false when a regular user' do + Guardian.new(user).can_create?(Category).should be_false + end + + it 'returns true when a moderator' do + Guardian.new(moderator).can_create?(Category).should be_true + end + + it 'returns true when an admin' do + Guardian.new(admin).can_create?(Category).should be_true + end + end + + describe 'a Post' do + + it "is false when not logged in" do + Guardian.new.can_create?(Post, topic).should be_false + end + + it 'is true for a regular user' do + Guardian.new(topic.user).can_create?(Post, topic).should be_true + end + + it "is false when you can't see the topic" do + Guardian.any_instance.expects(:can_see?).with(topic).returns(false) + Guardian.new(topic.user).can_create?(Post, topic).should be_false + end + + context 'closed topic' do + before do + topic.closed = true + end + + it "doesn't allow new posts from regular users" do + Guardian.new(topic.user).can_create?(Post, topic).should be_false + end + + it 'allows editing of posts' do + Guardian.new(topic.user).can_edit?(post).should be_true + end + + it "allows new posts from moderators" do + Guardian.new(moderator).can_create?(Post, topic).should be_true + end + + it "allows new posts from admins" do + Guardian.new(admin).can_create?(Post, topic).should be_true + end + end + + context 'archived topic' do + before do + topic.archived = true + end + + context 'regular users' do + + it "doesn't allow new posts from regular users" do + Guardian.new(coding_horror).can_create?(Post, topic).should be_false + end + + it 'allows editing of posts' do + Guardian.new(coding_horror).can_edit?(post).should be_false + end + + end + + it "allows new posts from moderators" do + Guardian.new(moderator).can_create?(Post, topic).should be_true + end + + it "allows new posts from admins" do + Guardian.new(admin).can_create?(Post, topic).should be_true + end + end + + end + + end + + describe 'post_can_act?' do + + it "isn't allowed on nil" do + Guardian.new(user).post_can_act?(nil, nil).should be_false + end + + describe 'a Post' do + + let (:guardian) do + Guardian.new(user) + end + + + it "isn't allowed when not logged in" do + Guardian.new(nil).post_can_act?(post,:vote).should be_false + end + + it "is allowed as a regular user" do + guardian.post_can_act?(post,:vote).should be_true + end + + it "doesn't allow voting if the user has an action from voting already" do + guardian.post_can_act?(post,:vote,taken_actions: {PostActionType.Types[:vote] => 1}).should be_false + end + + it "allows voting if the user has performed a different action" do + guardian.post_can_act?(post,:vote,taken_actions: {PostActionType.Types[:like] => 1}).should be_true + end + + it "isn't allowed on archived topics" do + topic.archived = true + Guardian.new(user).post_can_act?(post,:like).should be_false + end + + + describe 'multiple voting' do + + it "isn't allowed if the user voted and the topic doesn't allow multiple votes" do + Topic.any_instance.expects(:has_meta_data_boolean?).with(:single_vote).returns(true) + Guardian.new(user).can_vote?(post, :voted_in_topic => true).should be_false + end + + it "is allowed if the user voted and the topic doesn't allow multiple votes" do + Guardian.new(user).can_vote?(post, :voted_in_topic => false).should be_true + end + end + + end + end + + describe 'can_edit?' do + + it 'returns false with a nil object' do + Guardian.new(user).can_edit?(nil).should be_false + end + + describe 'a Post' do + + it 'returns false when not logged in' do + Guardian.new.can_edit?(post).should be_false + end + + it 'returns true if you want to edit your own post' do + Guardian.new(post.user).can_edit?(post).should be_true + end + + it 'returns false if another regular user tries to edit your post' do + Guardian.new(coding_horror).can_edit?(post).should be_false + end + + it 'returns true as a moderator' do + Guardian.new(moderator).can_edit?(post).should be_true + end + + it 'returns true as an admin' do + Guardian.new(admin).can_edit?(post).should be_true + end + end + + describe 'a Topic' do + + it 'returns false when not logged in' do + Guardian.new.can_edit?(topic).should be_false + end + + it 'returns true for editing your own post' do + Guardian.new(topic.user).can_edit?(topic).should be_true + end + + + it 'returns false as a regular user' do + Guardian.new(coding_horror).can_edit?(topic).should be_false + end + + it 'returns true as a moderator' do + Guardian.new(moderator).can_edit?(topic).should be_true + end + + it 'returns true as an admin' do + Guardian.new(admin).can_edit?(topic).should be_true + end + end + + describe 'a Category' do + + let(:category) { Fabricate(:category) } + + it 'returns false when not logged in' do + Guardian.new.can_edit?(category).should be_false + end + + it 'returns false as a regular user' do + Guardian.new(category.user).can_edit?(category).should be_false + end + + it 'returns true as a moderator' do + Guardian.new(moderator).can_edit?(category).should be_true + end + + it 'returns true as an admin' do + Guardian.new(admin).can_edit?(category).should be_true + end + end + + describe 'a User' do + + it 'returns false when not logged in' do + Guardian.new.can_edit?(user).should be_false + end + + it 'returns false as a different user' do + Guardian.new(coding_horror).can_edit?(user).should be_false + end + + it 'returns true when trying to edit yourself' do + Guardian.new(user).can_edit?(user).should be_true + end + + it 'returns false as a moderator' do + Guardian.new(moderator).can_edit?(user).should be_false + end + + it 'returns true as an admin' do + Guardian.new(admin).can_edit?(user).should be_true + end + end + + end + + context 'can_moderate?' do + + it 'returns false with a nil object' do + Guardian.new(user).can_moderate?(nil).should be_false + end + + context 'a Topic' do + + it 'returns false when not logged in' do + Guardian.new.can_moderate?(topic).should be_false + end + + it 'returns false when not a moderator' do + Guardian.new(user).can_moderate?(topic).should be_false + end + + it 'returns true when a moderator' do + Guardian.new(moderator).can_moderate?(topic).should be_true + end + + it 'returns true when an admin' do + Guardian.new(admin).can_moderate?(topic).should be_true + end + + end + + end + + context 'can_see_flags?' do + + it "returns false when there is no post" do + Guardian.new(moderator).can_see_flags?(nil).should be_false + end + + it "returns false when there is no user" do + Guardian.new(nil).can_see_flags?(post).should be_false + end + + it "allow regular uses to see flags" do + Guardian.new(user).can_see_flags?(post).should be_false + end + + it "allows moderators to see flags" do + Guardian.new(moderator).can_see_flags?(post).should be_true + end + + it "allows moderators to see flags" do + Guardian.new(admin).can_see_flags?(post).should be_true + end + end + + context 'can_move_posts?' do + + it 'returns false with a nil object' do + Guardian.new(user).can_move_posts?(nil).should be_false + end + + context 'a Topic' do + + it 'returns false when not logged in' do + Guardian.new.can_move_posts?(topic).should be_false + end + + it 'returns false when not a moderator' do + Guardian.new(user).can_move_posts?(topic).should be_false + end + + it 'returns true when a moderator' do + Guardian.new(moderator).can_move_posts?(topic).should be_true + end + + it 'returns true when an admin' do + Guardian.new(admin).can_move_posts?(topic).should be_true + end + + end + + end + + + + context 'can_delete?' do + + it 'returns false with a nil object' do + Guardian.new(user).can_delete?(nil).should be_false + end + + context 'a Topic' do + + it 'returns false when not logged in' do + Guardian.new.can_delete?(topic).should be_false + end + + it 'returns false when not a moderator' do + Guardian.new(user).can_delete?(topic).should be_false + end + + it 'returns true when a moderator' do + Guardian.new(moderator).can_delete?(topic).should be_true + end + + it 'returns true when an admin' do + Guardian.new(admin).can_delete?(topic).should be_true + end + end + + context 'a Post' do + + before do + post.post_number = 2 + end + + it 'returns false when not logged in' do + Guardian.new.can_delete?(post).should be_false + end + + it 'returns false when not a moderator' do + Guardian.new(user).can_delete?(post).should be_false + end + + it "returns false when it's the OP, even as a moderator" do + post.update_attribute :post_number, 1 + Guardian.new(moderator).can_delete?(post).should be_false + end + + it 'returns true when a moderator' do + Guardian.new(moderator).can_delete?(post).should be_true + end + + it 'returns true when an admin' do + Guardian.new(admin).can_delete?(post).should be_true + end + end + + context 'a Category' do + + let(:category) { Fabricate(:category, user: moderator) } + + it 'returns false when not logged in' do + Guardian.new.can_delete?(category).should be_false + end + + it 'returns false when a regular user' do + Guardian.new(user).can_delete?(category).should be_false + end + + it 'returns true when a moderator' do + Guardian.new(moderator).can_delete?(category).should be_true + end + + it 'returns true when an admin' do + Guardian.new(admin).can_delete?(category).should be_true + end + + it "can't be deleted if it has a forum topic" do + category.topic_count = 10 + Guardian.new(moderator).can_delete?(category).should be_false + end + + end + + context 'a PostAction' do + let(:post_action) { PostAction.create(user_id: user.id, post_id: post.id, post_action_type_id: 1)} + + it 'returns false when not logged in' do + Guardian.new.can_delete?(post_action).should be_false + end + + it 'returns false when not the user who created it' do + Guardian.new(coding_horror).can_delete?(post_action).should be_false + end + + it "returns false if the window has expired" do + post_action.created_at = 20.minutes.ago + SiteSetting.expects(:post_undo_action_window_mins).returns(10) + Guardian.new(user).can_delete?(post_action).should be_false + end + + it "returns true if it's yours" do + Guardian.new(user).can_delete?(post_action).should be_true + end + + end + + end + + context 'can_approve?' do + + it "wont allow a non-logged in user to approve" do + Guardian.new.can_approve?(user).should be_false + end + + it "wont allow a non-admin to approve a user" do + Guardian.new(coding_horror).can_approve?(user).should be_false + end + + it "returns false when the user is already approved" do + user.approved = true + Guardian.new(admin).can_approve?(user).should be_false + end + + it "allows an admin to approve a user" do + Guardian.new(admin).can_approve?(user).should be_true + end + + it "allows a moderator to approve a user" do + Guardian.new(moderator).can_approve?(user).should be_true + end + + + end + + context 'can_grant_admin?' do + it "wont allow a non logged in user to grant an admin's access" do + Guardian.new.can_grant_admin?(another_admin).should be_false + end + + it "wont allow a regular user to revoke an admin's access" do + Guardian.new(user).can_grant_admin?(another_admin).should be_false + end + + it 'wont allow an admin to grant their own access' do + Guardian.new(admin).can_grant_admin?(admin).should be_false + end + + it "allows an admin to grant a regular user access" do + Guardian.new(admin).can_grant_admin?(user).should be_true + end + end + + context 'can_revoke_admin?' do + it "wont allow a non logged in user to revoke an admin's access" do + Guardian.new.can_revoke_admin?(another_admin).should be_false + end + + it "wont allow a regular user to revoke an admin's access" do + Guardian.new(user).can_revoke_admin?(another_admin).should be_false + end + + it 'wont allow an admin to revoke their own access' do + Guardian.new(admin).can_revoke_admin?(admin).should be_false + end + + it "allows an admin to revoke another admin's access" do + Guardian.new(admin).can_revoke_admin?(another_admin).should be_true + end + end + + context "can_see_pending_invites_from?" do + + it 'is false without a logged in user' do + Guardian.new(nil).can_see_pending_invites_from?(user).should be_false + end + + it 'is false without a user to look at' do + Guardian.new(user).can_see_pending_invites_from?(nil).should be_false + end + + it 'is true when looking at your own invites' do + Guardian.new(user).can_see_pending_invites_from?(user).should be_true + end + + end + +end + diff --git a/spec/components/image_sizer_spec.rb b/spec/components/image_sizer_spec.rb new file mode 100644 index 00000000000..78fd4c780db --- /dev/null +++ b/spec/components/image_sizer_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' +require 'image_sizer' + +describe ImageSizer do + + before do + SiteSetting.expects(:max_image_width).returns(500) + end + + it 'returns the same dimensions if the width is less than the maximum' do + ImageSizer.resize(400, 200).should == [400, 200] + end + + it 'returns nil if the width is nil' do + ImageSizer.resize(nil, 100).should be_nil + end + + it 'returns nil if the height is nil' do + ImageSizer.resize(100, nil).should be_nil + end + + it 'works with string parameters' do + ImageSizer.resize('100', '101').should == [100, 101] + end + + describe 'when larger than the maximum' do + + before do + @w, @h = ImageSizer.resize(600, 123) + end + + it 'returns the maxmimum width if larger than the maximum' do + @w.should == 500 + end + + it 'resizes the height retaining the aspect ratio' do + @h.should == 102 + end + + end + +end diff --git a/spec/components/import/adapter/base_spec.rb b/spec/components/import/adapter/base_spec.rb new file mode 100644 index 00000000000..c4ef3ae4489 --- /dev/null +++ b/spec/components/import/adapter/base_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' +require 'import/adapter/base' + +describe Import::Adapter::Base do + + describe 'the base implementation' do + let(:adapter) { Import::Adapter::Base.new } + + describe 'apply_to_column_names' do + it 'should return the column names passed in' do + cols = ['first', 'second'] + adapter.apply_to_column_names('table_name', cols).should == cols + end + end + + describe 'apply_to_row' do + it 'should return the row passed in' do + row = [1,2,3,4] + adapter.apply_to_row('table_name', row).should == row + end + end + end + +end \ No newline at end of file diff --git a/spec/components/import/import_spec.rb b/spec/components/import/import_spec.rb new file mode 100644 index 00000000000..f5dfe8180ea --- /dev/null +++ b/spec/components/import/import_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' +require 'import/import' + +class AdapterX < Import::Adapter::Base; end + +class Adapter1 < Import::Adapter::Base; end +class Adapter2 < Import::Adapter::Base; end +class Adapter3 < Import::Adapter::Base; end + +describe Import do + describe "is_import_running?" do + it "should return true when an import is in progress" do + $redis.stubs(:get).with(Import.import_running_key).returns('1') + Import.is_import_running?.should be_true + end + + it "should return false when an import is not happening" do + $redis.stubs(:get).with(Import.import_running_key).returns('0') + Import.is_import_running?.should be_false + end + + it "should return false when an import has never been run" do + $redis.stubs(:get).with(Import.import_running_key).returns(nil) + Import.is_import_running?.should be_false + end + end + + describe 'add_import_adapter' do + it "should return true" do + Import.clear_adapters + Import.add_import_adapter(AdapterX, '20130110121212', ['users']).should be_true + end + end + + describe 'adapters_for_version' do + it "should return an empty Hash when there are no adapters" do + Import.clear_adapters + Import.adapters_for_version('1').should == {} + end + + context 'when there are some adapters' do + before do + Import.clear_adapters + Import.add_import_adapter(Adapter1, '10', ['users']) + Import.add_import_adapter(Adapter2, '20', ['users']) + Import.add_import_adapter(Adapter3, '30', ['users']) + end + + it "should return no adapters when the version is newer than all adapters" do + Import.adapters_for_version('31')['users'].should have(0).adapters + end + + it "should return adapters that are newer than the given version" do + Import.adapters_for_version('12')['users'].should have(2).adapters + Import.adapters_for_version('22')['users'].should have(1).adapters + end + + it "should return the adapters in order" do + adapters = Import.adapters_for_version('1')['users'] + adapters[0].should be_a(Adapter1) + adapters[1].should be_a(Adapter2) + adapters[2].should be_a(Adapter3) + end + end + end +end \ No newline at end of file diff --git a/spec/components/import/json_decoder_spec.rb b/spec/components/import/json_decoder_spec.rb new file mode 100644 index 00000000000..b132105791f --- /dev/null +++ b/spec/components/import/json_decoder_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' +require 'import/json_decoder' + +describe Import::JsonDecoder do + + describe "start" do + context "given valid arguments" do + before do + @version = '20121201205642' + @export_data = { + schema: { source: 'discourse', version: @version}, + categories: { + fields: Category.columns.map(&:name), + rows: [ + ["3", "entertainment", "AB9364", "155", nil, nil, nil, nil, "19", "2012-07-12 18:55:56.355932", "2012-07-12 18:55:56.355932", "1186", "17", "0", "0", "entertainment"], + ["4", "question", "AB9364", "164", nil, nil, nil, nil, "1", "2012-07-12 18:55:56.355932", "2012-07-12 18:55:56.355932", "1186", "1", "0", "0", "question"] + ] + }, + notifications: { + fields: Notification.columns.map(&:name), + rows: [ + ["1416", "2", "1214", "{\"topic_title\":\"UI: Where did the 'Create a Topic' button go?\",\"display_username\":\"Lowell Heddings\"}", "t", "2012-12-09 18:05:09.862898", "2012-12-09 18:05:09.862898", "394", "2", nil], + ["1415", "2", "1187", "{\"topic_title\":\"Jenkins Config.xml\",\"display_username\":\"Sam\"}", "t", "2012-12-08 10:11:17.599724", "2012-12-08 10:11:17.599724", "392", "3", nil] + ] + } + } + @testIO = StringIO.new(@export_data.to_json, 'r') + @decoder = Import::JsonDecoder.new('json_decoder_spec.json.gz') + @decoder.stubs(:input_stream).returns(@testIO) + @valid_args = { callbacks: { schema_info: stub_everything, table_data: stub_everything } } + end + + it "should call the schema_info callback before sending table data" do + callback_sequence = sequence('callbacks') + @valid_args[:callbacks][:schema_info].expects(:call).in_sequence(callback_sequence) + @valid_args[:callbacks][:table_data].expects(:call).in_sequence(callback_sequence).at_least_once + @decoder.start( @valid_args ) + end + + it "should call the schema_info callback with source and version parameters when export data is from discourse" do + @valid_args[:callbacks][:schema_info].expects(:call).with do |arg| + arg.should have_key(:source) + arg.should have_key(:version) + arg[:source].should == @export_data[:schema][:source] + arg[:version].should == @export_data[:schema][:version] + end + @decoder.start( @valid_args ) + end + + it "should call the table_data callback at least once for each table in the export file" do + @valid_args[:callbacks][:table_data].expects(:call).with('categories', @export_data[:categories][:fields], anything, anything).at_least_once + @valid_args[:callbacks][:table_data].expects(:call).with('notifications', @export_data[:notifications][:fields], anything, anything).at_least_once + @decoder.start( @valid_args ) + end + end + + context "given invalid arguments" do + + end + end + +end \ No newline at end of file diff --git a/spec/components/jobs/calculate_view_counts_spec.rb b/spec/components/jobs/calculate_view_counts_spec.rb new file mode 100644 index 00000000000..19a4062cadb --- /dev/null +++ b/spec/components/jobs/calculate_view_counts_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' +require 'jobs' + +describe Jobs::CalculateViewCounts do + + + it "delegates to User" do + User.expects(:update_view_counts) + Jobs::CalculateViewCounts.new.execute({}) + end + +end \ No newline at end of file diff --git a/spec/components/jobs/enqueue_digest_emails_spec.rb b/spec/components/jobs/enqueue_digest_emails_spec.rb new file mode 100644 index 00000000000..1e54c02752f --- /dev/null +++ b/spec/components/jobs/enqueue_digest_emails_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' +require 'jobs' + +describe Jobs::EnqueueDigestEmails do + + + describe '#target_users' do + + context 'disabled digests' do + let!(:user_no_digests) { Fabricate(:user, email_digests: false, last_emailed_at: 8.days.ago, last_seen_at: 10.days.ago) } + + it "doesn't return users with email disabled" do + Jobs::EnqueueDigestEmails.new.target_users.include?(user_no_digests).should be_false + end + end + + context 'recently emailed' do + let!(:user_emailed_recently) { Fabricate(:user, last_emailed_at: 6.days.ago) } + + it "doesn't return users who have been emailed recently" do + Jobs::EnqueueDigestEmails.new.target_users.include?(user_emailed_recently).should be_false + end + end + + context 'visited the site today' do + let!(:user_visited_today) { Fabricate(:user, last_seen_at: 6.days.ago) } + + it "doesn't return users who have been emailed recently" do + Jobs::EnqueueDigestEmails.new.target_users.include?(user_visited_today).should be_false + end + end + + + context 'regular users' do + let!(:user) { Fabricate(:user) } + + it "returns the user" do + Jobs::EnqueueDigestEmails.new.target_users.should == [user] + end + end + + end + + describe '#execute' do + + let(:user) { Fabricate(:user) } + + before do + Jobs::EnqueueDigestEmails.any_instance.expects(:target_users).returns([user]) + end + + it "enqueues the digest email job" do + Jobs.expects(:enqueue).with(:user_email, type: :digest, user_id: user.id) + Jobs::EnqueueDigestEmails.new.execute({}) + end + + end + + +end + diff --git a/spec/components/jobs/exporter_spec.rb b/spec/components/jobs/exporter_spec.rb new file mode 100644 index 00000000000..3b245ac802c --- /dev/null +++ b/spec/components/jobs/exporter_spec.rb @@ -0,0 +1,190 @@ +require 'spec_helper' + +describe Jobs::Exporter do + before do + Jobs::Exporter.any_instance.stubs(:log).returns(true) + Jobs::Exporter.any_instance.stubs(:create_tar_file).returns(true) + Export::JsonEncoder.any_instance.stubs(:tmp_directory).returns( File.join(Rails.root, 'tmp', 'exporter_spec') ) + Discourse.stubs(:enable_maintenance_mode).returns(true) + Discourse.stubs(:disable_maintenance_mode).returns(true) + end + + describe "execute" do + context 'when no export or import is running' do + before do + @testIO = StringIO.new + Export::JsonEncoder.any_instance.stubs(:json_output_stream).returns(@testIO) + Jobs::Exporter.any_instance.stubs(:ordered_models_for_export).returns([]) + Export.stubs(:is_export_running?).returns(false) + Export.stubs(:is_import_running?).returns(false) + @exporter_args = {} + end + + it "should indicate that an export is now running" do + Export.expects(:set_export_started) + Jobs::Exporter.new.execute( @exporter_args ) + end + + it "should indicate that an export is not running after it's done" do + Export.expects(:set_export_is_not_running) + Jobs::Exporter.new.execute( @exporter_args ) + end + + it "should put the site in maintenance mode when it starts" do + encoder = stub_everything + Export::JsonEncoder.stubs(:new).returns(encoder) + seq = sequence('export-sequence') + Discourse.expects(:enable_maintenance_mode).in_sequence(seq).at_least_once + encoder.expects(:write_schema_info).in_sequence(seq).at_least_once + Jobs::Exporter.new.execute( @exporter_args ) + end + + it "should take the site out of maintenance mode when it ends" do + encoder = stub_everything + Export::JsonEncoder.stubs(:new).returns(encoder) + seq = sequence('export-sequence') + encoder.expects(:write_schema_info).in_sequence(seq).at_least_once + Discourse.expects(:disable_maintenance_mode).in_sequence(seq).at_least_once + Jobs::Exporter.new.execute( @exporter_args ) + end + + describe "without specifying a format" do + it "should use json as the default" do + Export::JsonEncoder.expects(:new).returns( stub_everything ) + Jobs::Exporter.new.execute( @exporter_args.reject { |key, val| key == :format } ) + end + end + + describe "specifying an invalid format" do + it "should raise an exception and not flag that an export has started" do + Jobs::Exporter.expects(:set_export_started).never + expect { + Jobs::Exporter.new.execute( @exporter_args.merge( format: :interpretive_dance ) ) + }.to raise_error(Export::FormatInvalidError) + end + end + + context "using json format" do + before do + @exporter_args = {format: :json} + end + + it "should export metadata" do + version = '201212121212' + encoder = stub_everything + encoder.expects(:write_schema_info).with do |arg| + arg[:source].should == 'discourse' + arg[:version].should == version + end + Export::JsonEncoder.stubs(:new).returns(encoder) + Export.stubs(:current_schema_version).returns(version) + Jobs::Exporter.new.execute( @exporter_args ) + end + + describe "exporting tables" do + before do + # Create some real database records + @user1, @user2 = Fabricate(:user), Fabricate(:user) + @topic1 = Fabricate(:topic, user: @user1) + @topic2 = Fabricate(:topic, user: @user2) + @topic3 = Fabricate(:topic, user: @user1) + @post1 = Fabricate(:post, topic: @topic1, user: @user1) + @post1 = Fabricate(:post, topic: @topic3, user: @user1) + @reply1 = Fabricate(:basic_reply, user: @user2, topic: @topic3) + @reply2 = Fabricate(:basic_reply, user: @user1, topic: @topic1) + @reply3 = Fabricate(:basic_reply, user: @user1, topic: @topic3) + end + + it "should export all rows from the topics table in ascending id order" do + Jobs::Exporter.any_instance.stubs(:ordered_models_for_export).returns([Topic]) + Jobs::Exporter.new.execute( @exporter_args ) + json = JSON.parse( @testIO.string ) + json.should have_key('topics') + json['topics'].should have_key('rows') + json['topics']['rows'].should have(3).rows + json['topics']['rows'][0][0].to_i.should == @topic1.id + json['topics']['rows'][1][0].to_i.should == @topic2.id + json['topics']['rows'][2][0].to_i.should == @topic3.id + end + + it "should export all rows from the post_replies table in ascending order by post_id, reply_id" do + # because post_replies doesn't have an id column, so order by one of its indexes + Jobs::Exporter.any_instance.stubs(:ordered_models_for_export).returns([PostReply]) + Jobs::Exporter.new.execute( @exporter_args ) + json = JSON.parse( @testIO.string ) + json.should have_key('post_replies') + json['post_replies'].should have_key('rows') + json['post_replies']['rows'].should have(3).rows + json['post_replies']['rows'][0][1].to_i.should == @reply2.id + json['post_replies']['rows'][1][1].to_i.should == @reply1.id + json['post_replies']['rows'][2][1].to_i.should == @reply3.id + end + + it "should export column names for each table" do + Jobs::Exporter.any_instance.stubs(:ordered_models_for_export).returns([Topic, TopicUser, PostReply]) + Jobs::Exporter.new.execute( @exporter_args ) + json = JSON.parse( @testIO.string ) + json['topics'].should have_key('fields') + json['topic_users'].should have_key('fields') + json['post_replies'].should have_key('fields') + json['topics']['fields'].should == Topic.columns.map(&:name) + json['topic_users']['fields'].should == TopicUser.columns.map(&:name) + json['post_replies']['fields'].should == PostReply.columns.map(&:name) + end + end + end + + context "when it finishes successfully" do + context "and no user was given" do + it "should not send a notification to anyone" do + expect { + Jobs::Exporter.new.execute( @exporter_args ) + }.to_not change { Notification.count } + end + end + + context "and a user was given" do + before do + @user = Fabricate(:user) + @admin = Fabricate(:admin) + end + + it "should send a notification to the user who started the export" do + expect { + Jobs::Exporter.new.execute( @exporter_args.merge( user_id: @user.id ) ) + }.to change { Notification.count }.by(1) + end + end + end + end + + context 'when an export is already running' do + before do + Export.expects(:is_export_running?).returns(true) + end + + it "should not start an export and raise an exception" do + Export.expects(:set_export_started).never + Jobs::Exporter.any_instance.expects(:start_export).never + expect { + Jobs::Exporter.new.execute({}) + }.to raise_error(Export::ExportInProgressError) + end + end + + context 'when an import is running' do + before do + Import.expects(:is_import_running?).returns(true) + end + + it "should not start an export and raise an exception" do + Export.expects(:set_export_started).never + Jobs::Exporter.any_instance.expects(:start_export).never + expect { + Jobs::Exporter.new.execute({}) + }.to raise_error(Import::ImportInProgressError) + end + end + end + +end \ No newline at end of file diff --git a/spec/components/jobs/feature_topic_users_spec.rb b/spec/components/jobs/feature_topic_users_spec.rb new file mode 100644 index 00000000000..5b73e0ecd2c --- /dev/null +++ b/spec/components/jobs/feature_topic_users_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' +require 'jobs/process_post' + +describe Jobs::FeatureTopicUsers do + + it "raises an error without a topic_id" do + lambda { Jobs::FeatureTopicUsers.new.execute({}) }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error with a missing topic_id" do + lambda { Jobs::FeatureTopicUsers.new.execute(topic_id: 123) }.should raise_error(Discourse::InvalidParameters) + end + + context 'with a topic' do + let!(:post) { Fabricate(:post) } + let(:topic) { post.topic } + let!(:coding_horror) { Fabricate(:coding_horror) } + let!(:evil_trout) { Fabricate(:evil_trout) } + let!(:second_post) { Fabricate(:post, topic: topic, user: coding_horror)} + let!(:third_post) { Fabricate(:post, topic: topic, user: evil_trout)} + + it "won't feature the OP" do + Jobs::FeatureTopicUsers.new.execute(topic_id: topic.id) + topic.reload.featured_user_ids.include?(topic.user_id).should be_false + end + + it "features the second poster" do + Jobs::FeatureTopicUsers.new.execute(topic_id: topic.id) + topic.reload.featured_user_ids.include?(coding_horror.id).should be_true + end + + it "will not feature the second poster if we supply their post to be ignored" do + Jobs::FeatureTopicUsers.new.execute(topic_id: topic.id, except_post_id: second_post.id) + topic.reload.featured_user_ids.include?(coding_horror.id).should be_false + end + + it "won't feature the last poster" do + Jobs::FeatureTopicUsers.new.execute(topic_id: topic.id) + topic.reload.featured_user_ids.include?(evil_trout.id).should be_false + end + + end + +end diff --git a/spec/components/jobs/importer_spec.rb b/spec/components/jobs/importer_spec.rb new file mode 100644 index 00000000000..e6db01fc7f0 --- /dev/null +++ b/spec/components/jobs/importer_spec.rb @@ -0,0 +1,541 @@ +require 'spec_helper' + +describe Jobs::Importer do + def stub_schema_changes + Jobs::Importer.any_instance.stubs(:create_backup_schema).returns( true ) + Jobs::Importer.any_instance.stubs(:backup_and_setup_table).returns( true ) + end + + def stub_data_loading + Jobs::Importer.any_instance.stubs(:set_schema_info).returns( true ) + Jobs::Importer.any_instance.stubs(:load_table).returns( true ) + Jobs::Importer.any_instance.stubs(:create_indexes).returns( true ) + end + + before do + Discourse.stubs(:enable_maintenance_mode).returns(true) + Discourse.stubs(:disable_maintenance_mode).returns(true) + Jobs::Importer.any_instance.stubs(:log).returns(true) + Jobs::Importer.any_instance.stubs(:extract_uploads).returns(true) + Jobs::Importer.any_instance.stubs(:extract_files).returns(true) + Jobs::Importer.any_instance.stubs(:tmp_directory).returns( File.join(Rails.root, 'tmp', 'importer_spec') ) + @importer_args = { filename: 'importer_spec.json.gz' } + end + + context "SiteSetting to enable imports" do + it "should exist" do + SiteSetting.all_settings.detect {|s| s[:setting] == :allow_import }.should be_present + end + + it "should default to false" do + SiteSetting.allow_import?.should be_false + end + end + + context 'when import is disabled' do + before do + stub_schema_changes + stub_data_loading + Import::JsonDecoder.stubs(:new).returns( stub_everything ) + SiteSetting.stubs(:allow_import).returns(false) + end + + describe "execute" do + it "should raise an error" do + expect { + Jobs::Importer.new.execute( @importer_args ) + }.to raise_error(Import::ImportDisabledError) + end + + it "should not start an import" do + Import::JsonDecoder.expects(:new).never + Jobs::Importer.any_instance.expects(:backup_tables).never + Discourse.expects(:enable_maintenance_mode).never + Jobs::Importer.new.execute( @importer_args ) rescue nil + end + end + end + + context 'when import is enabled' do + before do + SiteSetting.stubs(:allow_import).returns(true) + end + + describe "execute" do + before do + stub_data_loading + end + + shared_examples_for "when import should not be started" do + it "should not start an import" do + Import::JsonDecoder.expects(:new).never + Jobs::Importer.any_instance.expects(:backup_tables).never + Jobs::Importer.new.execute( @invalid_args ) rescue nil + end + + it "should not put the site in maintenance mode" do + Discourse.expects(:enable_maintenance_mode).never + Jobs::Importer.new.execute( @invalid_args ) rescue nil + end + end + + context "when an import is already running" do + before do + Import::JsonDecoder.stubs(:new).returns( stub_everything ) + Import.stubs(:is_import_running?).returns( true ) + end + + it "should raise an error" do + expect { + Jobs::Importer.new.execute( @importer_args ) + }.to raise_error(Import::ImportInProgressError) + end + + it_should_behave_like "when import should not be started" + end + + context "when an export is running" do + before do + Export.stubs(:is_export_running?).returns( true ) + end + + it "should raise an error" do + expect { + Jobs::Importer.new.execute( @importer_args ) + }.to raise_error(Export::ExportInProgressError) + end + + it_should_behave_like "when import should not be started" + end + + context "when no export or import are running" do + before do + Import.stubs(:is_import_running?).returns( false ) + Export.stubs(:is_export_running?).returns( false ) + end + + it "without specifying a format should use json as the default format" do + stub_schema_changes + Import::JsonDecoder.expects(:new).returns( stub_everything ) + Jobs::Importer.new.execute( @importer_args.reject { |key, val| key == :format } ) + end + + it "when specifying json as the format it should use json" do + stub_schema_changes + Import::JsonDecoder.expects(:new).returns( stub_everything ) + Jobs::Importer.new.execute( @importer_args.merge(format: :json) ) + end + + context "when specifying an invalid format" do + before do + stub_schema_changes + @invalid_args = @importer_args.merge( format: :smoke_signals ) + end + + it "should raise an error" do + expect { + Jobs::Importer.new.execute( @invalid_args ) + }.to raise_error(Import::FormatInvalidError) + end + + it_should_behave_like "when import should not be started" + end + + context "when filename is not given" do + before do + stub_schema_changes + @invalid_args = @importer_args.reject { |k,v| k == :filename } + end + + it "should raise an error" do + expect { + Jobs::Importer.new.execute( @invalid_args ) + }.to raise_error(Import::FilenameMissingError) + end + + it_should_behave_like "when import should not be started" + end + + context "before loading data into tables" do + before do + Import::JsonDecoder.stubs(:new).returns( stub_everything ) + stub_data_loading + end + + shared_examples_for "a successful call to execute" do + it "should make a backup of the users table" do + Jobs::Importer.any_instance.stubs(:ordered_models_for_import).returns([User]) + Jobs::Importer.new.execute(@importer_args) + User.exec_sql_row_count("SELECT table_name FROM information_schema.tables WHERE table_schema = 'backup' AND table_name = 'users'").should == 1 + end + + it "should have a users table that's empty" do + @user1 = Fabricate(:user) + User.count.should == 1 + Jobs::Importer.any_instance.stubs(:ordered_models_for_import).returns([User]) + Jobs::Importer.new.execute(@importer_args) + User.count.should == 0 + end + + it "should indicate that an import is running when it starts" do + Import.expects(:set_import_started) + Jobs::Importer.new.execute(@importer_args) + end + + it "should indicate that an import is running when it's done" do + Import.expects(:set_import_is_not_running) + Jobs::Importer.new.execute(@importer_args) + end + + it "should put the site in maintenance mode" do + seq = sequence('call sequence') + Import.is_import_running?.should be_false + Discourse.expects(:enable_maintenance_mode).in_sequence(seq).at_least_once + Jobs::Importer.any_instance.expects(:backup_tables).in_sequence(seq).at_least_once + Jobs::Importer.any_instance.expects(:load_data).in_sequence(seq).at_least_once + # fails here + Jobs::Importer.new.execute( @importer_args ) + end + + it "should take the site out of maintenance mode when it's done" do + seq = sequence('call sequence') + Jobs::Importer.any_instance.expects(:backup_tables).in_sequence(seq).at_least_once + Jobs::Importer.any_instance.expects(:load_data).in_sequence(seq).at_least_once + Discourse.expects(:disable_maintenance_mode).in_sequence(seq).at_least_once + Jobs::Importer.new.execute( @importer_args ) + end + end + + context "the first time an import is run" do + it_should_behave_like "a successful call to execute" + end + + context "the second time an import is run" do + before do + Jobs::Importer.new.execute(@importer_args) + end + it_should_behave_like "a successful call to execute" + end + end + + # + # Import notifications don't work from the rake task. Why is activerecord inserting an "id" value of NULL? + # + # PG::Error: ERROR: null value in column "id" violates not-null constraint + # : INSERT INTO "topic_allowed_users" ("created_at", "id", "topic_id", "updated_at", "user_id") VALUES ($1, $2, $3, $4, $5) RETURNING "id" + # + + # context "when it finishes successfully" do + # before do + # stub_schema_changes + # Import::JsonDecoder.stubs(:new).returns( stub_everything ) + # end + + # context "and no user was given" do + # it "should not send a notification to anyone" do + # expect { + # Jobs::Importer.new.execute( @importer_args ) + # }.to_not change { Notification.count } + # end + # end + + # context "and a user was given" do + # before do + # @user = Fabricate(:user) + # @admin = Fabricate(:admin) + # end + + # it "should send a notification to the user who started the import" do + # expect { + # Jobs::Importer.new.execute( @importer_args.merge( user_id: @user.id ) ) + # }.to change { Notification.count }.by(1) + # end + # end + # end + end + end + + describe "set_schema_info" do + context "when source is Discourse" do + before do + @current_version = '20121216230719' + Export.stubs(:current_schema_version).returns(@current_version) + @valid_args = { source: 'discourse', version: @current_version, table_count: Export.models_included_in_export.size } + end + + it "succeeds when receiving the current schema version" do + Jobs::Importer.new.set_schema_info( @valid_args ).should be_true + end + + it "succeeds when receiving an older schema version" do + Jobs::Importer.new.set_schema_info( @valid_args.merge( version: "#{@current_version.to_i - 1}") ).should be_true + end + + it "raises an error if version is not given" do + expect { + Jobs::Importer.new.set_schema_info( @valid_args.reject {|key, val| key == :version} ) + }.to raise_error(ArgumentError) + end + + it "raises an error when receiving a newer schema version" do + expect { + Jobs::Importer.new.set_schema_info( @valid_args.merge( version: "#{@current_version.to_i + 1}") ) + }.to raise_error(Import::UnsupportedSchemaVersion) + end + + it "raises an error when it doesn't get the number of tables it expects" do + expect { + Jobs::Importer.new.set_schema_info( @valid_args.merge( table_count: 2 ) ) + }.to raise_error(Import::WrongTableCountError) + end + end + + it "raises an error when it receives an unsupported source" do + expect { + Jobs::Importer.new.set_schema_info( source: 'digg' ) + }.to raise_error(Import::UnsupportedExportSource) + end + end + + describe "load_table" do + before do + stub_schema_changes + @valid_field_list = ["id", "notification_type", "user_id", "data", "read", "created_at", "updated_at", "topic_id", "post_number", "post_action_id"] + @valid_notifications_row_data = [ + ['1409', '5', '1227', '', 't', '2012-12-07 19:59:56.691592', '2012-12-07 19:59:56.691592', '303', '16', '420'], + ['1408', '4', '1188', '', 'f', '2012-12-07 18:40:30.460404', '2012-12-07 18:40:30.460404', '304', '1', '421'] + ] + end + + context "when export data is at the current scheam version" do + before do + Import.stubs(:adapters_for_version).returns({}) + end + + context "with good data" do + it "should add rows to the notifcations table given valid row data" do + Jobs::Importer.new.load_table('notifications', @valid_field_list, @valid_notifications_row_data, @valid_notifications_row_data.size) + Notification.count.should == @valid_notifications_row_data.length + end + + it "should successfully load rows with double quote literals in the values" do + @valid_notifications_row_data[0][3] = "{\"topic_title\":\"Errors, errbit and you!\",\"display_username\":\"Coding Horror\"}" + Jobs::Importer.new.load_table('notifications', @valid_field_list, @valid_notifications_row_data, @valid_notifications_row_data.size) + Notification.count.should == @valid_notifications_row_data.length + end + + it "should successfully load rows with single quote literals in the values" do + @valid_notifications_row_data[0][3] = "{\"topic_title\":\"Bacon's Delicious, Am I Right\",\"display_username\":\"Celine Dion\"}" + Jobs::Importer.new.load_table('notifications', @valid_field_list, @valid_notifications_row_data, @valid_notifications_row_data.size) + Notification.count.should == @valid_notifications_row_data.length + end + + it "should succesfully load rows with null values" do + @valid_notifications_row_data[0][7] = nil + @valid_notifications_row_data[1][9] = nil + Jobs::Importer.new.load_table('notifications', @valid_field_list, @valid_notifications_row_data, @valid_notifications_row_data.size) + Notification.count.should == @valid_notifications_row_data.length + end + + it "should successfully load rows with question marks in the values" do + @valid_notifications_row_data[0][3] = "{\"topic_title\":\"Who took my sandwich?\",\"display_username\":\"Lunchless\"}" + Jobs::Importer.new.load_table('notifications', @valid_field_list, @valid_notifications_row_data, @valid_notifications_row_data.size) + Notification.count.should == @valid_notifications_row_data.length + end + end + + context "with fewer than the expected number of fields for a table" do + before do + @short_field_list = ["id", "notification_type", "user_id", "data", "read", "created_at", "updated_at", "topic_id", "post_number"] + @short_notifications_row_data = [ + ['1409', '5', '1227', '', 't', '2012-12-07 19:59:56.691592', '2012-12-07 19:59:56.691592', '303', '16'], + ['1408', '4', '1188', '', 'f', '2012-12-07 18:40:30.460404', '2012-12-07 18:40:30.460404', '304', '1'] + ] + end + + it "should not raise an error" do + expect { + Jobs::Importer.new.load_table('notifications', @short_field_list, @short_notifications_row_data, @short_notifications_row_data.size) + }.to_not raise_error + end + end + + context "with more than the expected number of fields for a table" do + before do + @too_long_field_list = ["id", "notification_type", "user_id", "data", "read", "created_at", "updated_at", "topic_id", "post_number", "post_action_id", "extra_col"] + @too_long_notifications_row_data = [ + ['1409', '5', '1227', '', 't', '2012-12-07 19:59:56.691592', '2012-12-07 19:59:56.691592', '303', '16', '420', 'extra'], + ['1408', '4', '1188', '', 'f', '2012-12-07 18:40:30.460404', '2012-12-07 18:40:30.460404', '304', '1', '421', 'extra'] + ] + end + + it "should raise an error" do + expect { + Jobs::Importer.new.load_table('notifications', @too_long_field_list, @too_long_notifications_row_data, @too_long_notifications_row_data.size) + }.to raise_error(Import::WrongFieldCountError) + end + end + + context "with an unrecognized table name" do + it "should not raise an error" do + expect { + Jobs::Importer.new.load_table('pork_chops', @valid_field_list, @valid_notifications_row_data, @valid_notifications_row_data.size) + }.to_not raise_error + end + + it "should report a warning" do + Jobs::Importer.any_instance.expects(:add_warning).once + Jobs::Importer.new.load_table('pork_chops', @valid_field_list, @valid_notifications_row_data, @valid_notifications_row_data.size) + end + end + end + + context "when import adapters are needed" do + before do + @version = (Export.current_schema_version.to_i - 1).to_s + Export.stubs(:current_schema_version).returns( @version ) + end + + it "should apply the adapter" do + @adapter = mock('adapter', apply_to_column_names: @valid_field_list, apply_to_row: @valid_notifications_row_data[0]) + Import.expects(:adapters_for_version).at_least_once.returns({'notifications' => [@adapter]}) + Jobs::Importer.new.load_table('notifications', @valid_field_list, @valid_notifications_row_data[0,1], 1) + end + end + end + + describe "create_indexes" do + before do + Import::JsonDecoder.stubs(:new).returns( stub_everything ) + Jobs::Importer.any_instance.stubs(:set_schema_info).returns( true ) + Jobs::Importer.any_instance.stubs(:load_table).returns( true ) + end + + it "should create the same indexes on the new tables" do + Jobs::Importer.any_instance.stubs(:ordered_models_for_import).returns([Topic]) + expect { + Jobs::Importer.new.execute( @importer_args ) + }.to_not change{ Topic.exec_sql("SELECT indexname FROM pg_indexes WHERE tablename = 'topics' and schemaname = 'public';").map {|x| x['indexname']}.sort } + end + + it "should create primary keys" do + Jobs::Importer.any_instance.stubs(:ordered_models_for_import).returns([User]) + Jobs::Importer.new.execute( @importer_args ) + User.connection.primary_key('users').should_not be_nil + end + end + + describe "rollback" do + it "should not get called if format parameter is invalid" do + stub_data_loading + Jobs::Importer.any_instance.stubs(:start_import).raises(Import::FormatInvalidError) + Jobs::Importer.any_instance.expects(:rollback).never + Jobs::Importer.new.execute( @importer_args ) rescue nil + end + + context "when creating the backup schema fails" do + it "should not call rollback" do + stub_data_loading + Jobs::Importer.any_instance.stubs(:create_backup_schema).raises(RuntimeError) + Jobs::Importer.any_instance.expects(:rollback).never + Jobs::Importer.new.execute( @importer_args ) rescue nil + end + end + + shared_examples_for "a case when rollback is needed" do + before do + Jobs::Importer.any_instance.stubs(:ordered_models_for_import).returns([User]) + @user1, @user2 = Fabricate(:user), Fabricate(:user) + @user_row1 = User.connection.select_rows("select * from users order by id DESC limit 1") + @user_row1[0] = '11111' # change the id + @export_data = { + schema: { source: 'discourse', version: '20121201205642'}, + users: { + fields: User.columns.map(&:name), + rows: [ *@user_row1 ] + } + } + @testIO = StringIO.new(@export_data.to_json, 'r') + Import::JsonDecoder.any_instance.stubs(:input_stream).returns(@testIO) + end + + it "should call rollback" do + Jobs::Importer.any_instance.expects(:rollback).once + Jobs::Importer.new.execute( @importer_args ) rescue nil + end + + it "should restore the data" do + expect { + Jobs::Importer.new.execute( @importer_args ) rescue nil + }.to_not change { User.count } + users = User.all + users.should include(@user1) + users.should include(@user2) + end + + it "should take the site out of maintenance mode" do + Discourse.expects(:disable_maintenance_mode).at_least_once + Jobs::Importer.new.execute( @importer_args ) rescue nil + end + end + + context "when backing up a table fails" do + it "should not call rollback" do # because the transaction will rollback automatically + stub_data_loading + Jobs::Importer.any_instance.stubs(:backup_and_setup_table).raises(ActiveRecord::StatementInvalid) + Jobs::Importer.any_instance.expects(:rollback).never + Jobs::Importer.new.execute( @importer_args ) rescue nil + end + end + + context "when export source is invalid" do + before do + Jobs::Importer.any_instance.stubs(:set_schema_info).raises(Import::UnsupportedExportSource) + end + it_should_behave_like "a case when rollback is needed" + end + + context "when schema version is not supported" do + before do + Jobs::Importer.any_instance.stubs(:set_schema_info).raises(Import::UnsupportedSchemaVersion) + end + it_should_behave_like "a case when rollback is needed" + end + + context "when schema info in export file is invalid for some other reason" do + before do + Jobs::Importer.any_instance.stubs(:set_schema_info).raises(ArgumentError) + end + it_should_behave_like "a case when rollback is needed" + end + + context "when loading a table fails" do + before do + Jobs::Importer.any_instance.stubs(:load_table).raises(ActiveRecord::StatementInvalid) + end + it_should_behave_like "a case when rollback is needed" + end + + context "when creating indexes fails" do + before do + Jobs::Importer.any_instance.stubs(:create_indexes).raises(ActiveRecord::StatementInvalid) + end + it_should_behave_like "a case when rollback is needed" + end + + context "when table count is wrong" do + before do + Jobs::Importer.any_instance.stubs(:set_schema_info).raises(Import::WrongTableCountError) + end + it_should_behave_like "a case when rollback is needed" + end + + context "when field count for a table is wrong" do + before do + Jobs::Importer.any_instance.stubs(:load_table).raises(Import::WrongFieldCountError) + end + it_should_behave_like "a case when rollback is needed" + end + end + end +end \ No newline at end of file diff --git a/spec/components/jobs/invite_email_spec.rb b/spec/components/jobs/invite_email_spec.rb new file mode 100644 index 00000000000..2815bb214ed --- /dev/null +++ b/spec/components/jobs/invite_email_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' +require 'jobs' + +describe Jobs::InviteEmail do + + context '.execute' do + + it 'raises an error when the invite_id is missing' do + lambda { Jobs::InviteEmail.new.execute({}) }.should raise_error(Discourse::InvalidParameters) + end + + context 'with an invite id' do + + let (:mailer) { Mail::Message.new(to: 'eviltrout@test.domain') } + let (:invite) { Fabricate(:invite) } + + it 'delegates to the test mailer' do + EmailSender.any_instance.expects(:send) + InviteMailer.expects(:send_invite).with(invite).returns(mailer) + Jobs::InviteEmail.new.execute(invite_id: invite.id) + end + + end + + end + + +end + diff --git a/spec/components/jobs/jobs_base_spec.rb b/spec/components/jobs/jobs_base_spec.rb new file mode 100644 index 00000000000..07fb92033ae --- /dev/null +++ b/spec/components/jobs/jobs_base_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' +require 'jobs' + +describe Jobs::Base do + + it 'delegates the process call to execute' do + Jobs::Base.any_instance.expects(:execute).with('hello' => 'world') + Jobs::Base.new.perform('hello' => 'world', 'sync_exec' => true) + end + + it 'converts to an indifferent access hash' do + Jobs::Base.any_instance.expects(:execute).with(instance_of(HashWithIndifferentAccess)) + Jobs::Base.new.perform('hello' => 'world', 'sync_exec' => true) + end + +end + diff --git a/spec/components/jobs/notify_moved_posts_spec.rb b/spec/components/jobs/notify_moved_posts_spec.rb new file mode 100644 index 00000000000..174a53e1196 --- /dev/null +++ b/spec/components/jobs/notify_moved_posts_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' +require 'jobs/process_post' + +describe Jobs::NotifyMovedPosts do + + it "raises an error without post_ids" do + lambda { Jobs::NotifyMovedPosts.new.execute(moved_by_id: 1234) }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error without moved_by_id" do + lambda { Jobs::NotifyMovedPosts.new.execute(post_ids: [1,2,3]) }.should raise_error(Discourse::InvalidParameters) + end + + + context 'with post ids' do + let!(:p1) { Fabricate(:post) } + let!(:p2) { Fabricate(:post, user: Fabricate(:evil_trout), topic: p1.topic) } + let!(:p3) { Fabricate(:post, user: p1.user, topic: p1.topic) } + let(:admin) { Fabricate(:admin) } + + let(:moved_post_notifications) { Notification.where(notification_type: Notification.Types[:moved_post]) } + + it "should create two notifications" do + lambda { Jobs::NotifyMovedPosts.new.execute(post_ids: [p1.id, p2.id, p3.id], moved_by_id: admin.id) }.should change(moved_post_notifications, :count).by(2) + end + + context 'when moved by one of the posters' do + it "create one notifications, because the poster is the mover" do + lambda { Jobs::NotifyMovedPosts.new.execute(post_ids: [p1.id, p2.id, p3.id], moved_by_id: p1.user_id) }.should change(moved_post_notifications, :count).by(1) + end + end + + end + + +end diff --git a/spec/components/jobs/process_post_spec.rb b/spec/components/jobs/process_post_spec.rb new file mode 100644 index 00000000000..e794b1dc4ff --- /dev/null +++ b/spec/components/jobs/process_post_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' +require 'jobs/process_post' + +describe Jobs::ProcessPost do + + it "returns when the post cannot be found" do + lambda { Jobs::ProcessPost.new.perform(post_id: 1, sync_exec: true) }.should_not raise_error + end + + context 'with a post' do + + before do + @post = Fabricate(:post) + end + + it 'calls process on a CookedPostProcessor' do + CookedPostProcessor.any_instance.expects(:post_process).once + Jobs::ProcessPost.new.execute(post_id: @post.id) + end + + it 'updates the html if the dirty flag is true' do + CookedPostProcessor.any_instance.expects(:dirty?).returns(true) + CookedPostProcessor.any_instance.expects(:html).returns('test') + Post.any_instance.expects(:update_column).with(:cooked, 'test').once + Jobs::ProcessPost.new.execute(post_id: @post.id) + end + + it "doesn't update the cooked content if dirty is false" do + CookedPostProcessor.any_instance.expects(:dirty?).returns(false) + Post.any_instance.expects(:update_column).never + Jobs::ProcessPost.new.execute(post_id: @post.id) + end + + end + + +end diff --git a/spec/components/jobs/send_system_message_spec.rb b/spec/components/jobs/send_system_message_spec.rb new file mode 100644 index 00000000000..aad6094266a --- /dev/null +++ b/spec/components/jobs/send_system_message_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +require 'jobs/send_system_message' + +describe Jobs::SendSystemMessage do + + it "raises an error without a user_id" do + lambda { Jobs::SendSystemMessage.new.execute(message_type: 'welcome_invite') }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error without a message_type" do + lambda { Jobs::SendSystemMessage.new.execute(user_id: 1234) }.should raise_error(Discourse::InvalidParameters) + end + + context 'with valid parameters' do + + let(:user) { Fabricate(:user) } + + it "should call SystemMessage.create" do + SystemMessage.any_instance.expects(:create).with('welcome_invite') + Jobs::SendSystemMessage.new.execute(user_id: user.id, message_type: 'welcome_invite') + end + + end + +end diff --git a/spec/components/jobs/test_email_spec.rb b/spec/components/jobs/test_email_spec.rb new file mode 100644 index 00000000000..4f53940e160 --- /dev/null +++ b/spec/components/jobs/test_email_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' +require 'jobs' + +describe Jobs::TestEmail do + + context '.execute' do + it 'raises an error when the address is missing' do + lambda { Jobs::TestEmail.new.execute({}) }.should raise_error(Discourse::InvalidParameters) + end + + context 'with an address' do + + let (:mailer) { Mail::Message.new(to: 'eviltrout@test.domain') } + + it 'delegates to the test mailer' do + EmailSender.any_instance.expects(:send) + TestMailer.expects(:send_test).with('eviltrout@test.domain').returns(mailer) + Jobs::TestEmail.new.execute(to_address: 'eviltrout@test.domain') + end + + end + + end + + +end + diff --git a/spec/components/jobs/user_email_spec.rb b/spec/components/jobs/user_email_spec.rb new file mode 100644 index 00000000000..d4b83ad481b --- /dev/null +++ b/spec/components/jobs/user_email_spec.rb @@ -0,0 +1,97 @@ +require 'spec_helper' +require 'jobs' + +describe Jobs::UserEmail do + + before do + SiteSetting.stubs(:email_time_window_mins).returns(10) + end + + let(:user) { Fabricate(:user, last_seen_at: 11.minutes.ago ) } + let(:mailer) { Mail::Message.new(to: user.email) } + + it "raises an error when there is no user" do + lambda { Jobs::UserEmail.new.execute(type: :digest) }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error when there is no type" do + lambda { Jobs::UserEmail.new.execute(user_id: user.id) }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error when the type doesn't exist" do + lambda { Jobs::UserEmail.new.execute(type: :no_method, user_id: user.id) }.should raise_error(Discourse::InvalidParameters) + end + + it "doesn't call the mailer when the user is missing" do + UserNotifications.expects(:digest).never + Jobs::UserEmail.new.execute(type: :digest, user_id: 1234) + end + + + context 'to_address' do + it 'overwrites a to_address when present' do + UserNotifications.expects(:authorize_email).returns(mailer) + EmailSender.any_instance.expects(:send) + Jobs::UserEmail.new.execute(type: :authorize_email, user_id: user.id, to_address: 'jake@adventuretime.ooo') + mailer.to.should == ['jake@adventuretime.ooo'] + end + end + + context "recently seen" do + let(:post) { Fabricate(:post, user: user) } + + it "doesn't send an email to a user that's been recently seen" do + user.update_column(:last_seen_at, 9.minutes.ago) + EmailSender.any_instance.expects(:send).never + Jobs::UserEmail.new.execute(type: :private_message, user_id: user.id, post_id: post.id) + end + end + + context 'args' do + + it 'passes a token as an argument when a token is present' do + UserNotifications.expects(:forgot_password).with(user, {email_token: 'asdfasdf'}).returns(mailer) + EmailSender.any_instance.expects(:send) + Jobs::UserEmail.new.execute(type: :forgot_password, user_id: user.id, email_token: 'asdfasdf') + end + + context "post" do + let(:post) { Fabricate(:post, user: user) } + + it 'passes a post as an argument when a post_id is present' do + UserNotifications.expects(:private_message).with(user, {post: post}).returns(mailer) + EmailSender.any_instance.expects(:send) + Jobs::UserEmail.new.execute(type: :private_message, user_id: user.id, post_id: post.id) + end + + it "doesn't send the email if you've seen the post" do + EmailSender.any_instance.expects(:send).never + PostTiming.record_timing(topic_id: post.topic_id, user_id: user.id, post_number: post.post_number, msecs: 6666) + Jobs::UserEmail.new.execute(type: :private_message, user_id: user.id, post_id: post.id) + end + + end + + + context 'notification' do + let!(:notification) { Fabricate(:notification, user: user)} + + it 'passes a notification as an argument when a notification_id is present' do + EmailSender.any_instance.expects(:send) + UserNotifications.expects(:user_mentioned).with(user, notification: notification).returns(mailer) + Jobs::UserEmail.new.execute(type: :user_mentioned, user_id: user.id, notification_id: notification.id) + end + + it "doesn't send the email if the notification has been seen" do + EmailSender.any_instance.expects(:send).never + notification.update_column(:read, true) + Jobs::UserEmail.new.execute(type: :user_mentioned, user_id: user.id, notification_id: notification.id) + end + + end + + end + + +end + diff --git a/spec/components/jobs_spec.rb b/spec/components/jobs_spec.rb new file mode 100644 index 00000000000..3ca5c0592a7 --- /dev/null +++ b/spec/components/jobs_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' +require 'jobs' + +describe Jobs do + + describe 'enqueue' do + + describe 'when queue_jobs is true' do + before do + SiteSetting.expects(:queue_jobs?).returns(true) + end + + it 'enqueues a job in sidekiq' do + Sidekiq::Client.expects(:enqueue).with(Jobs::ProcessPost, post_id: 1, current_site_id: 'default') + Jobs.enqueue(:process_post, post_id: 1) + end + + it "does not pass current_site_id when 'all_sites' is present" do + Sidekiq::Client.expects(:enqueue).with(Jobs::ProcessPost, post_id: 1) + Jobs.enqueue(:process_post, post_id: 1, all_sites: true) + end + + it "doesn't execute the job" do + Sidekiq::Client.stubs(:enqueue) + Jobs::ProcessPost.any_instance.expects(:perform).never + Jobs.enqueue(:process_post, post_id: 1) + end + + it "should enqueue with the correct database id when the current_site_id option is given" do + Sidekiq::Client.expects(:enqueue).with do |arg1, arg2| + arg2[:current_site_id] == 'test_db' and arg2[:sync_exec].nil? + end + Jobs.enqueue(:process_post, post_id: 1, current_site_id: 'test_db') + end + end + + describe 'when queue_jobs is false' do + before do + SiteSetting.expects(:queue_jobs?).returns(false) + end + + it "doesn't enqueue in sidekiq" do + Sidekiq::Client.expects(:enqueue).with(Jobs::ProcessPost, {}).never + Jobs.enqueue(:process_post, post_id: 1) + end + + it "executes the job right away" do + Jobs::ProcessPost.any_instance.expects(:perform).with(post_id: 1, sync_exec: true, current_site_id: "default") + Jobs.enqueue(:process_post, post_id: 1) + end + + context 'and current_site_id option is given and does not match the current connection' do + before do + Sidekiq::Client.stubs(:enqueue) + Jobs::ProcessPost.any_instance.stubs(:execute).returns(true) + end + + it 'should not execute the job' do + Jobs::ProcessPost.any_instance.expects(:execute).never + Jobs.enqueue(:process_post, post_id: 1, current_site_id: 'test_db') rescue nil + end + + it 'should raise an exception' do + expect { + Jobs.enqueue(:process_post, post_id: 1, current_site_id: 'test_db') + }.to raise_error(ArgumentError) + end + + it 'should not connect to the given database' do + RailsMultisite::ConnectionManagement.expects(:establish_connection).never + Jobs.enqueue(:process_post, post_id: 1, current_site_id: 'test_db') rescue nil + end + end + end + + end + +end + diff --git a/spec/components/mothership_spec.rb b/spec/components/mothership_spec.rb new file mode 100644 index 00000000000..cd8575e8b6a --- /dev/null +++ b/spec/components/mothership_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' +require_dependency 'mothership' + +describe Mothership do + describe '#nickname_available?' do + it 'should return true when nickname is available and no suggestion' do + RestClient.stubs(:get).returns( {success: 'OK', available: true}.to_json ) + Mothership.nickname_available?('MacGyver').should == [true, nil] + end + + it 'should return false and a suggestion when nickname is not available' do + RestClient.stubs(:get).returns( {success: 'OK', available: false, suggestion: 'MacGyver1'}.to_json ) + available, suggestion = Mothership.nickname_available?('MacGyver') + available.should be_false + suggestion.should_not be_nil + end + + # How to handle connect errors? timeout? 401? 403? 429? + end + + describe '#nickname_match?' do + it 'should return true when it is a match and no suggestion' do + RestClient.stubs(:get).returns( {success: 'OK', match: true, available: false}.to_json ) + Mothership.nickname_match?('MacGyver', 'macg@example.com').should == [true, false, nil] + end + + it 'should return false and a suggestion when it is not a match and the nickname is not available' do + RestClient.stubs(:get).returns( {success: 'OK', match: false, available: false, suggestion: 'MacGyver1'}.to_json ) + match, available, suggestion = Mothership.nickname_match?('MacGyver', 'macg@example.com') + match.should be_false + available.should be_false + suggestion.should_not be_nil + end + + it 'should return false and no suggestion when it is not a match and the nickname is available' do + RestClient.stubs(:get).returns( {success: 'OK', match: false, available: true}.to_json ) + match, available, suggestion = Mothership.nickname_match?('MacGyver', 'macg@example.com') + match.should be_false + available.should be_true + suggestion.should be_nil + end + end + + describe '#register_nickname' do + it 'should return true when registration succeeds' do + RestClient.stubs(:post).returns( {success: 'OK'}.to_json ) + Mothership.register_nickname('MacGyver', 'macg@example.com').should be_true + end + + it 'should return raise an exception when registration fails' do + RestClient.stubs(:post).returns( {failed: -200}.to_json ) + expect { + Mothership.register_nickname('MacGyver', 'macg@example.com') + }.to raise_error(Mothership::NicknameUnavailable) + end + end + + describe '#current_discourse_version' do + it 'should return the latest version of discourse' do + RestClient.stubs(:get).returns( {success: 'OK', version: 1.0}.to_json ) + Mothership.current_discourse_version().should == 1.0 + end + end +end diff --git a/spec/components/oneboxer_spec.rb b/spec/components/oneboxer_spec.rb new file mode 100644 index 00000000000..7ce5a44df0c --- /dev/null +++ b/spec/components/oneboxer_spec.rb @@ -0,0 +1,132 @@ +require 'spec_helper' +require 'oneboxer' + +describe Oneboxer do + + # A class to help us test + class DummyOnebox < Oneboxer::BaseOnebox + matcher /^https?:\/\/dummy.localhost/ + + def onebox + "dummy!" + end + end + + before do + Oneboxer.add_onebox DummyOnebox + @dummy_onebox_url = "http://dummy.localhost/dummy-object" + end + + it 'should have matchers set up by default' do + Oneboxer.matchers.should be_present + end + + context 'find onebox for url' do + + it 'returns blank with an unknown url' do + Oneboxer.onebox_for_url('http://asdfasdfasdfasdf.asdf').should be_blank + end + + it 'returns something when matched' do + Oneboxer.onebox_for_url(@dummy_onebox_url).should be_present + end + + it 'returns an instance of our class when matched' do + Oneboxer.onebox_for_url(@dummy_onebox_url).kind_of?(DummyOnebox).should be_true + end + + end + + context 'without caching' do + it 'calls the onebox method of our matched class' do + Oneboxer.onebox_nocache(@dummy_onebox_url).should == 'dummy!' + end + end + + context 'with caching' do + + context 'initial cache is empty' do + + it 'has no OneboxRender records' do + OneboxRender.count.should == 0 + end + + it 'calls the onebox_nocache method if there is no cache record yet' do + Oneboxer.expects(:onebox_nocache).with(@dummy_onebox_url).once + Oneboxer.onebox(@dummy_onebox_url) + end + end + + context 'caching result' do + before do + @post = Fabricate(:post) + @result = Oneboxer.onebox(@dummy_onebox_url, post_id: @post.id) + @onebox_render = OneboxRender.where(url: @dummy_onebox_url).first + end + + it "returns the correct result" do + @result.should == 'dummy!' + end + + it "created a OneboxRender record with the url" do + @onebox_render.should be_present + end + + it "created a OneboxRender record with the url" do + @onebox_render.url.should == @dummy_onebox_url + end + + it "associated the render with a post" do + @onebox_render.posts.should == [@post] + end + + it "has an expires_at value" do + @onebox_render.expires_at.should be_present + end + + it "doesn't call onebox_nocache on a cache hit" do + Oneboxer.expects(:onebox_nocache).never + Oneboxer.onebox(@dummy_onebox_url).should == 'dummy!' + end + + context 'invalidating cache' do + + it "deletes the onebox render" do + Oneboxer.expects(:onebox_nocache).once.returns('new cache value!') + Oneboxer.onebox(@dummy_onebox_url, invalidate_oneboxes: true).should == 'new cache value!' + end + + end + + end + + end + + context 'each_onebox_link' do + + before do + @html = "Discourse Link" + end + + it 'yields each url and element when given a string' do + result = Oneboxer.each_onebox_link(@html) do |url, element| + element.is_a?(Hpricot::Elem).should be_true + url.should == 'http://discourse.org' + end + result.kind_of?(Hpricot::Doc).should be_true + end + + it 'yields each url and element when given a doc' do + doc = Hpricot(@html) + Oneboxer.each_onebox_link(doc) do |url, element| + element.is_a?(Hpricot::Elem).should be_true + url.should == 'http://discourse.org' + end + end + + end + + +end + + \ No newline at end of file diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb new file mode 100644 index 00000000000..2096062108a --- /dev/null +++ b/spec/components/post_creator_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper' +require 'post_creator' + +describe PostCreator do + + let(:user) { Fabricate(:user) } + + it 'raises an error without a raw value' do + lambda { PostCreator.new(user, {}) }.should raise_error(Discourse::InvalidParameters) + end + + context 'new topic' do + let(:category) { Fabricate(:category, user: user) } + let(:basic_topic_params) { {title: 'hello world', raw: 'my name is fred', archetype_id: 1} } + let(:image_sizes) { {'http://an.image.host/image.jpg' => {'width' => 111, 'height' => 222}} } + + let(:creator) { PostCreator.new(user, basic_topic_params) } + let(:creator_with_category) { PostCreator.new(user, basic_topic_params.merge(category: category.name )) } + let(:creator_with_meta_data) { PostCreator.new(user, basic_topic_params.merge(meta_data: {hello: 'world'} )) } + let(:creator_with_image_sizes) { PostCreator.new(user, basic_topic_params.merge(image_sizes: image_sizes)) } + + it 'ensures the user can create the topic' do + Guardian.any_instance.expects(:can_create?).with(Topic,nil).returns(false) + lambda { creator.create }.should raise_error(Discourse::InvalidAccess) + end + + context 'success' do + it 'creates a topic' do + lambda { creator.create }.should change(Topic, :count).by(1) + end + + it 'returns a post' do + creator.create.is_a?(Post).should be_true + end + + it 'extracts links from the post' do + TopicLink.expects(:extract_from).with(instance_of(Post)) + creator.create + end + + it 'assigns a category when supplied' do + creator_with_category.create.topic.category.should == category + end + + it 'adds meta data from the post' do + creator_with_meta_data.create.topic.meta_data['hello'].should == 'world' + end + + it 'passes the image sizes through' do + Post.any_instance.expects(:image_sizes=).with(image_sizes) + creator_with_image_sizes.create + end + end + + end + + context 'existing topic' do + let!(:topic) { Fabricate(:topic, user: user) } + let(:creator) { PostCreator.new(user, raw: 'test reply', topic_id: topic.id, reply_to_post_number: 4) } + + it 'ensures the user can create the post' do + Guardian.any_instance.expects(:can_create?).with(Post, topic).returns(false) + lambda { creator.create }.should raise_error(Discourse::InvalidAccess) + end + + context 'success' do + it 'should create the post' do + lambda { creator.create }.should change(Post, :count).by(1) + end + + it "doesn't create a topic" do + lambda { creator.create }.should_not change(Topic, :count) + end + + it "passes through the reply_to_post_number" do + creator.create.reply_to_post_number.should == 4 + end + end + + end + + context 'private message' do + let(:target_user1) { Fabricate(:coding_horror) } + let(:target_user2) { Fabricate(:moderator) } + let(:post) do + PostCreator.create(user, title: 'hi there', + raw: 'this is my awesome message', + archetype: Archetype.private_message, + target_usernames: [target_user1.username, target_user2.username].join(',')) + end + + it 'has the right archetype' do + post.topic.archetype.should == Archetype.private_message + end + + it 'has the right count (me and 2 other users)' do + post.topic.topic_allowed_users.count.should == 3 + end + end + + +end + diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb new file mode 100644 index 00000000000..cb8536fd6d0 --- /dev/null +++ b/spec/components/pretty_text_spec.rb @@ -0,0 +1,143 @@ +require 'spec_helper' +require 'pretty_text' + +describe PrettyText do + + describe "Cooking" do + it "should support github style code blocks" do + PrettyText.cook("``` +test +```").should == "
            test  \n
            " + end + + it "should support quoting [] " do + PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"][sam][/quote]").should =~ /\[sam\]/ + end + + it "produces a quote even with new lines in it" do + PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd\n[/quote]").should == "

            " + end + + it "should produce a quote" do + PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd[/quote]").should == "

            " + end + + it "trims spaces on quote params" do + PrettyText.cook("[quote=\"EvilTrout, post:555, topic: 666\"]ddd[/quote]").should == "

            " + end + + + it "should handle 3 mentions in a row" do + PrettyText.cook('@hello @hello @hello').should == "

            @hello @hello @hello

            " + end + + it "should not do weird @ mention stuff inside a pre block" do + + PrettyText.cook("``` +a @test +```").should == "
            a @test  \n
            " + + end + + it "should sanitize the html" do + PrettyText.cook("").should == "alert(42)" + end + + it "should escape html within the code block" do + + PrettyText.cook("```text +
            hello
            +```").should == "
            <header>hello</header>  \n
            " + end + + it "should support language choices" do + + PrettyText.cook("```ruby +test +```").should == "
            test  \n
            " + end + + it 'should decorate @mentions' do + PrettyText.cook("Hello @eviltrout").should == "

            Hello @eviltrout

            " + end + + it 'should allow for @mentions to have punctuation' do + PrettyText.cook("hello @bob's @bob,@bob; @bob\"").should == + "

            hello @bob's @bob,@bob; @bob\"

            " + end + + it 'should add spoiler tags' do + PrettyText.cook("[spoiler]hello[/spoiler]").should == "

            hello

            " + end + + it "should only detect ``` at the begining of lines" do + PrettyText.cook(" ```\n hello\n ```") + .should == "
            ```\nhello\n```\n
            " + end + end + + describe "Excerpt" do + it "should preserve links" do + PrettyText.excerpt("cnn",100).should == "cnn" + end + + it "should dump images" do + PrettyText.excerpt("",100).should == "[image]" + end + + it "should keep alt tags" do + PrettyText.excerpt("car",100).should == "[car]" + end + + it "should keep title tags" do + PrettyText.excerpt("",100).should == "[car]" + end + + it "should deal with special keys properly" do + PrettyText.excerpt("
            ",100).should == "" + end + + it "should truncate stuff properly" do + PrettyText.excerpt("hello world",5).should == "hello…" + end + + it "should insert a space between to Ps" do + PrettyText.excerpt("

            a

            b

            ",5).should == "a b " + end + + it "should strip quotes" do + PrettyText.excerpt("boom",5).should == "boom" + end + + it "should not count the surrounds of a link" do + PrettyText.excerpt("cnn",3).should == "cnn" + end + + it "should truncate links" do + PrettyText.excerpt("cnn",2).should == "cn…" + end + + it "should be able to extract links" do + PrettyText.extract_links("http://bla.com").to_a.should == ["http://cnn.com"] + end + + it "should not preserve tags in code blocks" do + PrettyText.excerpt("
            <h3>Hours</h3>
            ",100).should == "<h3>Hours</h3>" + end + + it "should handle nil" do + PrettyText.excerpt(nil,100).should == '' + end + end + + describe "apply cdn" do + it "should detect bare links to images and apply a CDN" do + PrettyText.apply_cdn("hello","http://a.com").should == + "hello" + end + it "should not touch non images" do + PrettyText.apply_cdn("hello","http://a.com").should == + "hello" + end + end +end diff --git a/spec/components/promotion_spec.rb b/spec/components/promotion_spec.rb new file mode 100644 index 00000000000..a0efbaef149 --- /dev/null +++ b/spec/components/promotion_spec.rb @@ -0,0 +1,48 @@ +require 'spec_helper' +require 'promotion' + +describe Promotion do + + context "new user" do + + let(:user) { Fabricate(:user, trust_level: TrustLevel.Levels[:new])} + let(:promotion) { Promotion.new(user) } + + it "doesn't raise an error with a nil user" do + -> { Promotion.new(nil).review }.should_not raise_error + end + + context 'that has done nothing' do + let!(:result) { promotion.review } + + it "returns false" do + result.should be_false + end + + it "has not changed the user's trust level" do + user.trust_level.should == TrustLevel.Levels[:new] + end + end + + context "that has done the requisite things" do + + before do + user.topics_entered = SiteSetting.basic_requires_topics_entered + user.posts_read_count = SiteSetting.basic_requires_read_posts + user.time_read = SiteSetting.basic_requires_time_spent_mins * 60 + @result = promotion.review + end + + it "returns true" do + @result.should be_true + end + + it "has upgraded the user to basic" do + user.trust_level.should == TrustLevel.Levels[:basic] + end + end + + end + + +end diff --git a/spec/components/rate_limiter_spec.rb b/spec/components/rate_limiter_spec.rb new file mode 100644 index 00000000000..0bcb304a529 --- /dev/null +++ b/spec/components/rate_limiter_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper' +require 'rate_limiter' + +describe RateLimiter do + + let(:user) { Fabricate(:user) } + let(:rate_limiter) { RateLimiter.new(user, "peppermint-butler", 2, 60) } + + context 'disabled' do + before do + RateLimiter.stubs(:disabled?).returns(true) + rate_limiter.performed! + rate_limiter.performed! + end + + it "returns true for can_perform?" do + rate_limiter.can_perform?.should be_true + end + + it "doesn't raise an error on performed!" do + lambda { rate_limiter.performed! }.should_not raise_error + end + + end + + context 'enabled' do + before do + RateLimiter.stubs(:disabled?).returns(false) + rate_limiter.clear! + end + + context 'never done' do + it "should perform right away" do + rate_limiter.can_perform?.should be_true + end + + it "performs without an error" do + lambda { rate_limiter.performed! }.should_not raise_error + end + end + + context "multiple calls" do + before do + rate_limiter.performed! + rate_limiter.performed! + end + + it "returns false for can_perform when the limit has been hit" do + rate_limiter.can_perform?.should be_false + end + + it "raises an error the third time called" do + lambda { rate_limiter.performed! }.should raise_error + end + + context "as an admin/moderator" do + + it "returns true for can_perform if the user is an admin" do + user.admin = true + rate_limiter.can_perform?.should be_true + end + + it "doesn't raise an error when an admin performs the task" do + user.admin = true + lambda { rate_limiter.performed! }.should_not raise_error + end + + it "returns true for can_perform if the user is a mod" do + user.trust_level = TrustLevel.Levels[:moderator] + rate_limiter.can_perform?.should be_true + end + + it "doesn't raise an error when a moderator performs the task" do + user.trust_level = TrustLevel.Levels[:moderator] + lambda { rate_limiter.performed! }.should_not raise_error + end + + + end + + context "rollback!" do + before do + rate_limiter.rollback! + end + + it "returns true for can_perform since there is now room" do + rate_limiter.can_perform?.should be_true + end + + it "raises no error now that there is room" do + lambda { rate_limiter.performed! }.should_not raise_error + end + + end + + end + + end + + + + +end diff --git a/spec/components/score_calculator_spec.rb b/spec/components/score_calculator_spec.rb new file mode 100644 index 00000000000..703d1bfcc28 --- /dev/null +++ b/spec/components/score_calculator_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' +require 'score_calculator' + +describe ScoreCalculator do + + before do + @post = Fabricate(:post, reads: 111) + @topic = @post.topic + end + + context 'with weightings' do + before do + ScoreCalculator.new(reads: 3).calculate + @post.reload + end + + it 'takes the supplied weightings into effect' do + @post.score.should == 333 + end + end + + context 'best_of' do + + it "won't update the site settings when the site settings don't match" do + ScoreCalculator.new(reads: 3).calculate + @topic.reload + @topic.has_best_of.should be_false + end + + it "removes the best_of flag if the topic no longer qualifies" do + @topic.update_column(:has_best_of, true) + ScoreCalculator.new(reads: 3).calculate + @topic.reload + @topic.has_best_of.should be_false + end + + it "won't update the site settings when the site settings don't match" do + SiteSetting.expects(:best_of_likes_required).returns(0) + SiteSetting.expects(:best_of_posts_required).returns(1) + SiteSetting.expects(:best_of_score_threshold).returns(100) + + ScoreCalculator.new(reads: 3).calculate + @topic.reload + @topic.has_best_of.should be_true + end + + end + +end diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb new file mode 100644 index 00000000000..c2699d98976 --- /dev/null +++ b/spec/components/search_spec.rb @@ -0,0 +1,182 @@ +require 'spec_helper' +require 'search' + +describe Search do + + def first_of_type(results, type) + return nil if results.blank? + results.each do |r| + return r[:results].first if r[:type] == type + end + nil + end + + context 'post indexing observer' do + before do + @category = Fabricate(:category, name: 'america') + @topic = Fabricate(:topic, title: 'sam test', category: @category) + @post = Fabricate(:post, topic: @topic, raw: 'this fun test ') + @indexed = Topic.exec_sql("select search_data from posts_search where id = #{@post.id}").first["search_data"] + end + it "should include body in index" do + @indexed.should =~ /fun/ + end + it "should include title in index" do + @indexed.should =~ /sam/ + end + it "should include category in index" do + @indexed.should =~ /america/ + end + + it "should pick up on title updates" do + @topic.title = "harpi" + @topic.save! + @indexed = Topic.exec_sql("select search_data from posts_search where id = #{@post.id}").first["search_data"] + + @indexed.should =~ /harpi/ + end + end + + context 'user indexing observer' do + before do + @user = Fabricate(:user, username: 'fred', name: 'bob jones') + @indexed = User.exec_sql("select search_data from users_search where id = #{@user.id}").first["search_data"] + end + + it "should pick up on username" do + @indexed.should =~ /fred/ + end + + it "should pick up on name" do + @indexed.should =~ /jone/ + end + end + + context 'category indexing observer' do + before do + @category = Fabricate(:category, name: 'america') + @indexed = Topic.exec_sql("select search_data from categories_search where id = #{@category.id}").first["search_data"] + end + + it "should pick up on name" do + @indexed.should =~ /america/ + end + + end + + it 'returns something blank on a nil search' do + ActiveRecord::Base.expects(:exec_sql).never + Search.query(nil).should be_blank + end + + it 'escapes non alphanumeric characters' do + ActiveRecord::Base.expects(:exec_sql).never + Search.query(':!$').should be_blank + end + + it 'works when given two terms with spaces' do + lambda { Search.query('evil trout') }.should_not raise_error + end + + context 'users' do + let!(:user) { Fabricate(:user) } + let(:result) { first_of_type(Search.query('bruce'), 'user') } + + it 'returns a result' do + result.should be_present + end + + it 'has the display name as the title' do + result['title'].should == user.username + end + + it 'has the avatar_template is there so it can hand it to the client' do + result['avatar_template'].should_not be_nil + end + + it 'has a url for the record' do + result['url'].should == "/users/#{user.username_lower}" + end + + end + + context 'topics' do + let!(:topic) { Fabricate(:topic) } + + context 'searching the OP' do + + let!(:post) { Fabricate(:post, topic: topic, user: topic.user) } + let(:result) { first_of_type(Search.query('hello'), 'topic') } + + it 'returns a result' do + result.should be_present + end + + it 'has the topic title' do + result['title'].should == topic.title + end + + it 'has a url for the post' do + result['url'].should == topic.relative_url + end + end + + end + + context 'categories' do + + let!(:category) { Fabricate(:category) } + let(:result) { first_of_type(Search.query('amazing'), 'category') } + + it 'returns a result' do + result.should be_present + end + + it 'has the category name' do + result['title'].should == category.name + end + + it 'has a url for the topic' do + result['url'].should == "/category/#{category.slug}" + end + + end + + + context 'type_filter' do + + let!(:user) { Fabricate(:user, username: 'amazing', email: 'amazing@amazing.com') } + let!(:category) { Fabricate(:category, name: 'amazing category', user: user) } + + + context 'user filter' do + let(:results) { Search.query('amazing', 'user') } + + it "returns a user result" do + results.detect {|r| r[:type] == 'user'}.should be_present + end + + it "returns no category results" do + results.detect {|r| r[:type] == 'category'}.should be_blank + end + + end + + context 'category filter' do + let(:results) { Search.query('amazing', 'category') } + + it "returns a user result" do + results.detect {|r| r[:type] == 'user'}.should be_blank + end + + it "returns no category results" do + results.detect {|r| r[:type] == 'category'}.should be_present + end + + end + + + end + +end + diff --git a/spec/components/slug_spec.rb b/spec/components/slug_spec.rb new file mode 100644 index 00000000000..6111b73710e --- /dev/null +++ b/spec/components/slug_spec.rb @@ -0,0 +1,32 @@ +# encoding: utf-8 + +require 'spec_helper' + +require 'slug' + +describe Slug do + + + it 'replaces spaces with hyphens' do + Slug.for("hello world").should == 'hello-world' + end + + it 'changes accented characters' do + Slug.for('àllo').should == 'allo' + end + + it 'removes symbols' do + Slug.for('evil#trout').should == 'eviltrout' + end + + it 'handles a.b.c properly' do + Slug.for("a.b.c").should == "a-b-c" + end + + it 'handles double dots right' do + Slug.for("a....b.....c").should == "a-b-c" + end + + +end + diff --git a/spec/components/sql_builder_spec.rb b/spec/components/sql_builder_spec.rb new file mode 100644 index 00000000000..5edb367b091 --- /dev/null +++ b/spec/components/sql_builder_spec.rb @@ -0,0 +1,35 @@ +# encoding: utf-8 +require 'spec_helper' +require_dependency 'sql_builder' + +describe SqlBuilder do + + before do + @builder = SqlBuilder.new("select * from (select :a A union all select :b) as X /*where*/ /*order_by*/ /*limit*/ /*offset*/") + end + + it "should allow for 1 param exec" do + @builder.exec(a: 1, b: 2).values[0][0].should == '1' + end + + it "should allow for a single where" do + @builder.where(":a = 1") + @builder.exec(a: 1, b: 2).values[0][0].should == '1' + end + + it "should allow where chaining" do + @builder.where(":a = 1") + @builder.where("2 = 1") + @builder.exec(a: 1, b: 2).to_a.length.should == 0 + end + + it "should allow order by" do + @builder.order_by("A desc").limit(1) + .exec(a:1, b:2).values[0][0].should == "2" + end + it "should allow offset" do + @builder.order_by("A desc").offset(1) + .exec(a:1, b:2).values[0][0].should == "1" + end + +end diff --git a/spec/components/system_message_spec.rb b/spec/components/system_message_spec.rb new file mode 100644 index 00000000000..c15f47af6b0 --- /dev/null +++ b/spec/components/system_message_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' +require 'system_message' + +describe SystemMessage do + + let!(:admin) { Fabricate(:admin) } + + context 'send' do + + let(:user) { Fabricate(:user) } + let(:system_message) { SystemMessage.new(user) } + let(:post) { system_message.create(:welcome_invite) } + let(:topic) { post.topic } + + it 'should create a post' do + post.should be_present + end + + it 'should be a private message' do + topic.should be_private_message + end + + it 'should be visible by the user' do + topic.allowed_users.include?(user).should be_true + end + + end + + context '#system_user' do + + it 'returns the user specified by the site setting system_username' do + SiteSetting.stubs(:system_username).returns(admin.username) + SystemMessage.system_user.should == admin + end + + it 'returns the first admin user otherwise' do + SiteSetting.stubs(:system_username).returns(nil) + SystemMessage.system_user.should == admin + end + + end + +end diff --git a/spec/components/topic_query_spec.rb b/spec/components/topic_query_spec.rb new file mode 100644 index 00000000000..e0494ebdb58 --- /dev/null +++ b/spec/components/topic_query_spec.rb @@ -0,0 +1,260 @@ +require 'spec_helper' +require 'topic_view' + +describe TopicQuery do + + let!(:user) { Fabricate(:coding_horror) } + let(:creator) { Fabricate(:user) } + let(:topic_query) { TopicQuery.new(user) } + + let(:moderator) { Fabricate(:moderator) } + let(:admin) { Fabricate(:moderator) } + + context 'a bunch of topics' do + let!(:regular_topic) { Fabricate(:topic, title: 'regular', user: creator, bumped_at: 15.minutes.ago) } + let!(:pinned_topic) { Fabricate(:topic, title: 'pinned', user: creator, pinned: true, bumped_at: 10.minutes.ago) } + let!(:archived_topic) { Fabricate(:topic, title: 'archived', user: creator, archived: true, bumped_at: 6.minutes.ago) } + let!(:invisible_topic) { Fabricate(:topic, title: 'invisible', user: creator, visible: false, bumped_at: 5.minutes.ago) } + let!(:closed_topic) { Fabricate(:topic, title: 'closed', user: creator, closed: true, bumped_at: 1.minute.ago) } + + context 'list_popular' do + let(:topics) { topic_query.list_popular.topics } + + it "returns the topics in the correct order" do + topics.should == [pinned_topic, closed_topic, archived_topic, regular_topic] + end + + it "includes the invisible topic if you're a moderator" do + TopicQuery.new(moderator).list_popular.topics.include?(invisible_topic).should be_true + end + + it "includes the invisible topic if you're an admin" do + TopicQuery.new(admin).list_popular.topics.include?(invisible_topic).should be_true + end + end + + end + + context 'categorized' do + let(:category) { Fabricate(:category) } + let!(:topic_no_cat) { Fabricate(:topic) } + let!(:topic_in_cat) { Fabricate(:topic, category: category) } + + it "returns the topic without a category when filtering uncategorized" do + topic_query.list_uncategorized.topics.should == [topic_no_cat] + end + + it "returns the topic with a category when filtering by category" do + topic_query.list_category(category).topics.should == [topic_in_cat] + end + + it "returns nothing when filtering by another category" do + topic_query.list_category(Fabricate(:category, name: 'new cat')).topics.should be_blank + end + end + + context 'unread / read topics' do + + context 'with no data' do + + it "has no read topics" do + topic_query.list_unread.topics.should be_blank + end + + it "has no unread topics" do + topic_query.list_unread.topics.should be_blank + end + + it "has an unread count of 0" do + topic_query.unread_count.should == 0 + end + end + + context 'with read data' do + let!(:partially_read) { Fabricate(:post, user: creator).topic } + let!(:fully_read) { Fabricate(:post, user: creator).topic } + + before do + TopicUser.update_last_read(user, partially_read.id, 0, 0) + TopicUser.update_last_read(user, fully_read.id, 1, 0) + end + + context 'list_unread' do + it 'contains no topics' do + topic_query.list_unread.topics.should == [] + end + + it "returns 0 as the unread count" do + topic_query.unread_count.should == 0 + end + end + + context 'user with auto_track_topics list_unread' do + before do + user.auto_track_topics_after_msecs = 0 + user.save + end + + it 'only contains the partially read topic' do + topic_query.list_unread.topics.should == [partially_read] + end + + it "returns 1 as the unread count" do + topic_query.unread_count.should == 1 + end + end + + context 'list_read' do + it 'contain both topics ' do + topic_query.list_read.topics.should =~ [fully_read, partially_read] + end + end + end + + end + + context 'list_favorited' do + + let(:topic) { Fabricate(:topic) } + + it "returns no results when the user hasn't favorited anything" do + topic_query.list_favorited.topics.should be_blank + end + + context 'with a favorited topic' do + + before do + topic.toggle_star(user, true) + end + + it "returns the topic after it has been favorited" do + topic_query.list_favorited.topics.should == [topic] + end + end + + end + + context 'list_new' do + + context 'without a new topic' do + it "has an new_count of 0" do + topic_query.new_count.should == 0 + end + + it "has no new topics" do + topic_query.list_new.topics.should be_blank + end + end + + context 'with a new topic' do + let!(:new_topic) { Fabricate(:topic, user: creator, bumped_at: 10.minutes.ago) } + let(:topics) { topic_query.list_new.topics } + + + it "contains the new topic" do + topics.should == [new_topic] + end + + context "muted topics" do + before do + new_topic.notify_muted!(user) + end + + it "returns an empty set" do + topics.should be_blank + end + + context 'un-muted' do + before do + new_topic.notify_tracking!(user) + end + + it "returns the topic again" do + topics.should == [new_topic] + end + end + end + end + + end + + context 'list_posted' do + let(:topics) { topic_query.list_posted.topics } + + it "returns blank when there are no posted topics" do + topics.should be_blank + end + + context 'created topics' do + let!(:created_topic) { Fabricate(:post, user: user).topic } + + it "includes the created topic" do + topics.include?(created_topic).should be_true + end + end + + context "topic you've posted in" do + let(:other_users_topic) { Fabricate(:post, user: creator).topic } + let!(:your_post) { Fabricate(:post, user: user, topic: other_users_topic )} + + it "includes the posted topic" do + topics.include?(other_users_topic).should be_true + end + + end + end + + context 'suggested_for' do + + context 'when anonymous' do + let(:topic) { Fabricate(:topic) } + let!(:new_topic) { Fabricate(:post, user: creator).topic } + + it "should return the new topic" do + TopicQuery.new.list_suggested_for(topic).topics.should == [new_topic] + end + + end + + context 'when logged in' do + + let(:topic) { Fabricate(:topic) } + let(:suggested_topics) { topic_query.list_suggested_for(topic).topics.map{|t| t.id} } + + it "should return empty results when there is nothing to find" do + suggested_topics.should be_blank + end + + context 'with some existing topics' do + let!(:partially_read) { Fabricate(:post, user: creator).topic } + let!(:new_topic) { Fabricate(:post, user: creator).topic } + let!(:fully_read) { Fabricate(:post, user: creator).topic } + + before do + user.auto_track_topics_after_msecs = 0 + user.save + TopicUser.update_last_read(user, partially_read.id, 0, 0) + TopicUser.update_last_read(user, fully_read.id, 1, 0) + end + + it "won't return new or fully read if there are enough partially read topics" do + SiteSetting.stubs(:suggested_topics).returns(1) + suggested_topics.should == [partially_read.id] + end + + it "won't fully read if there are enough partially read topics and new topics" do + SiteSetting.stubs(:suggested_topics).returns(2) + suggested_topics.should == [partially_read.id, new_topic.id] + end + + it "returns unread, then new, then random" do + SiteSetting.stubs(:suggested_topics).returns(3) + suggested_topics.should == [partially_read.id, new_topic.id, fully_read.id] + end + + end + end + + end + +end diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb new file mode 100644 index 00000000000..c32ec01fcaa --- /dev/null +++ b/spec/components/topic_view_spec.rb @@ -0,0 +1,234 @@ +require 'spec_helper' +require 'topic_view' + +describe TopicView do + + let(:topic) { Fabricate(:topic) } + let(:coding_horror) { Fabricate(:coding_horror) } + let(:first_poster) { topic.user } + let!(:p1) { Fabricate(:post, topic: topic, user: first_poster )} + let!(:p2) { Fabricate(:post, topic: topic, user: coding_horror )} + let!(:p3) { Fabricate(:post, topic: topic, user: first_poster )} + + let(:topic_view) { TopicView.new(topic.id, coding_horror) } + + it "raises a not found error if the topic doesn't exist" do + lambda { TopicView.new(1231232, coding_horror) }.should raise_error(Discourse::NotFound) + end + + it "raises an error if the user can't see the topic" do + Guardian.any_instance.expects(:can_see?).with(topic).returns(false) + lambda { topic_view }.should raise_error(Discourse::InvalidAccess) + end + + it "raises NotLoggedIn if the user isn't logged in and is trying to view a private message" do + Topic.any_instance.expects(:private_message?).returns(true) + lambda { TopicView.new(topic.id, nil) }.should raise_error(Discourse::NotLoggedIn) + end + + context '.posts_count' do + it 'returns the two posters with their counts' do + topic_view.posts_count.to_a.should =~ [[first_poster.id, 2], [coding_horror.id, 1]] + end + end + + context '.participants' do + it 'returns the two participants hashed by id' do + topic_view.participants.to_a.should =~ [[first_poster.id, first_poster], [coding_horror.id, coding_horror]] + end + end + + context '.all_post_actions' do + it 'is blank at first' do + topic_view.all_post_actions.should be_blank + end + + it 'returns the like' do + PostAction.act(coding_horror, p1, PostActionType.Types[:like]) + topic_view.all_post_actions[p1.id][PostActionType.Types[:like]].should be_present + end + end + + it 'allows admins to see deleted posts' do + p3.destroy + admin = Fabricate(:admin) + topic_view = TopicView.new(topic.id, admin) + topic_view.posts.count.should == 3 + end + + it 'does not allow non admins to see deleted posts' do + p3.destroy + topic_view.posts.count.should == 2 + end + + # Sam: disabled for now, we only need this for poss, if we do, roll it into topic + # having to walk every post action is not really a good idea + # + # context '.voted_in_topic?' do + # it "is false when the user hasn't voted" do + # topic_view.voted_in_topic?.should be_false + # end + + # it "is true when the user has voted for a post" do + # PostAction.act(coding_horror, p1, PostActionType.Types[:vote]) + # topic_view.voted_in_topic?.should be_true + # end + # end + + context '.post_action_visibility' do + + it "is allows users to see likes" do + topic_view.post_action_visibility.include?(PostActionType.Types[:like]).should be_true + end + + end + + context '.read?' do + it 'is unread with no logged in user' do + TopicView.new(topic.id).read?(1).should be_false + end + + it 'makes posts as unread by default' do + topic_view.read?(1).should be_false + end + + it 'knows a post is read when it has a PostTiming' do + PostTiming.create(topic: topic, user: coding_horror, post_number: 1, msecs: 1000) + topic_view.read?(1).should be_true + end + end + + context '.topic_user' do + it 'returns nil when there is no user' do + TopicView.new(topic.id, nil).topic_user.should be_blank + end + + it 'returns a record once the user has some data' do + TopicView.new(topic.id, coding_horror).topic_user.should be_present + end + end + + context '.posts' do + context 'near a post_number' do + + context 'with a valid post_number' do + before do + topic.reload + topic_view.filter_posts_near(p2.post_number) + end + + it 'returns posts around a post number' do + topic_view.posts.should == [p1, p2, p3] + end + + it 'has a min of the 1st post number' do + topic_view.min.should == p1.post_number + end + + it 'has a max of the 3rd post number' do + topic_view.max.should == p3.post_number + end + + it 'is the inital load' do + topic_view.should be_initial_load + end + end + + end + + context 'before a post_number' do + before do + topic_view.filter_posts_before(p3.post_number) + end + + it 'returns posts before a post number' do + topic_view.posts.should == [p2, p1] + end + + it 'has a min of the 1st post number' do + topic_view.min.should == p1.post_number + end + + it 'has a max of the 2nd post number' do + topic_view.max.should == p2.post_number + end + + it "isn't the inital load" do + topic_view.should_not be_initial_load + end + + end + + context 'after a post_number' do + before do + topic_view.filter_posts_after(p1.post_number) + end + + it 'returns posts after a post number' do + topic_view.posts.should == [p2, p3] + end + + it 'has a min of the 1st post number' do + topic_view.min.should == p1.post_number + end + + it 'has a max of 3' do + topic_view.max.should == 3 + end + + it "isn't the inital load" do + topic_view.should_not be_initial_load + end + end + end + + context 'post range' do + context 'without gaps' do + before do + SiteSetting.stubs(:posts_per_page).returns(20) + TopicView.any_instance.stubs(:post_numbers).returns((1..50).to_a) + end + + it 'returns the first a full page if the post number is 1' do + topic_view.post_range(1).should == [1, 20] + end + + it 'returns 4 posts above and 16 below' do + topic_view.post_range(20).should == [15, 34] + end + + it "returns 20 previous results if we ask for the last post" do + topic_view.post_range(50).should == [30, 50] + end + + it "returns 20 previous results we would overlap the last post" do + topic_view.post_range(40).should == [30, 50] + end + end + + context 'with gaps' do + before do + SiteSetting.stubs(:posts_per_page).returns(20) + + post_numbers = ((1..20).to_a << [100, 105] << (110..150).to_a).flatten + TopicView.any_instance.stubs(:post_numbers).returns(post_numbers) + end + + it "will return posts even if the post required is missing" do + topic_view.post_range(80).should == [16, 122] + end + + it "works at the end of gapped post numbers" do + topic_view.post_range(140).should == [130, 150] + end + + it "works well past the end of the post numbers" do + topic_view.post_range(2000).should == [130, 150] + end + + end + + end + +end + diff --git a/spec/components/unread_spec.rb b/spec/components/unread_spec.rb new file mode 100644 index 00000000000..1d7d439d7c4 --- /dev/null +++ b/spec/components/unread_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' +require 'unread' + +describe Unread do + + + before do + @topic = Fabricate(:topic, posts_count: 13, highest_post_number: 13) + @topic_user = TopicUser.get(@topic, @topic.user) + @topic_user.stubs(:notification_level).returns(TopicUser::NotificationLevel::TRACKING) + @topic_user.notification_level = TopicUser::NotificationLevel::TRACKING + @unread = Unread.new(@topic, @topic_user) + end + + + describe 'unread_posts' do + it 'should have 0 unread posts if the user has seen all posts' do + @topic_user.stubs(:last_read_post_number).returns(13) + @topic_user.stubs(:seen_post_count).returns(13) + @unread.unread_posts.should == 0 + end + + it 'should have 6 unread posts if the user has seen all but 6 posts' do + @topic_user.stubs(:last_read_post_number).returns(5) + @topic_user.stubs(:seen_post_count).returns(11) + @unread.unread_posts.should == 6 + end + + it 'should have 0 unread posts if the user has seen more posts than exist (deleted)' do + @topic_user.stubs(:last_read_post_number).returns(100) + @topic_user.stubs(:seen_post_count).returns(13) + @unread.unread_posts.should == 0 + end + end + + describe 'new_posts' do + it 'should have 0 new posts if the user has read all posts' do + @topic_user.stubs(:last_read_post_number).returns(13) + @unread.new_posts.should == 0 + end + + it 'returns 0 when the topic is the same length as when you last saw it' do + @topic_user.stubs(:seen_post_count).returns(13) + @unread.new_posts.should == 0 + end + + it 'has 3 new posts if the user has read 10 posts' do + @topic_user.stubs(:seen_post_count).returns(10) + @unread.new_posts.should == 3 + end + + it 'has 0 new posts if the user has read 10 posts but is not tracking' do + @topic_user.stubs(:seen_post_count).returns(10) + @topic_user.stubs(:notification_level).returns(TopicUser::NotificationLevel::REGULAR) + @unread.new_posts.should == 0 + end + + it 'has 0 new posts if the user read more posts than exist (deleted)' do + @topic_user.stubs(:seen_post_count).returns(16) + @unread.new_posts.should == 0 + end + + end +end diff --git a/spec/controllers/admin/admin_controller_spec.rb b/spec/controllers/admin/admin_controller_spec.rb new file mode 100644 index 00000000000..e26da8259ce --- /dev/null +++ b/spec/controllers/admin/admin_controller_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Admin::AdminController do + + context 'index' do + + it 'needs you to be logged in' do + lambda { xhr :get, :index }.should raise_error(Discourse::NotLoggedIn) + end + + it "raises an error if you aren't an admin" do + user = log_in + xhr :get, :index + response.should be_forbidden + end + + end + + +end diff --git a/spec/controllers/admin/email_logs_controller_spec.rb b/spec/controllers/admin/email_logs_controller_spec.rb new file mode 100644 index 00000000000..0f027e00ba8 --- /dev/null +++ b/spec/controllers/admin/email_logs_controller_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Admin::EmailLogsController do + + it "is a subclass of AdminController" do + (Admin::EmailLogsController < Admin::AdminController).should be_true + end + + let!(:user) { log_in(:admin) } + + context '.index' do + before do + xhr :get, :index + end + + subject { response } + it { should be_success } + end + + context '.test' do + + it 'raises an error without the email parameter' do + lambda { xhr :post, :test }.should raise_error(Discourse::InvalidParameters) + end + + context 'with an email address' do + + it 'enqueues a test email job' do + Jobs.expects(:enqueue).with(:test_email, to_address: 'eviltrout@test.domain') + xhr :post, :test, email_address: 'eviltrout@test.domain' + end + + end + + end + +end \ No newline at end of file diff --git a/spec/controllers/admin/export_controller_spec.rb b/spec/controllers/admin/export_controller_spec.rb new file mode 100644 index 00000000000..938bcb8c6d7 --- /dev/null +++ b/spec/controllers/admin/export_controller_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe Admin::ExportController do + it "is a subclass of AdminController" do + (Admin::ExportController < Admin::AdminController).should be_true + end + + context 'while logged in as an admin' do + before do + @user = log_in(:admin) + end + + describe "create" do + it "should start an export job" do + Jobs::Exporter.any_instance.expects(:execute).returns(true) + xhr :post, :create + end + + it "should return a job id" do + job_id = 'abc123' + Jobs.stubs(:enqueue).returns( job_id ) + xhr :post, :create + json = JSON.parse(response.body) + json.should have_key('job_id') + json['job_id'].should == job_id + end + + shared_examples_for "when export should not be started" do + it "should return an error" do + xhr :post, :create + json = JSON.parse(response.body) + json['failed'].should_not be_nil + json['message'].should_not be_nil + end + + it "should not start an export job" do + Jobs::Exporter.any_instance.expects(:start_export).never + xhr :post, :create + end + end + + context "when an export is already running" do + before do + Export.stubs(:is_export_running?).returns( true ) + end + it_should_behave_like "when export should not be started" + end + + context "when an import is currently running" do + before do + Import.stubs(:is_import_running?).returns( true ) + end + it_should_behave_like "when export should not be started" + end + end + end +end \ No newline at end of file diff --git a/spec/controllers/admin/flags_controller_spec.rb b/spec/controllers/admin/flags_controller_spec.rb new file mode 100644 index 00000000000..7cffed99f67 --- /dev/null +++ b/spec/controllers/admin/flags_controller_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Admin::FlagsController do + + it "is a subclass of AdminController" do + (Admin::FlagsController < Admin::AdminController).should be_true + end + + context 'while logged in as an admin' do + before do + @user = log_in(:admin) + end + + context 'index' do + it 'returns empty json when nothing is flagged' do + xhr :get, :index + + data = ::JSON.parse(response.body) + data["users"].should == [] + data["posts"].should == [] + end + + it 'returns a valid json payload when some thing is flagged' do + p = Fabricate(:post) + u = Fabricate(:user) + + PostAction.act(u, p, PostActionType.Types[:spam]) + xhr :get, :index + + data = ::JSON.parse(response.body) + data["users"].length == 2 + data["posts"].length == 1 + end + end + end +end + diff --git a/spec/controllers/admin/impersonate_controller_spec.rb b/spec/controllers/admin/impersonate_controller_spec.rb new file mode 100644 index 00000000000..0665f179442 --- /dev/null +++ b/spec/controllers/admin/impersonate_controller_spec.rb @@ -0,0 +1,68 @@ +require 'spec_helper' + +describe Admin::ImpersonateController do + + it "is a subclass of AdminController" do + (Admin::ImpersonateController < Admin::AdminController).should be_true + end + + + context 'while logged in as an admin' do + let!(:admin) { log_in(:admin) } + let(:user) { Fabricate(:user) } + + context 'index' do + it 'returns success' do + xhr :get, :index + response.should be_success + end + end + + context 'create' do + + it 'requires a username_or_email parameter' do + lambda { xhr :put, :create }.should raise_error(Discourse::InvalidParameters) + end + + it 'returns 404 when that user does not exist' do + xhr :post, :create, username_or_email: 'hedonismbot' + response.status.should == 404 + end + + it "raises an invalid access error if the user can't be impersonated" do + Guardian.any_instance.expects(:can_impersonate?).with(user).returns(false) + xhr :post, :create, username_or_email: user.email + response.should be_forbidden + end + + context 'success' do + + it "changes the current user session id" do + xhr :post, :create, username_or_email: user.username + session[:current_user_id].should == user.id + end + + it "returns success" do + xhr :post, :create, username_or_email: user.email + response.should be_success + end + + it "also works with an email address" do + xhr :post, :create, username_or_email: user.email + session[:current_user_id].should == user.id + end + + it "also works with a name" do + xhr :post, :create, username_or_email: user.name + session[:current_user_id].should == user.id + end + + end + + end + + end + + + +end diff --git a/spec/controllers/admin/site_customizations_controller_spec.rb b/spec/controllers/admin/site_customizations_controller_spec.rb new file mode 100644 index 00000000000..f5f9e33dc4a --- /dev/null +++ b/spec/controllers/admin/site_customizations_controller_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Admin::SiteCustomizationsController do + + it "is a subclass of AdminController" do + (Admin::UsersController < Admin::AdminController).should be_true + end + + context 'while logged in as an admin' do + before do + @user = log_in(:admin) + end + + context ' .index' do + it 'returns success' do + xhr :get, :index + response.should be_success + end + + it 'returns JSON' do + xhr :get, :index + ::JSON.parse(response.body).should be_present + end + end + + context ' .create' do + it 'returns success' do + xhr :post, :create, site_customization: {name: 'my test name'} + response.should be_success + end + + it 'returns json' do + xhr :post, :create, site_customization: {name: 'my test name'} + ::JSON.parse(response.body).should be_present + end + end + + end + + + +end diff --git a/spec/controllers/admin/site_settings_controller_spec.rb b/spec/controllers/admin/site_settings_controller_spec.rb new file mode 100644 index 00000000000..2eeec9fd1e0 --- /dev/null +++ b/spec/controllers/admin/site_settings_controller_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe Admin::SiteSettingsController do + + it "is a subclass of AdminController" do + (Admin::SiteSettingsController < Admin::AdminController).should be_true + end + + context 'while logged in as an admin' do + before do + @user = log_in(:admin) + end + + context 'index' do + it 'returns success' do + xhr :get, :index + response.should be_success + end + + it 'returns JSON' do + xhr :get, :index + ::JSON.parse(response.body).should be_present + end + end + + context 'update' do + + it 'requires a value parameter' do + lambda { xhr :put, :update, id: 'test_setting' }.should raise_error(Discourse::InvalidParameters) + end + + it 'sets the value when the param is present' do + SiteSetting.expects(:'test_setting=').with('hello').once + xhr :put, :update, id: 'test_setting', value: 'hello' + end + + end + + end + + + +end \ No newline at end of file diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb new file mode 100644 index 00000000000..1268fddba62 --- /dev/null +++ b/spec/controllers/admin/users_controller_spec.rb @@ -0,0 +1,118 @@ +require 'spec_helper' + +describe Admin::UsersController do + + it "is a subclass of AdminController" do + (Admin::UsersController < Admin::AdminController).should be_true + end + + context 'while logged in as an admin' do + before do + @user = log_in(:admin) + end + + context '.index' do + it 'returns success' do + xhr :get, :index + response.should be_success + end + + it 'returns JSON' do + xhr :get, :index + ::JSON.parse(response.body).should be_present + end + end + + context '.show' do + it 'returns success' do + xhr :get, :show, id: @user.username + response.should be_success + end + end + + context '.approve_bulk' do + + let(:evil_trout) { Fabricate(:evil_trout) } + + it "does nothing without uesrs" do + User.any_instance.expects(:approve).never + xhr :put, :approve_bulk + end + + it "won't approve the user when not allowed" do + Guardian.any_instance.expects(:can_approve?).with(evil_trout).returns(false) + User.any_instance.expects(:approve).never + xhr :put, :approve_bulk, users: [evil_trout.id] + end + + it "approves the user when permitted" do + Guardian.any_instance.expects(:can_approve?).with(evil_trout).returns(true) + User.any_instance.expects(:approve).once + xhr :put, :approve_bulk, users: [evil_trout.id] + end + + end + + context '.approve' do + + let(:evil_trout) { Fabricate(:evil_trout) } + + it "raises an error when the user doesn't have permission" do + Guardian.any_instance.expects(:can_approve?).with(evil_trout).returns(false) + xhr :put, :approve, user_id: evil_trout.id + response.should be_forbidden + end + + it 'calls approve' do + User.any_instance.expects(:approve).with(@user) + xhr :put, :approve, user_id: evil_trout.id + end + + end + + context '.revoke_admin' do + before do + @another_admin = Fabricate(:another_admin) + end + + it 'raises an error unless the user can revoke access' do + Guardian.any_instance.expects(:can_revoke_admin?).with(@another_admin).returns(false) + xhr :put, :revoke_admin, user_id: @another_admin.id + response.should be_forbidden + end + + it 'updates the admin flag' do + xhr :put, :revoke_admin, user_id: @another_admin.id + @another_admin.reload + @another_admin.should_not be_admin + end + end + + context '.grant_admin' do + before do + @another_user = Fabricate(:coding_horror) + end + + it "raises an error when the user doesn't have permission" do + Guardian.any_instance.expects(:can_grant_admin?).with(@another_user).returns(false) + xhr :put, :grant_admin, user_id: @another_user.id + response.should be_forbidden + end + + it "returns a 404 if the username doesn't exist" do + xhr :put, :grant_admin, user_id: 123123 + response.should be_forbidden + end + + it 'updates the admin flag' do + xhr :put, :grant_admin, user_id: @another_user.id + @another_user.reload + @another_user.should be_admin + end + end + + end + + + +end \ No newline at end of file diff --git a/spec/controllers/admin/versions_controller_spec.rb b/spec/controllers/admin/versions_controller_spec.rb new file mode 100644 index 00000000000..8bf104cf3e5 --- /dev/null +++ b/spec/controllers/admin/versions_controller_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' +require_dependency 'version' + +describe Admin::VersionsController do + + before do + RestClient.stubs(:get).returns( {success: 'OK', version: '1.2.33'}.to_json ) + end + + it "is a subclass of AdminController" do + (Admin::VersionsController < Admin::AdminController).should be_true + end + + context 'while logged in as an admin' do + before do + @user = log_in(:admin) + end + + describe 'show' do + context 'when discourse_org_access_key is set' do + before do + SiteSetting.stubs(:discourse_org_access_key).returns('asdf') + end + + subject { xhr :get, :show } + it { should be_success } + + it 'should return the currently available version' do + json = JSON.parse(subject.body) + json['latest_version'].should == '1.2.33' + end + + it "should return the installed version" do + json = JSON.parse(subject.body) + json['installed_version'].should == Discourse::VERSION::STRING + end + end + + context 'when discourse_org_access_key is blank' do + subject { xhr :get, :show } + it { should be_success } + + it 'should return the installed version as the currently available version' do + json = JSON.parse(subject.body) + json['latest_version'].should == Discourse::VERSION::STRING + end + + it "should return the installed version" do + json = JSON.parse(subject.body) + json['installed_version'].should == Discourse::VERSION::STRING + end + end + end + end +end \ No newline at end of file diff --git a/spec/controllers/categories_controller_spec.rb b/spec/controllers/categories_controller_spec.rb new file mode 100644 index 00000000000..b9db1b58662 --- /dev/null +++ b/spec/controllers/categories_controller_spec.rb @@ -0,0 +1,147 @@ +require 'spec_helper' + +describe CategoriesController do + describe 'create' do + + it 'requires the user to be logged in' do + lambda { xhr :post, :create }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'logged in' do + before do + @user = log_in(:moderator) + end + + it "raises an exception when they don't have permission to create it" do + Guardian.any_instance.expects(:can_create?).with(Category, nil).returns(false) + xhr :post, :create, name: 'hello', color: '#ff0' + response.should be_forbidden + end + + it 'raises an exception when the name is missing' do + lambda { xhr :post, :create, color: '#ff0' }.should raise_error(Discourse::InvalidParameters) + end + + it 'raises an exception when the color is missing' do + lambda { xhr :post, :create, name: 'hello' }.should raise_error(Discourse::InvalidParameters) + end + + describe 'failure' do + before do + @category = Fabricate(:category, user: @user) + xhr :post, :create, name: @category.name, color: '#ff0' + end + + it { should_not respond_with(:success) } + + it 'returns errors on a duplicate category name' do + response.code.to_i.should == 422 + end + end + + + describe 'success' do + before do + xhr :post, :create, name: 'hello', color: '#ff0' + end + + it 'creates a category' do + Category.count.should == 1 + end + + it { should respond_with(:success) } + + end + + end + end + + describe 'destroy' do + + it 'requires the user to be logged in' do + lambda { xhr :delete, :destroy, id: 'category'}.should raise_error(Discourse::NotLoggedIn) + end + + describe 'logged in' do + before do + @user = log_in + @category = Fabricate(:category, user: @user) + end + + it "raises an exception if they don't have permission to delete it" do + Guardian.any_instance.expects(:can_delete_category?).returns(false) + xhr :delete, :destroy, id: @category.slug + response.should be_forbidden + end + + it "deletes the record" do + Guardian.any_instance.expects(:can_delete_category?).returns(true) + lambda { xhr :delete, :destroy, id: @category.slug}.should change(Category, :count).by(-1) + end + end + + end + + describe 'update' do + + it 'requires the user to be logged in' do + lambda { xhr :put, :update, id: 'category'}.should raise_error(Discourse::NotLoggedIn) + end + + + describe 'logged in' do + before do + @user = log_in(:moderator) + @category = Fabricate(:category, user: @user) + end + + it "raises an exception if they don't have permission to edit it" do + Guardian.any_instance.expects(:can_edit?).returns(false) + xhr :put, :update, id: @category.slug, name: 'hello', color: '#ff0' + response.should be_forbidden + end + + it "requires a name" do + lambda { xhr :put, :update, id: @category.slug, color: '#fff' }.should raise_error(Discourse::InvalidParameters) + end + + it "requires a color" do + lambda { xhr :put, :update, id: @category.slug, name: 'asdf'}.should raise_error(Discourse::InvalidParameters) + end + + describe 'failure' do + before do + @other_category = Fabricate(:category, name: 'Other', user: @user ) + xhr :put, :update, id: @category.id, name: @other_category.name, color: '#ff0' + end + + it 'returns errors on a duplicate category name' do + response.should_not be_success + end + + it 'returns errors on a duplicate category name' do + response.code.to_i.should == 422 + end + end + + describe 'success' do + before do + xhr :put, :update, id: @category.id, name: 'science', color: '#000' + @category.reload + end + + it 'updates the name' do + @category.name.should == 'science' + end + + it 'updates the color' do + @category.color.should == '#000' + end + + end + end + + + end + +end diff --git a/spec/controllers/clicks_controller_spec.rb b/spec/controllers/clicks_controller_spec.rb new file mode 100644 index 00000000000..dac92ed137f --- /dev/null +++ b/spec/controllers/clicks_controller_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe ClicksController do + + context 'create' do + + context 'missing params' do + it 'raises an error without the url param' do + lambda { xhr :get, :track, post_id: 123 }.should raise_error(Discourse::InvalidParameters) + end + + it "redirects to the url even without the topic_id or post_id params" do + xhr :get, :track, url: 'http://google.com' + response.should redirect_to("http://google.com") + end + + end + + context 'correct params' do + + before do + request.stubs(:remote_ip).returns('192.168.0.1') + end + + context 'with a post_id' do + it 'calls create_from' do + TopicLinkClick.expects(:create_from).with(url: 'http://discourse.org', post_id: 123, ip: '192.168.0.1') + xhr :get, :track, url: 'http://discourse.org', post_id: 123 + response.should redirect_to("http://discourse.org") + end + + it 'redirects to the url' do + TopicLinkClick.stubs(:create_from) + xhr :get, :track, url: 'http://discourse.org', post_id: 123 + response.should redirect_to("http://discourse.org") + end + + it 'will pass the user_id to create_from' do + TopicLinkClick.expects(:create_from).with(url: 'http://discourse.org', post_id: 123, ip: '192.168.0.1') + xhr :get, :track, url: 'http://discourse.org', post_id: 123 + response.should redirect_to("http://discourse.org") + end + + it "doesn't redirect with the redirect=false param" do + TopicLinkClick.expects(:create_from).with(url: 'http://discourse.org', post_id: 123, ip: '192.168.0.1') + xhr :get, :track, url: 'http://discourse.org', post_id: 123, redirect: 'false' + response.should_not be_redirect + end + + end + + context 'with a topic_id' do + it 'calls create_from' do + TopicLinkClick.expects(:create_from).with(url: 'http://discourse.org', topic_id: 789, ip: '192.168.0.1') + xhr :get, :track, url: 'http://discourse.org', topic_id: 789 + response.should redirect_to("http://discourse.org") + end + end + + end + + end + +end \ No newline at end of file diff --git a/spec/controllers/draft_controller_spec.rb b/spec/controllers/draft_controller_spec.rb new file mode 100644 index 00000000000..ce810f70d3d --- /dev/null +++ b/spec/controllers/draft_controller_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe DraftController do + + it 'requires you to be logged in' do + lambda { post :update }.should raise_error(Discourse::NotLoggedIn) + end + + it 'saves a draft on update' do + user = log_in + post :update, draft_key: 'xyz', data: 'my data', sequence: 0 + Draft.get(user, 'xyz', 0).should == 'my data' + end + + it 'destroys drafts when required' do + user = log_in + Draft.set(user, 'xxx', 0, 'hi') + delete :destroy, draft_key: 'xxx', sequence: 0 + Draft.get(user, 'xxx', 0).should be_nil + end + +end diff --git a/spec/controllers/email_controller_spec.rb b/spec/controllers/email_controller_spec.rb new file mode 100644 index 00000000000..31ebb5eff01 --- /dev/null +++ b/spec/controllers/email_controller_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +describe EmailController do + + context '.preferences_redirect' do + + it 'requires you to be logged in' do + lambda { get :preferences_redirect }.should raise_error(Discourse::NotLoggedIn) + end + + context 'when logged in' do + let!(:user) { log_in } + + it 'redirects to your user preferences' do + get :preferences_redirect + response.should redirect_to("/users/#{user.username}/preferences") + end + end + + end + + context '.resubscribe' do + + let(:user) { Fabricate(:user, email_digests: false) } + + context 'with a valid key' do + before do + get :resubscribe, key: user.temporary_key + user.reload + end + + it 'subscribes the user' do + user.email_digests.should be_true + end + end + + end + + context '.unsubscribe' do + + let(:user) { Fabricate(:user) } + + context 'with a valid key' do + before do + get :unsubscribe, key: user.temporary_key + user.reload + end + + it 'unsubscribes the user' do + user.email_digests.should be_false + end + + it "does not set the not_found instance variable" do + assigns(:not_found).should be_blank + end + end + + context 'when logged in as a different user' do + let!(:logged_in_user) { log_in(:coding_horror) } + + before do + get :unsubscribe, key: user.temporary_key + user.reload + end + + it 'does not unsubscribe the user' do + user.email_digests.should be_true + end + + it 'sets not found' do + assigns(:not_found).should be_true + end + end + + context 'when logged in as the keyed user' do + + before do + log_in_user(user) + get :unsubscribe, key: user.temporary_key + user.reload + end + + it 'unsubscribes the user' do + user.email_digests.should be_false + end + + it "doesn't set not found" do + assigns(:not_found).should be_blank + end + end + + it "sets not_found when the key didn't match anything" do + get :unsubscribe, key: 'asdfasdf' + assigns(:not_found).should be_true + end + + end + +end diff --git a/spec/controllers/excerpt_controller_spec.rb b/spec/controllers/excerpt_controller_spec.rb new file mode 100644 index 00000000000..6e0dce03cba --- /dev/null +++ b/spec/controllers/excerpt_controller_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +describe ExcerptController do + + describe 'show' do + it 'raises an error without the url param' do + lambda { xhr :get, :show }.should raise_error(Discourse::InvalidParameters) + end + + it 'returns 404 with a non-existant url' do + xhr :get, :show, url: 'http://madeup.com/url' + response.status.should == 404 + end + + it 'returns 404 from an invalid url' do + xhr :get, :show, url: 'asdfasdf' + response.status.should == 404 + end + + describe 'user excerpt' do + + before do + @user = Fabricate(:user) + @url = "http://test.host/users/#{@user.username}" + xhr :get, :show, url: @url + end + + it 'returns a valid status' do + response.should be_success + end + + it 'returns an excerpt type for the forum topic' do + parsed = JSON.parse(response.body) + parsed['type'].should == 'User' + end + + end + + describe 'forum topic excerpt' do + + before do + @post = Fabricate(:post) + @url = "http://test.host#{@post.topic.relative_url}" + xhr :get, :show, url: @url + end + + it 'returns a valid status' do + response.should be_success + end + + it 'returns an excerpt type for the forum topic' do + parsed = JSON.parse(response.body) + parsed['type'].should == 'Post' + end + + end + + describe 'post excerpt' do + + before do + @post = Fabricate(:post) + @url = "http://test.host#{@post.topic.relative_url}/1" + xhr :get, :show, url: @url + end + + it 'returns a valid status' do + response.should be_success + end + + it 'returns an excerpt type for the forum topic' do + parsed = JSON.parse(response.body) + parsed['type'].should == 'Post' + end + + end + + + end + + + +end diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb new file mode 100644 index 00000000000..f273ccf5e8a --- /dev/null +++ b/spec/controllers/invites_controller_spec.rb @@ -0,0 +1,151 @@ +require 'spec_helper' + +describe InvitesController do + + context '.destroy' do + + it 'requires you to be logged in' do + lambda { + delete :destroy, email: 'jake@adventuretime.ooo' + }.should raise_error(Discourse::NotLoggedIn) + end + + context 'while logged in' do + let!(:user) { log_in } + let!(:invite) { Fabricate(:invite, invited_by: user) } + let(:another_invite) { Fabricate(:invite, email: 'anotheremail@address.com') } + + + it 'raises an error when the email is missing' do + lambda { delete :destroy }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error when the email cannot be found" do + lambda { delete :destroy, email: 'finn@adventuretime.ooo' }.should raise_error(Discourse::InvalidParameters) + end + + it 'raises an error when the invite is not yours' do + lambda { delete :destroy, email: another_invite.email }.should raise_error(Discourse::InvalidParameters) + end + + it "destroys the invite" do + Invite.any_instance.expects(:destroy) + delete :destroy, email: invite.email + end + + end + + + end + + context '.show' do + + context 'with an invalid invite id' do + + before do + get :show, id: "doesn't exist" + end + + it "redirects to the root" do + response.should redirect_to("/") + end + + it "should not change the session" do + session[:current_user_id].should be_blank + end + + end + + context 'with a deleted invite' do + let(:topic) { Fabricate(:topic) } + let(:invite) { topic.invite_by_email(topic.user, "iceking@adventuretime.ooo") } + let(:deleted_invite) { invite.destroy; invite } + before do + get :show, id: deleted_invite.invite_key + end + + it "redirects to the root" do + response.should redirect_to("/") + end + + it "should not change the session" do + session[:current_user_id].should be_blank + end + + end + + + context 'with a valid invite id' do + let(:topic) { Fabricate(:topic) } + let(:invite) { topic.invite_by_email(topic.user, "iceking@adventuretime.ooo") } + + + it 'redeems the invite' do + Invite.any_instance.expects(:redeem) + get :show, id: invite.invite_key + end + + context 'when redeem returns a user' do + let(:user) { Fabricate(:coding_horror) } + + context 'success' do + before do + Invite.any_instance.expects(:redeem).returns(user) + get :show, id: invite.invite_key + end + + it 'logs in the user' do + session[:current_user_id].should == user.id + end + + it 'redirects to the first topic the user was invited to' do + response.should redirect_to(topic.relative_url) + end + end + + context 'welcome message' do + before do + Invite.any_instance.stubs(:redeem).returns(user) + Jobs.expects(:enqueue).with(:invite_email, has_key(:invite_id)) + end + + it 'sends a welcome message if set' do + user.send_welcome_message = true + user.expects(:enqueue_welcome_message).with('welcome_invite') + get :show, id: invite.invite_key + end + + it "doesn't send a welcome message if not set" do + user.expects(:enqueue_welcome_message).with('welcome_invite').never + get :show, id: invite.invite_key + end + + end + + context 'access_required' do + + it "doesn't set a cookie for access if there is no access required" do + SiteSetting.expects(:restrict_access?).returns(false) + Invite.any_instance.expects(:redeem).returns(user) + get :show, id: invite.invite_key + cookies[:_access].should be_blank + end + + it "sets the cookie when access is required" do + SiteSetting.expects(:restrict_access?).returns(true) + SiteSetting.expects(:access_password).returns('adventure time!') + Invite.any_instance.expects(:redeem).returns(user) + get :show, id: invite.invite_key + cookies[:_access].should == 'adventure time!' + end + + end + + end + + end + + + end + +end diff --git a/spec/controllers/list_controller_spec.rb b/spec/controllers/list_controller_spec.rb new file mode 100644 index 00000000000..a0581b4e12d --- /dev/null +++ b/spec/controllers/list_controller_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper' + +describe ListController do + + # we need some data + before do + @user = Fabricate(:coding_horror) + @post = Fabricate(:post, :user => @user) + end + + context 'index' do + before do + xhr :get, :index + end + + it { should respond_with(:success) } + end + + context 'category' do + + context 'in a category' do + let(:category) { Fabricate(:category) } + + it "raises an invalid access error when the user can't see the category" do + Guardian.any_instance.expects(:can_see?).with(category).returns(false) + xhr :get, :category, category: category.slug + response.should be_forbidden + end + + context 'with access to see the category' do + before do + xhr :get, :category, category: category.slug + end + + it { should respond_with(:success) } + end + end + + context 'uncategorized' do + + it "doesn't check access to see the category, since we didn't provide one" do + Guardian.any_instance.expects(:can_see?).never + xhr :get, :category, category: SiteSetting.uncategorized_name + end + + it "responds with success" do + xhr :get, :category, category: SiteSetting.uncategorized_name + response.should be_success + end + + end + + + + end + + context 'favorited' do + it 'raises an error when not logged in' do + lambda { xhr :get, :favorited }.should raise_error(Discourse::NotLoggedIn) + end + + context 'when logged in' do + before do + log_in_user(@user) + xhr :get, :favorited + end + + it { should respond_with(:success) } + end + end + + context 'read' do + it 'raises an error when not logged in' do + lambda { xhr :get, :read }.should raise_error(Discourse::NotLoggedIn) + end + + context 'when logged in' do + before do + log_in_user(@user) + xhr :get, :read + end + + it { should respond_with(:success) } + end + end + +end diff --git a/spec/controllers/notifications_controller_spec.rb b/spec/controllers/notifications_controller_spec.rb new file mode 100644 index 00000000000..4d58df56dc1 --- /dev/null +++ b/spec/controllers/notifications_controller_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe NotificationsController do + + context 'when logged in' do + let!(:user) { log_in } + + before do + xhr :get, :index + end + + subject { response } + it { should be_success } + end + + context 'when not logged in' do + it 'should raise an error' do + lambda { xhr :get, :index }.should raise_error(Discourse::NotLoggedIn) + end + end + +end diff --git a/spec/controllers/onebox_controller_spec.rb b/spec/controllers/onebox_controller_spec.rb new file mode 100644 index 00000000000..99247add560 --- /dev/null +++ b/spec/controllers/onebox_controller_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe OneboxController do + + it 'asks the oneboxer for the preview' do + Oneboxer.expects(:preview).with('http://google.com') + xhr :get, :show, url: 'http://google.com' + end + + it 'invalidates the cache if refresh is passed' do + Oneboxer.expects(:invalidate).with('http://google.com') + xhr :get, :show, url: 'http://google.com', refresh: true + end + +end diff --git a/spec/controllers/post_actions_controller_spec.rb b/spec/controllers/post_actions_controller_spec.rb new file mode 100644 index 00000000000..e9f93f86630 --- /dev/null +++ b/spec/controllers/post_actions_controller_spec.rb @@ -0,0 +1,127 @@ +require 'spec_helper' + +describe PostActionsController do + + describe 'create' do + it 'requires you to be logged in' do + lambda { xhr :post, :create }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'logged in' do + before do + @user = log_in + @post = Fabricate(:post, user: Fabricate(:coding_horror)) + end + + it 'raises an error when the id is missing' do + lambda { xhr :post, :create, post_action_type_id: PostActionType.Types[:like] }.should raise_error(Discourse::InvalidParameters) + end + + it 'raises an error when the post_action_type_id index is missing' do + lambda { xhr :post, :create, id: @post.id }.should raise_error(Discourse::InvalidParameters) + end + + it "fails when the user doesn't have permission to see the post" do + Guardian.any_instance.expects(:can_see?).with(@post).returns(false) + xhr :post, :create, id: @post.id, post_action_type_id: PostActionType.Types[:like] + response.should be_forbidden + end + + it "fails when the user doesn't have permission to perform that action" do + Guardian.any_instance.expects(:post_can_act?).with(@post, :like).returns(false) + xhr :post, :create, id: @post.id, post_action_type_id: PostActionType.Types[:like] + response.should be_forbidden + end + + it 'allows us to create an post action on a post' do + PostAction.expects(:act).once.with(@user, @post, PostActionType.Types[:like], nil) + xhr :post, :create, id: @post.id, post_action_type_id: PostActionType.Types[:like] + end + end + + end + + context 'destroy' do + + let(:post) { Fabricate(:post, user: Fabricate(:coding_horror)) } + + it 'requires you to be logged in' do + lambda { xhr :delete, :destroy, id: post.id }.should raise_error(Discourse::NotLoggedIn) + end + + context 'logged in' do + let!(:user) { log_in } + + it 'raises an error when the post_action_type_id is missing' do + lambda { xhr :delete, :destroy, id: post.id }.should raise_error(Discourse::InvalidParameters) + end + + it "returns 404 when the post action type doesn't exist for that user" do + xhr :delete, :destroy, id: post.id, post_action_type_id: 1 + response.code.should == '404' + end + + context 'with a post_action record ' do + let!(:post_action) { PostAction.create(user_id: user.id, post_id: post.id, post_action_type_id: 1)} + + it 'returns success' do + xhr :delete, :destroy, id: post.id, post_action_type_id: 1 + response.should be_success + end + + it 'deletes the action' do + xhr :delete, :destroy, id: post.id, post_action_type_id: 1 + PostAction.exists?(user_id: user.id, post_id: post.id, post_action_type_id: 1, deleted_at: nil).should be_false + end + + it 'ensures it can be deleted' do + Guardian.any_instance.expects(:can_delete?).with(post_action).returns(false) + xhr :delete, :destroy, id: post.id, post_action_type_id: 1 + response.should be_forbidden + end + end + + end + + end + + + + describe 'users' do + + let!(:post) { Fabricate(:post, user: log_in) } + + it 'raises an error without an id' do + lambda { + xhr :get, :users, post_action_type_id: PostActionType.Types[:like] + }.should raise_error(Discourse::InvalidParameters) + end + + it 'raises an error without a post action type' do + lambda { + xhr :get, :users, id: post.id + }.should raise_error(Discourse::InvalidParameters) + end + + it "fails when the user doesn't have permission to see the post" do + Guardian.any_instance.expects(:can_see?).with(post).returns(false) + xhr :get, :users, id: post.id, post_action_type_id: PostActionType.Types[:like] + response.should be_forbidden + end + + it 'raises an error when the post action type cannot be seen' do + Guardian.any_instance.expects(:can_see_post_actors?).with(instance_of(Topic), PostActionType.Types[:like]).returns(false) + xhr :get, :users, id: post.id, post_action_type_id: PostActionType.Types[:like] + response.should be_forbidden + end + + it 'succeeds' do + xhr :get, :users, id: post.id, post_action_type_id: PostActionType.Types[:like] + response.should be_success + end + + end + + + +end diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb new file mode 100644 index 00000000000..a96c16cc3f9 --- /dev/null +++ b/spec/controllers/posts_controller_spec.rb @@ -0,0 +1,278 @@ +require 'spec_helper' + +describe PostsController do + + + describe 'show' do + + let(:post) { Fabricate(:post, user: log_in) } + + it 'ensures the user can see the post' do + Guardian.any_instance.expects(:can_see?).with(post).returns(false) + xhr :get, :show, id: post.id + response.should be_forbidden + end + + it 'suceeds' do + xhr :get, :show, id: post.id + response.should be_success + end + + end + + describe 'versions' do + + it 'raises an exception when not logged in' do + lambda { xhr :get, :versions, post_id: 123 }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + let(:post) { Fabricate(:post, user: log_in) } + + it "raises an error if the user doesn't have permission to see the post" do + Guardian.any_instance.expects(:can_see?).with(post).returns(false) + xhr :get, :versions, post_id: post.id + response.should be_forbidden + end + + it 'renders JSON' do + xhr :get, :versions, post_id: post.id + ::JSON.parse(response.body).should be_present + end + + end + + end + + describe 'delete a post' do + it 'raises an exception when not logged in' do + lambda { xhr :delete, :destroy, id: 123 }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + + let(:post) { Fabricate(:post, user: log_in(:moderator), post_number: 2) } + + it "raises an error when the user doesn't have permission to see the post" do + Guardian.any_instance.expects(:can_delete?).with(post).returns(false) + xhr :delete, :destroy, id: post.id + response.should be_forbidden + end + + it "deletes the post" do + Post.any_instance.expects(:destroy) + xhr :delete, :destroy, id: post.id + end + + it "updates the highest read data for the forum" do + Topic.expects(:reset_highest).with(post.topic_id) + xhr :delete, :destroy, id: post.id + end + + end + end + + describe 'destroy_many' do + it 'raises an exception when not logged in' do + lambda { xhr :delete, :destroy_many, post_ids: [123, 345] }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + + let!(:poster) { log_in(:moderator) } + let!(:post1) { Fabricate(:post, user: poster, post_number: 2) } + let!(:post2) { Fabricate(:post, topic_id: post1.topic_id, user: poster, post_number: 3) } + + it "raises invalid parameters no post_ids" do + lambda { xhr :delete, :destroy_many }.should raise_error(Discourse::InvalidParameters) + end + + it "raises invalid parameters with missing ids" do + lambda { xhr :delete, :destroy_many, post_ids: [12345] }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error when the user doesn't have permission to delete the posts" do + Guardian.any_instance.expects(:can_delete?).with(instance_of(Post)).returns(false) + xhr :delete, :destroy_many, post_ids: [post1.id, post2.id] + response.should be_forbidden + end + + it "deletes the post" do + Post.any_instance.expects(:destroy).twice + xhr :delete, :destroy_many, post_ids: [post1.id, post2.id] + end + + it "updates the highest read data for the forum" do + Topic.expects(:reset_highest) + xhr :delete, :destroy_many, post_ids: [post1.id, post2.id] + end + + end + + end + + + describe 'edit a post' do + + it 'raises an exception when not logged in' do + lambda { xhr :put, :update, id: 2 }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + + let(:post) { Fabricate(:post, user: log_in) } + let(:update_params) do + {id: post.id, + post: {raw: 'edited body'}, + image_sizes: {'http://image.com/image.jpg' => {'width' => 123, 'height' => 456}}} + end + + it 'passes the image sizes through' do + Post.any_instance.expects(:image_sizes=) + xhr :put, :update, update_params + end + + it "raises an error when the post parameter is missing" do + update_params.delete(:post) + lambda { + xhr :put, :update, update_params + }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error when the user doesn't have permission to see the post" do + Guardian.any_instance.expects(:can_edit?).with(post).returns(false) + xhr :put, :update, update_params + response.should be_forbidden + end + + it "calls revise with valid parameters" do + Post.any_instance.expects(:revise).with(post.user, 'edited body') + xhr :put, :update, update_params + end + + it "extracts links from the new body" do + TopicLink.expects(:extract_from).with(post) + xhr :put, :update, update_params + end + + end + + end + + describe 'bookmark a post' do + + it 'raises an exception when not logged in' do + lambda { xhr :put, :bookmark, post_id: 2 }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + + let(:post) { Fabricate(:post, user: log_in) } + + it "raises an error if the user doesn't have permission to see the post" do + Guardian.any_instance.expects(:can_see?).with(post).returns(false) + xhr :put, :bookmark, post_id: post.id, bookmarked: 'true' + response.should be_forbidden + end + + it 'creates a bookmark' do + PostAction.expects(:act).with(post.user, post, PostActionType.Types[:bookmark]) + xhr :put, :bookmark, post_id: post.id, bookmarked: 'true' + end + + it 'removes a bookmark' do + PostAction.expects(:remove_act).with(post.user, post, PostActionType.Types[:bookmark]) + xhr :put, :bookmark, post_id: post.id + end + + end + + end + + describe 'creating a post' do + + it 'raises an exception when not logged in' do + lambda { xhr :post, :create }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + + let!(:user) { log_in } + let(:new_post) { Fabricate.build(:post, user: user) } + + it "raises an exception without a post parameter" do + lambda { xhr :post, :create }.should raise_error(Discourse::InvalidParameters) + end + + it 'calls the post creator' do + PostCreator.any_instance.expects(:create).returns(new_post) + xhr :post, :create, post: {raw: 'test'} + response.should be_success + end + + it 'returns JSON of the post' do + PostCreator.any_instance.expects(:create).returns(new_post) + xhr :post, :create, post: {raw: 'test'} + ::JSON.parse(response.body).should be_present + end + + context "parameters" do + + let(:post_creator) { mock } + + before do + post_creator.expects(:create).returns(new_post) + post_creator.stubs(:errors).returns(nil) + end + + it "passes raw through" do + PostCreator.expects(:new).with(user, has_entries(raw: 'hello')).returns(post_creator) + xhr :post, :create, post: {raw: 'hello'} + end + + it "passes title through" do + PostCreator.expects(:new).with(user, has_entries(title: 'new topic title')).returns(post_creator) + xhr :post, :create, post: {raw: 'hello'}, title: 'new topic title' + end + + it "passes topic_id through" do + PostCreator.expects(:new).with(user, has_entries(topic_id: '1234')).returns(post_creator) + xhr :post, :create, post: {raw: 'hello', topic_id: 1234} + end + + it "passes archetype through" do + PostCreator.expects(:new).with(user, has_entries(archetype: 'private_message')).returns(post_creator) + xhr :post, :create, post: {raw: 'hello'}, archetype: 'private_message' + end + + it "passes category through" do + PostCreator.expects(:new).with(user, has_entries(category: 'cool')).returns(post_creator) + xhr :post, :create, post: {raw: 'hello', category: 'cool'} + end + + it "passes target_usernames through" do + PostCreator.expects(:new).with(user, has_entries(target_usernames: 'evil,trout')).returns(post_creator) + xhr :post, :create, post: {raw: 'hello'}, target_usernames: 'evil,trout' + end + + it "passes reply_to_post_number through" do + PostCreator.expects(:new).with(user, has_entries(reply_to_post_number: '6789')).returns(post_creator) + xhr :post, :create, post: {raw: 'hello', reply_to_post_number: 6789} + end + + it "passes image_sizes through" do + PostCreator.expects(:new).with(user, has_entries(image_sizes: 'test')).returns(post_creator) + xhr :post, :create, post: {raw: 'hello'}, image_sizes: 'test' + end + + it "passes meta_data through" do + PostCreator.expects(:new).with(user, has_entries(meta_data: {'xyz' => 'abc'})).returns(post_creator) + xhr :post, :create, post: {raw: 'hello'}, meta_data: {xyz: 'abc'} + end + + end + + end + end + +end diff --git a/spec/controllers/request_access_controller_spec.rb b/spec/controllers/request_access_controller_spec.rb new file mode 100644 index 00000000000..d4b9ebc69cc --- /dev/null +++ b/spec/controllers/request_access_controller_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe RequestAccessController do + + context '.new' do + it "sets a default return path" do + get :new + assigns(:return_path).should == "/" + end + + it "assigns the return path we provide" do + get :new, return_path: '/asdf' + assigns(:return_path).should == "/asdf" + end + end + + + context '.create' do + + context 'without an invalid password' do + before do + post :create, password: 'asdf' + end + + it "adds a flash" do + flash[:error].should be_present + end + + it "doesn't set the cookie" do + cookies[:_access].should be_blank + end + end + + context 'with a valid password' do + before do + SiteSetting.stubs(:access_password).returns 'test password' + post :create, password: 'test password', return_path: '/the-path' + end + + it 'creates the cookie' do + cookies[:_access].should == 'test password' + end + + it 'redirects to the return path' do + response.should redirect_to('/the-path') + end + + it 'sets no flash error' do + flash[:error].should be_blank + end + + end + + end + +end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb new file mode 100644 index 00000000000..e6305b279c5 --- /dev/null +++ b/spec/controllers/search_controller_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe SearchController do + + it 'performs the query' do + Search.expects(:query).with('test', nil) + xhr :get, :query, term: 'test' + end + + it 'performs the query with a filter' do + Search.expects(:query).with('test', 'topic') + xhr :get, :query, term: 'test', type_filter: 'topic' + end + + +end diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb new file mode 100644 index 00000000000..45f312e1e52 --- /dev/null +++ b/spec/controllers/session_controller_spec.rb @@ -0,0 +1,130 @@ +require 'spec_helper' + +describe SessionController do + + describe '.create' do + + let(:user) { Fabricate(:user) } + + it "raises an error when the login isn't present" do + lambda { xhr :post, :create }.should raise_error(Discourse::InvalidParameters) + end + + describe 'invalid password' do + + it "should return an error with an invalid password" do + xhr :post, :create, login: user.username, password: 'sssss' + ::JSON.parse(response.body)['error'].should be_present + end + + end + + describe 'success by username' do + before do + xhr :post, :create, login: user.username, password: 'myawesomepassword' + user.reload + end + + it 'sets a session id' do + session[:current_user_id].should == user.id + end + + it 'gives the user an auth token' do + user.auth_token.should be_present + end + + it 'sets a cookie with the auth token' do + cookies[:_t].should == user.auth_token + end + end + + describe 'strips leading @ symbol' do + before do + xhr :post, :create, login: "@" + user.username, password: 'myawesomepassword' + user.reload + end + + it 'sets a session id' do + session[:current_user_id].should == user.id + end + end + + describe 'also allow login by email' do + before do + xhr :post, :create, login: user.email, password: 'myawesomepassword' + end + + it 'sets a session id' do + session[:current_user_id].should == user.id + end + end + + describe "when the site requires approval of users" do + before do + SiteSetting.expects(:must_approve_users?).returns(true) + end + + context 'with an unapproved user' do + before do + xhr :post, :create, login: user.email, password: 'myawesomepassword' + end + + it "doesn't log in the user" do + session[:current_user_id].should be_blank + end + + end + + end + + end + + describe '.destroy' do + before do + @user = log_in + xhr :delete, :destroy, id: @user.username + end + + it 'removes the session variable' do + session[:current_user_id].should be_blank + end + + + it 'removes the auth token cookie' do + cookies[:_t].should be_blank + end + end + + describe '.forgot_password' do + + it 'raises an error without a username parameter' do + lambda { xhr :post, :forgot_password }.should raise_error(Discourse::InvalidParameters) + end + + context 'for a non existant username' do + it "doesn't generate a new token for a made up username" do + lambda { xhr :post, :forgot_password, username: 'made_up'}.should_not change(EmailToken, :count) + end + + it "doesn't enqueue an email" do + Jobs.expects(:enqueue).with(:user_mail, anything).never + xhr :post, :forgot_password, username: 'made_up' + end + end + + context 'for an existing username' do + let(:user) { Fabricate(:user) } + + it "generates a new token for a made up username" do + lambda { xhr :post, :forgot_password, username: user.username}.should change(EmailToken, :count) + end + + it "enqueues an email" do + Jobs.expects(:enqueue).with(:user_email, has_entries(type: :forgot_password, user_id: user.id)) + xhr :post, :forgot_password, username: user.username + end + end + + end + +end diff --git a/spec/controllers/static_controller_spec.rb b/spec/controllers/static_controller_spec.rb new file mode 100644 index 00000000000..b828f20265e --- /dev/null +++ b/spec/controllers/static_controller_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe StaticController do + + context "with a static file that's present" do + + before do + xhr :get, :show, :id => 'faq' + end + + it 'renders the static file if present' do + response.should be_success + end + + it "renders the file" do + response.should render_template('faq') + end + end + + context "with a missing file" do + it "should respond 404" do + xhr :get, :show, :id => 'does-not-exist' + response.response_code.should == 404 + end + end + +end diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb new file mode 100644 index 00000000000..961713e6474 --- /dev/null +++ b/spec/controllers/topics_controller_spec.rb @@ -0,0 +1,419 @@ +require 'spec_helper' + +describe TopicsController do + + context 'move_posts' do + it 'needs you to be logged in' do + lambda { xhr :post, :move_posts, topic_id: 111, title: 'blah', post_ids: [1,2,3] }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + let!(:user) { log_in(:moderator) } + let(:p1) { Fabricate(:post, user: user) } + let(:topic) { p1.topic } + + it "raises an error without a title" do + lambda { xhr :post, :move_posts, topic_id: topic.id, post_ids: [1,2,3] }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error without postIds" do + lambda { xhr :post, :move_posts, topic_id: topic.id, title: 'blah' }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error when the user doesn't have permission to move the posts" do + Guardian.any_instance.expects(:can_move_posts?).returns(false) + xhr :post, :move_posts, topic_id: topic.id, title: 'blah', post_ids: [1,2,3] + response.should be_forbidden + end + + context 'success' do + let(:p2) { Fabricate(:post, user: user) } + + before do + Topic.any_instance.expects(:move_posts).with(user, 'blah', [p2.id]).returns(topic) + xhr :post, :move_posts, topic_id: topic.id, title: 'blah', post_ids: [p2.id] + end + + it "returns success" do + response.should be_success + end + + it "has a JSON response" do + ::JSON.parse(response.body)['success'].should be_true + end + + it "has a url" do + ::JSON.parse(response.body)['url'].should be_present + end + end + + context 'failure' do + let(:p2) { Fabricate(:post, user: user) } + + before do + Topic.any_instance.expects(:move_posts).with(user, 'blah', [p2.id]).returns(nil) + xhr :post, :move_posts, topic_id: topic.id, title: 'blah', post_ids: [p2.id] + end + + it "returns success" do + response.should be_success + end + + it "has success in the JSON" do + ::JSON.parse(response.body)['success'].should be_false + end + + it "has a url" do + ::JSON.parse(response.body)['url'].should be_blank + end + + end + + end + + + end + + context 'status' do + it 'needs you to be logged in' do + lambda { xhr :put, :status, topic_id: 1, status: 'visible', enabled: true }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + before do + @user = log_in(:moderator) + @topic = Fabricate(:topic, user: @user) + end + + it "raises an exception if you can't change it" do + Guardian.any_instance.expects(:can_moderate?).with(@topic).returns(false) + xhr :put, :status, topic_id: @topic.id, status: 'visible', enabled: 'true' + response.should be_forbidden + end + + it 'requires the status parameter' do + lambda { xhr :put, :status, topic_id: @topic.id, enabled: true }.should raise_error(Discourse::InvalidParameters) + end + + it 'requires the enabled parameter' do + lambda { xhr :put, :status, topic_id: @topic.id, status: 'visible' }.should raise_error(Discourse::InvalidParameters) + end + + it 'raises an error with a status not in the whitelist' do + lambda { xhr :put, :status, topic_id: @topic.id, status: 'title', enabled: 'true' }.should raise_error(Discourse::InvalidParameters) + end + + it 'calls update_status on the forum topic with false' do + Topic.any_instance.expects(:update_status).with('closed', false, @user) + xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'false' + end + + it 'calls update_status on the forum topic with true' do + Topic.any_instance.expects(:update_status).with('closed', true, @user) + xhr :put, :status, topic_id: @topic.id, status: 'closed', enabled: 'true' + end + + end + + end + + context 'delete_timings' do + + it 'needs you to be logged in' do + lambda { xhr :delete, :destroy_timings, topic_id: 1 }.should raise_error(Discourse::NotLoggedIn) + end + + context 'when logged in' do + before do + @user = log_in + @topic = Fabricate(:topic, user: @user) + @topic_user = TopicUser.get(@topic, @topic.user) + end + + it 'deletes the forum topic user record' do + PostTiming.expects(:destroy_for).with(@user.id, @topic.id) + xhr :delete, :destroy_timings, topic_id: @topic.id + end + + end + + end + + + describe 'mute/unmute' do + + it 'needs you to be logged in' do + lambda { xhr :put, :mute, topic_id: 99}.should raise_error(Discourse::NotLoggedIn) + end + + it 'needs you to be logged in' do + lambda { xhr :put, :unmute, topic_id: 99}.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + before do + @topic = Fabricate(:topic, user: log_in) + end + + it "changes the user's starred flag when the parameter is present" do + Topic.any_instance.expects(:toggle_mute).with(@topic.user, true) + xhr :put, :mute, topic_id: @topic.id, starred: 'true' + end + + it "removes the user's starred flag when the parameter is not true" do + Topic.any_instance.expects(:toggle_mute).with(@topic.user, false) + xhr :put, :unmute, topic_id: @topic.id, starred: 'false' + end + + end + + end + + describe 'star' do + + it 'needs you to be logged in' do + lambda { xhr :put, :star, topic_id: 1, starred: true }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + before do + @topic = Fabricate(:topic, user: log_in) + end + + it "ensures the user can see the topic" do + Guardian.any_instance.expects(:can_see?).with(@topic).returns(false) + xhr :put, :star, topic_id: @topic.id, starred: 'true' + response.should be_forbidden + end + + it "changes the user's starred flag when the parameter is present" do + Topic.any_instance.expects(:toggle_star).with(@topic.user, true) + xhr :put, :star, topic_id: @topic.id, starred: 'true' + end + + it "removes the user's starred flag when the parameter is not true" do + Topic.any_instance.expects(:toggle_star).with(@topic.user, false) + xhr :put, :star, topic_id: @topic.id, starred: 'false' + end + end + end + + describe 'delete' do + it "won't allow us to delete a topic when we're not logged in" do + lambda { xhr :delete, :destroy, id: 1 }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + before do + @topic = Fabricate(:topic, user: log_in) + end + + describe 'without access' do + it "raises an exception when the user doesn't have permission to delete the topic" do + Guardian.any_instance.expects(:can_delete?).with(@topic).returns(false) + xhr :delete, :destroy, id: @topic.id + response.should be_forbidden + end + end + + describe 'with permission' do + before do + Guardian.any_instance.expects(:can_delete?).with(@topic).returns(true) + end + + it 'succeeds' do + xhr :delete, :destroy, id: @topic.id + response.should be_success + end + + it 'deletes the topic' do + xhr :delete, :destroy, id: @topic.id + Topic.exists?(id: @topic_id).should be_false + end + + end + + end + end + + describe 'show' do + + let(:topic) { Fabricate(:post).topic } + let!(:p1) { Fabricate(:post, user: topic.user) } + let!(:p2) { Fabricate(:post, user: topic.user) } + + it 'shows a topic correctly' do + xhr :get, :show, id: topic.id + response.should be_success + end + + it 'records a view' do + lambda { xhr :get, :show, id: topic.id }.should change(View, :count).by(1) + end + + it 'tracks a visit for all html requests' do + current_user = log_in(:coding_horror) + TopicUser.expects(:track_visit!).with(topic, current_user) + get :show, id: topic.id + end + + context 'consider for a promotion' do + let!(:user) { log_in(:coding_horror) } + let(:promotion) do + result = mock + Promotion.stubs(:new).with(user).returns(result) + result + end + + it "reviews the user for a promotion if they're new" do + user.update_column(:trust_level, TrustLevel.Levels[:new]) + promotion.expects(:review) + get :show, id: topic.id + end + + it "doesn't reviews the user for a promotion if they're basic" do + promotion.expects(:review).never + get :show, id: topic.id + end + + end + + context 'filters' do + + + it 'grabs first page when no post number is selected' do + TopicView.any_instance.expects(:filter_posts_paged).with(0) + xhr :get, :show, id: topic.id + end + + it 'delegates a post_number param to TopicView#filter_posts_near' do + TopicView.any_instance.expects(:filter_posts_near).with(p2.post_number) + xhr :get, :show, id: topic.id, post_number: p2.post_number + end + + it 'delegates a posts_after param to TopicView#filter_posts_after' do + TopicView.any_instance.expects(:filter_posts_after).with(p1.post_number) + xhr :get, :show, id: topic.id, posts_after: p1.post_number + end + + it 'delegates a posts_before param to TopicView#filter_posts_before' do + TopicView.any_instance.expects(:filter_posts_before).with(p2.post_number) + xhr :get, :show, id: topic.id, posts_before: p2.post_number + end + + end + + end + + describe 'update' do + it "won't allow us to update a topic when we're not logged in" do + lambda { xhr :put, :update, topic_id: 1, slug: 'xyz' }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + before do + @topic = Fabricate(:topic, user: log_in) + end + + describe 'without permission' do + it "raises an exception when the user doesn't have permission to update the topic" do + Guardian.any_instance.expects(:can_edit?).with(@topic).returns(false) + xhr :put, :update, topic_id: @topic.id, slug: @topic.title + response.should be_forbidden + end + end + + describe 'with permission' do + before do + Guardian.any_instance.expects(:can_edit?).with(@topic).returns(true) + end + + it 'succeeds' do + xhr :put, :update, topic_id: @topic.id, slug: @topic.title + response.should be_success + end + + it 'allows a change of title' do + xhr :put, :update, topic_id: @topic.id, slug: @topic.title, title: 'new title' + @topic.reload + @topic.title.should == 'new title' + end + + it 'triggers a change of category' do + Topic.any_instance.expects(:change_category).with('incredible') + xhr :put, :update, topic_id: @topic.id, slug: @topic.title, category: 'incredible' + end + + end + end + end + + describe 'invite' do + it "won't allow us to invite toa topic when we're not logged in" do + lambda { xhr :post, :invite, topic_id: 1, email: 'jake@adventuretime.ooo' }.should raise_error(Discourse::NotLoggedIn) + end + + describe 'when logged in' do + before do + @topic = Fabricate(:topic, user: log_in) + end + + it 'requires an email parameter' do + lambda { xhr :post, :invite, topic_id: @topic.id }.should raise_error(Discourse::InvalidParameters) + end + + describe 'without permission' do + it "raises an exception when the user doesn't have permission to invite to the topic" do + Guardian.any_instance.expects(:can_invite_to?).with(@topic).returns(false) + xhr :post, :invite, topic_id: @topic.id, user: 'jake@adventuretime.ooo' + response.should be_forbidden + end + end + + describe 'with permission' do + + before do + Guardian.any_instance.expects(:can_invite_to?).with(@topic).returns(true) + end + + context 'when it returns an invite' do + before do + Topic.any_instance.expects(:invite_by_email).with(@topic.user, 'jake@adventuretime.ooo').returns(Invite.new) + xhr :post, :invite, topic_id: @topic.id, user: 'jake@adventuretime.ooo' + end + + it 'should succeed' do + response.should be_success + end + + it 'returns success JSON' do + ::JSON.parse(response.body).should == {'success' => 'OK'} + end + end + + context 'when it fails and returns nil' do + + before do + Topic.any_instance.expects(:invite_by_email).with(@topic.user, 'jake@adventuretime.ooo').returns(nil) + xhr :post, :invite, topic_id: @topic.id, user: 'jake@adventuretime.ooo' + end + + it 'should succeed' do + response.should_not be_success + end + + it 'returns success JSON' do + ::JSON.parse(response.body).should == {'failed' => 'FAILED'} + end + + end + + end + + + + end + + end + +end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb new file mode 100644 index 00000000000..ac076be5ef1 --- /dev/null +++ b/spec/controllers/users_controller_spec.rb @@ -0,0 +1,635 @@ +require 'spec_helper' + +describe UsersController do + + describe '.show' do + let!(:user) { log_in } + + it 'returns success' do + xhr :get, :show, username: user.username + response.should be_success + end + + it "returns not found when the username doesn't exist" do + xhr :get, :show, username: 'madeuppity' + response.should_not be_success + end + + it "raises an error on invalid access" do + Guardian.any_instance.expects(:can_see?).with(user).returns(false) + xhr :get, :show, username: user.username + response.should be_forbidden + end + end + + describe '.user_preferences_redirect' do + it 'requires the user to be logged in' do + lambda { get :user_preferences_redirect }.should raise_error(Discourse::NotLoggedIn) + end + + it "redirects to their profile when logged in" do + user = log_in + get :user_preferences_redirect + response.should redirect_to("/users/#{user.username_lower}/preferences") + end + end + + describe '.authorize_email' do + context 'invalid token' do + before do + EmailToken.expects(:confirm).with('asdfasdf').returns(nil) + get :authorize_email, token: 'asdfasdf' + end + + it 'return success' do + response.should be_success + end + + it 'sets a flash error' do + flash[:error].should be_present + end + end + + context 'valid token' do + let(:user) { Fabricate(:user) } + + before do + EmailToken.expects(:confirm).with('asdfasdf').returns(user) + get :authorize_email, token: 'asdfasdf' + end + + it 'returns success' do + response.should be_success + end + + it "doesn't set an error" do + flash[:error].should be_blank + end + + it 'logs in as the user' do + session[:current_user_id].should be_present + end + end + end + + describe '.activate_account' do + context 'invalid token' do + before do + EmailToken.expects(:confirm).with('asdfasdf').returns(nil) + get :activate_account, token: 'asdfasdf' + end + + it 'return success' do + response.should be_success + end + + it 'sets a flash error' do + flash[:error].should be_present + end + end + + context 'valid token' do + let(:user) { Fabricate(:user) } + + context 'welcome message' do + before do + EmailToken.expects(:confirm).with('asdfasdf').returns(user) + end + + it 'enqueues a welcome message if the user object indicates so' do + user.send_welcome_message = true + user.expects(:enqueue_welcome_message).with('welcome_user') + get :activate_account, token: 'asdfasdf' + end + + it "doesn't enqueue the welcome message if the object returns false" do + user.send_welcome_message = false + user.expects(:enqueue_welcome_message).with('welcome_user').never + get :activate_account, token: 'asdfasdf' + end + + end + + context 'reponse' do + before do + EmailToken.expects(:confirm).with('asdfasdf').returns(user) + get :activate_account, token: 'asdfasdf' + end + + it 'returns success' do + response.should be_success + end + + it "doesn't set an error" do + flash[:error].should be_blank + end + + it 'logs in as the user' do + session[:current_user_id].should be_present + end + + it "doesn't set @needs_approval" do + assigns[:needs_approval].should be_blank + end + + end + + context 'must_approve_users' do + before do + SiteSetting.expects(:must_approve_users?).returns(true) + EmailToken.expects(:confirm).with('asdfasdf').returns(user) + get :activate_account, token: 'asdfasdf' + end + + it 'returns success' do + response.should be_success + end + + it 'sets @needs_approval' do + assigns[:needs_approval].should be_present + end + + it "doesn't set an error" do + flash[:error].should be_blank + end + + it "doesn't log the user in" do + session[:current_user_id].should be_blank + end + end + + end + end + + describe '.change_email' do + let(:new_email) { 'bubblegum@adventuretime.ooo' } + + it "requires you to be logged in" do + lambda { xhr :put, :change_email, username: 'asdf', email: new_email }.should raise_error(Discourse::NotLoggedIn) + end + + context 'when logged in' do + let!(:user) { log_in } + + it 'raises an error without an email parameter' do + lambda { xhr :put, :change_email, username: user.username }.should raise_error(Discourse::InvalidParameters) + end + + it "raises an error if you can't edit the user" do + Guardian.any_instance.expects(:can_edit?).with(user).returns(false) + xhr :put, :change_email, username: user.username, email: new_email + response.should be_forbidden + end + + context 'when the new email address is taken' do + let!(:other_user) { Fabricate(:coding_horror) } + it 'raises an error' do + lambda { xhr :put, :change_email, username: user.username, email: other_user.email }.should raise_error(Discourse::InvalidParameters) + end + end + + context 'success' do + + it 'has an email token' do + lambda { xhr :put, :change_email, username: user.username, email: new_email }.should change(EmailToken, :count) + end + + it 'enqueues an email authorization' do + Jobs.expects(:enqueue).with(:user_email, has_entries(type: :authorize_email, user_id: user.id, to_address: new_email)) + xhr :put, :change_email, username: user.username, email: new_email + end + end + end + + end + + describe '.password_reset' do + let(:user) { Fabricate(:user) } + + context 'invalid token' do + before do + EmailToken.expects(:confirm).with('asdfasdf').returns(nil) + get :password_reset, token: 'asdfasdf' + end + + it 'return success' do + response.should be_success + end + + it 'sets a flash error' do + flash[:error].should be_present + end + + it "doesn't log in the user" do + session[:current_user_id].should be_blank + end + end + + context 'valid token' do + before do + EmailToken.expects(:confirm).with('asdfasdf').returns(user) + get :password_reset, token: 'asdfasdf' + end + + it 'returns success' do + response.should be_success + end + + it "doesn't set an error" do + flash[:error].should be_blank + end + end + + context 'submit change' do + before do + EmailToken.expects(:confirm).with('asdfasdf').returns(user) + end + + it "logs in the user" do + put :password_reset, token: 'asdfasdf', password: 'newpassword' + session[:current_user_id].should be_present + end + + it "doesn't log in the user when not approved" do + SiteSetting.expects(:must_approve_users?).returns(true) + put :password_reset, token: 'asdfasdf', password: 'newpassword' + session[:current_user_id].should be_blank + end + end + + + end + + + describe '.create' do + before do + @user = Fabricate.build(:user) + @user.password = "strongpassword" + Mothership.stubs(:register_nickname).returns([true, nil]) + end + + context 'when creating a non active user (unconfirmed email)' do + it 'should enqueue a signup email' do + Jobs.expects(:enqueue).with(:user_email, has_entries(type: :signup)) + xhr :post, :create, :name => @user.name, :username => @user.username, :password => "strongpassword", :email => @user.email + end + + it "doesn't send a welcome email" do + User.any_instance.expects(:enqueue_welcome_message).with('welcome_user').never + xhr :post, :create, :name => @user.name, :username => @user.username, :password => "strongpassword", :email => @user.email + end + end + + context 'when creating an active user (confirmed email)' do + + before do + User.any_instance.stubs(:active?).returns(true) + end + + it 'should enqueue a signup email' do + User.any_instance.expects(:enqueue_welcome_message).with('welcome_user') + xhr :post, :create, :name => @user.name, :username => @user.username, :password => "strongpassword", :email => @user.email + end + + it "should be logged in" do + User.any_instance.expects(:enqueue_welcome_message) + xhr :post, :create, :name => @user.name, :username => @user.username, :password => "strongpassword", :email => @user.email + session[:current_user_id].should be_present + end + + it "returns true in the active part of the JSON" do + User.any_instance.expects(:enqueue_welcome_message) + xhr :post, :create, :name => @user.name, :username => @user.username, :password => "strongpassword", :email => @user.email + ::JSON.parse(response.body)['active'].should == true + end + + context 'when approving of users is required' do + before do + SiteSetting.expects(:must_approve_users).returns(true) + xhr :post, :create, :name => @user.name, :username => @user.username, :password => "strongpassword", :email => @user.email + end + + it "doesn't log in the user" do + session[:current_user_id].should be_blank + end + + it "doesn't return active in the JSON" do + ::JSON.parse(response.body)['active'].should == false + end + + end + + end + + context 'after success' do + before do + xhr :post, :create, :name => @user.name, :username => @user.username, :password => "strongpassword", :email => @user.email + end + + it 'should succeed' do + should respond_with(:success) + end + + it 'has the proper JSON' do + json = JSON::parse(response.body) + json["success"].should be_true + end + + it 'should not result in an active account' do + User.where(username: @user.username).first.active.should be_false + end + end + + end + + context '.username' do + it 'raises an error when not logged in' do + lambda { xhr :put, :username, username: 'somename' }.should raise_error(Discourse::NotLoggedIn) + end + + context 'while logged in' do + let!(:user) { log_in } + let(:new_username) { "#{user.username}1234" } + + it 'raises an error without a new_username param' do + lambda { xhr :put, :username, username: user.username }.should raise_error(Discourse::InvalidParameters) + end + + it 'raises an error when you don\'t have permission to change the user' do + Guardian.any_instance.expects(:can_edit?).with(user).returns(false) + xhr :put, :username, username: user.username, new_username: new_username + response.should be_forbidden + end + + it 'raises an error when change_username fails' do + User.any_instance.expects(:change_username).with(new_username).returns(false) + lambda { xhr :put, :username, username: user.username, new_username: new_username }.should raise_error(Discourse::InvalidParameters) + end + + it 'should succeed when the change_username returns true' do + User.any_instance.expects(:change_username).with(new_username).returns(true) + xhr :put, :username, username: user.username, new_username: new_username + response.should be_success + end + + end + end + + context '.check_username' do + before do + Mothership.stubs(:nickname_available?).returns([true, nil]) + end + + it 'raises an error without a username parameter' do + lambda { xhr :get, :check_username }.should raise_error(Discourse::InvalidParameters) + end + + shared_examples_for 'when username is unavailable locally' do + it 'should return success' do + response.should be_success + end + + it 'should return available as false in the JSON' do + ::JSON.parse(response.body)['available'].should be_false + end + + it 'should return a suggested username' do + ::JSON.parse(response.body)['suggestion'].should be_present + end + end + + shared_examples_for 'when username is available everywhere' do + it 'should return success' do + response.should be_success + end + + it 'should return available in the JSON' do + ::JSON.parse(response.body)['available'].should be_true + end + end + + context 'when call_mothership is disabled' do + before do + SiteSetting.stubs(:call_mothership?).returns(false) + Mothership.expects(:nickname_available?).never + Mothership.expects(:nickname_match?).never + end + + context 'available everywhere' do + before do + xhr :get, :check_username, username: 'BruceWayne' + end + it_should_behave_like 'when username is available everywhere' + end + + context 'available locally but not globally' do + before do + xhr :get, :check_username, username: 'BruceWayne' + end + it_should_behave_like 'when username is available everywhere' + end + + context 'unavailable locally but available globally' do + let!(:user) { Fabricate(:user) } + before do + xhr :get, :check_username, username: user.username + end + it_should_behave_like 'when username is unavailable locally' + end + + context 'unavailable everywhere' do + let!(:user) { Fabricate(:user) } + before do + xhr :get, :check_username, username: user.username + end + it_should_behave_like 'when username is unavailable locally' + end + end + + context 'when call_mothership is enabled' do + before do + SiteSetting.stubs(:call_mothership?).returns(true) + end + + context 'available locally and globally' do + before do + Mothership.stubs(:nickname_available?).returns([true, nil]) + Mothership.stubs(:nickname_match?).returns([false, true, nil]) # match = false, available = true, suggestion = nil + end + + shared_examples_for 'check_username when nickname is available everywhere' do + it 'should return success' do + response.should be_success + end + + it 'should return available in the JSON' do + ::JSON.parse(response.body)['available'].should be_true + end + + it 'should return global_match false in the JSON' do + ::JSON.parse(response.body)['global_match'].should be_false + end + end + + context 'and email is not given' do + before do + xhr :get, :check_username, username: 'BruceWayne' + end + it_should_behave_like 'check_username when nickname is available everywhere' + end + + context 'and email is given' do + before do + xhr :get, :check_username, username: 'BruceWayne', email: 'brucie@gmail.com' + end + it_should_behave_like 'check_username when nickname is available everywhere' + end + end + + shared_examples_for 'when email is needed to check nickname match' do + it 'should return success' do + response.should be_success + end + + it 'should return available as false in the JSON' do + ::JSON.parse(response.body)['available'].should be_false + end + + it 'should not return a suggested username' do + ::JSON.parse(response.body)['suggestion'].should_not be_present + end + end + + context 'available locally but not globally' do + before do + Mothership.stubs(:nickname_available?).returns([false, 'suggestion']) + end + + context 'email param is not given' do + before do + xhr :get, :check_username, username: 'BruceWayne' + end + it_should_behave_like 'when email is needed to check nickname match' + end + + context 'email param is an empty string' do + before do + xhr :get, :check_username, username: 'BruceWayne', email: '' + end + it_should_behave_like 'when email is needed to check nickname match' + end + + context 'email matches global nickname' do + before do + Mothership.stubs(:nickname_match?).returns([true, false, nil]) + xhr :get, :check_username, username: 'BruceWayne', email: 'brucie@example.com' + end + it_should_behave_like 'when username is available everywhere' + + it 'should indicate a global match' do + ::JSON.parse(response.body)['global_match'].should be_true + end + end + + context 'email does not match global nickname' do + before do + Mothership.stubs(:nickname_match?).returns([false, false, 'suggestion']) + xhr :get, :check_username, username: 'BruceWayne', email: 'brucie@example.com' + end + it_should_behave_like 'when username is unavailable locally' + + it 'should not indicate a global match' do + ::JSON.parse(response.body)['global_match'].should be_false + end + end + end + + context 'unavailable locally and globally' do + let!(:user) { Fabricate(:user) } + + before do + Mothership.stubs(:nickname_available?).returns([false, 'suggestion']) + xhr :get, :check_username, username: user.username + end + + it_should_behave_like 'when username is unavailable locally' + end + + context 'unavailable locally and available globally' do + let!(:user) { Fabricate(:user) } + + before do + Mothership.stubs(:nickname_available?).returns([true, nil]) + xhr :get, :check_username, username: user.username + end + + it_should_behave_like 'when username is unavailable locally' + end + end + + context 'when discourse_org_access_key is wrong' do + before do + SiteSetting.stubs(:call_mothership?).returns(true) + Mothership.stubs(:nickname_available?).raises(RestClient::Forbidden) + Mothership.stubs(:nickname_match?).raises(RestClient::Forbidden) + end + + it 'should return an error message' do + xhr :get, :check_username, username: 'horsie' + json = JSON.parse(response.body) + json['errors'].should_not be_nil + json['errors'][0].should_not be_nil + end + end + end + + describe '.invited' do + + let(:user) { Fabricate(:user) } + + it 'returns success' do + xhr :get, :invited, username: user.username + response.should be_success + end + + end + + describe '.update' do + + context 'not logged in' do + it 'raises an error when not logged in' do + lambda { xhr :put, :update, username: 'somename' }.should raise_error(Discourse::NotLoggedIn) + end + end + + context 'logged in' do + let!(:user) { log_in } + + context 'without a token' do + it 'should ensure you can update the user' do + Guardian.any_instance.expects(:can_edit?).with(user).returns(false) + put :update, username: user.username + response.should be_forbidden + end + + context 'as a user who can edit the user' do + + before do + put :update, username: user.username, bio_raw: 'brand new bio' + user.reload + end + + it 'updates the user' do + user.bio_raw.should == 'brand new bio' + end + + it 'returns json success' do + response.should be_success + end + end + end + end + + end + +end diff --git a/spec/fabricators/category_fabricator.rb b/spec/fabricators/category_fabricator.rb new file mode 100644 index 00000000000..f99f8c374fc --- /dev/null +++ b/spec/fabricators/category_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:category) do + name 'Amazing Category' + user +end diff --git a/spec/fabricators/incoming_link_fabricator.rb b/spec/fabricators/incoming_link_fabricator.rb new file mode 100644 index 00000000000..baec9e3d448 --- /dev/null +++ b/spec/fabricators/incoming_link_fabricator.rb @@ -0,0 +1,9 @@ +Fabricator(:incoming_link) do + url 'http://localhost:3000/t/pinball/76/6' + referer 'https://twitter.com/evil_trout' +end + +Fabricator(:incoming_link_not_topic, from: :incoming_link) do + url 'http://localhost:3000/made-up-url' + referer 'https://twitter.com/evil_trout' +end diff --git a/spec/fabricators/invite_fabricator.rb b/spec/fabricators/invite_fabricator.rb new file mode 100644 index 00000000000..6f18a20fc2a --- /dev/null +++ b/spec/fabricators/invite_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:invite) do + invited_by(fabricator: :user) + email 'iceking@ADVENTURETIME.ooo' +end \ No newline at end of file diff --git a/spec/fabricators/notification_fabricator.rb b/spec/fabricators/notification_fabricator.rb new file mode 100644 index 00000000000..dcb6439fb9b --- /dev/null +++ b/spec/fabricators/notification_fabricator.rb @@ -0,0 +1,18 @@ +Fabricator(:notification) do + notification_type Notification.Types[:mentioned] + data '{"poison":"ivy","killer":"croc"}' + user + topic {|attrs| Fabricate(:topic, user: attrs[:user] ) } +end + +Fabricator(:quote_notification, from: :notification) do + notification_type Notification.Types[:quoted] + user + topic {|attrs| Fabricate(:topic, user: attrs[:user] ) } +end + +Fabricator(:private_message_notification, from: :notification) do + notification_type Notification.Types[:private_message] + user + topic {|attrs| Fabricate(:topic, user: attrs[:user] ) } +end diff --git a/spec/fabricators/post_fabricator.rb b/spec/fabricators/post_fabricator.rb new file mode 100644 index 00000000000..55c562c285a --- /dev/null +++ b/spec/fabricators/post_fabricator.rb @@ -0,0 +1,74 @@ +Fabricator(:post) do + user + topic {|attrs| Fabricate(:topic, user: attrs[:user] ) } + raw "Hello world" +end + +Fabricator(:post_with_youtube, from: :post) do + cooked 'http://www.youtube.com/watch?v=9bZkp7q19f0' +end + +Fabricator(:old_post, from: :post) do + topic {|attrs| Fabricate(:topic, user: attrs[:user], created_at: (DateTime.now - 100) ) } + created_at (DateTime.now - 100) +end + +Fabricator(:moderator_post, from: :post) do + user + topic {|attrs| Fabricate(:topic, user: attrs[:user] ) } + post_type Post::MODERATOR_ACTION + raw "Hello world" +end + + +Fabricator(:post_with_images, from: :post) do + raw " + +![Alt text](/second_image.jpg) + " +end + +Fabricator(:post_with_image_url, from: :post) do + cooked " + + " +end + + +Fabricator(:basic_reply, from: :post) do + user(:coding_horror) + reply_to_post_number 1 + topic + raw 'this reply has no quotes' +end + +Fabricator(:reply, from: :post) do + user(:coding_horror) + topic + raw ' + [quote="Evil Trout, post:1"]hello[/quote] + Hmmm! + ' +end + +Fabricator(:multi_quote_reply, from: :post) do + user(:coding_horror) + topic + raw ' + [quote="Evil Trout, post:1"]post1 quote[/quote] + Aha! + [quote="Evil Trout, post:2"]post2 quote[/quote] + Neat-o + ' +end + +Fabricator(:post_with_external_links, from: :post) do + user + topic + raw " +Here's a link to twitter: http://twitter.com +And a link to google: http://google.com +And a markdown link: [forumwarz](http://forumwarz.com) +And a markdown link with a period after it [codinghorror](http://www.codinghorror.com/blog). + " +end diff --git a/spec/fabricators/topic_fabricator.rb b/spec/fabricators/topic_fabricator.rb new file mode 100644 index 00000000000..8647b0e17fd --- /dev/null +++ b/spec/fabricators/topic_fabricator.rb @@ -0,0 +1,17 @@ +Fabricator(:topic) do + user + title { sequence(:title) { |i| "Test topic #{i}" } } +end + +Fabricator(:topic_allowed_user) do +end + +Fabricator(:private_message_topic, from: :topic) do + user + title { sequence(:title) { |i| "Private Message #{i}" } } + archetype "private_message" + topic_allowed_users{|t| [ + Fabricate.build(:topic_allowed_user, user_id: t[:user].id), + Fabricate.build(:topic_allowed_user, user_id: Fabricate(:coding_horror).id) + ]} +end diff --git a/spec/fabricators/user_action_fabricator.rb b/spec/fabricators/user_action_fabricator.rb new file mode 100644 index 00000000000..8e8595dd008 --- /dev/null +++ b/spec/fabricators/user_action_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:user_action) do + + user + action_type UserAction::STAR + +end diff --git a/spec/fabricators/user_fabricator.rb b/spec/fabricators/user_fabricator.rb new file mode 100644 index 00000000000..879bd63662a --- /dev/null +++ b/spec/fabricators/user_fabricator.rb @@ -0,0 +1,51 @@ +Fabricator(:user) do + name 'Bruce Wayne' + username { sequence(:username) { |i| "bruce#{i}" } } + email { sequence(:email) { |i| "bruce#{i}@wayne.com" } } + password 'myawesomepassword' + trust_level TrustLevel.Levels[:basic] + bio_raw "I'm batman!" +end + +Fabricator(:coding_horror, from: :user) do + name 'Coding Horror' + username 'CodingHorror' + email 'jeff@somewhere.com' + password 'mymoreawesomepassword' +end + +Fabricator(:evil_trout, from: :user) do + name 'Evil Trout' + username 'eviltrout' + email 'eviltrout@somewhere.com' + password 'imafish' +end + +Fabricator(:walter_white, from: :user) do + name 'Walter White' + username 'heisenberg' + email 'wwhite@bluemeth.com' + password 'letscook' +end + +Fabricator(:moderator, from: :user) do + name 'A. Moderator' + username 'moderator' + email 'moderator@discourse.org' + trust_level TrustLevel.Levels[:moderator] +end + +Fabricator(:admin, from: :user) do + name 'Anne Admin' + username 'anne' + email 'anne@discourse.org' + admin true +end + +Fabricator(:another_admin, from: :user) do + name 'Anne Admin the 2nd' + username 'anne2' + email 'anne2@discourse.org' + admin true +end + diff --git a/spec/integrity/i18n_spec.rb b/spec/integrity/i18n_spec.rb new file mode 100644 index 00000000000..cea7f54f287 --- /dev/null +++ b/spec/integrity/i18n_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe "i18n integrity checks" do + + it 'should have an i18n key for all trust levels' do + TrustLevel.all.each do |ts| + ts.name.should_not =~ /translation missing/ + end + end + + it "needs an i18n key (description) for each Site Setting" do + SiteSetting.all_settings.each do |s| + next if s[:setting] =~ /^test/ + s[:description].should_not =~ /translation missing/ + end + end + + it "needs an i18n key (notification_types) for each Notification type" do + Notification.Types.keys.each do |type| + I18n.t("notification_types.#{type}").should_not =~ /translation missing/ + end + end + + +end diff --git a/spec/javascripts/bbcode_spec.js.coffee b/spec/javascripts/bbcode_spec.js.coffee new file mode 100644 index 00000000000..7158ea4735e --- /dev/null +++ b/spec/javascripts/bbcode_spec.js.coffee @@ -0,0 +1,138 @@ +describe "Discourse.BBCode", -> + + format = Discourse.BBCode.format + + describe 'default replacer', -> + + describe "simple tags", -> + it "bolds text", -> + expect(format("[b]strong[/b]")).toBe("strong") + + it "italics text", -> + expect(format("[i]emphasis[/i]")).toBe("emphasis") + + it "underlines text", -> + expect(format("[u]underlined[/u]")).toBe("underlined") + + it "strikes-through text", -> + expect(format("[s]strikethrough[/s]")).toBe("strikethrough") + + it "makes code into pre", -> + expect(format("[code]\nx++\n[/code]")).toBe("
            \nx++\n
            ") + + it "supports spoiler tags", -> + expect(format("[spoiler]it's a sled[/spoiler]")).toBe("it's a sled") + + it "links images", -> + expect(format("[img]http://eviltrout.com/eviltrout.png[/img]")).toBe("") + + it "supports [url] without a title", -> + expect(format("[url]http://bettercallsaul.com[/url]")).toBe("http://bettercallsaul.com") + + it "supports [email] without a title", -> + expect(format("[email]eviltrout@mailinator.com[/email]")).toBe("eviltrout@mailinator.com") + + describe "lists", -> + it "creates an ul", -> + expect(format("[ul][li]option one[/li][/ul]")).toBe("
            • option one
            ") + + it "creates an ol", -> + expect(format("[ol][li]option one[/li][/ol]")).toBe("
            1. option one
            ") + + + describe "color", -> + + it "supports [color=] with a short hex value", -> + expect(format("[color=#00f]blue[/color]")).toBe("blue") + + it "supports [color=] with a long hex value", -> + expect(format("[color=#ffff00]yellow[/color]")).toBe("yellow") + + it "supports [color=] with an html color", -> + expect(format("[color=red]red[/color]")).toBe("red") + + it "it performs a noop on invalid input", -> + expect(format("[color=javascript:alert('wat')]noop[/color]")).toBe("noop") + + describe "tags with arguments", -> + + it "supports [size=]", -> + expect(format("[size=35]BIG[/size]")).toBe("BIG") + + it "supports [url] with a title", -> + expect(format("[url=http://bettercallsaul.com]better call![/url]")).toBe("better call!") + + it "supports [email] with a title", -> + expect(format("[email=eviltrout@mailinator.com]evil trout[/email]")).toBe("evil trout") + + describe "more complicated", -> + + it "can nest tags", -> + expect(format("[u][i]abc[/i][/u]")).toBe("abc") + + it "can bold two things on the same line", -> + expect(format("[b]first[/b] [b]second[/b]")).toBe("first second") + + describe 'email environment', -> + + describe "simple tags", -> + it "bolds text", -> + expect(format("[b]strong[/b]", environment: 'email')).toBe("strong") + + it "italics text", -> + expect(format("[i]emphasis[/i]", environment: 'email')).toBe("emphasis") + + it "underlines text", -> + expect(format("[u]underlined[/u]", environment: 'email')).toBe("underlined") + + it "strikes-through text", -> + expect(format("[s]strikethrough[/s]", environment: 'email')).toBe("strikethrough") + + it "makes code into pre", -> + expect(format("[code]\nx++\n[/code]", environment: 'email')).toBe("
            \nx++\n
            ") + + it "supports spoiler tags", -> + expect(format("[spoiler]it's a sled[/spoiler]", environment: 'email')).toBe("it's a sled") + + it "links images", -> + expect(format("[img]http://eviltrout.com/eviltrout.png[/img]", environment: 'email')).toBe("") + + it "supports [url] without a title", -> + expect(format("[url]http://bettercallsaul.com[/url]", environment: 'email')).toBe("http://bettercallsaul.com") + + it "supports [email] without a title", -> + expect(format("[email]eviltrout@mailinator.com[/email]", environment: 'email')).toBe("eviltrout@mailinator.com") + + describe "lists", -> + it "creates an ul", -> + expect(format("[ul][li]option one[/li][/ul]", environment: 'email')).toBe("
            • option one
            ") + + it "creates an ol", -> + expect(format("[ol][li]option one[/li][/ol]", environment: 'email')).toBe("
            1. option one
            ") + + + describe "color", -> + + it "supports [color=] with a short hex value", -> + expect(format("[color=#00f]blue[/color]", environment: 'email')).toBe("blue") + + it "supports [color=] with a long hex value", -> + expect(format("[color=#ffff00]yellow[/color]", environment: 'email')).toBe("yellow") + + it "supports [color=] with an html color", -> + expect(format("[color=red]red[/color]", environment: 'email')).toBe("red") + + it "it performs a noop on invalid input", -> + expect(format("[color=javascript:alert('wat')]noop[/color]", environment: 'email')).toBe("noop") + + describe "tags with arguments", -> + + it "supports [size=]", -> + expect(format("[size=35]BIG[/size]", environment: 'email')).toBe("BIG") + + it "supports [url] with a title", -> + expect(format("[url=http://bettercallsaul.com]better call![/url]", environment: 'email')).toBe("better call!") + + it "supports [email] with a title", -> + expect(format("[email=eviltrout@mailinator.com]evil trout[/email]", environment: 'email')).toBe("evil trout") + diff --git a/spec/javascripts/key_value_store_spec.js.coffee b/spec/javascripts/key_value_store_spec.js.coffee new file mode 100644 index 00000000000..b412f83f059 --- /dev/null +++ b/spec/javascripts/key_value_store_spec.js.coffee @@ -0,0 +1,17 @@ +describe "Discourse.KeyValueStore", -> + + describe "Setting values", -> + + store = Discourse.KeyValueStore + store.init("test") + + it "able to get the value back from the store", -> + store.set(key: "bob", value: "uncle") + expect(store.get("bob")).toBe("uncle") + + it "able to nuke the store", -> + store.set(key: "bob1", value: "uncle") + store.abandonLocal() + localStorage["a"] = 1 + expect(store.get("bob1")).toBe(undefined) + expect(localStorage["a"]).toBe("1") diff --git a/spec/javascripts/message_bus_spec.js.coffee b/spec/javascripts/message_bus_spec.js.coffee new file mode 100644 index 00000000000..ddabbfe70f4 --- /dev/null +++ b/spec/javascripts/message_bus_spec.js.coffee @@ -0,0 +1,24 @@ +describe "Discourse.MessageBus", -> + + describe "Web Sockets", -> + + bus = Discourse.MessageBus + bus.start() + + # PENDING: Fix to allow these to run in jasmine-guard + + #it "is able to get a response from the echo server", -> + # response = null + # bus.send("/echo", "hello world", (r) -> response = r) + # # give it some time to spin up + # waitsFor((-> response == "hello world"),"gotEcho",500) + + #it "should get responses from broadcast channel", -> + # response = null + # # note /message_bus/broadcast is dev only + # bus.subscribe("/animals", (r) -> response = r) + # $.ajax + # url: '/message-bus/broadcast' + # data: {channel: "/animals", data: "kitten"} + # cache: false + # waitsFor((-> response == "kitten"),"gotBroadcast", 500) diff --git a/spec/javascripts/preload_store_spec.js.coffee b/spec/javascripts/preload_store_spec.js.coffee new file mode 100644 index 00000000000..d7932204108 --- /dev/null +++ b/spec/javascripts/preload_store_spec.js.coffee @@ -0,0 +1,81 @@ +describe "PreloadStore", -> + + beforeEach -> + PreloadStore.store('bane', 'evil') + + describe "contains", -> + + it "returns false for a key that doesn't exist", -> + expect(PreloadStore.contains('joker')).toBe(false) + + it "returns true for a stored key", -> + expect(PreloadStore.contains('bane')).toBe(true) + + describe 'getStatic', -> + + it "returns undefined if the key doesn't exist", -> + expect(PreloadStore.getStatic('joker')).toBe(undefined) + + it "returns the the key if it exists", -> + expect(PreloadStore.getStatic('bane')).toBe('evil') + + it "removes the key after being called", -> + PreloadStore.getStatic('bane') + expect(PreloadStore.getStatic('bane')).toBe(undefined) + + + describe 'get', -> + + + it "returns a promise that resolves to undefined", -> + done = storeResult = null + PreloadStore.get('joker').then (result) -> + done = true + storeResult = result + waitsFor (-> return done), "Promise never resolved", 1000 + runs -> expect(storeResult).toBe(undefined) + + it "returns a promise that resolves to the result of the finder", -> + done = storeResult = null + finder = -> 'evil' + PreloadStore.get('joker', finder).then (result) -> + done = true + storeResult = result + waitsFor (-> return done), "Promise never resolved", 1000 + runs -> expect(storeResult).toBe('evil') + + it "returns a promise that resolves to the result of the finder's promise", -> + done = storeResult = null + finder = -> + promise = new RSVP.Promise + promise.resolve('evil') + promise + + PreloadStore.get('joker', finder).then (result) -> + done = true + storeResult = result + waitsFor (-> return done), "Promise never resolved", 1000 + runs -> expect(storeResult).toBe('evil') + + it "returns a promise that resolves to the result of the finder's rejected promise", -> + done = storeResult = null + finder = -> + promise = new RSVP.Promise + promise.reject('evil') + promise + + PreloadStore.get('joker', finder).then null, (rejectedResult) -> + done = true + storeResult = rejectedResult + + waitsFor (-> return done), "Promise never rejected", 1000 + runs -> expect(storeResult).toBe('evil') + + + it "returns a promise that resolves to 'evil'", -> + done = storeResult = null + PreloadStore.get('bane').then (result) -> + done = true + storeResult = result + waitsFor (-> return done), "Promise never resolved", 1000 + runs -> expect(storeResult).toBe('evil') diff --git a/spec/javascripts/spec.css b/spec/javascripts/spec.css new file mode 100644 index 00000000000..2358dbf4242 --- /dev/null +++ b/spec/javascripts/spec.css @@ -0,0 +1,3 @@ +/* + + */ \ No newline at end of file diff --git a/spec/javascripts/spec.js b/spec/javascripts/spec.js new file mode 100644 index 00000000000..ce66fda801f --- /dev/null +++ b/spec/javascripts/spec.js @@ -0,0 +1,11 @@ +//= require env +//= require jquery + +//= require external/jquery.ui.widget.js +//= require external/handlebars-1.0.rc.2.js + +//= require preload_store +//= require_tree ../../app/assets/javascripts/external +//= require_tree ../../app/assets/javascripts/discourse/components +//= require_tree ../../app/assets/javascripts/discourse/templates +//= require_tree . diff --git a/spec/javascripts/utilities_spec.js.coffee b/spec/javascripts/utilities_spec.js.coffee new file mode 100644 index 00000000000..47b280f5a6d --- /dev/null +++ b/spec/javascripts/utilities_spec.js.coffee @@ -0,0 +1,82 @@ +describe "Discourse.Utilities", -> + + + describe "Cooking", -> + + cook = (contents, opts) -> + opts = opts || {} + opts.mentionLookup = opts.mentionLookup || (() -> false) + Discourse.Utilities.cook(contents, opts) + + it "surrounds text with paragraphs", -> + expect(cook("hello")).toBe("

            hello

            ") + + it "automatically handles trivial newlines", -> + expect(cook("1\n2\n3")).toBe("

            1
            \n2
            \n3

            ") + + it "handles quotes properly", -> + cooked = cook("1[quote=\"bob, post:1\"]my quote[/quote]2", {topicId: 2, lookupAvatar: (name) -> "#{name}"}) + expect(cooked).toBe("

            1

            \n

            2

            ") + + it "includes no avatar if none is found", -> + cooked = cook("1[quote=\"bob, post:1\"]my quote[/quote]2", {topicId: 2, lookupAvatar: (name) -> null}) + expect(cooked).toBe("

            1

            \n

            2

            ") + + describe "Links", -> + + it "allows links to contain query params", -> + expect(cook("Youtube: http://www.youtube.com/watch?v=1MrpeBRkM5A")).toBe('

            Youtube: http://www.youtube.com/watch?v=1MrpeBRkM5A

            ') + + it "escapes double underscores in URLs", -> + expect(cook("Derpy: http://derp.com?__test=1")).toBe('

            Derpy: http://derp.com?__test=1

            ') + + it "autolinks something that begins with www", -> + expect(cook("Atwood: www.codinghorror.com")).toBe('

            Atwood: www.codinghorror.com

            ') + + it "autolinks a URL with http://www", -> + expect(cook("Atwood: http://www.codinghorror.com")).toBe('

            Atwood: http://www.codinghorror.com

            ') + + it "autolinks a URL", -> + expect(cook("EvilTrout: http://eviltrout.com")).toBe('

            EvilTrout: http://eviltrout.com

            ') + + it "supports markdown style links", -> + expect(cook("here is [an example](http://twitter.com)")).toBe('

            here is an example

            ') + + it "autolinks a URL with parentheses (like Wikipedia)", -> + expect(cook("Batman: http://en.wikipedia.org/wiki/The_Dark_Knight_(film)")).toBe('

            Batman: http://en.wikipedia.org/wiki/The_Dark_Knight_(film)

            ') + + describe "Mentioning", -> + + it "translates mentions to links", -> + expect(cook("Hello @sam", {mentionLookup: (->true)})).toBe("

            Hello @sam

            ") + + it "adds a mention class", -> + expect(cook("Hello @EvilTrout")).toBe("

            Hello @EvilTrout

            ") + + it "won't add mention class to an email address", -> + expect(cook("robin@email.host")).toBe("

            robin@email.host

            ") + + it "won't be affected by email addresses that have a number before the @ symbol", -> + expect(cook("hanzo55@yahoo.com")).toBe("

            hanzo55@yahoo.com

            ") + + it "supports a @mention at the beginning of a post", -> + expect(cook("@EvilTrout yo")).toBe("

            @EvilTrout yo

            ") + + # Oneboxing functionality + describe "Oneboxing", -> + + + it "doesn't onebox a link within a list", -> + expect(cook("- http://www.textfiles.com/bbs/MINDVOX/FORUMS/ethics\n\n- http://drupal.org")).not.toMatch(/onebox/) + + it "adds a onebox class to a link on its own line", -> + expect(cook("http://test.com")).toMatch(/onebox/) + + it "supports multiple links", -> + expect(cook("http://test.com\nhttp://test2.com")).toMatch(/onebox[\s\S]+onebox/m) + + it "doesn't onebox links that have trailing text", -> + expect(cook("http://test.com bob")).not.toMatch(/onebox/) + + it "works with links that have underscores in them", -> + expect(cook("http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street")).toBe("

            http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street

            ") diff --git a/spec/mailers/invite_mailer_spec.rb b/spec/mailers/invite_mailer_spec.rb new file mode 100644 index 00000000000..08703f76909 --- /dev/null +++ b/spec/mailers/invite_mailer_spec.rb @@ -0,0 +1,16 @@ +require "spec_helper" + +describe InviteMailer do + + describe "send_invite" do + let(:invite) { Fabricate(:invite) } + subject { InviteMailer.send_invite(invite) } + + its(:to) { should == [invite.email] } + its(:subject) { should be_present } + its(:body) { should be_present } + its(:from) { should == [SiteSetting.notification_email] } + end + + +end diff --git a/spec/mailers/test_mailer_spec.rb b/spec/mailers/test_mailer_spec.rb new file mode 100644 index 00000000000..a9a78373641 --- /dev/null +++ b/spec/mailers/test_mailer_spec.rb @@ -0,0 +1,15 @@ +require "spec_helper" + +describe TestMailer do + + describe "send_test" do + subject { TestMailer.send_test('marcheline@adventuretime.ooo') } + + its(:to) { should == ['marcheline@adventuretime.ooo'] } + its(:subject) { should be_present } + its(:body) { should be_present } + its(:from) { should == [SiteSetting.notification_email] } + end + + +end diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb new file mode 100644 index 00000000000..b75f899bd69 --- /dev/null +++ b/spec/mailers/user_notifications_spec.rb @@ -0,0 +1,60 @@ +require "spec_helper" + +describe UserNotifications do + + let(:user) { Fabricate(:user) } + + describe ".signup" do + subject { UserNotifications.signup(user) } + + its(:to) { should == [user.email] } + its(:subject) { should be_present } + its(:from) { should == [SiteSetting.notification_email] } + its(:body) { should be_present } + end + + describe ".forgot_password" do + subject { UserNotifications.forgot_password(user) } + + its(:to) { should == [user.email] } + its(:subject) { should be_present } + its(:from) { should == [SiteSetting.notification_email] } + its(:body) { should be_present } + end + + describe '.daily_digest' do + subject { UserNotifications.digest(user) } + + context "without new topics" do + its(:to) { should be_blank } + end + + context "with new topics" do + before do + Topic.expects(:new_topics).returns([Fabricate(:topic, user: Fabricate(:coding_horror))]) + end + + its(:to) { should == [user.email] } + its(:subject) { should be_present } + its(:from) { should == [SiteSetting.notification_email] } + its(:body) { should be_present } + end + end + + describe '.user_mentioned' do + + let(:post) { Fabricate(:post, user: user) } + let(:notification) do + Fabricate(:notification, user: user, topic: post.topic, post_number: post.post_number ) + end + + subject { UserNotifications.user_mentioned(user, notification: notification, post: notification.post) } + + its(:to) { should == [user.email] } + its(:subject) { should be_present } + its(:from) { should == [SiteSetting.notification_email] } + its(:body) { should be_present } + end + + +end diff --git a/spec/models/category_featured_topic_spec.rb b/spec/models/category_featured_topic_spec.rb new file mode 100644 index 00000000000..3d8dc25a205 --- /dev/null +++ b/spec/models/category_featured_topic_spec.rb @@ -0,0 +1,9 @@ +require 'spec_helper' + +describe CategoryFeaturedTopic do + + it { should belong_to :category } + it { should belong_to :topic } + +end + diff --git a/spec/models/category_featured_user_spec.rb b/spec/models/category_featured_user_spec.rb new file mode 100644 index 00000000000..35982499fba --- /dev/null +++ b/spec/models/category_featured_user_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe CategoryFeaturedUser do + + it { should belong_to :category } + it { should belong_to :user } + + + context 'featuring users' do + + before do + @category = Fabricate(:category) + CategoryFeaturedUser.feature_users_in(@category) + end + + it 'has a featured user' do + CategoryFeaturedUser.count.should_not == 0 + end + + it 'returns the user via the category association' do + @category.featured_users.should be_present + end + + end + +end diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb new file mode 100644 index 00000000000..de8606f1b8c --- /dev/null +++ b/spec/models/category_spec.rb @@ -0,0 +1,175 @@ +require 'spec_helper' + +describe Category do + + it { should validate_presence_of :name } + + it 'validates uniqueness of name' do + Fabricate(:category) + should validate_uniqueness_of(:name) + end + + it { should belong_to :topic } + it { should belong_to :user } + + it { should have_many :topics } + it { should have_many :category_featured_topics } + it { should have_many :featured_topics } + + describe "uncategorized name" do + + let(:category) { Fabricate.build(:category, name: SiteSetting.uncategorized_name) } + + it "is invalid to create a category with the reserved name" do + category.should_not be_valid + end + + end + + describe "short name" do + let!(:category) { Fabricate(:category, name: 'xx') } + + it "creates the category" do + category.should be_present + end + + it 'has one topic' do + Topic.where(category_id: category.id).count.should == 1 + end + + end + + describe 'caching' do + + it "invalidates the site cache on creation" do + Site.expects(:invalidate_cache).once + Fabricate(:category) + end + + it "invalidates the site cache on update" do + cat = Fabricate(:category) + Site.expects(:invalidate_cache).once + cat.update_attributes(name: 'new name') + end + + it "invalidates the site cache on destroy" do + cat = Fabricate(:category) + Site.expects(:invalidate_cache).once + cat.destroy + end + end + + describe 'after create' do + + before do + @category = Fabricate(:category) + @topic = @category.topic + end + + it 'creates a slug' do + @category.slug.should == 'amazing-category' + end + + it 'has one topic' do + Topic.where(category_id: @category).count.should == 1 + end + + it 'creates a topic post' do + @topic.should be_present + end + + it 'points back to itself' do + @topic.category.should == @category + end + + it 'is an invisible topic' do + @topic.should_not be_visible + end + + it 'is an undeletable topic' do + Guardian.new(@category.user).can_delete?(@topic).should be_false + end + + it 'should have one post' do + @topic.posts.count.should == 1 + end + + it 'should have an excerpt' do + @category.excerpt.should be_present + end + + it 'should have a topic url' do + @category.topic_url.should be_present + end + + describe "trying to change the category topic's category" do + + before do + @new_cat = Fabricate(:category, name: '2nd Category', user: @category.user) + @topic.change_category(@new_cat.name) + @topic.reload + @category.reload + end + + it 'still has 0 forum topics' do + @category.topic_count.should == 0 + end + + it "didn't change the category" do + @topic.category.should == @category + end + + it "didn't change the category's forum topic" do + @category.topic.should == @topic + end + end + end + + describe 'destroy' do + + before do + @category = Fabricate(:category) + @category_id = @category.id + @topic_id = @category.topic_id + @category.destroy + end + + it 'deletes the category' do + Category.exists?(id: @category_id).should be_false + end + + it 'deletes the forum topic' do + Topic.exists?(id: @topic_id).should be_false + end + + end + + describe 'update_stats' do + + # We're going to test with one topic. That's enough for stats! + before do + @category = Fabricate(:category) + + # Create a non-invisible category to make sure count is 1 + @topic = Fabricate(:topic, user: @category.user, category: @category) + + Category.update_stats + @category.reload + end + + it 'updates topics_week' do + @category.topics_week.should == 1 + end + + it 'updates topics_month' do + @category.topics_month.should == 1 + end + + it 'updates topics_year' do + @category.topics_year.should == 1 + end + + end + +end + diff --git a/spec/models/draft_sequence_spec.rb b/spec/models/draft_sequence_spec.rb new file mode 100644 index 00000000000..0b747ecad50 --- /dev/null +++ b/spec/models/draft_sequence_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe DraftSequence do + it 'should produce next sequence for a key' do + u = Fabricate(:user) + DraftSequence.next!(u, 'test').should == 1 + DraftSequence.next!(u, 'test').should == 2 + end + + it 'should return 0 by default' do + u = Fabricate(:user) + DraftSequence.current(u, 'test').should == 0 + end +end diff --git a/spec/models/draft_spec.rb b/spec/models/draft_spec.rb new file mode 100644 index 00000000000..93114bcf129 --- /dev/null +++ b/spec/models/draft_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' + +describe Draft do + before do + @user = Fabricate(:user) + end + it "can get a draft by user" do + Draft.set(@user, "test", 0, "data") + Draft.get(@user, "test", 0).should == "data" + end + + it "uses the user id and key correctly" do + Draft.set(@user, "test", 0,"data") + Draft.get(Fabricate(:coding_horror), "test", 0).should be_nil + end + + it "should overwrite draft data correctly" do + Draft.set(@user, "test", 0, "data") + Draft.set(@user, "test", 0, "new data") + Draft.get(@user, "test", 0).should == "new data" + end + + it "should clear drafts on request" do + Draft.set(@user, "test", 0, "data") + Draft.clear(@user, "test", 0) + Draft.get(@user, "test", 0).should be_nil + end + + it "should disregard old draft if sequence decreases" do + Draft.set(@user, "test", 0, "data") + Draft.set(@user, "test", 1, "hello") + Draft.set(@user, "test", 0, "foo") + Draft.get(@user, "test", 0).should be_nil + Draft.get(@user, "test", 1).should == "hello" + end + + + context 'key expiry' do + it 'nukes new topic draft after a topic is created' do + u = Fabricate(:user) + Draft.set(u, Draft::NEW_TOPIC, 0, 'my draft') + t = Fabricate(:topic, user: u) + s = DraftSequence.current(u, Draft::NEW_TOPIC) + Draft.get(u, Draft::NEW_TOPIC, s).should be_nil + end + + it 'nukes new pm draft after a pm is created' do + u = Fabricate(:user) + Draft.set(u, Draft::NEW_PRIVATE_MESSAGE, 0, 'my draft') + t = Fabricate(:topic, user: u, archetype: Archetype.private_message) + s = DraftSequence.current(t.user, Draft::NEW_PRIVATE_MESSAGE) + Draft.get(u, Draft::NEW_PRIVATE_MESSAGE, s).should be_nil + end + + it 'does not nuke new topic draft after a pm is created' do + u = Fabricate(:user) + Draft.set(u, Draft::NEW_TOPIC, 0, 'my draft') + t = Fabricate(:topic, user: u, archetype: Archetype.private_message) + s = DraftSequence.current(t.user, Draft::NEW_TOPIC) + Draft.get(u, Draft::NEW_TOPIC, s).should == 'my draft' + end + + it 'nukes the post draft when a post is created' do + p = Fabricate(:post) + Draft.set(p.user, p.topic.draft_key, 0,'hello') + Fabricate(:post, topic: p.topic, user: p.user) + Draft.get(p.user, p.topic.draft_key, DraftSequence.current(p.user, p.topic.draft_key)).should be_nil + end + + it 'nukes the post draft when a post is revised' do + p = Fabricate(:post) + Draft.set(p.user, p.topic.draft_key, 0,'hello') + p.revise(p.user, 'another test') + s = DraftSequence.current(p.user, p.topic.draft_key) + Draft.get(p.user, p.topic.draft_key, s).should be_nil + end + + it 'increases the sequence number when a post is revised' do + end + end +end diff --git a/spec/models/email_log_spec.rb b/spec/models/email_log_spec.rb new file mode 100644 index 00000000000..9072af6adfa --- /dev/null +++ b/spec/models/email_log_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe EmailLog do + + it { should belong_to :user } + + it { should validate_presence_of :to_address } + it { should validate_presence_of :email_type } + + + context 'after_create with user' do + + let(:user) { Fabricate(:user) } + + it 'updates the last_emailed_at value for the user' do + lambda { + user.email_logs.create(email_type: 'blah', to_address: user.email) + user.reload + }.should change(user, :last_emailed_at) + end + + end + +end diff --git a/spec/models/email_token_spec.rb b/spec/models/email_token_spec.rb new file mode 100644 index 00000000000..30bda644a1f --- /dev/null +++ b/spec/models/email_token_spec.rb @@ -0,0 +1,128 @@ +require 'spec_helper' + +describe EmailToken do + + it { should validate_presence_of :user_id } + it { should validate_presence_of :email } + it { should belong_to :user } + + + context '#create' do + let(:user) { Fabricate(:user) } + let!(:original_token) { user.email_tokens.first } + let!(:email_token) { user.email_tokens.create(email: 'bubblegum@adevnturetime.ooo') } + + it 'should create the email token' do + email_token.should be_present + end + + it 'is valid' do + email_token.should be_valid + end + + it 'has a token' do + email_token.token.should be_present + end + + it 'is not confirmed' do + email_token.should_not be_confirmed + end + + it 'is not expired' do + email_token.should_not be_expired + end + + it 'marks the older token as expired' do + original_token.reload + original_token.should be_expired + end + end + + + + context '#confirm' do + + let(:user) { Fabricate(:user) } + let(:email_token) { user.email_tokens.first } + + it 'returns nil with a nil token' do + EmailToken.confirm(nil).should be_blank + end + + it 'returns nil with a made up token' do + EmailToken.confirm(EmailToken.generate_token).should be_blank + end + + it 'returns nil unless the token is the right length' do + EmailToken.confirm('a').should be_blank + end + + it 'returns nil when a token is expired' do + email_token.update_column(:expired, true) + EmailToken.confirm(email_token.token).should be_blank + end + + it 'returns nil when a token is older than a specific time' do + EmailToken.expects(:valid_after).returns(1.week.ago) + email_token.update_column(:created_at, 2.weeks.ago) + EmailToken.confirm(email_token.token).should be_blank + end + + context 'taken email address' do + + before do + @other_user = Fabricate(:coding_horror) + email_token.update_attribute :email, @other_user.email + end + + it 'returns nil when the email has been taken since the token has been generated' do + EmailToken.confirm(email_token.token).should be_blank + end + + end + + context 'welcome message' do + it 'sends a welcome message when the user is activated' do + user = EmailToken.confirm(email_token.token) + user.send_welcome_message.should be_true + end + + context "when using the code a second time" do + before do + EmailToken.confirm(email_token.token) + end + + it "doesn't send the welcome message" do + user = EmailToken.confirm(email_token.token) + user.send_welcome_message.should be_false + end + end + + end + + context 'success' do + + let!(:confirmed_user) { EmailToken.confirm(email_token.token) } + + it "returns the correct user" do + confirmed_user.should == user + end + + it 'marks the user as active' do + confirmed_user.reload + confirmed_user.should be_active + end + + it 'marks the token as confirmed' do + email_token.reload + email_token.should be_confirmed + end + + end + + + end + + + +end diff --git a/spec/models/error_log_spec.rb b/spec/models/error_log_spec.rb new file mode 100644 index 00000000000..1a1cc971a38 --- /dev/null +++ b/spec/models/error_log_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' +describe ErrorLog do + + def boom + raise "boom" + end + + def exception + begin + boom + rescue => e + return e + end + end + + def controller + DraftController.new + end + + def request + ActionController::TestRequest.new(:host => 'test') + end + + describe "add_row!" do + it "creates a non empty file on first call" do + ErrorLog.clear_all! + ErrorLog.add_row!(hello: "world") + File.exists?(ErrorLog.filename).should be_true + end + end + + describe "logging data" do + it "is able to read the data it writes" do + ErrorLog.clear_all! + ErrorLog.report!(exception, controller, request, nil) + ErrorLog.report!(exception, controller, request, nil) + i = 0 + ErrorLog.each do |h| + i += 1 + end + i.should == 2 + end + + it "is able to skip rows" do + ErrorLog.clear_all! + ErrorLog.report!(exception, controller, request, nil) + ErrorLog.report!(exception, controller, request, nil) + ErrorLog.report!(exception, controller, request, nil) + ErrorLog.report!(exception, controller, request, nil) + i = 0 + ErrorLog.skip(3) do |h| + i += 1 + end + i.should == 1 + end + end + +end diff --git a/spec/models/incoming_link_spec.rb b/spec/models/incoming_link_spec.rb new file mode 100644 index 00000000000..eb07888a8fe --- /dev/null +++ b/spec/models/incoming_link_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +describe IncomingLink do + + it { should belong_to :topic } + it { should validate_presence_of :url } + + it { should ensure_length_of(:referer).is_at_least(3).is_at_most(1000) } + it { should ensure_length_of(:domain).is_at_least(1).is_at_most(100) } + + describe 'local topic link' do + + it 'should validate properly' do + Fabricate.build(:incoming_link).should be_valid + end + + describe 'saving local link' do + + before do + @post = Fabricate(:post) + @topic = @post.topic + @incoming_link = IncomingLink.create(url: "/t/slug/#{@topic.id}/#{@post.post_number}", + referer: "http://twitter.com") + end + + describe 'incoming link counts' do + it "increases the post's incoming link count" do + lambda { @incoming_link.save; @post.reload }.should change(@post, :incoming_link_count).by(1) + end + + it "increases the topic's incoming link count" do + lambda { @incoming_link.save; @topic.reload }.should change(@topic, :incoming_link_count).by(1) + end + + end + + describe 'after save' do + before do + @incoming_link.save + end + + it 'has a domain' do + @incoming_link.domain.should == "twitter.com" + end + + it 'has the topic_id' do + @incoming_link.topic_id.should == @topic.id + end + + it 'has the post_number' do + @incoming_link.post_number.should == @post.post_number + end + end + + end + end + + describe 'non-topic url' do + + before do + @link = Fabricate(:incoming_link_not_topic) + end + + it 'has no topic_id' do + @link.topic_id.should be_blank + end + + it 'has no post_number' do + @link.topic_id.should be_blank + end + + end + + +end diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb new file mode 100644 index 00000000000..4dafae8bd27 --- /dev/null +++ b/spec/models/invite_spec.rb @@ -0,0 +1,286 @@ +require 'spec_helper' + +describe Invite do + + it { should belong_to :user } + it { should have_many :topic_invites } + it { should belong_to :invited_by } + it { should have_many :topics } + it { should validate_presence_of :email } + it { should validate_presence_of :invited_by_id } + + context 'user validators' do + let(:coding_horror) { Fabricate(:coding_horror) } + let(:user) { Fabricate(:user) } + let(:invite) { Invite.create(email: user.email, invited_by: coding_horror) } + + it "should not allow an invite with the same email as an existing user" do + invite.should_not be_valid + end + + it "should not allow a user to invite themselves" do + invite.email_already_exists.should be_true + end + + end + + context '#create' do + + context 'saved' do + subject { Fabricate(:invite) } + its(:invite_key) { should be_present } + its(:email_already_exists) { should be_false } + + it 'should store a lower case version of the email' do + subject.email.should == "iceking@adventuretime.ooo" + end + end + + + context 'to a topic' do + let!(:topic) { Fabricate(:topic) } + let(:inviter) { topic.user } + + context 'email' do + it 'enqueues a job to email the invite' do + Jobs.expects(:enqueue).with(:invite_email, has_key(:invite_id)) + topic.invite_by_email(inviter, 'iceking@adventuretime.ooo') + end + end + + context 'destroyed' do + it "can invite the same user after their invite was destroyed" do + invite = topic.invite_by_email(inviter, 'iceking@adventuretime.ooo') + invite.destroy + invite = topic.invite_by_email(inviter, 'iceking@adventuretime.ooo') + invite.should be_present + end + end + + context 'after created' do + before do + @invite = topic.invite_by_email(inviter, 'iceking@adventuretime.ooo') + end + + it 'belongs to the topic' do + topic.invites.should == [@invite] + end + + it 'has a topic' do + @invite.topics.should == [topic] + end + + it 'is pending in the invite list for the creator' do + InvitedList.new(inviter).pending.should == [@invite] + end + + + context 'when added by another user' do + let(:coding_horror) { Fabricate(:coding_horror) } + let(:new_invite) { topic.invite_by_email(coding_horror, 'iceking@adventuretime.ooo') } + + it 'returns a different invite' do + new_invite.should_not == @invite + end + + it 'has a different key' do + new_invite.invite_key.should_not == @invite.invite_key + end + + it 'has the topic relationship' do + new_invite.topics.should == [topic] + end + end + + context 'when adding a duplicate' do + it 'returns the original invite' do + topic.invite_by_email(inviter, 'iceking@adventuretime.ooo').should == @invite + end + + it 'matches case insensitively' do + topic.invite_by_email(inviter, 'ICEKING@adventuretime.ooo').should == @invite + end + end + + context 'when adding to another topic' do + let!(:another_topic) { Fabricate(:topic, user: topic.user) } + + before do + @new_invite = another_topic.invite_by_email(inviter, 'iceking@adventuretime.ooo') + end + + it 'should be the same invite' do + @new_invite.should == @invite + end + + it 'belongs to the new topic' do + another_topic.invites.should == [@invite] + end + + it 'has references to both topics' do + @invite.topics.should =~ [topic, another_topic] + end + end + end + end + end + + context 'an existing user' do + let(:topic) { Fabricate(:topic, archetype: Archetype.private_message) } + let(:coding_horror) { Fabricate(:coding_horror) } + let!(:invite) { topic.invite_by_email(topic.user, coding_horror.email) } + + it "doesn't create an invite" do + invite.should be_blank + end + + it "gives the user permission to access the topic" do + topic.allowed_users.include?(coding_horror).should be_true + end + end + + context '.redeem' do + + let(:invite) { Fabricate(:invite) } + + it 'creates a notification for the invitee' do + lambda { invite.redeem }.should change(Notification, :count) + end + + it 'wont redeem an expired invite' do + SiteSetting.expects(:invite_expiry_days).returns(10) + invite.update_column(:created_at, 20.days.ago) + invite.redeem.should be_blank + end + + it 'wont redeem a deleted invite' do + invite.destroy + invite.redeem.should be_blank + end + + context 'invite trust levels' do + + it "returns the trust level in default_invitee_trust_level" do + SiteSetting.stubs(:default_invitee_trust_level).returns(TrustLevel.Levels[:experienced]) + invite.redeem.trust_level.should == TrustLevel.Levels[:experienced] + end + + end + + context 'simple invite' do + + let!(:user) { invite.redeem } + + it 'returns a user record' do + user.is_a?(User).should be_true + end + + it 'wants us to send a welcome message' do + user.send_welcome_message.should be_true + end + + it 'has the default_invitee_trust_level' do + user.trust_level.should == SiteSetting.default_invitee_trust_level + end + + context 'after redeeming' do + before do + invite.reload + end + + it 'no longer in the pending list for that user' do + InvitedList.new(invite.invited_by).pending.should be_blank + end + + it 'is redeeemed in the invite list for the creator' do + InvitedList.new(invite.invited_by).redeemed.should == [invite] + end + + it 'has set the user_id attribute' do + invite.user.should == user + end + + it 'returns true for redeemed' do + invite.should be_redeemed + end + + context 'again' do + it 'will not redeem twice' do + invite.redeem.should == user + end + + it "doesn't want us to send a welcome message" do + invite.redeem.send_welcome_message.should be_false + end + + end + end + + end + + context 'invited to topics' do + let!(:topic) { Fabricate(:private_message_topic) } + let!(:invite) { topic.invite(topic.user, 'jake@adventuretime.ooo')} + + context 'redeem topic invite' do + let!(:user) { invite.redeem } + + it 'adds the user to the topic_users' do + topic.allowed_users.include?(user).should be_true + end + + it 'can see the private topic' do + Guardian.new(user).can_see?(topic).should be_true + end + end + + context 'invited by another user to the same topic' do + let(:coding_horror) { User.where(username: 'CodingHorror').first } + let!(:another_invite) { topic.invite(coding_horror, 'jake@adventuretime.ooo') } + let!(:user) { invite.redeem } + + it 'adds the user to the topic_users' do + topic.allowed_users.include?(user).should be_true + end + end + + context 'invited by another user to a different topic' do + let(:coding_horror) { User.where(username: 'CodingHorror').first } + let(:another_topic) { Fabricate(:topic, archetype: "private_message", user: coding_horror) } + let!(:another_invite) { another_topic.invite(coding_horror, 'jake@adventuretime.ooo') } + let!(:user) { invite.redeem } + + it 'adds the user to the topic_users of the first topic' do + topic.allowed_users.include?(user).should be_true + end + + it 'adds the user to the topic_users of the second topic' do + another_topic.allowed_users.include?(user).should be_true + end + + it 'does not redeem the second invite' do + another_invite.reload + another_invite.should_not be_redeemed + end + + context 'if they redeem the other invite afterwards' do + + before do + @result = another_invite.redeem + end + + it 'returns the same user' do + @result.should == user + end + + it 'marks the second invite as redeemed' do + another_invite.reload + another_invite.should be_redeemed + end + + end + end + end + end + +end diff --git a/spec/models/message_bus_observer_spec.rb b/spec/models/message_bus_observer_spec.rb new file mode 100644 index 00000000000..6af9b095f20 --- /dev/null +++ b/spec/models/message_bus_observer_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe MessageBusObserver do + + context 'after create topic' do + + after do + @topic = Fabricate(:topic) + end + + it 'publishes the topic to the list' do + + end + + end + + +end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb new file mode 100644 index 00000000000..8d362bb20f2 --- /dev/null +++ b/spec/models/notification_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' + +describe Notification do + + it { should validate_presence_of :notification_type } + it { should validate_presence_of :data } + + it { should belong_to :user } + it { should belong_to :topic } + + describe 'unread counts' do + + let(:user) { Fabricate(:user) } + + context 'a regular notification' do + it 'increases unread_notifications' do + lambda { Fabricate(:notification, user: user); user.reload }.should change(user, :unread_notifications) + end + + it "doesn't increase unread_private_messages" do + lambda { Fabricate(:notification, user: user); user.reload }.should_not change(user, :unread_private_messages) + end + end + + context 'a private message' do + it "doesn't increase unread_notifications" do + lambda { Fabricate(:private_message_notification, user: user); user.reload }.should_not change(user, :unread_notifications) + end + + it "increases unread_private_messages" do + lambda { Fabricate(:private_message_notification, user: user); user.reload }.should change(user, :unread_private_messages) + end + end + + end + + describe 'message bus' do + + it 'updates the notification count on create' do + MessageBusObserver.any_instance.expects(:refresh_notification_count).with(instance_of(Notification)) + Fabricate(:notification) + end + + context 'destroy' do + + let!(:notification) { Fabricate(:notification) } + + it 'updates the notification count on destroy' do + MessageBusObserver.any_instance.expects(:refresh_notification_count).with(instance_of(Notification)) + notification.destroy + end + + end + end + + describe '@mention' do + + it "calls email_user_mentioned on creating a notification" do + UserEmailObserver.any_instance.expects(:email_user_mentioned).with(instance_of(Notification)) + Fabricate(:notification) + end + + end + + describe '@mention' do + it "calls email_user_quoted on creating a quote notification" do + UserEmailObserver.any_instance.expects(:email_user_quoted).with(instance_of(Notification)) + Fabricate(:quote_notification) + end + end + + describe 'private message' do + before do + @topic = Fabricate(:private_message_topic) + @post = Fabricate(:post, :topic => @topic, :user => @topic.user) + @target = @post.topic.topic_allowed_users.reject{|a| a.user_id == @post.user_id}[0].user + end + + it 'should create a private message notification' do + @target.notifications.first.notification_type.should == Notification.Types[:private_message] + end + + it 'should not add a pm notification for the creator' do + @post.user.unread_notifications.should == 0 + end + end + + describe '.post' do + + let(:post) { Fabricate(:post) } + let!(:notification) { Fabricate(:notification, user: post.user, topic: post.topic, post_number: post.post_number) } + + it 'returns the post' do + notification.post.should == post + end + + end + + describe 'data' do + let(:notification) { Fabricate.build(:notification) } + + it 'should have a data hash' do + notification.data_hash.should be_present + end + + it 'should have the data within the json' do + notification.data_hash[:poison].should == 'ivy' + end + end + +end diff --git a/spec/models/onebox_render_spec.rb b/spec/models/onebox_render_spec.rb new file mode 100644 index 00000000000..3b97423bcb9 --- /dev/null +++ b/spec/models/onebox_render_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe OneboxRender do + + it { should validate_presence_of :url } + it { should validate_presence_of :cooked } + it { should validate_presence_of :expires_at } + it { should have_many :post_onebox_renders } + it { should have_many :posts } + +end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb new file mode 100644 index 00000000000..95c7062c2ee --- /dev/null +++ b/spec/models/post_action_spec.rb @@ -0,0 +1,159 @@ +require 'spec_helper' + +describe PostAction do + + it { should belong_to :user } + it { should belong_to :post } + it { should belong_to :post_action_type } + it { should rate_limit } + + let(:codinghorror) { Fabricate(:coding_horror) } + let(:post) { Fabricate(:post) } + let(:bookmark) { PostAction.new(user_id: post.user_id, post_action_type_id: PostActionType.Types[:bookmark] , post_id: post.id) } + + describe "flag counts" do + before do + PostAction.update_flagged_posts_count + end + it "starts of with 0 flag counts" do + PostAction.flagged_posts_count.should == 0 + end + + it "increments the numbers correctly" do + PostAction.act(codinghorror, post, PostActionType.Types[:off_topic]) + PostAction.flagged_posts_count.should == 1 + + PostAction.clear_flags!(post, -1) + PostAction.flagged_posts_count.should == 0 + end + + end + + it "increases the post's bookmark count when saved" do + lambda { bookmark.save; post.reload }.should change(post, :bookmark_count).by(1) + end + + it "increases the forum topic's bookmark count when saved" do + lambda { bookmark.save; post.topic.reload }.should change(post.topic, :bookmark_count).by(1) + end + + + describe 'when a user likes something' do + it 'should increase the post counts when a user likes' do + lambda { + PostAction.act(codinghorror, post, PostActionType.Types[:like]) + post.reload + }.should change(post, :like_count).by(1) + end + + it 'should increase the forum topic like count when a user likes' do + lambda { + PostAction.act(codinghorror, post, PostActionType.Types[:like]) + post.topic.reload + }.should change(post.topic, :like_count).by(1) + end + + end + + + describe 'when a user votes for something' do + it 'should increase the vote counts when a user likes' do + lambda { + PostAction.act(codinghorror, post, PostActionType.Types[:vote]) + post.reload + }.should change(post, :vote_count).by(1) + end + + it 'should increase the forum topic vote count when a user votes' do + lambda { + PostAction.act(codinghorror, post, PostActionType.Types[:vote]) + post.topic.reload + }.should change(post.topic, :vote_count).by(1) + end + end + + + describe 'when deleted' do + before do + bookmark.save + post.reload + @topic = post.topic + @topic.reload + bookmark.deleted_at = DateTime.now + bookmark.save + end + + it 'reduces the bookmark count of the post' do + lambda { + post.reload + }.should change(post, :bookmark_count).by(-1) + end + + it 'reduces the bookmark count of the forum topic' do + lambda { + @topic.reload + }.should change(post.topic, :bookmark_count).by(-1) + end + end + + describe 'flagging' do + + it 'should update counts when you clear flags' do + post = Fabricate(:post) + u1 = Fabricate(:evil_trout) + PostAction.act(u1, post, PostActionType.Types[:spam]) + + post.reload + post.spam_count.should == 1 + + PostAction.clear_flags!(post, -1) + post.reload + + post.spam_count.should == 0 + end + + it 'should follow the rules for automatic hiding workflow' do + + post = Fabricate(:post) + u1 = Fabricate(:evil_trout) + u2 = Fabricate(:walter_white) + + # we need an admin for the messages + admin = Fabricate(:admin) + + SiteSetting.flags_required_to_hide_post = 2 + + PostAction.act(u1, post, PostActionType.Types[:spam]) + PostAction.act(u2, post, PostActionType.Types[:spam]) + + post.reload + + post.hidden.should.should be_true + post.hidden_reason_id.should == Post::HiddenReason::FLAG_THRESHOLD_REACHED + post.topic.visible.should be_false + + post.revise(post.user, post.raw + " ha I edited it ") + post.reload + + post.hidden.should be_false + post.hidden_reason_id.should be_nil + post.topic.visible.should be_true + + PostAction.act(u1, post, PostActionType.Types[:spam]) + PostAction.act(u2, post, PostActionType.Types[:off_topic]) + + post.reload + + post.hidden.should be_true + post.hidden_reason_id.should == Post::HiddenReason::FLAG_THRESHOLD_REACHED_AGAIN + + post.revise(post.user, post.raw + " ha I edited it again ") + + post.reload + + post.hidden.should be_true + post.hidden_reason_id.should == Post::HiddenReason::FLAG_THRESHOLD_REACHED_AGAIN + end + end + +end diff --git a/spec/models/post_action_type_spec.rb b/spec/models/post_action_type_spec.rb new file mode 100644 index 00000000000..8736c1edd78 --- /dev/null +++ b/spec/models/post_action_type_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe PostActionType do + +end diff --git a/spec/models/post_alert_observer_spec.rb b/spec/models/post_alert_observer_spec.rb new file mode 100644 index 00000000000..a1c9de1ef2f --- /dev/null +++ b/spec/models/post_alert_observer_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe PostAlertObserver do + + let!(:evil_trout) { Fabricate(:evil_trout) } + let(:post) { Fabricate(:post) } + + context 'liking' do + context 'when liking a post' do + it 'creates a notification' do + lambda { + PostAction.act(evil_trout, post, PostActionType.Types[:like]) + }.should change(Notification, :count).by(1) + end + end + + context 'when removing a liked post' do + before do + PostAction.act(evil_trout, post, PostActionType.Types[:like]) + end + + it 'removes a notification' do + lambda { + PostAction.remove_act(evil_trout, post, PostActionType.Types[:like]) + }.should change(Notification, :count).by(-1) + end + end + end + + context 'when editing a post' do + it 'notifies a user of the revision' do + lambda { + post.revise(evil_trout, "world") + }.should change(post.user.notifications, :count).by(1) + end + end + + + context 'quotes' do + it 'notifies a user by display username' do + lambda { + Fabricate(:post, raw: '[quote="Evil Trout, post:1"]whatup[/quote]') + }.should change(evil_trout.notifications, :count).by(1) + end + + it 'notifies a user by username' do + lambda { + Fabricate(:post, raw: '[quote="EvilTrout, post:1"]whatup[/quote]') + }.should change(evil_trout.notifications, :count).by(1) + end + + it "won't notify the user a second time on revision" do + p1 = Fabricate(:post, raw: '[quote="Evil Trout, post:1"]whatup[/quote]') + lambda { + p1.revise(p1.user, '[quote="Evil Trout, post:1"]whatup now?[/quote]') + }.should_not change(evil_trout.notifications, :count) + end + + it "doesn't notify the poster" do + topic = post.topic + lambda { + new_post = Fabricate(:post, topic: topic, user: topic.user, raw: '[quote="Bruce Wayne, post:1"]whatup[/quote]') + }.should_not change(topic.user.notifications, :count).by(1) + end + end + + context '@mentions' do + + let(:user) { Fabricate(:user) } + let(:mention_post) { Fabricate(:post, user: user, raw: 'Hello @eviltrout')} + let(:topic) { mention_post.topic } + + it 'notifies a user' do + lambda { + mention_post + }.should change(evil_trout.notifications, :count).by(1) + end + + it "won't notify the user a second time on revision" do + mention_post + lambda { + mention_post.revise(mention_post.user, "New raw content that still mentions @eviltrout") + }.should_not change(evil_trout.notifications, :count) + end + + + it "doesn't notify the user who created the topic in regular mode" do + topic.notify_regular!(user) + mention_post + lambda { + Fabricate(:post, user: user, raw: 'second post', topic: topic) + }.should_not change(user.notifications, :count).by(1) + end + + it 'removes notifications' do + post = mention_post + lambda { + post.destroy + }.should change(evil_trout.notifications, :count).by(-1) + end + + end + +end diff --git a/spec/models/post_onebox_render_spec.rb b/spec/models/post_onebox_render_spec.rb new file mode 100644 index 00000000000..21b52996092 --- /dev/null +++ b/spec/models/post_onebox_render_spec.rb @@ -0,0 +1,8 @@ +require 'spec_helper' + +describe PostOneboxRender do + + it { should belong_to :onebox_render } + it { should belong_to :post } + +end diff --git a/spec/models/post_reply_spec.rb b/spec/models/post_reply_spec.rb new file mode 100644 index 00000000000..d49e4662881 --- /dev/null +++ b/spec/models/post_reply_spec.rb @@ -0,0 +1,8 @@ +require 'spec_helper' + +describe PostReply do + + it { should belong_to :post } + it { should belong_to :reply } + +end diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb new file mode 100644 index 00000000000..682200f1a3f --- /dev/null +++ b/spec/models/post_spec.rb @@ -0,0 +1,704 @@ +require 'spec_helper' + +describe Post do + + it { should belong_to :user } + it { should belong_to :topic } + it { should validate_presence_of :raw } + + # Min/max body lengths, respecting padding + it { should_not allow_value("x").for(:raw) } + it { should_not allow_value("x" * (SiteSetting.max_post_length + 1)).for(:raw) } + it { should_not allow_value((" " * SiteSetting.min_post_length) + "x").for(:raw) } + + it { should have_many :post_replies } + it { should have_many :replies } + + it { should rate_limit } + + let(:topic) { Fabricate(:topic) } + let(:post_args) do + {user: topic.user, topic: topic} + end + + + describe 'post uniqueness' do + + context "disabled" do + before do + SiteSetting.stubs(:unique_posts_mins).returns(0) + Fabricate(:post, post_args) + end + + it "returns true for another post with the same content" do + Fabricate.build(:post, post_args).should be_valid + end + end + + context 'enabled' do + before do + SiteSetting.stubs(:unique_posts_mins).returns(10) + Fabricate(:post, post_args) + end + + it "returns false for another post with the same content" do + Fabricate.build(:post, post_args).should_not be_valid + end + + it "returns true for admins" do + topic.user.admin = true + Fabricate.build(:post, post_args).should be_valid + end + + it "returns true for moderators" do + topic.user.trust_level = TrustLevel.Levels[:moderator] + Fabricate.build(:post, post_args).should be_valid + end + + + end + + end + + + describe 'message bus' do + it 'enqueues the post on the message bus' do + topic = self.topic + MessageBus.expects(:publish).with("/topic/#{topic.id}", instance_of(Hash)) + Fabricate(:post, post_args) + end + end + + describe "maximum images" do + let(:post_no_images) { Fabricate.build(:post, post_args) } + let(:post_one_image) { Fabricate.build(:post, post_args.merge(raw: "![sherlock](http://bbc.co.uk/sherlock.jpg)")) } + let(:post_two_images) { Fabricate.build(:post, post_args.merge(raw: " ")) } + let(:post_with_emoticons) { Fabricate.build(:post, post_args.merge(raw: 'smiley wink')) } + + it "returns 0 images for an empty post" do + Fabricate.build(:post).image_count.should == 0 + end + + it "finds images from markdown" do + post_one_image.image_count.should == 1 + end + + it "finds images from HTML" do + post_two_images.image_count.should == 2 + end + + it "doesn't count emoticons as images" do + post_with_emoticons.image_count.should == 0 + end + + + context "validation" do + it "allows a new user to make a post with one image" do + post_no_images.user.trust_level = TrustLevel.Levels[:new] + post_no_images.should be_valid + end + + it "doesn't allow multiple images for new accounts" do + post_one_image.user.trust_level = TrustLevel.Levels[:new] + post_one_image.should_not be_valid + end + + it "allows multiple images for basic accounts" do + post_one_image.user.trust_level = TrustLevel.Levels[:basic] + post_one_image.should be_valid + end + + it "doesn't allow a new user to edit their post to insert an image" do + post_no_images.user.trust_level = TrustLevel.Levels[:new] + post_no_images.save + -> { + post_no_images.revise(post_no_images.user, post_two_images.raw) + post_no_images.reload + }.should_not change(post_no_images, :raw) + + end + + end + + end + + describe "maximum links" do + let(:post_one_link) { Fabricate.build(:post, post_args.merge(raw: "[sherlock](http://www.bbc.co.uk/programmes/b018ttws)")) } + let(:post_two_links) { Fabricate.build(:post, post_args.merge(raw: "discourse twitter")) } + + it "returns 0 images for an empty post" do + Fabricate.build(:post).link_count.should == 0 + end + + it "finds images from markdown" do + post_one_link.link_count.should == 1 + end + + it "finds images from HTML" do + post_two_links.link_count.should == 2 + end + + context "validation" do + it "allows a new user to make a post with one image" do + post_one_link.user.trust_level = TrustLevel.Levels[:new] + post_one_link.should be_valid + end + + it "doesn't allow multiple images for new accounts" do + post_two_links.user.trust_level = TrustLevel.Levels[:new] + post_two_links.should_not be_valid + end + + it "allows multiple images for basic accounts" do + post_two_links.user.trust_level = TrustLevel.Levels[:basic] + post_two_links.should be_valid + end + end + + end + + + describe "maximum @mentions" do + + let(:post) { Fabricate.build(:post, post_args.merge(raw: "@Jake @Finn")) } + + it "will accept a post with 2 @mentions as valid" do + post.should be_valid + end + + context 'raw_mentions' do + + it "returns an empty array with no matches" do + post = Fabricate.build(:post, post_args.merge(raw: "Hello Jake and Finn!")) + post.raw_mentions.should == [] + end + + it "returns lowercase unique versions of the mentions" do + post = Fabricate.build(:post, post_args.merge(raw: "@Jake @Finn @Jake")) + post.raw_mentions.should == ['jake', 'finn'] + end + + it "ignores pre" do + post = Fabricate.build(:post, post_args.merge(raw: "
            @Jake
            @Finn")) + post.raw_mentions.should == ['finn'] + end + + it "catches content between pre tags" do + post = Fabricate.build(:post, post_args.merge(raw: "
            hello
            @Finn
            "))
            +        post.raw_mentions.should == ['finn']
            +      end
            +
            +      it "ignores code" do
            +        post = Fabricate.build(:post, post_args.merge(raw: "@Jake @Finn"))
            +        post.raw_mentions.should == ['jake']
            +      end      
            +
            +      it "ignores quotes" do
            +        post = Fabricate.build(:post, post_args.merge(raw: "[quote=\"Evil Trout\"]@Jake[/quote] @Finn"))
            +        post.raw_mentions.should == ['finn']
            +      end       
            +
            +    end
            +
            +    context "With a @mention limit of 1" do
            +      before do
            +        SiteSetting.stubs(:max_mentions_per_post).returns(1)
            +      end
            +
            +      it "wont accept the post as valid because there are too many mentions" do
            +        post.should_not be_valid
            +      end
            +    end
            +
            +  end
            +
            +  it 'validates' do
            +    Fabricate.build(:post, post_args).should be_valid
            +  end
            +
            +  context "raw_hash" do
            +
            +    let(:raw) { "this is our test post body"}
            +    let(:post) { Fabricate.build(:post, raw: raw) }
            +    
            +    it "returns a value" do
            +      post.raw_hash.should be_present
            +    end
            +
            +    it "returns blank for a nil body" do
            +      post.raw = nil
            +      post.raw_hash.should be_blank
            +    end
            +
            +    it "returns the same value for the same raw" do
            +      post.raw_hash.should == Fabricate.build(:post, raw: raw).raw_hash
            +    end
            +
            +    it "returns a different value for a different raw" do
            +      post.raw_hash.should_not == Fabricate.build(:post, raw: "something else").raw_hash
            +    end
            +
            +    it "returns the same hash even with different white space" do
            +      post.raw_hash.should == Fabricate.build(:post, raw: " thisis ourt est postbody").raw_hash
            +    end
            +
            +    it "returns the same hash even with different text case" do
            +      post.raw_hash.should == Fabricate.build(:post, raw: "THIS is OUR TEST post BODy").raw_hash
            +    end
            +  end
            +
            +  context 'revise' do
            +
            +    let(:post) { Fabricate(:post, post_args) }
            +    let(:first_version_at) { post.last_version_at }
            +
            +    it 'has an initial version of 1' do
            +      post.cached_version.should == 1
            +    end
            +
            +    it 'has one version in all_versions' do
            +      post.all_versions.size.should == 1
            +    end
            +
            +    it "has an initial last_version" do
            +      first_version_at.should be_present
            +    end
            +
            +    describe 'with the same body' do
            +
            +      it 'returns false' do
            +        post.revise(post.user, post.raw).should be_false
            +      end
            +
            +      it "doesn't change cached_version" do
            +        lambda { post.revise(post.user, post.raw); post.reload }.should_not change(post, :cached_version)
            +      end
            +
            +    end
            +
            +    describe 'ninja editing' do
            +      before do
            +        SiteSetting.expects(:ninja_edit_window).returns(1.minute.to_i)
            +        post.revise(post.user, 'updated body', revised_at: post.updated_at + 10.seconds)
            +        post.reload
            +      end
            +
            +      it 'does not update cached_version' do
            +        post.cached_version.should == 1
            +      end
            +
            +      it 'does not create a new version' do
            +        post.all_versions.size.should == 1
            +      end
            +
            +      it "doesn't change the last_version_at" do
            +        post.last_version_at.should == first_version_at
            +      end
            +    end
            +
            +    describe 'revision much later' do
            +
            +      let!(:revised_at) { post.updated_at + 2.minutes }
            +
            +      before do
            +        SiteSetting.stubs(:ninja_edit_window).returns(1.minute.to_i)
            +        post.revise(post.user, 'updated body', revised_at: revised_at)
            +        post.reload
            +      end
            +
            +      it 'updates the cached_version' do
            +        post.cached_version.should == 2
            +      end
            +
            +      it 'creates a new version' do
            +        post.all_versions.size.should == 2
            +      end
            +
            +      it "updates the last_version_at" do
            +        post.last_version_at.to_i.should == revised_at.to_i
            +      end
            +
            +      describe "new edit window" do
            +
            +        before do
            +          post.revise(post.user, 'yet another updated body', revised_at: revised_at)
            +          post.reload
            +        end
            +
            +        it "doesn't create a new version if you do another" do
            +          post.cached_version.should == 2          
            +        end
            +
            +        it "doesn't change last_version_at" do
            +          post.last_version_at.to_i.should == revised_at.to_i         
            +        end
            +
            +        context "after second window" do
            +
            +          let!(:new_revised_at) {revised_at + 2.minutes}
            +
            +          before do
            +            post.revise(post.user, 'yet another, another updated body', revised_at: new_revised_at)
            +            post.reload
            +          end
            +
            +          it "does create a new version after the edit window" do
            +            post.cached_version.should == 3          
            +          end
            +
            +          it "does create a new version after the edit window" do
            +            post.last_version_at.to_i.should == new_revised_at.to_i
            +          end
            +
            +        end
            +
            +
            +      end
            +
            +    end
            +
            +    describe 'rate limiter' do
            +      let(:changed_by) { Fabricate(:coding_horror) }
            +      
            +      it "triggers a rate limiter" do
            +        Post::EditRateLimiter.any_instance.expects(:performed!)
            +        post.revise(changed_by, 'updated body')
            +      end
            +    end
            +
            +    describe 'with a new body' do
            +      let(:changed_by) { Fabricate(:coding_horror) }
            +      let!(:result) { post.revise(changed_by, 'updated body') }
            +
            +      it 'returns true' do
            +        result.should be_true
            +      end
            +
            +      it 'updates the body' do
            +        post.raw.should == 'updated body'
            +      end
            +
            +      it 'sets the invalidate oneboxes attribute' do
            +        post.invalidate_oneboxes.should == true
            +      end
            +
            +      it 'increased the cached_version' do
            +        post.cached_version.should == 2
            +      end
            +
            +      it 'has the new version in all_versions' do
            +        post.all_versions.size.should == 2
            +      end
            +
            +      it 'has versions' do
            +        post.versions.should be_present
            +      end
            +
            +      it "saved the user who made the change in the version" do
            +        post.versions.first.user.should be_present
            +      end
            +
            +      context 'second poster posts again quickly' do
            +        before do
            +          SiteSetting.expects(:ninja_edit_window).returns(1.minute.to_i)
            +          post.revise(changed_by, 'yet another updated body', revised_at: post.updated_at + 10.seconds)
            +          post.reload
            +        end
            +
            +        it 'is a ninja edit, because the second poster posted again quickly' do
            +          post.cached_version.should == 2
            +        end
            +
            +        it 'is a ninja edit, because the second poster posted again quickly' do
            +          post.all_versions.size.should == 2
            +        end        
            +
            +      end
            +
            +    end
            +  end
            +
            +  it 'should feature users after create' do
            +    Jobs.stubs(:enqueue).with(:process_post, anything)
            +    Jobs.expects(:enqueue).with(:feature_topic_users, has_key(:topic_id))
            +    Fabricate(:post, post_args)
            +  end
            +
            +  it 'should queue up a post processing job when saved' do
            +    Jobs.stubs(:enqueue).with(:feature_topic_users, has_key(:topic_id))
            +    Jobs.expects(:enqueue).with(:process_post, has_key(:post_id))
            +    Fabricate(:post, post_args)
            +  end
            +
            +  it 'passes the invalidate_oneboxes along to the job if present' do
            +    Jobs.stubs(:enqueue).with(:feature_topic_users, has_key(:topic_id))
            +    Jobs.expects(:enqueue).with(:process_post, has_key(:invalidate_oneboxes))    
            +    post = Fabricate.build(:post, post_args)
            +    post.invalidate_oneboxes = true
            +    post.save
            +  end
            +
            +  it 'passes the image_sizes along to the job if present' do
            +    Jobs.stubs(:enqueue).with(:feature_topic_users, has_key(:topic_id))
            +    Jobs.expects(:enqueue).with(:process_post, has_key(:image_sizes))    
            +    post = Fabricate.build(:post, post_args)
            +    post.image_sizes = {'http://an.image.host/image.jpg' => {'width' => 17, 'height' => 31}}
            +    post.save
            +  end
            +
            +  describe 'notifications' do
            +
            +    let(:coding_horror) { Fabricate(:coding_horror) }
            +
            +    describe 'replies' do
            +
            +      let(:post) { Fabricate(:post, post_args.merge(raw: "Hello @CodingHorror")) }
            +      
            +      it 'notifies the poster on reply' do
            +        lambda {
            +          @reply = Fabricate(:basic_reply, user: coding_horror, topic: post.topic)
            +        }.should change(post.user.notifications, :count).by(1)
            +      end
            +
            +      it "doesn't notify the poster when they reply to their own post" do
            +        lambda {
            +          @reply = Fabricate(:basic_reply, user: post.user, topic: post.topic)
            +        }.should_not change(post.user.notifications, :count).by(1)
            +      end
            +    end
            +
            +    describe 'watching' do
            +      it "does notify watching users of new posts" do
            +        post = Fabricate(:post, post_args)
            +        user2 = Fabricate(:coding_horror)
            +        post_args[:topic].notify_watch!(user2)
            +        lambda {
            +          Fabricate(:post, user: post.user, topic: post.topic)
            +        }.should change(user2.notifications, :count).by(1)
            +      end
            +    end
            +
            +    describe 'muting' do 
            +      it "does not notify users of new posts" do
            +        post = Fabricate(:post, post_args)
            +        user = post_args[:user]
            +        user2 = Fabricate(:coding_horror)
            +
            +        post_args[:topic].notify_muted!(user)
            +        lambda {
            +          Fabricate(:post, user: user2, topic: post.topic, raw: 'hello @' + user.username)
            +        }.should change(user.notifications, :count).by(0)
            +      end
            +    end
            +
            +  end
            +
            +  describe 'after delete' do
            +
            +    let!(:coding_horror) { Fabricate(:coding_horror) }
            +    let!(:post) { Fabricate(:post, post_args.merge(raw: "Hello @CodingHorror")) }
            +
            +    it "should feature the users again (in case they've changed)" do
            +      Jobs.expects(:enqueue).with(:feature_topic_users, has_entries(topic_id: post.topic_id, except_post_id: post.id))
            +      post.destroy
            +    end
            +
            +    describe 'with a reply' do
            +
            +      let!(:reply) { Fabricate(:basic_reply, user: coding_horror, topic: post.topic) }
            +
            +      it 'changes the post count of the topic' do
            +        post.reload
            +        lambda {
            +          reply.destroy
            +          post.topic.reload
            +        }.should change(post.topic, :posts_count).by(-1)
            +      end
            +
            +      it 'lowers the reply_count when the reply is deleted' do
            +        lambda {
            +          reply.destroy
            +          post.reload
            +        }.should change(post.post_replies, :count).by(-1)
            +      end
            +
            +      it 'should increase the post_number when there are deletion gaps' do 
            +        reply.destroy
            +        p = Fabricate(:post, user: post.user, topic: post.topic)
            +        p.post_number.should == 3
            +      end
            +
            +    end
            +  
            +  end
            +
            +
            +
            +  describe 'after save' do
            +
            +    let(:post) { Fabricate(:post, post_args) }
            +
            +    it 'has a post nubmer' do
            +      post.post_number.should be_present
            +    end
            +
            +    it 'has an excerpt' do
            +      post.excerpt.should be_present
            +    end
            +
            +    it 'is of the regular post type' do
            +      post.post_type.should == Post::REGULAR
            +    end
            +
            +    it 'has no versions' do
            +      post.versions.should be_blank
            +    end    
            +
            +    it 'has cooked content' do
            +      post.cooked.should be_present
            +    end
            +
            +    it 'has an external id' do
            +      post.external_id.should be_present      
            +    end
            +
            +    it 'has no quotes' do
            +      post.quote_count.should == 0
            +    end
            +
            +    it 'has no replies' do
            +      post.replies.should be_blank
            +    end
            +
            +    describe 'a forum topic user record for the topic' do
            +
            +      let(:topic_user) { post.user.topic_users.where(topic_id: topic.id).first }
            +
            +      it 'exists' do
            +        topic_user.should be_present
            +      end
            +
            +      it 'has the posted flag set' do
            +        topic_user.should be_posted
            +      end
            +
            +      it 'recorded the latest post as read' do
            +        topic_user.last_read_post_number.should == post.post_number
            +      end
            +
            +      it 'recorded the latest post as the last seen' do
            +        topic_user.seen_post_count.should == post.post_number
            +      end
            +
            +    end
            +
            +    describe 'quote counts' do
            +
            +      let!(:post) { Fabricate(:post, post_args) }
            +      let(:reply) { Fabricate.build(:post, post_args) }
            +
            +      it "finds the quote when in the same topic" do
            +        reply.raw = "[quote=\"EvilTrout, post:#{post.post_number}, topic:#{post.topic_id}\"]hello[/quote]"
            +        reply.extract_quoted_post_numbers
            +        reply.quoted_post_numbers.should == [post.post_number]
            +      end
            +
            +      it "doesn't find the quote in a different topic" do
            +        reply.raw = "[quote=\"EvilTrout, post:#{post.post_number}, topic:#{post.topic_id+1}\"]hello[/quote]"
            +        reply.extract_quoted_post_numbers
            +        reply.quoted_post_numbers.should be_blank
            +      end
            +
            +    end
            +
            +    describe 'a new reply' do
            +
            +      let!(:post) { Fabricate(:post, post_args) }
            +      let!(:reply) { Fabricate(:reply, post_args.merge(reply_to_post_number: post.post_number)) }
            +
            +      it 'has a quote' do
            +        reply.quote_count.should == 1
            +      end
            +
            +      it "isn't quoteless" do
            +        reply.should_not be_quoteless
            +      end
            +
            +      it 'has a reply to the user of the original user' do
            +        reply.reply_to_user.should == post.user
            +      end
            +
            +      it 'increases the reply count of the parent' do
            +        post.reload
            +        post.reply_count.should == 1
            +      end
            +
            +      it 'increases the reply count of the topic' do
            +        topic.reload
            +        topic.reply_count.should == 1
            +      end
            +
            +      it 'is the child of the parent post' do
            +        post.replies.should == [reply]
            +      end
            +
            +
            +      it "doesn't change the post count when you edit the reply" do
            +        reply.raw = 'updated raw'
            +        reply.save
            +        post.reload
            +        post.reply_count.should == 1
            +      end
            +
            +      context 'a multi-quote reply' do
            +
            +        let!(:multi_reply) { Fabricate(:multi_quote_reply, post_args.merge(reply_to_post_number: post.post_number)) }
            +
            +        it 'has two quotes' do
            +          multi_reply.quote_count.should == 2
            +        end
            +
            +        it 'is a child of the parent post' do
            +          post.replies.include?(multi_reply).should be_true
            +        end
            +
            +        it 'is a child of the second post quoted' do
            +          reply.replies.include?(multi_reply).should be_true
            +        end
            +
            +      end
            +
            +    end
            +
            +  end
            +
            +  context 'best_of' do
            +    let!(:p1) { Fabricate(:post, post_args.merge(score: 4)) }
            +    let!(:p2) { Fabricate(:post, post_args.merge(score: 10)) }
            +    let!(:p3) { Fabricate(:post, post_args.merge(score: 5)) }
            +
            +    it "returns the OP and posts above the threshold in best of mode" do
            +      SiteSetting.stubs(:best_of_score_threshold).returns(10)
            +      Post.best_of.order(:post_number).should == [p1, p2]
            +    end
            +
            +  end
            +
            +
            +  context 'sort_order' do
            +
            +    context 'regular topic' do
            +
            +      let!(:p1) { Fabricate(:post, post_args) }
            +      let!(:p2) { Fabricate(:post, post_args) }
            +      let!(:p3) { Fabricate(:post, post_args) }
            +
            +      it 'defaults to created order' do
            +        Post.regular_order.should == [p1, p2, p3]
            +      end
            +    end
            +
            +  end
            +
            +
            +end
            diff --git a/spec/models/post_timing_spec.rb b/spec/models/post_timing_spec.rb
            new file mode 100644
            index 00000000000..afdd45e6fa0
            --- /dev/null
            +++ b/spec/models/post_timing_spec.rb
            @@ -0,0 +1,113 @@
            +require 'spec_helper'
            +
            +describe PostTiming do
            +
            +  it { should belong_to :topic }
            +  it { should belong_to :user }
            +
            +  it { should validate_presence_of :post_number }
            +  it { should validate_presence_of :msecs }
            +
            +  describe 'recording' do
            +    before do
            +      @post = Fabricate(:post)
            +      @topic = @post.topic
            +      @coding_horror = Fabricate(:coding_horror)
            +      @timing_attrs = {msecs: 1234, topic_id: @post.topic_id, user_id: @coding_horror.id, post_number: @post.post_number}
            +    end
            +
            +    it 'creates a post timing record' do
            +      lambda {
            +        PostTiming.record_timing(@timing_attrs)
            +      }.should change(PostTiming, :count).by(1)
            +    end
            +
            +    it 'adds a view to the post' do
            +      lambda {
            +        PostTiming.record_timing(@timing_attrs)
            +        @post.reload
            +      }.should change(@post, :reads).by(1)
            +    end
            +
            +    describe 'multiple calls' do
            +      before do
            +        PostTiming.record_timing(@timing_attrs)
            +        PostTiming.record_timing(@timing_attrs)
            +        @timing = PostTiming.where(topic_id: @post.topic_id, user_id: @coding_horror.id, post_number: @post.post_number).first
            +      end
            +
            +      it 'creates a timing record' do
            +        @timing.should be_present
            +      end
            +
            +      it 'sums the msecs together' do
            +        @timing.msecs.should == 2468
            +      end
            +    end    
            +
            +    describe 'avg times' do
            +
            +      describe 'posts' do
            +        it 'has no avg_time by default' do
            +          @post.avg_time.should be_blank
            +        end    
            +
            +        it "doesn't change when we calculate the avg time for the post because there's no timings" do
            +          Post.calculate_avg_time
            +          @post.reload
            +          @post.avg_time.should be_blank
            +        end
            +      end
            +
            +      describe 'topics' do
            +        it 'has no avg_time by default' do
            +          @topic.avg_time.should be_blank
            +        end    
            +
            +        it "doesn't change when we calculate the avg time for the post because there's no timings" do
            +          Topic.calculate_avg_time
            +          @topic.reload
            +          @topic.avg_time.should be_blank
            +        end
            +      end
            +
            +      describe "it doesn't create an avg time for the same user" do
            +        it 'something' do
            +          PostTiming.record_timing(@timing_attrs.merge(user_id: @post.user_id))
            +          Post.calculate_avg_time
            +          @post.reload
            +          @post.avg_time.should be_blank
            +        end
            +
            +      end
            +
            +      describe 'with a timing for another user' do
            +        before do
            +          PostTiming.record_timing(@timing_attrs)
            +          Post.calculate_avg_time
            +          @post.reload
            +        end
            +
            +        it 'has a post avg_time from the timing' do
            +          @post.avg_time.should be_present
            +        end
            +
            +        describe 'forum topics' do
            +          before do
            +            Topic.calculate_avg_time
            +            @topic.reload
            +          end
            +
            +          it 'has an avg_time from the timing' do
            +            @topic.avg_time.should be_present
            +          end
            +
            +        end
            +
            +      end
            +
            +    end
            +
            +  end
            +
            +end
            diff --git a/spec/models/site_customization_spec.rb b/spec/models/site_customization_spec.rb
            new file mode 100644
            index 00000000000..7be20ae75c3
            --- /dev/null
            +++ b/spec/models/site_customization_spec.rb
            @@ -0,0 +1,64 @@
            +require 'spec_helper'
            +
            +describe SiteCustomization do
            +
            +  let :user do
            +    Fabricate(:user)
            +  end
            +
            +  let :customization do 
            +    SiteCustomization.create!(name: 'my name', user_id: user.id, header: "my awesome header", stylesheet: "my awesome css")
            +  end
            +
            +  it 'should set default key when creating a new customization' do 
            +    s = SiteCustomization.create!(name: 'my name', user_id: user.id)
            +    s.key.should_not == nil
            +  end
            +
            +  context 'caching' do
            +    it 'should allow me to lookup a filename containing my preview stylesheet' do
            +      SiteCustomization.custom_stylesheet(customization.key).should == 
            +        ""  
            +    end
            +
            +    it 'should fix stylesheet files after changing the stylesheet' do 
            +      old_file = customization.stylesheet_fullpath 
            +      original = SiteCustomization.custom_stylesheet(customization.key)
            +      
            +      File.exists?(old_file).should == true
            +      customization.stylesheet = "div { clear:both; }"
            +      customization.save
            +
            +      SiteCustomization.custom_stylesheet(customization.key).should_not == original
            +    end
            +
            +    it 'should delete old stylesheet files after deleting' do
            +      old_file = customization.stylesheet_fullpath 
            +      customization.ensure_stylesheet_on_disk!
            +      customization.destroy
            +      File.exists?(old_file).should == false
            +    end
            +
            +    it 'should nuke old revs out of the cache' do 
            +      old_style = SiteCustomization.custom_stylesheet(customization.key)
            +      
            +      customization.stylesheet = "hello worldz"
            +      customization.save
            +      SiteCustomization.custom_stylesheet(customization.key).should_not == old_style
            +    end
            +
            +
            +    it 'should compile scss' do 
            +      c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: '$black: #000; #a { color: $black; }', header: '')
            +      c.stylesheet_baked.should == "#a {\n  color: black; }\n"
            +    end
            +
            +    it 'should provide an awesome error on failure' do 
            +      c = SiteCustomization.create!(user_id: user.id, name: "test", stylesheet: "$black: #000; #a { color: $black; }\n\n\nboom", header: '')
            +
            +      c.stylesheet_baked.should =~ /Syntax error/
            +    end
            +
            +  end
            +
            +end
            diff --git a/spec/models/site_setting_spec.rb b/spec/models/site_setting_spec.rb
            new file mode 100644
            index 00000000000..bfe0fa969ea
            --- /dev/null
            +++ b/spec/models/site_setting_spec.rb
            @@ -0,0 +1,110 @@
            +require 'spec_helper'
            +
            +describe SiteSetting do
            +
            +  describe "int setting" do 
            +    before :all do 
            +      SiteSetting.setting(:test_setting, 77)
            +      SiteSetting.refresh!
            +    end
            +
            +    it 'should have a key in all_settings' do
            +      SiteSetting.all_settings.detect {|s| s[:setting] == :test_setting }.should be_present
            +    end
            +
            +    it "should have the correct desc" do 
            +      I18n.expects(:t).with("site_settings.test_setting").returns("test description")
            +      SiteSetting.description(:test_setting).should == "test description"
            +    end
            +    
            +    it "should have the correct default" do 
            +      SiteSetting.test_setting.should == 77
            +    end
            +
            +    describe "when overidden" do 
            +      before :all do 
            +        SiteSetting.test_setting = 100
            +      end
            +
            +      after :all do 
            +        SiteSetting.remove_override!(:test_setting)
            +      end
            +    
            +      it "should have the correct override" do 
            +        SiteSetting.test_setting.should == 100
            +      end
            +
            +    end
            +  end
            +
            +  describe "string setting" do 
            +    before :all do 
            +      SiteSetting.setting(:test_str, "str")
            +      SiteSetting.refresh!
            +    end
            +
            +    it "should have the correct default" do 
            +      SiteSetting.test_str.should == "str"
            +    end
            +  end 
            +
            +  describe "bool setting" do 
            +    before :all do 
            +      SiteSetting.setting(:test_hello?, false) 
            +      SiteSetting.refresh!
            +    end
            +
            +    it "should have the correct default" do 
            +      SiteSetting.test_hello?.should == false
            +    end
            +    
            +    it "should be overridable" do
            +      SiteSetting.test_hello = true
            +      SiteSetting.refresh!
            +      SiteSetting.test_hello?.should == true
            +    end
            +
            +    it "should coerce true strings to true" do 
            +      SiteSetting.test_hello = "true"
            +      SiteSetting.refresh!
            +      SiteSetting.test_hello?.should == true
            +    end
            +
            +    it "should coerce all other strings to false" do 
            +      SiteSetting.test_hello = "f"
            +      SiteSetting.refresh!
            +      SiteSetting.test_hello?.should == false
            +    end
            +  end
            +
            +  describe 'call_mothership?' do
            +    it 'should be true when enforce_global_nicknames is true and discourse_org_access_key is set' do
            +      SiteSetting.enforce_global_nicknames = true
            +      SiteSetting.discourse_org_access_key = 'asdfasfsafd'
            +      SiteSetting.refresh!
            +      SiteSetting.call_mothership?.should == true
            +    end
            +
            +    it 'should be false when enforce_global_nicknames is false and discourse_org_access_key is set' do
            +      SiteSetting.enforce_global_nicknames = false
            +      SiteSetting.discourse_org_access_key = 'asdfasfsafd'
            +      SiteSetting.refresh!
            +      SiteSetting.call_mothership?.should == false
            +    end
            +
            +    it 'should be false when enforce_global_nicknames is true and discourse_org_access_key is not set' do
            +      SiteSetting.enforce_global_nicknames = true
            +      SiteSetting.discourse_org_access_key = ''
            +      SiteSetting.refresh!
            +      SiteSetting.call_mothership?.should == false
            +    end
            +
            +    it 'should be false when enforce_global_nicknames is false and discourse_org_access_key is not set' do
            +      SiteSetting.enforce_global_nicknames = false
            +      SiteSetting.discourse_org_access_key = ''
            +      SiteSetting.refresh!
            +      SiteSetting.call_mothership?.should == false
            +    end
            +  end
            +
            +end
            diff --git a/spec/models/topic_allowed_user_spec.rb b/spec/models/topic_allowed_user_spec.rb
            new file mode 100644
            index 00000000000..5f45cb0edba
            --- /dev/null
            +++ b/spec/models/topic_allowed_user_spec.rb
            @@ -0,0 +1,6 @@
            +require 'spec_helper'
            +
            +describe TopicAllowedUser do
            +  it { should belong_to :user }
            +  it { should belong_to :topic }
            +end
            diff --git a/spec/models/topic_invite_spec.rb b/spec/models/topic_invite_spec.rb
            new file mode 100644
            index 00000000000..aa2544e057d
            --- /dev/null
            +++ b/spec/models/topic_invite_spec.rb
            @@ -0,0 +1,10 @@
            +require 'spec_helper'
            +
            +describe TopicInvite do
            +  
            +  it { should belong_to :topic }
            +  it { should belong_to :invite }
            +  it { should validate_presence_of :topic_id }
            +  it { should validate_presence_of :invite_id }
            +  
            +end
            diff --git a/spec/models/topic_link_click_spec.rb b/spec/models/topic_link_click_spec.rb
            new file mode 100644
            index 00000000000..9841c9c60ff
            --- /dev/null
            +++ b/spec/models/topic_link_click_spec.rb
            @@ -0,0 +1,130 @@
            +require 'discourse'
            +require 'spec_helper'
            +
            +describe TopicLinkClick do
            +  
            +  it { should belong_to :topic_link }
            +  it { should belong_to :user }
            +
            +  it { should validate_presence_of :topic_link_id }
            +
            +  def test_uri
            +    URI.parse('http://test.host')
            +  end
            +
            +  it 'returns blank from counts_for without posts' do
            +    TopicLinkClick.counts_for(nil, nil).should be_blank
            +  end
            +
            +  context 'topic_links' do
            +    before do
            +      @topic = Fabricate(:topic)   
            +      @post = Fabricate(:post_with_external_links, user: @topic.user, topic: @topic)
            +      TopicLink.extract_from(@post)     
            +      @topic_link = @topic.topic_links.first
            +    end    
            +
            +    it 'has 0 clicks at first' do
            +      @topic_link.clicks.should == 0
            +    end
            +
            +    context 'create' do
            +      before do
            +        TopicLinkClick.create(topic_link: @topic_link, ip: '192.168.1.1')
            +      end
            +
            +      it 'creates the forum topic link click' do
            +        TopicLinkClick.count.should == 1
            +      end
            +
            +      it 'has 0 clicks at first' do
            +        @topic_link.reload
            +        @topic_link.clicks.should == 1
            +      end
            +
            +      it 'serializes and deserializes the IP' do
            +        TopicLinkClick.first.ip.to_s.should == '192.168.1.1'
            +      end
            +
            +      context 'counts for' do
            +
            +        before do
            +          @counts_for = TopicLinkClick.counts_for(@topic, [@post])          
            +        end
            +
            +        it 'has a counts_for result' do
            +          @counts_for[@post.id].should be_present
            +        end
            +
            +        it 'contains the click we made' do
            +          @counts_for[@post.id].first[:clicks].should == 1
            +        end
            +
            +        it 'has no clicks on another url in the post' do
            +          @counts_for[@post.id].find {|l| l[:url] == 'http://google.com'}[:clicks].should == 0
            +        end
            +
            +      end
            +    end
            +
            +    context 'create_from' do
            +
            +      context 'without a url' do
            +        it "doesn't raise an exception" do
            +          TopicLinkClick.create_from(url: "url that doesn't exist", post_id: @post.id, ip: '127.0.0.1')
            +        end
            +      end
            +
            +      context 'clicking on your own link' do
            +        it "should not record the click" do
            +          lambda {
            +            TopicLinkClick.create_from(url: @topic_link.url, post_id: @post.id, ip: '127.0.0.1', user_id: @post.user_id)
            +          }.should_not change(TopicLinkClick, :count)
            +
            +        end
            +
            +      end
            +
            +
            +      context 'with a valid url and post_id' do
            +        before do
            +          TopicLinkClick.create_from(url: @topic_link.url, post_id: @post.id, ip: '127.0.0.1')
            +          @click = TopicLinkClick.last
            +        end
            +
            +        it 'creates a click' do
            +          @click.should be_present
            +        end
            +
            +        it 'has the topic_link id' do
            +          @click.topic_link.should == @topic_link
            +        end
            +
            +        context "clicking again" do
            +          it "should not record the click due to rate limiting" do
            +            -> { TopicLinkClick.create_from(url: @topic_link.url, post_id: @post.id, ip: '127.0.0.1') }.should_not change(TopicLinkClick, :count)
            +          end
            +        end
            +      end
            +
            +      context 'with a valid url and topic_id' do
            +        before do
            +          TopicLinkClick.create_from(url: @topic_link.url, topic_id: @topic.id, ip: '127.0.0.1')
            +          @click = TopicLinkClick.last
            +        end
            +
            +        it 'creates a click' do
            +          @click.should be_present
            +        end
            +
            +        it 'has the topic_link id' do
            +          @click.topic_link.should == @topic_link
            +        end
            +      end
            +
            +
            +    end
            +
            +  end
            +
            +end
            diff --git a/spec/models/topic_link_spec.rb b/spec/models/topic_link_spec.rb
            new file mode 100644
            index 00000000000..3d992f980b8
            --- /dev/null
            +++ b/spec/models/topic_link_spec.rb
            @@ -0,0 +1,206 @@
            +require 'spec_helper'
            +
            +describe TopicLink do
            +
            +  it { should belong_to :topic }
            +  it { should belong_to :post }
            +  it { should belong_to :user }
            +  it { should have_many :topic_link_clicks }
            +  it { should validate_presence_of :url }
            +
            +  def test_uri
            +    URI.parse(Discourse.base_url)
            +  end
            +
            +  before do
            +    @topic = Fabricate(:topic, title: 'unique topic name')   
            +    @user = @topic.user
            +  end
            +
            +  it "can't link to the same topic" do
            +    ftl = TopicLink.new(url: "/t/#{@topic.id}", 
            +                              topic_id: @topic.id, 
            +                              link_topic_id: @topic.id)
            +    ftl.valid?.should be_false
            +  end
            +
            +  describe 'external links' do
            +    before do
            +      @post = Fabricate(:post_with_external_links, user: @user, topic: @topic)
            +      TopicLink.extract_from(@post)      
            +    end
            +
            +    it 'has the forum topic links' do
            +      @topic.topic_links.count.should == 4
            +    end
            +
            +    it 'works with markdown links' do
            +      @topic.topic_links.exists?(url: "http://forumwarz.com").should be_true
            +    end
            +
            +    it 'works with markdown links followed by a period' do
            +      @topic.topic_links.exists?(url: "http://www.codinghorror.com/blog").should be_true
            +    end
            +
            +  end
            +
            +  describe 'domain-less link' do 
            +    let(:post) { @topic.posts.create(user: @user, raw: "hello") }
            +    let!(:link) do 
            +      TopicLink.extract_from(post)
            +      @topic.topic_links.first
            +    end
            +
            +    it 'is extracted' do 
            +      link.should be_present 
            +    end
            +
            +    it 'has the correct domain' do 
            +      link.domain.should == test_uri.host
            +    end
            +
            +    it "is not destroyed when we call extract from again" do
            +      TopicLink.extract_from(post)
            +      link.reload
            +      link.should be_present
            +    end
            +
            +  end
            +
            +  describe 'internal links' do
            +
            +    before do
            +      @other_topic = Fabricate(:topic, user: @user)
            +      @other_post = @other_topic.posts.create(user: @user, raw: "some content")
            +
            +      @url = "http://#{test_uri.host}/t/topic-slug/#{@other_topic.id}"
            +
            +      @topic.posts.create(user: @user, raw: 'initial post')
            +      @post = @topic.posts.create(user: @user, raw: "Link to another topic: #{@url}")
            +
            +      TopicLink.extract_from(@post)
            +
            +      @link = @topic.topic_links.first
            +    end
            +
            +    it 'extracted the link' do
            +      @link.should be_present
            +    end
            +
            +    it 'is set to internal' do
            +      @link.should be_internal
            +    end
            +
            +    it 'has the correct url' do
            +      @link.url.should == @url
            +    end
            +
            +    it 'has the extracted domain' do
            +      @link.domain.should == test_uri.host
            +    end
            +
            +    it 'should have the id of the linked forum' do
            +      @link.link_topic_id == @other_topic.id
            +    end
            +
            +    it 'should not be the reflection' do
            +      @link.should_not be_reflection
            +    end
            +
            +    describe 'reflection in the other topic' do
            +
            +      before do
            +        @reflection = @other_topic.topic_links.first
            +      end
            +
            +      it 'exists' do
            +        @reflection.should be_present
            +      end
            +
            +      it 'is a reflection' do
            +        @reflection.should be_reflection
            +      end
            +
            +      it 'has a post_id' do
            +        @reflection.post_id.should be_present
            +      end
            +
            +      it 'has the correct host' do
            +        @reflection.domain.should == test_uri.host
            +      end
            +
            +      it 'has the correct url' do
            +        @reflection.url.should == "http://#{test_uri.host}/t/unique-topic-name/#{@topic.id}/#{@post.post_number}"
            +      end
            +
            +      it 'links to the original forum topic' do
            +        @reflection.link_topic_id.should == @topic.id
            +      end
            +
            +      it 'links to the original post' do
            +        @reflection.link_post_id.should == @post.id
            +      end
            +
            +      it 'has the user id of the original link' do
            +        @reflection.user_id.should == @link.user_id
            +      end
            +    end
            +
            +    context 'removing a link' do
            +
            +      before do
            +        @post.revise(@post.user, "no more linkies")
            +        TopicLink.extract_from(@post)
            +      end
            +
            +      it 'should remove the link' do
            +        @topic.topic_links.where(post_id: @post.id).should be_blank
            +      end
            +
            +      it 'should remove the reflected link' do
            +        @reflection = @other_topic.topic_links.should be_blank
            +      end
            +
            +    end 
            +
            +  end
            +
            +  describe 'internal link from pm' do 
            +    before do 
            +      @pm = Fabricate(:topic, user: @user, archetype: 'private_message')
            +      @other_post = @pm.posts.create(user: @user, raw: "some content")
            +      
            +      @url = "http://#{test_uri.host}/t/topic-slug/#{@topic.id}"
            +      
            +      @pm.posts.create(user: @user, raw: 'initial post')
            +      @linked_post = @pm.posts.create(user: @user, raw: "Link to another topic: #{@url}")
            +
            +      TopicLink.extract_from(@linked_post)
            +
            +      @link = @topic.topic_links.first
            +    end
            +
            +    it 'should not create a reflection' do
            +      @topic.topic_links.first.should be_nil
            +    end
            +    
            +    it 'should not create a normal link' do
            +      @pm.topic_links.first.should_not be_nil
            +    end
            +  end
            +
            +  describe 'internal link with non-standard port' do
            +    it 'includes the non standard port if present' do
            +      @other_topic = Fabricate(:topic, user: @user)
            +      SiteSetting.stubs(:port).returns(5678)
            +      alternate_uri = URI.parse(Discourse.base_url)
            +      @url = "http://#{alternate_uri.host}:5678/t/topic-slug/#{@other_topic.id}"
            +      @post = @topic.posts.create(user: @user,
            +                                         raw: "Link to another topic: #{@url}")
            +      TopicLink.extract_from(@post)
            +      @reflection = @other_topic.topic_links.first
            +      @reflection.url.should == "http://#{alternate_uri.host}:5678/t/unique-topic-name/#{@topic.id}"
            +    end
            +  end
            +
            +end
            diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb
            new file mode 100644
            index 00000000000..a65d0350d6d
            --- /dev/null
            +++ b/spec/models/topic_spec.rb
            @@ -0,0 +1,960 @@
            +require 'spec_helper'
            +
            +describe Topic do
            +
            +  it { should validate_presence_of :title }
            +  it { should_not allow_value("x" * (SiteSetting.max_topic_title_length + 1)).for(:title) }
            +  it { should_not allow_value("x").for(:title) }
            +  it { should_not allow_value((" " * SiteSetting.min_topic_title_length) + "x").for(:title) }
            +
            +  it { should belong_to :category }
            +  it { should belong_to :user }  
            +  it { should belong_to :last_poster }  
            +  it { should belong_to :featured_user1 }
            +  it { should belong_to :featured_user2 }
            +  it { should belong_to :featured_user3 }
            +  it { should belong_to :featured_user4 }
            +
            +  it { should have_many :posts }
            +  it { should have_many :topic_users }
            +  it { should have_many :topic_links }  
            +  it { should have_many :topic_allowed_users }
            +  it { should have_many :allowed_users }
            +  it { should have_many :invites }
            +
            +  it { should rate_limit }
            +
            +
            +  context 'topic title uniqueness' do
            +
            +    let!(:topic) { Fabricate(:topic) }
            +    let(:new_topic) { Fabricate.build(:topic, title: topic.title) }
            +
            +    context "when duplicates aren't allowed" do
            +      before do
            +        SiteSetting.expects(:allow_duplicate_topic_titles?).returns(false)
            +      end
            +
            +      it "won't allow another topic to be created with the same name" do
            +        new_topic.should_not be_valid
            +      end
            +
            +      it "won't allow another topic with an upper case title to be created" do
            +        new_topic.title = new_topic.title.upcase
            +        new_topic.should_not be_valid
            +      end
            +
            +      it "allows it when the topic is deleted" do
            +        topic.destroy
            +        new_topic.should be_valid
            +      end
            +
            +      it "allows a private message to be created with the same topic" do
            +        new_topic.archetype = Archetype.private_message
            +        new_topic.should be_valid
            +      end
            +
            +    end
            +
            +    context "when duplicates are allowed" do
            +      before do
            +        SiteSetting.expects(:allow_duplicate_topic_titles?).returns(true)
            +      end
            +
            +      it "won't allow another topic to be created with the same name" do
            +        new_topic.should be_valid
            +      end
            +    end    
            +
            +  end
            +
            +
            +  context 'message bus' do
            +    it 'calls the message bus observer after create' do
            +      MessageBusObserver.any_instance.expects(:after_create_topic).with(instance_of(Topic))
            +      Fabricate(:topic)
            +    end
            +  end
            +
            +  context 'post_numbers' do
            +    let!(:topic) { Fabricate(:topic) }
            +    let!(:p1) { Fabricate(:post, topic: topic, user: topic.user) }
            +    let!(:p2) { Fabricate(:post, topic: topic, user: topic.user) }
            +    let!(:p3) { Fabricate(:post, topic: topic, user: topic.user) }
            +
            +    it "returns the post numbers of the topic" do
            +      topic.post_numbers.should == [1, 2, 3]
            +    end
            +
            +    it "skips deleted posts" do
            +      p2.destroy
            +      topic.post_numbers.should == [1, 3]
            +    end
            +
            +  end
            +
            +  context 'move_posts' do
            +    let(:user) { Fabricate(:user) }
            +    let(:category) { Fabricate(:category, user: user) }
            +    let!(:topic) { Fabricate(:topic, user: user, category: category) }    
            +    let!(:p1) { Fabricate(:post, topic: topic, user: user) }
            +    let!(:p2) { Fabricate(:post, topic: topic, user: user)}
            +    let!(:p3) { Fabricate(:post, topic: topic, user: user)}
            +    let!(:p4) { Fabricate(:post, topic: topic, user: user)}
            +
            +    context 'success' do
            +
            +      it "enqueues a job to notify users" do
            +        topic.stubs(:add_moderator_post)
            +        Jobs.expects(:enqueue).with(:notify_moved_posts, post_ids: [p1.id, p4.id], moved_by_id: user.id)
            +        topic.move_posts(user, "new topic name", [p1.id, p4.id])
            +      end
            +
            +      it "adds a moderator post at the location of the first moved post" do
            +        topic.expects(:add_moderator_post).with(user, instance_of(String), has_entries(post_number: 2))
            +        topic.move_posts(user, "new topic name", [p2.id, p4.id])
            +      end
            +
            +    end
            +
            +    context "errors" do
            +
            +      it "raises an error when one of the posts doesn't exist" do
            +        lambda { topic.move_posts(user, "new topic name", [1003]) }.should raise_error(Discourse::InvalidParameters)
            +      end
            +
            +      it "raises an error if no posts were moved" do
            +        lambda { topic.move_posts(user, "new topic name", []) }.should raise_error(Discourse::InvalidParameters)
            +      end
            +
            +    end
            +
            +    context "afterwards" do
            +      before do
            +        topic.expects(:add_moderator_post)
            +        TopicUser.update_last_read(user, topic.id, p4.post_number, 0)
            +      end
            +      
            +      let!(:new_topic) { topic.move_posts(user, "new topic name", [p2.id, p4.id]) }
            +
            +
            +      it "updates the user's last_read_post_number" do
            +        TopicUser.where(user_id: user.id, topic_id: topic.id).first.last_read_post_number.should == p3.post_number
            +      end
            +
            +      context 'new topic' do
            +        it "exists" do
            +          new_topic.should be_present
            +        end
            +
            +        it "has the correct category" do
            +          new_topic.category.should == category
            +        end
            +
            +        it "has two posts" do
            +          new_topic.reload
            +          new_topic.posts_count.should == 2
            +        end
            +
            +        it "has the moved posts" do
            +          new_topic.posts.should =~ [p2, p4]
            +        end
            +
            +        it "has renumbered the first post" do
            +          p2.reload
            +          p2.post_number.should == 1      
            +        end
            +
            +        it "has changed the first post's sort order" do
            +          p2.reload
            +          p2.sort_order.should == 1
            +        end
            +
            +        it "has renumbered the forth post" do
            +          p4.reload
            +          p4.post_number.should == 2
            +        end
            +
            +        it "has changed the fourth post's sort order" do
            +          p4.reload
            +          p4.sort_order.should == 2
            +        end      
            +
            +        it "has the correct highest_post_number" do
            +          new_topic.reload
            +          new_topic.highest_post_number.should == 2
            +        end
            +      end
            +
            +      context "original topic" do
            +        before do
            +          topic.reload
            +        end
            +
            +        it "has 2 posts now" do
            +          topic.posts_count.should == 2
            +        end
            +
            +        it "contains the leftover posts" do
            +          topic.posts.should =~ [p1, p3]
            +        end
            +
            +        it "has the correct highest_post_number" do
            +          topic.reload
            +          topic.highest_post_number.should == p3.post_number
            +        end
            +
            +      end
            +
            +    end
            +
            +
            +
            +  end
            +
            +  context 'private message' do 
            +    let(:coding_horror) { User.where(username: 'CodingHorror').first }    
            +    let(:evil_trout) { Fabricate(:evil_trout) }
            +    let!(:topic) { Fabricate(:private_message_topic) } 
            +
            +    it "should allow the allowed users to see the topic" do
            +      Guardian.new(topic.user).can_see?(topic).should be_true
            +    end
            +
            +    it "should disallow anon to see the topic" do 
            +      Guardian.new.can_see?(topic).should be_false
            +    end
            +
            +    it "should disallow a different user to see the topic" do 
            +      Guardian.new(evil_trout).can_see?(topic).should be_false
            +    end
            +
            +    it "should allow the recipient user to see the topic" do 
            +      Guardian.new(coding_horror).can_see?(topic).should be_true
            +    end    
            +
            +    it "should be excluded from the list view" do 
            +      TopicQuery.new(evil_trout).list_popular.topics.should_not include(topic)
            +    end
            +
            +    context 'invite' do
            +      it "returns false if the username doesn't exist" do
            +        topic.invite(topic.user, 'duhhhhh').should be_false
            +      end
            +
            +      it "delegates to topic.invite_by_email when the user doesn't exist, but it's an email" do
            +        topic.expects(:invite_by_email).with(topic.user, 'jake@adventuretime.ooo')
            +        topic.invite(topic.user, 'jake@adventuretime.ooo')
            +      end
            +
            +      context 'existing user' do
            +        let(:walter) { Fabricate(:walter_white) }
            +
            +        context 'by username' do
            +          it 'returns true' do
            +            topic.invite(topic.user, walter.username).should be_true
            +          end
            +
            +          it 'adds walter to the allowed users' do
            +            topic.invite(topic.user, walter.username)
            +            topic.allowed_users.include?(walter).should be_true
            +          end
            +
            +          it 'creates a notification' do
            +            lambda { topic.invite(topic.user, walter.username) }.should change(Notification, :count)
            +          end
            +        end
            +
            +        context 'by email' do
            +          it 'returns true' do
            +            topic.invite(topic.user, walter.email).should be_true
            +          end
            +
            +          it 'adds walter to the allowed users' do
            +            topic.invite(topic.user, walter.email)
            +            topic.allowed_users.include?(walter).should be_true
            +          end
            +
            +          it 'creates a notification' do
            +            lambda { topic.invite(topic.user, walter.email) }.should change(Notification, :count)
            +          end
            +
            +        end        
            +      end
            +
            +    end
            +
            +    context "user actions" do 
            +      let(:actions) { topic.user.user_actions }
            +
            +      it "should not log a user action for the creation of the topic" do 
            +        actions.map{|a| a.action_type}.should_not include(UserAction::NEW_TOPIC)
            +      end
            +      
            +      it "should log a user action for the creation of a private message" do 
            +        actions.map{|a| a.action_type}.should include(UserAction::NEW_PRIVATE_MESSAGE)
            +      end
            +
            +      it "should log a user action for the recepient of the private message" do 
            +        coding_horror.user_actions.map{|a| a.action_type}.should include(UserAction::GOT_PRIVATE_MESSAGE)
            +      end
            +    end
            +
            +    context "other user" do
            +
            +      it "sends the other user an email when there's a new post" do
            +        UserNotifications.expects(:private_message).with(coding_horror, has_key(:post))
            +        Fabricate(:post, topic: topic, user: topic.user)
            +      end
            +
            +      it "doesn't send the user an email when they have them disabled" do
            +        coding_horror.update_column(:email_private_messages, false)
            +        UserNotifications.expects(:private_message).with(coding_horror, has_key(:post)).never
            +        Fabricate(:post, topic: topic, user: topic.user)
            +      end
            +
            +    end
            +
            +
            +  end
            +
            +
            +  context 'bumping topics' do
            +
            +    before do
            +      @topic = Fabricate(:topic, bumped_at: 1.year.ago)
            +    end
            +
            +    it 'has a bumped at value after creation' do
            +      @topic.bumped_at.should be_present
            +    end
            +
            +    it 'updates the bumped_at field when a new post is made' do
            +      lambda {
            +        Fabricate(:post, topic: @topic, user: @topic.user)
            +        @topic.reload
            +      }.should change(@topic, :bumped_at)
            +    end
            +
            +    context 'editing posts' do
            +      before do
            +        @earlier_post = Fabricate(:post, topic: @topic, user: @topic.user)
            +        @last_post = Fabricate(:post, topic: @topic, user: @topic.user)
            +        @topic.reload
            +      end
            +
            +      it "doesn't bump the topic on an edit to the last post that doesn't result in a new version" do
            +        lambda {
            +          SiteSetting.expects(:ninja_edit_window).returns(5.minutes)
            +          @last_post.revise(@last_post.user, 'updated contents', revised_at: @last_post.created_at + 10.seconds)
            +          @topic.reload
            +        }.should_not change(@topic, :bumped_at)
            +      end
            +
            +      it "bumps the topic when a new version is made of the last post" do
            +        lambda {
            +          @last_post.revise(Fabricate(:moderator), 'updated contents')
            +          @topic.reload
            +        }.should change(@topic, :bumped_at)
            +      end      
            +
            +      it "doesn't bump the topic when a post that isn't the last post receives a new version" do
            +        lambda {
            +          @earlier_post.revise(Fabricate(:moderator), 'updated contents')
            +          @topic.reload
            +        }.should_not change(@topic, :bumped_at)
            +      end      
            +
            +
            +    end
            +
            +  end
            +
            +  context 'moderator posts' do
            +    before do
            +      @moderator = Fabricate(:moderator)
            +      @topic = Fabricate(:topic)
            +      @mod_post = @topic.add_moderator_post(@moderator, "Moderator did something. http://discourse.org", post_number: 999)
            +    end
            +
            +    it 'creates a moderator post' do
            +      @mod_post.should be_present 
            +    end
            +
            +    it 'has the moderator action type' do
            +      @mod_post.post_type.should == Post::MODERATOR_ACTION
            +    end
            +
            +    it 'increases the moderator_posts count' do
            +      @topic.reload
            +      @topic.moderator_posts_count.should == 1
            +    end
            +
            +    it "inserts the post at the number we provided" do
            +      @mod_post.post_number.should == 999
            +    end
            +
            +    it "has the custom sort order we specified" do
            +      @mod_post.sort_order.should == 999
            +    end    
            +
            +    it 'creates a topic link' do
            +      @topic.topic_links.count.should == 1
            +    end
            +  end
            +
            +
            +  context 'update_status' do
            +    before do
            +      @topic = Fabricate(:topic, bumped_at: 1.hour.ago)
            +      @topic.reload
            +      @original_bumped_at = @topic.bumped_at.to_f
            +      @user = @topic.user
            +    end
            +
            +    context 'visibility' do
            +      context 'disable' do
            +        before do          
            +          @topic.update_status('visible', false, @user)
            +          @topic.reload
            +        end
            +
            +        it 'should not be visible' do
            +          @topic.should_not be_visible          
            +        end
            +
            +        it 'adds a moderator post' do
            +          @topic.moderator_posts_count.should == 1
            +        end
            +
            +        it "doesn't bump the topic" do
            +          @topic.bumped_at.to_f.should == @original_bumped_at
            +        end
            +
            +      end
            +
            +      context 'enable' do
            +        before do
            +          @topic.update_attribute :visible, false
            +          @topic.update_status('visible', true, @user)
            +          @topic.reload
            +        end
            +
            +        it 'should be visible' do
            +          @topic.should be_visible          
            +        end
            +
            +        it 'adds a moderator post' do
            +          @topic.moderator_posts_count.should == 1
            +        end
            +
            +        it "doesn't bump the topic" do
            +          @topic.bumped_at.to_f.should == @original_bumped_at
            +        end        
            +      end      
            +    end
            +
            +    context 'pinned' do
            +      context 'disable' do
            +        before do
            +          @topic.update_status('pinned', false, @user)
            +          @topic.reload
            +        end
            +
            +        it 'should not be pinned' do
            +          @topic.should_not be_pinned          
            +        end
            +
            +        it 'adds a moderator post' do
            +          @topic.moderator_posts_count.should == 1
            +        end
            +
            +        it "doesn't bump the topic" do
            +          @topic.bumped_at.to_f.should == @original_bumped_at
            +        end        
            +      end
            +
            +      context 'enable' do
            +        before do
            +          @topic.update_attribute :pinned, false
            +          @topic.update_status('pinned', true, @user)
            +          @topic.reload
            +        end
            +
            +        it 'should be pinned' do
            +          @topic.should be_pinned          
            +        end
            +
            +        it 'adds a moderator post' do
            +          @topic.moderator_posts_count.should == 1
            +        end
            +
            +        it "doesn't bump the topic" do
            +          @topic.bumped_at.to_f.should == @original_bumped_at
            +        end
            +      end      
            +    end
            +
            +    context 'archived' do
            +      context 'disable' do
            +        before do
            +          @topic.update_status('archived', false, @user)
            +          @topic.reload
            +        end
            +
            +        it 'should not be pinned' do
            +          @topic.should_not be_archived         
            +        end
            +
            +        it 'adds a moderator post' do
            +          @topic.moderator_posts_count.should == 1
            +        end
            +
            +        it "doesn't bump the topic" do
            +          @topic.bumped_at.to_f.should == @original_bumped_at
            +        end        
            +      end
            +
            +      context 'enable' do
            +        before do
            +          @topic.update_attribute :archived, false
            +          @topic.update_status('archived', true, @user)
            +          @topic.reload
            +        end
            +
            +        it 'should be archived' do
            +          @topic.should be_archived         
            +        end
            +
            +        it 'adds a moderator post' do
            +          @topic.moderator_posts_count.should == 1
            +        end
            +
            +        it "doesn't bump the topic" do
            +          @topic.bumped_at.to_f.should == @original_bumped_at
            +        end        
            +      end      
            +    end
            +
            +    context 'closed' do
            +      context 'disable' do
            +        before do
            +          @topic.update_status('closed', false, @user)
            +          @topic.reload
            +        end
            +
            +        it 'should not be pinned' do
            +          @topic.should_not be_closed
            +        end
            +
            +        it 'adds a moderator post' do
            +          @topic.moderator_posts_count.should == 1
            +        end
            +
            +        # We bump the topic when a topic is re-opened
            +        it "bumps the topic" do
            +          @topic.bumped_at.to_f.should_not == @original_bumped_at
            +        end    
            +
            +      end
            +
            +      context 'enable' do
            +        before do
            +          @topic.update_attribute :closed, false
            +          @topic.update_status('closed', true, @user)
            +          @topic.reload
            +        end
            +
            +        it 'should be closed' do
            +          @topic.should be_closed         
            +        end
            +
            +        it 'adds a moderator post' do
            +          @topic.moderator_posts_count.should == 1
            +        end
            +
            +        it "doesn't bump the topic" do
            +          @topic.bumped_at.to_f.should == @original_bumped_at
            +        end        
            +    
            +      end      
            +    end
            +
            +
            +  end
            +
            +  describe 'toggle_star' do
            +
            +    before do
            +      @topic = Fabricate(:topic)
            +      @user = @topic.user
            +    end
            +
            +    it 'triggers a forum topic user change with true' do
            +      # otherwise no chance the mock will work
            +      freeze_time do 
            +        TopicUser.expects(:change).with(@user, @topic.id, starred: true, starred_at: DateTime.now)
            +        @topic.toggle_star(@user, true)
            +      end
            +    end
            +
            +    it 'increases the star_count of the forum topic' do
            +      lambda { 
            +        @topic.toggle_star(@user, true) 
            +        @topic.reload
            +      }.should change(@topic, :star_count).by(1)
            +    end
            +
            +    it 'triggers the rate limiter' do
            +      Topic::FavoriteLimiter.any_instance.expects(:performed!)
            +      @topic.toggle_star(@user, true)
            +    end
            +
            +    describe 'removing a star' do
            +      before do
            +        @topic.toggle_star(@user, true) 
            +        @topic.reload        
            +      end
            +
            +      it 'rolls back the rate limiter' do
            +        Topic::FavoriteLimiter.any_instance.expects(:rollback!)
            +        @topic.toggle_star(@user, false)
            +      end
            +
            +      it 'triggers a forum topic user change with false' do
            +        TopicUser.expects(:change).with(@user, @topic.id, starred: false, starred_at: nil)
            +        @topic.toggle_star(@user, false)
            +      end
            +
            +      it 'reduces the star_count' do
            +        lambda { 
            +          @topic.toggle_star(@user, false) 
            +          @topic.reload
            +        }.should change(@topic, :star_count).by(-1)        
            +      end
            +
            +    end
            +  end
            +
            +  context 'last_poster info' do
            +
            +    before do
            +      @user = Fabricate(:user)
            +      @post = Fabricate(:post, user: @user)
            +      @topic = @post.topic
            +    end
            +
            +    it 'initially has the last_post_user_id of the OP' do
            +      @topic.last_post_user_id.should == @user.id
            +    end
            +
            +    context 'after a second post' do
            +      before do
            +        @second_user = Fabricate(:coding_horror)
            +        @new_post = Fabricate(:post, topic: @topic, user: @second_user)
            +        @topic.reload
            +      end
            +
            +      it 'updates the last_post_user_id to the second_user' do
            +        @topic.last_post_user_id.should == @second_user.id
            +      end
            +
            +      it 'resets the last_posted_at back to the OP' do
            +        @topic.last_posted_at.to_i.should == @new_post.created_at.to_i
            +      end
            +
            +      it 'has a posted flag set for the second user' do
            +        topic_user = @second_user.topic_users.where(topic_id: @topic.id).first
            +        topic_user.posted?.should be_true
            +      end
            +
            +
            +      context 'after deleting that post' do
            +
            +        before do
            +          @new_post.destroy
            +          Topic.reset_highest(@topic.id)
            +          @topic.reload
            +        end
            +
            +        it 'resets the last_poster_id back to the OP' do
            +          @topic.last_post_user_id.should == @user.id
            +        end
            +
            +        it 'resets the last_posted_at back to the OP' do
            +          @topic.last_posted_at.to_i.should == @post.created_at.to_i
            +        end
            +
            +        context 'topic_user' do
            +          before do
            +            @topic_user = @second_user.topic_users.where(topic_id: @topic.id).first
            +          end
            +
            +          it 'clears the posted flag for the second user' do          
            +            @topic_user.posted?.should be_false
            +          end
            +
            +          it "sets the second user's last_read_post_number back to 1" do          
            +            @topic_user.last_read_post_number.should == 1
            +          end
            +
            +          it "sets the second user's last_read_post_number back to 1" do          
            +            @topic_user.seen_post_count.should == 1
            +          end
            +                    
            +        end
            +
            +
            +      end
            +
            +    end
            +
            +  end
            +
            +  describe 'with category' do
            +    before do
            +      @category = Fabricate(:category)
            +    end
            +
            +    it "should not increase the topic_count with no category" do
            +      lambda { Fabricate(:topic, user: @category.user); @category.reload }.should_not change(@category, :topic_count)
            +    end
            +
            +    it "should increase the category's topic_count" do
            +      lambda { Fabricate(:topic, user: @category.user, category_id: @category.id); @category.reload }.should change(@category, :topic_count).by(1)
            +    end
            +  end
            +
            +  describe 'meta data' do
            +    let(:topic) { Fabricate(:topic, :meta_data => {hello: 'world'}) }
            +
            +    it 'allows us to create a topic with meta data' do
            +      topic.meta_data['hello'].should == 'world'
            +    end
            +
            +    context 'updating' do
            +
            +      context 'existing key' do
            +        before do
            +          topic.update_meta_data(hello: 'bane')
            +        end
            +
            +        it 'updates the key' do
            +          topic.meta_data['hello'].should == 'bane'
            +        end
            +      end
            +
            +      context 'new key' do
            +        before do
            +          topic.update_meta_data(city: 'gotham')
            +        end
            +
            +        it 'adds the new key' do
            +          topic.meta_data['city'].should == 'gotham'
            +        end
            +
            +        it 'still has the old key' do
            +          topic.meta_data['hello'].should == 'world'
            +        end
            +
            +      end
            +
            +
            +    end
            +
            +  end
            +
            +  describe 'after create' do
            +
            +    let(:topic) { Fabricate(:topic) }
            +
            +    it 'is a regular topic by default' do
            +      topic.archetype.should == Archetype.default
            +    end
            +
            +    it 'is not a best_of' do
            +      topic.has_best_of.should be_false 
            +    end
            +
            +    it 'is not invisible' do
            +      topic.should be_visible
            +    end
            +
            +    it 'is not pinned' do
            +      topic.should_not be_pinned
            +    end
            +
            +    it 'is not closed' do
            +      topic.should_not be_closed
            +    end
            +
            +    it 'is not archived' do
            +      topic.should_not be_archived
            +    end
            +
            +    it 'has no moderator posts' do
            +      topic.moderator_posts_count.should == 0
            +    end
            +
            +
            +
            +    context 'post' do
            +      let(:post) { Fabricate(:post, topic: topic, user: topic.user) }
            +
            +      it 'has the same archetype as the topic' do
            +        post.archetype.should == topic.archetype
            +      end
            +    end
            +  end
            +  
            +  describe 'versions' do
            +    let(:topic) { Fabricate(:topic) }
            +
            +    it "has version 1 by default" do
            +      topic.version.should == 1
            +    end
            +
            +    context 'changing title' do
            +      before do
            +        topic.title = "new title"
            +        topic.save
            +      end
            +
            +      it "creates a new version" do
            +        topic.version.should == 2
            +      end
            +    end
            +
            +    context 'changing category' do
            +      let(:category) { Fabricate(:category) }
            +
            +      before do
            +        topic.change_category(category.name)
            +      end
            +
            +      it "creates a new version" do
            +        topic.version.should == 2
            +      end
            +
            +      context "removing a category" do
            +        before do
            +          topic.change_category(nil)
            +        end
            +
            +        it "creates a new version" do
            +          topic.version.should == 3
            +        end
            +      end
            +
            +    end
            +
            +    context 'bumping the topic' do
            +      before do
            +        topic.bumped_at = 10.minutes.from_now
            +        topic.save
            +      end
            +
            +      it "doesn't craete a new version" do
            +        topic.version.should == 1
            +      end
            +    end
            +
            +  end
            +
            +  describe 'change_category' do
            +
            +    before do
            +      @topic = Fabricate(:topic)
            +      @category = Fabricate(:category, user: @topic.user)      
            +      @user = @topic.user      
            +    end
            +
            +    describe 'without a previous category' do
            +
            +      it 'should not change the topic_count when not changed' do
            +       lambda { @topic.change_category(nil); @category.reload }.should_not change(@category, :topic_count)
            +      end
            +
            +      describe 'changed category' do
            +        before do
            +          @topic.change_category(@category.name)
            +          @category.reload
            +        end
            +
            +        it 'changes the category' do      
            +          @topic.category.should == @category
            +        end
            +
            +        it 'increases the topic_count' do
            +          @category.topic_count.should == 1
            +        end
            +
            +      end
            +
            +
            +      it "doesn't change the category when it can't be found" do
            +        @topic.change_category('made up')
            +        @topic.category.should be_blank
            +      end
            +    end
            +
            +    describe 'with a previous category' do
            +      before do
            +        @topic.change_category(@category.name)
            +        @topic.reload
            +        @category.reload
            +      end
            +
            +      it 'increases the topic_count' do
            +        @category.topic_count.should == 1
            +      end
            +
            +      it "doesn't change the topic_count when the value doesn't change" do
            +        lambda { @topic.change_category(@category.name); @category.reload }.should_not change(@category, :topic_count)
            +      end
            +
            +      it "doesn't reset the category when given a name that doesn't exist" do
            +        @topic.change_category('made up')
            +        @topic.category_id.should be_present
            +      end
            +
            +      describe 'to a different category' do
            +        before do
            +          @new_category = Fabricate(:category, user: @user, name: '2nd category')
            +          @topic.change_category(@new_category.name)
            +          @topic.reload
            +          @new_category.reload
            +          @category.reload
            +        end
            +
            +        it "should increase the new category's topic count" do
            +          @new_category.topic_count.should == 1
            +        end
            +
            +        it "should lower the original category's topic count" do
            +          @category.topic_count.should == 0
            +        end
            +        
            +      end
            +
            +      describe 'when the category exists' do
            +        before do
            +          @topic.change_category(nil)
            +          @category.reload          
            +        end
            +
            +        it "resets the category" do        
            +          @topic.category_id.should be_blank
            +        end
            +
            +        it "lowers the forum topic count" do        
            +          @category.topic_count.should == 0
            +        end
            +
            +      end
            +
            +    end
            +
            +  end
            +
            +end
            diff --git a/spec/models/topic_user_spec.rb b/spec/models/topic_user_spec.rb
            new file mode 100644
            index 00000000000..6376fa59212
            --- /dev/null
            +++ b/spec/models/topic_user_spec.rb
            @@ -0,0 +1,199 @@
            +require 'spec_helper'
            +
            +describe TopicUser do
            +
            +  it { should belong_to :user }
            +  it { should belong_to :topic }
            +
            +  before do
            +    #mock time so we can test dates
            +    @now = DateTime.now.yesterday
            +    DateTime.expects(:now).at_least_once.returns(@now)
            +    @topic = Fabricate(:topic)
            +    @user = Fabricate(:coding_horror)
            +  end
            +
            +  describe 'notifications' do 
            +
            +    it 'should be set to tracking if auto_track_topics is enabled' do 
            +      @user.auto_track_topics_after_msecs = 0
            +      @user.save
            +      TopicUser.change(@user, @topic, {:starred_at => DateTime.now})
            +      TopicUser.get(@topic,@user).notification_level.should == TopicUser::NotificationLevel::TRACKING
            +    end
            +
            +    it 'should reset regular topics to tracking topics if auto track is changed' do 
            +      TopicUser.change(@user, @topic, {:starred_at => DateTime.now})
            +      @user.auto_track_topics_after_msecs = 0
            +      @user.save
            +      TopicUser.get(@topic,@user).notification_level.should == TopicUser::NotificationLevel::TRACKING
            +    end
            +
            +    it 'should be set to "regular" notifications, by default on non creators' do 
            +      TopicUser.change(@user, @topic, {:starred_at => DateTime.now})
            +      TopicUser.get(@topic,@user).notification_level.should == TopicUser::NotificationLevel::REGULAR
            +    end
            +
            +    it 'reason should reset when changed' do 
            +      @topic.notify_muted!(@topic.user)
            +      TopicUser.get(@topic,@topic.user).notifications_reason_id.should == TopicUser::NotificationReasons::USER_CHANGED
            +    end
            +    
            +    it 'should have the correct reason for a user change when watched' do 
            +      @topic.notify_watch!(@user)
            +      tu = TopicUser.get(@topic,@user)
            +      tu.notification_level.should == TopicUser::NotificationLevel::WATCHING
            +      tu.notifications_reason_id.should == TopicUser::NotificationReasons::USER_CHANGED
            +      tu.notifications_changed_at.should_not be_nil
            +    end
            +    
            +    it 'should have the correct reason for a user change when set to regular' do 
            +      @topic.notify_regular!(@user)
            +      tu = TopicUser.get(@topic,@user)
            +      tu.notification_level.should == TopicUser::NotificationLevel::REGULAR
            +      tu.notifications_reason_id.should == TopicUser::NotificationReasons::USER_CHANGED
            +      tu.notifications_changed_at.should_not be_nil
            +    end
            +    
            +    it 'should have the correct reason for a user change when set to regular' do 
            +      @topic.notify_muted!(@user)
            +      tu = TopicUser.get(@topic,@user)
            +      tu.notification_level.should == TopicUser::NotificationLevel::MUTED
            +      tu.notifications_reason_id.should == TopicUser::NotificationReasons::USER_CHANGED
            +      tu.notifications_changed_at.should_not be_nil
            +    end
            +
            +    it 'should watch topics a user created' do
            +      tu = TopicUser.get(@topic,@topic.user)
            +      tu.notification_level.should == TopicUser::NotificationLevel::WATCHING
            +      tu.notifications_reason_id.should == TopicUser::NotificationReasons::CREATED_TOPIC
            +    end
            +  end
            +
            +  describe 'visited at' do
            +    before do
            +      TopicUser.track_visit!(@topic, @user)
            +      @topic_user = TopicUser.get(@topic,@user)
            +
            +    end   
            +    
            +    it 'set upon initial visit' do 
            +      @topic_user.first_visited_at.to_i.should == @now.to_i
            +      @topic_user.last_visited_at.to_i.should == @now.to_i
            +    end
            +
            +    it 'updates upon repeat visit' do 
            +      tomorrow = @now.tomorrow
            +      DateTime.expects(:now).returns(tomorrow)
            +      
            +      TopicUser.track_visit!(@topic,@user)
            +      # reload is a no go
            +      @topic_user = TopicUser.get(@topic,@user)
            +      @topic_user.first_visited_at.to_i.should == @now.to_i
            +      @topic_user.last_visited_at.to_i.should == tomorrow.to_i
            +    end
            +
            +  end
            +
            +  describe 'read tracking' do 
            +    before do 
            +      @post = Fabricate(:post, topic: @topic, user: @topic.user) 
            +      TopicUser.update_last_read(@user, @topic.id, 1, 0)
            +      @topic_user = TopicUser.get(@topic,@user)
            +    end
            +
            +    it 'should create a new record for a visit' do 
            +      @topic_user.last_read_post_number.should == 1
            +      @topic_user.last_visited_at.to_i.should == @now.to_i
            +      @topic_user.first_visited_at.to_i.should == @now.to_i
            +    end
            +    
            +    it 'should update the record for repeat visit' do 
            +      Fabricate(:post, topic: @topic, user: @user) 
            +      TopicUser.update_last_read(@user, @topic.id, 2, 0)
            +      @topic_user = TopicUser.get(@topic,@user)
            +      @topic_user.last_read_post_number.should == 2
            +      @topic_user.last_visited_at.to_i.should == @now.to_i
            +      @topic_user.first_visited_at.to_i.should == @now.to_i
            +    end
            +
            +    context 'auto tracking' do 
            +      before do
            +        Fabricate(:post, topic: @topic, user: @user) 
            +        @new_user = Fabricate(:user, auto_track_topics_after_msecs: 1000)
            +        TopicUser.update_last_read(@new_user, @topic.id, 2, 0)
            +        @topic_user = TopicUser.get(@topic,@new_user)
            +      end
            +      
            +      it 'should automatically track topics you reply to' do
            +        post = Fabricate(:post, topic: @topic, user: @new_user)
            +        @topic_user = TopicUser.get(@topic,@new_user)
            +        @topic_user.notification_level.should == TopicUser::NotificationLevel::TRACKING
            +        @topic_user.notifications_reason_id.should == TopicUser::NotificationReasons::CREATED_POST
            +      end
            +      
            +      it 'should not automatically track topics you reply to and have set state manually' do
            +        Fabricate(:post, topic: @topic, user: @new_user)
            +        TopicUser.change(@new_user, @topic, notification_level: TopicUser::NotificationLevel::REGULAR)
            +        @topic_user = TopicUser.get(@topic,@new_user)
            +        @topic_user.notification_level.should == TopicUser::NotificationLevel::REGULAR
            +        @topic_user.notifications_reason_id.should == TopicUser::NotificationReasons::USER_CHANGED
            +      end
            +
            +      it 'should automatically track topics after they are read for long enough' do 
            +        @topic_user.notification_level.should == TopicUser::NotificationLevel::REGULAR
            +        TopicUser.update_last_read(@new_user, @topic.id, 2, 1001)
            +        @topic_user = TopicUser.get(@topic,@new_user)
            +        @topic_user.notification_level.should == TopicUser::NotificationLevel::TRACKING
            +      end
            +      
            +      it 'should not automatically track topics after they are read for long enough if changed manually' do 
            +        TopicUser.change(@new_user, @topic, notification_level: TopicUser::NotificationLevel::REGULAR)
            +        @topic_user = TopicUser.get(@topic,@new_user)
            +
            +        TopicUser.update_last_read(@new_user, @topic, 2, 1001)
            +        @topic_user = TopicUser.get(@topic,@new_user)
            +        @topic_user.notification_level.should == TopicUser::NotificationLevel::REGULAR
            +      end
            +    end
            +  end
            +
            +  describe 'change a flag' do
            +
            +    it 'creates a forum topic user record' do
            +      lambda {
            +        TopicUser.change(@user, @topic.id, starred: true)
            +      }.should change(TopicUser, :count).by(1)
            +    end
            +
            +    it "only inserts a row once, even on repeated calls" do
            +      lambda {
            +        TopicUser.change(@user, @topic.id, starred: true)
            +        TopicUser.change(@user, @topic.id, starred: false)
            +        TopicUser.change(@user, @topic.id, starred: true)
            +      }.should change(TopicUser, :count).by(1)
            +    end
            +    
            +    describe 'after creating a row' do
            +      before do
            +        TopicUser.change(@user, @topic.id, starred: true)
            +        @topic_user = TopicUser.where(user_id: @user.id, topic_id: @topic.id).first
            +      end
            +
            +      it 'has the correct starred value' do
            +        @topic_user.should be_starred
            +      end
            +
            +      it 'has a lookup' do
            +        TopicUser.lookup_for(@user, [@topic]).should be_present
            +      end
            +
            +      it 'has a key in the lookup for this forum topic' do
            +        TopicUser.lookup_for(@user, [@topic]).has_key?(@topic.id).should be_true
            +      end
            +
            +    end
            +
            +  end
            +
            +end 
            diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb
            new file mode 100644
            index 00000000000..e136d7dd962
            --- /dev/null
            +++ b/spec/models/upload_spec.rb
            @@ -0,0 +1,9 @@
            +require 'spec_helper'
            +
            +describe Upload do
            +
            +  it { should belong_to :user }
            +  it { should belong_to :topic }
            +  it { should validate_presence_of :original_filename }
            +  it { should validate_presence_of :filesize }
            +end
            diff --git a/spec/models/user_action_spec.rb b/spec/models/user_action_spec.rb
            new file mode 100644
            index 00000000000..0416b4fcff9
            --- /dev/null
            +++ b/spec/models/user_action_spec.rb
            @@ -0,0 +1,174 @@
            +require 'spec_helper'
            +
            +describe UserAction do
            +
            +  it { should validate_presence_of :action_type }
            +  it { should validate_presence_of :user_id }
            +
            +
            +  describe 'lists' do 
            +
            +    before do 
            +      a = UserAction.new 
            +      @post = Fabricate(:post)
            +      @user = Fabricate(:coding_horror)
            +      row = { 
            +        action_type: UserAction::NEW_PRIVATE_MESSAGE,
            +        user_id: @user.id, 
            +        acting_user_id: @user.id, 
            +        target_topic_id: @post.topic_id,
            +        target_post_id: @post.id, 
            +      }
            +
            +      UserAction.log_action!(row)
            +      
            +      row[:action_type] = UserAction::GOT_PRIVATE_MESSAGE
            +      UserAction.log_action!(row)
            +     
            +      row[:action_type] = UserAction::NEW_TOPIC
            +      UserAction.log_action!(row)
            +        
            +    end
            +
            +    describe 'stats' do
            +
            +      let :mystats do 
            +        UserAction.stats(@user.id,Guardian.new(@user))
            +      end
            +
            +      it 'should include non private message events' do 
            +        mystats.map{|r| r["action_type"].to_i}.should include(UserAction::NEW_TOPIC)
            +      end
            +
            +      it 'should exclude private messages for non owners' do 
            +        UserAction.stats(@user.id,Guardian.new).map{|r| r["action_type"].to_i}.should_not include(UserAction::NEW_PRIVATE_MESSAGE)
            +      end
            +
            +      it 'should not include got private messages for owners' do 
            +        UserAction.stats(@user.id,Guardian.new).map{|r| r["action_type"].to_i}.should_not include(UserAction::GOT_PRIVATE_MESSAGE)
            +      end
            +    
            +      it 'should include private messages for owners' do 
            +        mystats.map{|r| r["action_type"].to_i}.should include(UserAction::NEW_PRIVATE_MESSAGE)
            +      end
            +
            +      it 'should include got private messages for owners' do 
            +        mystats.map{|r| r["action_type"].to_i}.should include(UserAction::GOT_PRIVATE_MESSAGE)
            +      end
            +    end
            +
            +    describe 'stream' do 
            +
            +      it 'should have 1 item for non owners' do
            +        UserAction.stream(user_id: @user.id, guardian: Guardian.new).count.should == 1
            +      end
            +      
            +      it 'should have 3 items for non owners' do
            +        UserAction.stream(user_id: @user.id, guardian: @user.guardian).count.should == 3
            +      end
            +
            +    end
            +  end
            +
            +  it 'calls the message bus observer' do
            +    MessageBusObserver.any_instance.expects(:after_create_user_action).with(instance_of(UserAction))
            +    Fabricate(:user_action)
            +  end
            +
            +  describe 'when user likes' do 
            +    before do 
            +      @post = Fabricate(:post)
            +      @likee = @post.user
            +      @liker = Fabricate(:coding_horror) 
            +      PostAction.act(@liker, @post, PostActionType.Types[:like])
            +      @liker_action = @liker.user_actions.where(action_type: UserAction::LIKE).first
            +      @likee_action = @likee.user_actions.where(action_type: UserAction::WAS_LIKED).first
            +    end
            +
            +    it 'should create a like action on the liker' do 
            +      @liker_action.should_not be_nil 
            +    end
            +
            +    it 'should create a like action on the likee' do
            +      @likee_action.should_not be_nil 
            +    end
            +  end
            +
            +  describe 'when a user posts a new topic' do
            +    before do 
            +      @post = Fabricate(:old_post)
            +    end
            +
            +    describe 'topic action' do 
            +      before do 
            +        @action = @post.user.user_actions.where(action_type: UserAction::NEW_TOPIC).first
            +      end
            +      it 'should exist' do 
            +        @action.should_not be_nil
            +      end
            +      it 'shoule have the correct date' do 
            +        @action.created_at.should be_within(1).of(@post.topic.created_at)
            +      end
            +    end
            +
            + 
            +    it 'should not log a post user action' do 
            +      @post.user.user_actions.where(action_type: UserAction::POST).first.should be_nil
            +    end
            +
            +
            +    describe 'when another user posts on the topic' do 
            +      before do 
            +        @other_user = Fabricate(:coding_horror)
            +        @mentioned = Fabricate(:admin)
            +        @response = Fabricate(:post, topic: @post.topic, user: @other_user, raw: "perhaps @#{@mentioned.username} knows how this works?")
            +      end
            +      
            +      it 'should log a post action for the poster' do 
            +        @response.user.user_actions.where(action_type: UserAction::POST).first.should_not be_nil
            +      end
            +
            +      it 'should log a post action for the original poster' do 
            +        @post.user.user_actions.where(action_type: UserAction::TOPIC_RESPONSE).first.should_not be_nil
            +      end
            +
            +      it 'should log a mention for the mentioned' do 
            +        @mentioned.user_actions.where(action_type: UserAction::MENTION).first.should_not be_nil
            +      end
            +
            +      it 'should not log a double notification for a post edit' do
            +        @response.raw = "here it goes again"
            +        @response.save! 
            +        @response.user.user_actions.where(action_type: UserAction::POST).count.should == 1
            +      end
            +
            +    end
            +
            +  end
            +
            +  describe 'when user bookmarks' do
            +    before do 
            +      @post = Fabricate(:post)
            +      @user = @post.user
            +      PostAction.act(@user, @post, PostActionType.Types[:bookmark])
            +      @action = @user.user_actions.where(action_type: UserAction::BOOKMARK).first
            +    end
            +
            +    it 'should create a bookmark action' do 
            +      @action.action_type.should == UserAction::BOOKMARK
            +    end
            +    it 'should point to the correct post' do
            +      @action.target_post_id.should == @post.id
            +    end
            +    it 'should have the right acting_user' do
            +      @action.acting_user_id.should == @user.id 
            +    end
            +    it 'should target the correct user' do 
            +      @action.user_id.should == @user.id
            +    end
            +    it 'should nuke the action when unbookmarked' do 
            +      PostAction.remove_act(@user, @post, PostActionType.Types[:bookmark])
            +      @user.user_actions.where(action_type: UserAction::BOOKMARK).first.should be_nil
            +    end
            +  end
            +end
            diff --git a/spec/models/user_email_observer_spec.rb b/spec/models/user_email_observer_spec.rb
            new file mode 100644
            index 00000000000..ae8c198066a
            --- /dev/null
            +++ b/spec/models/user_email_observer_spec.rb
            @@ -0,0 +1,77 @@
            +require 'spec_helper'
            +
            +describe UserEmailObserver do
            +
            +  context 'user_mentioned' do
            +
            +    let(:user) { Fabricate(:user) }
            +    let!(:notification) { Fabricate(:notification, user: user) }
            +
            +    it "enqueues a job for the email" do
            +      Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_mentioned, user_id: notification.user_id, notification_id: notification.id)
            +      UserEmailObserver.send(:new).email_user_mentioned(notification)
            +    end
            +
            +    it "doesn't enqueue an email if the user has mention emails disabled" do
            +      user.expects(:email_direct?).returns(false)
            +      Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, has_entry(type: :user_mentioned)).never
            +      UserEmailObserver.send(:new).email_user_mentioned(notification)
            +    end
            +
            +  end
            +
            +  context 'user_replied' do
            +
            +    let(:user) { Fabricate(:user) }
            +    let!(:notification) { Fabricate(:notification, user: user) }
            +
            +    it "enqueues a job for the email" do
            +      Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_replied, user_id: notification.user_id, notification_id: notification.id)
            +      UserEmailObserver.send(:new).email_user_replied(notification)
            +    end
            +
            +    it "doesn't enqueue an email if the user has mention emails disabled" do
            +      user.expects(:email_direct?).returns(false)
            +      Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, has_entry(type: :user_replied)).never
            +      UserEmailObserver.send(:new).email_user_replied(notification)
            +    end
            +
            +  end
            +
            +  context 'user_quoted' do
            +
            +    let(:user) { Fabricate(:user) }
            +    let!(:notification) { Fabricate(:notification, user: user) }
            +
            +    it "enqueues a job for the email" do
            +      Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_quoted, user_id: notification.user_id, notification_id: notification.id)
            +      UserEmailObserver.send(:new).email_user_quoted(notification)
            +    end
            +
            +    it "doesn't enqueue an email if the user has mention emails disabled" do
            +      user.expects(:email_direct?).returns(false)
            +      Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, has_entry(type: :user_quoted)).never
            +      UserEmailObserver.send(:new).email_user_quoted(notification)
            +    end
            +
            +  end
            +
            +  context 'email_user_invited_to_private_message' do
            +
            +    let(:user) { Fabricate(:user) }
            +    let!(:notification) { Fabricate(:notification, user: user) }
            +
            +    it "enqueues a job for the email" do
            +      Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, type: :user_invited_to_private_message, user_id: notification.user_id, notification_id: notification.id)
            +      UserEmailObserver.send(:new).email_user_invited_to_private_message(notification)
            +    end
            +
            +    it "doesn't enqueue an email if the user has mention emails disabled" do
            +      user.expects(:email_direct?).returns(false)
            +      Jobs.expects(:enqueue_in).with(SiteSetting.email_time_window_mins.minutes, :user_email, has_entry(type: :user_invited_to_private_message)).never
            +      UserEmailObserver.send(:new).email_user_invited_to_private_message(notification)
            +    end
            +
            +  end
            +
            +end
            \ No newline at end of file
            diff --git a/spec/models/user_open_id_spec.rb b/spec/models/user_open_id_spec.rb
            new file mode 100644
            index 00000000000..fb6825930a4
            --- /dev/null
            +++ b/spec/models/user_open_id_spec.rb
            @@ -0,0 +1,8 @@
            +require 'spec_helper'
            +
            +describe UserOpenId do
            +
            +  it { should belong_to :user }
            +  it { should validate_presence_of :email }
            +  it { should validate_presence_of :url }
            +end
            \ No newline at end of file
            diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
            new file mode 100644
            index 00000000000..54eedbc15d8
            --- /dev/null
            +++ b/spec/models/user_spec.rb
            @@ -0,0 +1,586 @@
            +require 'spec_helper'
            +
            +describe User do
            +
            +  it { should have_many :posts }
            +  it { should have_many :notifications }
            +  it { should have_many :topic_users }
            +  it { should have_many :post_actions }
            +  it { should have_many :user_actions }
            +  it { should have_many :topics }
            +  it { should have_many :user_open_ids }
            +  it { should have_many :post_timings }
            +  it { should have_many :email_tokens }
            +  it { should have_many :views }
            +  it { should have_many :user_visits }
            +  it { should belong_to :approved_by }
            +
            +  it { should validate_presence_of :username }
            +  it { should validate_presence_of :email }
            +
            +  context '#update_view_counts' do
            +
            +    let(:user) { Fabricate(:user) }
            +
            +    context 'topics_entered' do
            +      context 'without any views' do
            +        it "doesn't increase the user's topics_entered" do
            +          lambda { User.update_view_counts; user.reload }.should_not change(user, :topics_entered)
            +        end
            +      end
            +
            +      context 'with a view' do
            +        let(:topic) { Fabricate(:topic) }
            +        let!(:view) { View.create_for(topic, '127.0.0.1', user) }
            +
            +        it "adds one to the topics entered" do
            +          User.update_view_counts
            +          user.reload
            +          user.topics_entered.should == 1        
            +        end
            +
            +        it "won't record a second view as a different topic" do
            +          View.create_for(topic, '127.0.0.1', user)
            +          User.update_view_counts
            +          user.reload
            +          user.topics_entered.should == 1
            +        end      
            +
            +      end      
            +    end
            +
            +    context 'posts_read_count' do
            +      context 'without any post timings' do
            +        it "doesn't increase the user's posts_read_count" do
            +          lambda { User.update_view_counts; user.reload }.should_not change(user, :posts_read_count)
            +        end
            +      end
            +
            +      context 'with a post timing' do
            +        let!(:post) { Fabricate(:post) }
            +        let!(:post_timings) do 
            +          PostTiming.record_timing(msecs: 1234, topic_id: post.topic_id, user_id: user.id, post_number: post.post_number)
            +        end
            +
            +        it "increases posts_read_count" do
            +          User.update_view_counts
            +          user.reload
            +          user.posts_read_count.should == 1
            +        end
            +
            +      end
            +
            +    end
            +
            +
            +  end
            +
            +  context '.enqueue_welcome_message' do
            +    let(:user) { Fabricate(:user) }
            +
            +    it 'enqueues the system message' do
            +      Jobs.expects(:enqueue).with(:send_system_message, user_id: user.id, message_type: 'welcome_user')
            +      user.enqueue_welcome_message('welcome_user')
            +    end
            +
            +    it "doesn't enqueue the system message when the site settings disable it" do
            +      SiteSetting.expects(:send_welcome_message?).returns(false)
            +      Jobs.expects(:enqueue).with(:send_system_message, user_id: user.id, message_type: 'welcome_user').never
            +      user.enqueue_welcome_message('welcome_user')
            +    end
            +
            +  end
            +
            +  describe '.approve!' do
            +    let(:user) { Fabricate(:user) }
            +    let(:admin) { Fabricate(:admin) }
            +
            +    it "generates a welcome message" do
            +      user.expects(:enqueue_welcome_message).with('welcome_approved')
            +      user.approve(admin)
            +    end
            +
            +    context 'after approval' do
            +      before do
            +        user.approve(admin)
            +      end
            +
            +      it 'marks the user as approved' do
            +        user.should be_approved
            +      end
            +
            +      it 'has the admin as the approved by' do
            +        user.approved_by.should == admin
            +      end
            +
            +      it 'has a value for approved_at' do
            +        user.approved_at.should be_present
            +      end
            +    end  
            +  end
            +
            +
            +  describe 'bookmark' do
            +    before do
            +      @post = Fabricate(:post)
            +    end
            +
            +    it "creates a bookmark with the true parameter" do
            +      lambda { 
            +        PostAction.act(@post.user, @post, PostActionType.Types[:bookmark])
            +      }.should change(PostAction, :count).by(1)
            +    end
            +
            +    describe 'when removing a bookmark' do
            +      before do
            +        PostAction.act(@post.user, @post, PostActionType.Types[:bookmark])
            +      end
            +
            +      it 'reduces the bookmark count of the post' do
            +        active = PostAction.where(deleted_at: nil)
            +        lambda { 
            +          PostAction.remove_act(@post.user, @post, PostActionType.Types[:bookmark])
            +        }.should change(active, :count).by(-1)
            +      end
            +    end
            +  end
            +
            +  describe 'change_username' do
            +
            +    let(:user) { Fabricate(:user) }
            +
            +    context 'success' do
            +      let(:new_username) { "#{user.username}1234" }
            +
            +      before do
            +        @result = user.change_username(new_username)
            +      end
            +
            +      it 'returns true' do
            +        @result.should be_true
            +      end
            +
            +      it 'should change the username' do
            +        user.reload
            +        user.username.should == new_username
            +      end
            +
            +      it 'should change the username_lower' do
            +        user.reload
            +        user.username_lower.should == new_username.downcase
            +      end
            +
            +    end
            +
            +  end
            +
            +
            +  describe 'new' do
            +
            +    subject { Fabricate.build(:user) }
            +
            +    it { should be_valid }
            +    it { should_not be_admin }
            +    it { should_not be_active }
            +    it { should_not be_approved }
            +    its(:approved_at) { should be_blank }
            +    its(:approved_by_id) { should be_blank }
            +    its(:email_digests) { should be_true }
            +    its(:email_private_messages) { should be_true }
            +    its(:email_direct ) { should be_true }
            +    its(:time_read) { should == 0}
            +
            +    # Default to digests after one week
            +    its(:digest_after_days) { should == 7 }
            +
            +    context 'after_save' do
            +      before do
            +        subject.save        
            +      end
            +
            +      its(:email_tokens) { should be_present }      
            +      its(:bio_cooked) { should be_present }
            +      its(:topics_entered) { should == 0 }
            +      its(:posts_read_count) { should == 0 }
            +    end
            +  end
            +
            +  describe "trust levels" do
            +    let(:user) { Fabricate(:user, trust_level: TrustLevel.Levels[:new]) }
            +
            +    it "sets to the default trust level setting" do
            +      SiteSetting.expects(:default_trust_level).returns(TrustLevel.Levels[:advanced])
            +      User.new.trust_level.should == TrustLevel.Levels[:advanced]
            +    end
            +
            +    describe 'has_trust_level' do
            +
            +      it "raises an error with an invalid level" do
            +        lambda { user.has_trust_level?(:wat) }.should raise_error
            +      end
            +
            +      it "is true for your basic level" do
            +        user.has_trust_level?(:new).should be_true
            +      end
            +
            +      it "is false for a higher level" do
            +        user.has_trust_level?(:moderator).should be_false
            +      end
            +
            +      it "is true if you exceed the level" do
            +        user.trust_level = TrustLevel.Levels[:advanced]
            +        user.has_trust_level?(:basic).should be_true
            +      end
            +
            +      it "is true for an admin even with a low trust level" do
            +        user.trust_level = TrustLevel.Levels[:new]
            +        user.admin = true
            +        user.has_trust_level?(:advanced).should be_true
            +      end
            +
            +    end
            +
            +    describe 'moderator' do
            +      it "isn't a moderator by default" do
            +        user.has_trust_level?(:moderator).should be_false
            +      end
            +
            +      it "is a moderator if the user level is moderator" do
            +        user.trust_level = TrustLevel.Levels[:moderator]
            +        user.has_trust_level?(:moderator).should be_true
            +      end      
            +
            +      it "is a moderator if the user is an admin" do
            +        user.admin = true
            +        user.has_trust_level?(:moderator).should be_true
            +      end      
            +
            +    end
            +
            +
            +  end
            +
            +  describe 'temporary_key' do
            +
            +    let(:user) { Fabricate(:user) }
            +    let!(:temporary_key) { user.temporary_key}
            +
            +    it 'has a temporary key' do
            +      temporary_key.should be_present
            +    end
            +
            +    describe 'User#find_by_temporary_key' do
            +
            +      it 'can be used to find the user' do
            +        User.find_by_temporary_key(temporary_key).should == user
            +      end
            +
            +      it 'returns nil with an invalid key' do
            +        User.find_by_temporary_key('asdfasdf').should be_blank
            +      end
            +
            +    end
            +
            +  end
            +
            +  describe 'email_hash' do 
            +    before do 
            +      @user = Fabricate(:user)
            +    end
            +
            +    it 'should have a sane email hash' do 
            +      @user.email_hash.should =~ /^[0-9a-f]{32}$/
            +    end 
            +  end
            +
            +  describe 'name heuristics' do 
            +    it 'is able to guess a decent username from an email' do 
            +      User.suggest_username('bob@bob.com').should == 'bob'
            +    end
            +
            +    it 'is able to guess a decent name from an email' do 
            +      User.suggest_name('sam.saffron@gmail.com').should == 'Sam Saffron'
            +    end
            +  end
            +
            +  describe 'username format' do 
            +    it "should always be 3 chars or longer" do 
            +      @user = Fabricate.build(:user)
            +      @user.username = 'ss'
            +      @user.save.should == false
            +    end
            +
            +    it "should never end with a ." do 
            +      @user = Fabricate.build(:user)
            +      @user.username = 'sam.'
            +      @user.save.should == false
            +    end
            +
            +    it "should never contain spaces" do 
            +      @user = Fabricate.build(:user)
            +      @user.username = 'sam s'
            +      @user.save.should == false
            +    end
            +
            +    ['Bad One', 'Giraf%fe', 'Hello!', '@twitter', 'me@example.com', 'no.dots', 'purple.', '.bilbo', '_nope', 'sa$sy'].each do |bad_nickname|
            +      it "should not allow username '#{bad_nickname}'" do
            +        @user = Fabricate.build(:user)
            +        @user.username = bad_nickname
            +        @user.save.should == false
            +      end
            +    end
            +  end
            +
            +  describe 'username uniqueness' do
            +    before do
            +      @user = Fabricate.build(:user)
            +      @user.save!
            +      @codinghorror = Fabricate.build(:coding_horror)
            +    end
            +  
            +    it "should not allow saving if username is reused" do
            +       @codinghorror.username = @user.username
            +       @codinghorror.save.should be_false
            +    end
            +
            +    it "should not allow saving if username is reused in different casing" do 
            +       @codinghorror.username = @user.username.upcase
            +       @codinghorror.save.should be_false
            +    end
            +  end
            +
            +  context '.username_available?' do
            +    it "returns true for a username that is available" do
            +      User.username_available?('BruceWayne').should be_true
            +    end
            +
            +    it 'returns false when a username is taken' do
            +      User.username_available?(Fabricate(:user).username).should be_false
            +    end
            +  end
            +
            +  describe '.suggest_username' do 
            +    it 'corrects weird characters' do
            +      User.suggest_username("Darth%^Vadar").should == "Darth_Vadar"
            +    end
            +
            +    it 'adds 1 to an existing username' do
            +      user = Fabricate(:user)
            +      User.suggest_username(user.username).should == "#{user.username}1"
            +    end
            +
            +    it "adds numbers if it's too short" do
            +      User.suggest_username('a').should == 'a11'
            +    end
            +
            +    it "has a special case for me emails" do
            +      User.suggest_username('me@eviltrout.com').should == 'eviltrout'
            +    end
            +
            +    it "shortens very long suggestions" do
            +      User.suggest_username("myreallylongnameisrobinwardesquire").should == 'myreallylongnam'
            +    end
            +
            +    it "makes room for the digit added if the username is too long" do
            +      User.create(username: 'myreallylongnam', email: 'fake@discourse.org')
            +      User.suggest_username("myreallylongnam").should == 'myreallylongna1'
            +    end 
            +
            +    it "removes leading character if it is not alphanumeric" do
            +      User.suggest_username("_myname").should == 'myname'
            +    end
            +
            +    it "removes trailing characters if they are invalid" do
            +      User.suggest_username("myname!^$=").should == 'myname'
            +    end
            +
            +    it "replace dots" do
            +      User.suggest_username("my.name").should == 'my_name'
            +    end
            +
            +    it "remove leading dots" do
            +      User.suggest_username(".myname").should == 'myname'
            +    end
            +
            +    it "remove trailing dots" do
            +      User.suggest_username("myname.").should == 'myname'
            +    end
            +
            +    it 'should handle typical facebook usernames' do
            +      User.suggest_username('roger.nelson.3344913').should == 'roger_nelson_33'
            +    end
            +  end
            +
            +  describe 'passwords' do 
            +    before do 
            +      @user = Fabricate.build(:user)
            +      @user.password = "ilovepasta" 
            +      @user.save!
            +    end
            +
            +    it "should have a valid password after the initial save" do 
            +      @user.confirm_password?("ilovepasta").should be_true
            +    end
            +
            +    it "should not have an active account after initial save" do 
            +      @user.active.should be_false
            +    end
            +  end
            +
            +  describe 'changing bio' do
            +    let(:user) { Fabricate(:user) }
            +
            +    before do
            +      user.bio_raw = "**turtle power!**"
            +      user.save
            +      user.reload
            +    end
            +
            +    it "should markdown the raw_bio and put it in cooked_bio" do
            +      user.bio_cooked.should == "

            turtle power!

            " + end + + end + + describe "previous_visit_at" do + let(:user) { Fabricate(:user) } + + before do + SiteSetting.stubs(:active_user_rate_limit_secs).returns(0) + end + + it "should be blank on creation" do + user.previous_visit_at.should be_nil + end + + describe "first time" do + let!(:first_visit_date) { DateTime.now } + + before do + DateTime.stubs(:now).returns(first_visit_date) + user.update_last_seen! + end + + it "should have no value" do + user.previous_visit_at.should be_nil + end + + describe "another call right after" do + before do + # A different time, to make sure it doesn't change + DateTime.stubs(:now).returns(10.minutes.from_now) + user.update_last_seen! + end + + it "still has no value" do + user.previous_visit_at.should be_nil + end + end + + describe "second visit" do + let!(:second_visit_date) { 2.hours.from_now } + + before do + DateTime.stubs(:now).returns(second_visit_date) + user.update_last_seen! + end + + it "should have the previous visit value" do + user.previous_visit_at.should == first_visit_date + end + + describe "third visit" do + let!(:third_visit_date) { 5.hours.from_now } + + before do + DateTime.stubs(:now).returns(third_visit_date) + user.update_last_seen! + end + + it "should have the second visit value" do + user.previous_visit_at.should == second_visit_date + end + + end + + end + + end + + end + + describe "last_seen_at" do + let(:user) { Fabricate(:user) } + + it "should have a blank last seen on creation" do + user.last_seen_at.should be_nil + end + + it "should have 0 for days_visited" do + user.days_visited.should == 0 + end + + describe 'with no previous values' do + let!(:date) { DateTime.now } + + before do + DateTime.stubs(:now).returns(date) + user.update_last_seen! + end + + it "updates last_seen_at" do + user.last_seen_at.should == date + end + + it "should have 0 for days_visited" do + user.reload + user.days_visited.should == 1 + end + + it "should log a user_visit with the date" do + user.user_visits.first.visited_at.should == date.to_date + end + + context "called twice" do + + before do + DateTime.stubs(:now).returns(date) + user.update_last_seen! + user.update_last_seen! + user.reload + end + + it "doesn't increase days_visited twice" do + user.days_visited.should == 1 + end + + end + + describe "after 3 days" do + let!(:future_date) { 3.days.from_now } + + before do + DateTime.stubs(:now).returns(future_date) + user.update_last_seen! + end + + it "should log a second visited_at record when we log an update later" do + user.user_visits.count.should == 2 + end + end + + end + + + + end + + describe '#create_for_email' do + let(:subject) { User.create_for_email('test@email.com') } + it { should be_present } + its(:username) { should == 'test' } + its(:name) { should == 'test'} + it { should_not be_active } + end + +end diff --git a/spec/models/user_visit_spec.rb b/spec/models/user_visit_spec.rb new file mode 100644 index 00000000000..017a1b4e6dc --- /dev/null +++ b/spec/models/user_visit_spec.rb @@ -0,0 +1,4 @@ +require 'spec_helper' + +describe UserVisit do +end diff --git a/spec/models/view_spec.rb b/spec/models/view_spec.rb new file mode 100644 index 00000000000..6a2ac8d9f15 --- /dev/null +++ b/spec/models/view_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe View do + + it { should belong_to :parent } + it { should belong_to :user } + it { should validate_presence_of :parent_type } + it { should validate_presence_of :parent_id } + it { should validate_presence_of :ip } + it { should validate_presence_of :viewed_at } + + +end diff --git a/spec/requests/store_incoming_spec.rb b/spec/requests/store_incoming_spec.rb new file mode 100644 index 00000000000..83e80054159 --- /dev/null +++ b/spec/requests/store_incoming_spec.rb @@ -0,0 +1,45 @@ +require "spec_helper" + +describe "Stores incoming links" do + before do + TopicUser.stubs(:track_visit!) + end + + let :topic do + Fabricate(:post).topic + end + + it "doesn't store an incoming link when there's no referer" do + lambda { + get topic.relative_url + }.should_not change(IncomingLink, :count) + end + + it "doesn't raise an error on a very long link" do + lambda { get topic.relative_url, nil, {'HTTP_REFERER' => "http://#{'a' * 2000}.com"} }.should_not raise_error + end + + it "stores an incoming link when there is an off-site referer" do + lambda { + get topic.relative_url, nil, {'HTTP_REFERER' => "http://google.com/search"} + }.should change(IncomingLink, :count).by(1) + end + + describe 'after inserting an incoming link' do + + before do + get topic.relative_url + "/1", nil, {'HTTP_REFERER' => "http://google.com/search"} + @last_link = IncomingLink.last + end + + it 'should have the proper topic_id' do + @last_link.topic_id.should == topic.id + end + + it 'should have the proper topic_id' do + @last_link.post_number.should == 1 + end + + end + +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000000..fa9098f8d59 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,122 @@ +if ENV['COVERAGE'] + require 'simplecov' + SimpleCov.start +end + +require 'rubygems' +require 'spork' +#uncomment the following line to use spork with the debugger +#require 'spork/ext/ruby-debug' + +module Helpers + def log_in(fabricator=nil) + user = Fabricate(fabricator || :user) + log_in_user(user) + user + end + + def log_in_user(user) + session[:current_user_id] = user.id + end + +end + +Spork.prefork do + # Loading more in this block will cause your tests to run faster. However, + # if you change any configuration or code from libraries loaded here, you'll + # need to restart spork for it take effect. + ENV["RAILS_ENV"] ||= 'test' + require File.expand_path("../../config/environment", __FILE__) + require 'rspec/rails' + require 'rspec/autorun' + + # Requires supporting ruby files with custom matchers and macros, etc, + # in spec/support/ and its subdirectories. + Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f} + + load "#{Rails.root}/db/seeds.rb" + + RSpec.configure do |config| + + config.include Helpers + config.mock_framework = :mocha + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, remove the following line or assign false + # instead of true. + config.use_transactional_fixtures = true + + # If true, the base class of anonymous controllers will be inferred + # automatically. This will be the default behavior in future versions of + # rspec-rails. + config.infer_base_class_for_anonymous_controllers = true + + config.before(:suite) do + SeedFu.seed + end + + config.before(:all) do + DiscoursePluginRegistry.clear + end + + end + + class DateTime + class << self + alias_method :old_now, :now + def now + @now || old_now + end + def now=(v) + @now = v + end + end + end + + def freeze_time(d=nil) + begin + d ||= DateTime.now + DateTime.now = d + yield + ensure + DateTime.now = nil + end + end + +end + +Spork.each_run do + # This code will be run each time you run your specs. + $redis.client.reconnect +end + +# --- Instructions --- +# Sort the contents of this file into a Spork.prefork and a Spork.each_run +# block. +# +# The Spork.prefork block is run only once when the spork server is started. +# You typically want to place most of your (slow) initializer code in here, in +# particular, require'ing any 3rd-party gems that you don't normally modify +# during development. +# +# The Spork.each_run block is run each time you run your specs. In case you +# need to load files that tend to change during development, require them here. +# With Rails, your application modules are loaded automatically, so sometimes +# this block can remain empty. +# +# Note: You can modify files loaded *from* the Spork.each_run block without +# restarting the spork server. However, this file itself will not be reloaded, +# so if you change any of the code inside the each_run block, you still need to +# restart the server. In general, if you have non-trivial code in this file, +# it's advisable to move it into a separate file so you can easily edit it +# without restarting spork. (For example, with RSpec, you could move +# non-trivial code into a file spec/support/my_helper.rb, making sure that the +# spec/support/* files are require'd from inside the each_run block.) +# +# Any code that is left outside the two blocks will be run during preforking +# *and* during each_run -- that's probably not what you want. +# +# These instructions should self-destruct in 10 seconds. If they don't, feel +# free to delete them. + + diff --git a/spec/support/rate_limit_matcher.rb b/spec/support/rate_limit_matcher.rb new file mode 100644 index 00000000000..e189c83bcac --- /dev/null +++ b/spec/support/rate_limit_matcher.rb @@ -0,0 +1,5 @@ +RSpec::Matchers.define :rate_limit do |attribute| + match do |model| + model.class.include? RateLimiter::OnCreateRecord + end +end diff --git a/sublime-project b/sublime-project new file mode 100644 index 00000000000..59d6457f2cd --- /dev/null +++ b/sublime-project @@ -0,0 +1,23 @@ +{ + "folders": + [ + { + "path": "app", + "folder_exclude_patterns": ["external", "external_production", "images", "imported", "fonts"] + }, + {"path": "config"}, + { + "path": "db", + "file_exclude_patterns": ["*.sqlite3"] + }, + {"path": "lib"}, + {"path": "script"}, + {"path": "cookbooks"}, + {"path": "spec"} + ], + "settings": + { + "tab_size": 2, + "translate_tabs_to_spaces": true + } +} diff --git a/vendor/backports/notification.rb b/vendor/backports/notification.rb new file mode 100644 index 00000000000..886b120dd03 --- /dev/null +++ b/vendor/backports/notification.rb @@ -0,0 +1,368 @@ +module ActiveSupport + remove_const :Notifications +end + +module ActiveSupport + module Notifications + # Instrumentors are stored in a thread local. + class Instrumenter + attr_reader :id + + def initialize(notifier) + @id = unique_id + @notifier = notifier + end + + # Instrument the given block by measuring the time taken to execute it + # and publish it. Notice that events get sent even if an error occurs + # in the passed-in block + def instrument(name, payload={}) + @notifier.start(name, @id, payload) + begin + yield + rescue Exception => e + payload[:exception] = [e.class.name, e.message] + raise e + ensure + @notifier.finish(name, @id, payload) + end + end + + private + def unique_id + SecureRandom.hex(10) + end + end + + class Event + attr_reader :name, :time, :end, :transaction_id, :payload, :duration + + def initialize(name, start, ending, transaction_id, payload) + @name = name + @payload = payload.dup + @time = start + @transaction_id = transaction_id + @end = ending + @duration = 1000.0 * (@end - @time) + end + + def parent_of?(event) + start = (time - event.time) * 1000 + start <= 0 && (start + duration >= event.duration) + end + end + end +end + +module ActiveSupport + module Notifications + # This is a default queue implementation that ships with Notifications. + # It just pushes events to all registered log subscribers. + class Fanout + def initialize + @subscribers = [] + @listeners_for = {} + end + + def subscribe(pattern = nil, block = Proc.new) + subscriber = Subscribers.new pattern, block + @subscribers << subscriber + @listeners_for.clear + subscriber + end + + def unsubscribe(subscriber) + @subscribers.reject! { |s| s.matches?(subscriber) } + @listeners_for.clear + end + + def start(name, id, payload) + listeners_for(name).each { |s| s.start(name, id, payload) } + end + + def finish(name, id, payload) + listeners_for(name).each { |s| s.finish(name, id, payload) } + end + + def publish(name, *args) + listeners_for(name).each { |s| s.publish(name, *args) } + end + + def listeners_for(name) + @listeners_for[name] ||= @subscribers.select { |s| s.subscribed_to?(name) } + end + + def listening?(name) + listeners_for(name).any? + end + + # This is a sync queue, so there is no waiting. + def wait + end + + module Subscribers # :nodoc: + def self.new(pattern, listener) + if listener.respond_to?(:call) + subscriber = Timed.new pattern, listener + else + subscriber = Evented.new pattern, listener + end + + unless pattern + AllMessages.new(subscriber) + else + subscriber + end + end + + class Evented #:nodoc: + def initialize(pattern, delegate) + @pattern = pattern + @delegate = delegate + end + + def start(name, id, payload) + @delegate.start name, id, payload + end + + def finish(name, id, payload) + @delegate.finish name, id, payload + end + + def subscribed_to?(name) + @pattern === name.to_s + end + + def matches?(subscriber_or_name) + self === subscriber_or_name || + @pattern && @pattern === subscriber_or_name + end + end + + class Timed < Evented + def initialize(pattern, delegate) + @timestack = Hash.new { |h,id| + h[id] = Hash.new { |ids,name| ids[name] = [] } + } + super + end + + def publish(name, *args) + @delegate.call name, *args + end + + def start(name, id, payload) + @timestack[id][name].push Time.now + end + + def finish(name, id, payload) + started = @timestack[id][name].pop + @delegate.call(name, started, Time.now, id, payload) + end + end + + class AllMessages # :nodoc: + def initialize(delegate) + @delegate = delegate + end + + def start(name, id, payload) + @delegate.start name, id, payload + end + + def finish(name, id, payload) + @delegate.finish name, id, payload + end + + def publish(name, *args) + @delegate.publish name, *args + end + + def subscribed_to?(name) + true + end + + alias :matches? :=== + end + end + end + end +end + +module ActiveSupport + # = Notifications + # + # ActiveSupport::Notifications provides an instrumentation API for Ruby. + # + # == Instrumenters + # + # To instrument an event you just need to do: + # + # ActiveSupport::Notifications.instrument("render", :extra => :information) do + # render :text => "Foo" + # end + # + # That executes the block first and notifies all subscribers once done. + # + # In the example above "render" is the name of the event, and the rest is called + # the _payload_. The payload is a mechanism that allows instrumenters to pass + # extra information to subscribers. Payloads consist of a hash whose contents + # are arbitrary and generally depend on the event. + # + # == Subscribers + # + # You can consume those events and the information they provide by registering + # a subscriber. For instance, let's store all "render" events in an array: + # + # events = [] + # + # ActiveSupport::Notifications.subscribe("render") do |*args| + # events << ActiveSupport::Notifications::Event.new(*args) + # end + # + # That code returns right away, you are just subscribing to "render" events. + # The block will be called asynchronously whenever someone instruments "render": + # + # ActiveSupport::Notifications.instrument("render", :extra => :information) do + # render :text => "Foo" + # end + # + # event = events.first + # event.name # => "render" + # event.duration # => 10 (in milliseconds) + # event.payload # => { :extra => :information } + # + # The block in the subscribe call gets the name of the event, start + # timestamp, end timestamp, a string with a unique identifier for that event + # (something like "535801666f04d0298cd6"), and a hash with the payload, in + # that order. + # + # If an exception happens during that particular instrumentation the payload will + # have a key :exception with an array of two elements as value: a string with + # the name of the exception class, and the exception message. + # + # As the previous example depicts, the class ActiveSupport::Notifications::Event + # is able to take the arguments as they come and provide an object-oriented + # interface to that data. + # + # It is also possible to pass an object as the second parameter passed to the + # subscribe method instead of a block: + # + # module ActionController + # class PageRequest + # def call(name, started, finished, unique_id, payload) + # Rails.logger.debug ["notification:", name, started, finished, unique_id, payload].join(" ") + # end + # end + # end + # + # ActiveSupport::Notifications.subscribe('process_action.action_controller', ActionController::PageRequest.new) + # + # resulting in the following output within the logs including a hash with the payload: + # + # notification: process_action.action_controller 2012-04-13 01:08:35 +0300 2012-04-13 01:08:35 +0300 af358ed7fab884532ec7 { + # :controller=>"Devise::SessionsController", + # :action=>"new", + # :params=>{"action"=>"new", "controller"=>"devise/sessions"}, + # :format=>:html, + # :method=>"GET", + # :path=>"/login/sign_in", + # :status=>200, + # :view_runtime=>279.3080806732178, + # :db_runtime=>40.053 + # } + # + # You can also subscribe to all events whose name matches a certain regexp: + # + # ActiveSupport::Notifications.subscribe(/render/) do |*args| + # ... + # end + # + # and even pass no argument to subscribe, in which case you are subscribing + # to all events. + # + # == Temporary Subscriptions + # + # Sometimes you do not want to subscribe to an event for the entire life of + # the application. There are two ways to unsubscribe. + # + # WARNING: The instrumentation framework is designed for long-running subscribers, + # use this feature sparingly because it wipes some internal caches and that has + # a negative impact on performance. + # + # === Subscribe While a Block Runs + # + # You can subscribe to some event temporarily while some block runs. For + # example, in + # + # callback = lambda {|*args| ... } + # ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + # ... + # end + # + # the callback will be called for all "sql.active_record" events instrumented + # during the execution of the block. The callback is unsubscribed automatically + # after that. + # + # === Manual Unsubscription + # + # The +subscribe+ method returns a subscriber object: + # + # subscriber = ActiveSupport::Notifications.subscribe("render") do |*args| + # ... + # end + # + # To prevent that block from being called anymore, just unsubscribe passing + # that reference: + # + # ActiveSupport::Notifications.unsubscribe(subscriber) + # + # == Default Queue + # + # Notifications ships with a queue implementation that consumes and publish events + # to log subscribers in a thread. You can use any queue implementation you want. + # + module Notifications + @instrumenters = Hash.new { |h,k| h[k] = notifier.listening?(k) } + + class << self + attr_accessor :notifier + + def publish(name, *args) + notifier.publish(name, *args) + end + + def instrument(name, payload = {}) + if @instrumenters[name] + instrumenter.instrument(name, payload) { yield payload if block_given? } + else + yield payload if block_given? + end + end + + def subscribe(*args, &block) + notifier.subscribe(*args, &block).tap do + @instrumenters.clear + end + end + + def subscribed(callback, *args, &block) + subscriber = subscribe(*args, &callback) + yield + ensure + unsubscribe(subscriber) + end + + def unsubscribe(args) + notifier.unsubscribe(args) + @instrumenters.clear + end + + def instrumenter + Thread.current[:"instrumentation_#{notifier.object_id}"] ||= Instrumenter.new(notifier) + end + end + + self.notifier = Fanout.new + end +end diff --git a/vendor/gems/discourse_emoji/Gemfile b/vendor/gems/discourse_emoji/Gemfile new file mode 100644 index 00000000000..fcb7fdc2990 --- /dev/null +++ b/vendor/gems/discourse_emoji/Gemfile @@ -0,0 +1,13 @@ +source 'https://rubygems.org' + +group :test do + gem 'rails' + gem 'rspec' + gem 'mocha' +end + +# TODO: We need our own gem server +gem 'discourse_plugin', path: '../discourse_plugin' + +# Specify your gem's dependencies in rails_multisite.gemspec +gemspec diff --git a/vendor/gems/discourse_emoji/Gemfile.lock b/vendor/gems/discourse_emoji/Gemfile.lock new file mode 100644 index 00000000000..f75be530afb --- /dev/null +++ b/vendor/gems/discourse_emoji/Gemfile.lock @@ -0,0 +1,111 @@ +PATH + remote: . + specs: + discourse_emoji (0.0.1) + +PATH + remote: ../discourse_plugin + specs: + discourse_plugin (0.0.1) + +GEM + remote: https://rubygems.org/ + specs: + actionmailer (3.2.8) + actionpack (= 3.2.8) + mail (~> 2.4.4) + actionpack (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + builder (~> 3.0.0) + erubis (~> 2.7.0) + journey (~> 1.0.4) + rack (~> 1.4.0) + rack-cache (~> 1.2) + rack-test (~> 0.6.1) + sprockets (~> 2.1.3) + activemodel (3.2.8) + activesupport (= 3.2.8) + builder (~> 3.0.0) + activerecord (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + arel (~> 3.0.2) + tzinfo (~> 0.3.29) + activeresource (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + activesupport (3.2.8) + i18n (~> 0.6) + multi_json (~> 1.0) + arel (3.0.2) + builder (3.0.3) + diff-lcs (1.1.3) + erubis (2.7.0) + hike (1.2.1) + i18n (0.6.1) + journey (1.0.4) + json (1.7.5) + mail (2.4.4) + i18n (>= 0.4.0) + mime-types (~> 1.16) + treetop (~> 1.4.8) + metaclass (0.0.1) + mime-types (1.19) + mocha (0.12.7) + metaclass (~> 0.0.1) + multi_json (1.3.6) + polyglot (0.3.3) + rack (1.4.1) + rack-cache (1.2) + rack (>= 0.4) + rack-ssl (1.3.2) + rack + rack-test (0.6.2) + rack (>= 1.0) + rails (3.2.8) + actionmailer (= 3.2.8) + actionpack (= 3.2.8) + activerecord (= 3.2.8) + activeresource (= 3.2.8) + activesupport (= 3.2.8) + bundler (~> 1.0) + railties (= 3.2.8) + railties (3.2.8) + actionpack (= 3.2.8) + activesupport (= 3.2.8) + rack-ssl (~> 1.3.2) + rake (>= 0.8.7) + rdoc (~> 3.4) + thor (>= 0.14.6, < 2.0) + rake (0.9.2.2) + rdoc (3.12) + json (~> 1.4) + rspec (2.11.0) + rspec-core (~> 2.11.0) + rspec-expectations (~> 2.11.0) + rspec-mocks (~> 2.11.0) + rspec-core (2.11.1) + rspec-expectations (2.11.3) + diff-lcs (~> 1.1.3) + rspec-mocks (2.11.3) + sprockets (2.1.3) + hike (~> 1.2) + rack (~> 1.0) + tilt (~> 1.1, != 1.3.0) + thor (0.16.0) + tilt (1.3.3) + treetop (1.4.10) + polyglot + polyglot (>= 0.3.1) + tzinfo (0.3.33) + +PLATFORMS + ruby + +DEPENDENCIES + discourse_emoji! + discourse_plugin! + mocha + rails + rspec diff --git a/vendor/gems/discourse_emoji/LICENSE b/vendor/gems/discourse_emoji/LICENSE new file mode 100644 index 00000000000..198cab0843c --- /dev/null +++ b/vendor/gems/discourse_emoji/LICENSE @@ -0,0 +1,14 @@ +octocat, squirrel, shipit +Copyright (c) 2012 GitHub Inc. All rights reserved. + +bowtie, neckbeard +Copyright (c) 2012 37signals, LLC. All rights reserved. + +feelsgood, finnadie, goberserk, godmode, hurtrealbad, rage 1-4, suspect +Copyright (c) 2012 id Software. All rights reserved. + +trollface +Copyright (c) 2012 whynne@deviantart. All rights reserved. + +All other images +Copyright (c) 2012 Apple Inc. All rights reserved. \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/README.md b/vendor/gems/discourse_emoji/README.md new file mode 100644 index 00000000000..b8a6fa9828a --- /dev/null +++ b/vendor/gems/discourse_emoji/README.md @@ -0,0 +1,3 @@ +# Discourse Emoji Gem + +Adds Emoji support to discourse. Thanks to the gemoji gem for the assets. diff --git a/vendor/gems/discourse_emoji/Rakefile b/vendor/gems/discourse_emoji/Rakefile new file mode 100644 index 00000000000..ecd0cbf9288 --- /dev/null +++ b/vendor/gems/discourse_emoji/Rakefile @@ -0,0 +1,7 @@ +#!/usr/bin/env rake +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:test) do |spec| + spec.pattern = 'spec/*_spec.rb' +end diff --git a/vendor/gems/discourse_emoji/discourse_emoji.gemspec b/vendor/gems/discourse_emoji/discourse_emoji.gemspec new file mode 100644 index 00000000000..4d9daffbf71 --- /dev/null +++ b/vendor/gems/discourse_emoji/discourse_emoji.gemspec @@ -0,0 +1,19 @@ +# -*- encoding: utf-8 -*- +require File.expand_path('../lib/discourse_emoji/version', __FILE__) + +Gem::Specification.new do |gem| + gem.authors = ["Robin Ward"] + gem.email = ["robin.ward@gmail.com"] + gem.description = %q{This gem adds emoji support to discourse} + gem.summary = %q{This gem adds emoji support to discourse} + gem.homepage = "" + + # when this is extracted comment it back in, prd has no .git + gem.files = Dir['README*','LICENSE','lib/**/*.rb'] + + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + gem.name = "discourse_emoji" + gem.require_paths = ["lib"] + gem.version = DiscourseEmoji::VERSION +end diff --git a/vendor/gems/discourse_emoji/lib/discourse_emoji.rb b/vendor/gems/discourse_emoji/lib/discourse_emoji.rb new file mode 100644 index 00000000000..f8d3208432a --- /dev/null +++ b/vendor/gems/discourse_emoji/lib/discourse_emoji.rb @@ -0,0 +1,2 @@ +require 'discourse_emoji/version' +require 'discourse_emoji/engine' if defined?(Rails) && (!Rails.env.test?) diff --git a/vendor/gems/discourse_emoji/lib/discourse_emoji/engine.rb b/vendor/gems/discourse_emoji/lib/discourse_emoji/engine.rb new file mode 100644 index 00000000000..3944406a357 --- /dev/null +++ b/vendor/gems/discourse_emoji/lib/discourse_emoji/engine.rb @@ -0,0 +1,16 @@ +require 'discourse_emoji/plugin' + +module DiscourseEmoji + class Engine < Rails::Engine + + engine_name 'discourse_emoji' + + initializer "discourse_emoji.configure_rails_initialization" do |app| + + app.config.after_initialize do + DiscoursePluginRegistry.setup(DiscourseEmoji::Plugin) + end + end + + end +end \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/lib/discourse_emoji/plugin.rb b/vendor/gems/discourse_emoji/lib/discourse_emoji/plugin.rb new file mode 100644 index 00000000000..a9f89ac9d8d --- /dev/null +++ b/vendor/gems/discourse_emoji/lib/discourse_emoji/plugin.rb @@ -0,0 +1,15 @@ +require 'discourse_plugin' + +module DiscourseEmoji + + class Plugin < DiscoursePlugin + + def setup + # Add our Assets + register_js('discourse_emoji', + server_side: File.expand_path('../../../vendor/assets/javascripts/discourse_emoji.js', __FILE__)) + register_css('discourse_emoji') + end + + end +end diff --git a/vendor/gems/discourse_emoji/lib/discourse_emoji/version.rb b/vendor/gems/discourse_emoji/lib/discourse_emoji/version.rb new file mode 100644 index 00000000000..955b1d1c62a --- /dev/null +++ b/vendor/gems/discourse_emoji/lib/discourse_emoji/version.rb @@ -0,0 +1,3 @@ +module DiscourseEmoji + VERSION = "0.0.1" +end diff --git a/vendor/gems/discourse_emoji/spec/plugin_spec.rb b/vendor/gems/discourse_emoji/spec/plugin_spec.rb new file mode 100644 index 00000000000..bf926c85e5e --- /dev/null +++ b/vendor/gems/discourse_emoji/spec/plugin_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' +require 'discourse_emoji/plugin' +require 'ostruct' + +describe DiscourseEmoji::Plugin do + + let(:registry) { stub_everything } + let(:plugin) { DiscourseEmoji::Plugin.new(registry) } + + context '.setup' do + + it 'registers its js' do + plugin.expects(:register_js).with('discourse_emoji', any_parameters) + plugin.setup + end + + it 'registers its css' do + plugin.expects(:register_css).with('discourse_emoji') + plugin.setup + end + + end + +end diff --git a/vendor/gems/discourse_emoji/spec/spec_helper.rb b/vendor/gems/discourse_emoji/spec/spec_helper.rb new file mode 100644 index 00000000000..820326fdff5 --- /dev/null +++ b/vendor/gems/discourse_emoji/spec/spec_helper.rb @@ -0,0 +1,13 @@ +require 'rubygems' +require 'rails' + +ENV["RAILS_ENV"] ||= 'test' + +RSpec.configure do |config| + + config.mock_framework = :mocha + config.color_enabled = true + +end + + diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/+1.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/+1.png new file mode 120000 index 00000000000..366a049dfa7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/+1.png @@ -0,0 +1 @@ +unicode/e00e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/-1.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/-1.png new file mode 120000 index 00000000000..3605da79d7f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/-1.png @@ -0,0 +1 @@ +unicode/e421.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/100.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/100.png new file mode 120000 index 00000000000..e30cb952449 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/100.png @@ -0,0 +1 @@ +unicode/1f4af.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/109.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/109.png new file mode 120000 index 00000000000..190d0aba683 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/109.png @@ -0,0 +1 @@ +unicode/e50a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/1234.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/1234.png new file mode 120000 index 00000000000..4bf19006458 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/1234.png @@ -0,0 +1 @@ +unicode/1f522.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/8ball.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/8ball.png new file mode 120000 index 00000000000..7f4376083bd --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/8ball.png @@ -0,0 +1 @@ +unicode/e42c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/a.png new file mode 120000 index 00000000000..3f364b6f9ce --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/a.png @@ -0,0 +1 @@ +unicode/e532.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ab.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ab.png new file mode 120000 index 00000000000..74e3bde319b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ab.png @@ -0,0 +1 @@ +unicode/e534.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/abc.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/abc.png new file mode 120000 index 00000000000..129ae4d246e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/abc.png @@ -0,0 +1 @@ +unicode/1f524.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/abcd.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/abcd.png new file mode 120000 index 00000000000..8e0e9ab6171 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/abcd.png @@ -0,0 +1 @@ +unicode/1f521.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/accept.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/accept.png new file mode 120000 index 00000000000..2209f8c9b76 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/accept.png @@ -0,0 +1 @@ +unicode/1f251.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/aerial_tramway.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/aerial_tramway.png new file mode 120000 index 00000000000..4c5c67d0be3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/aerial_tramway.png @@ -0,0 +1 @@ +unicode/1f6a1.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/airplane.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/airplane.png new file mode 120000 index 00000000000..5b1b23520b3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/airplane.png @@ -0,0 +1 @@ +unicode/e01d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/alarm_clock.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/alarm_clock.png new file mode 120000 index 00000000000..d252dcded59 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/alarm_clock.png @@ -0,0 +1 @@ +unicode/23f0.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/alien.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/alien.png new file mode 120000 index 00000000000..09849d6bb09 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/alien.png @@ -0,0 +1 @@ +unicode/e10c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ambulance.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ambulance.png new file mode 120000 index 00000000000..77a533ee2ea --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ambulance.png @@ -0,0 +1 @@ +unicode/e431.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/anchor.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/anchor.png new file mode 120000 index 00000000000..5d08956426b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/anchor.png @@ -0,0 +1 @@ +unicode/2693.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/angel.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/angel.png new file mode 120000 index 00000000000..ee3dc1e0130 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/angel.png @@ -0,0 +1 @@ +unicode/e04e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/anger.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/anger.png new file mode 120000 index 00000000000..ea745d61e3f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/anger.png @@ -0,0 +1 @@ +unicode/e334.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/angry.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/angry.png new file mode 120000 index 00000000000..91231b3e6ef --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/angry.png @@ -0,0 +1 @@ +unicode/e059.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ant.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ant.png new file mode 120000 index 00000000000..c40148e514c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ant.png @@ -0,0 +1 @@ +unicode/1f41c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/apple.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/apple.png new file mode 120000 index 00000000000..f5b4d8d53c9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/apple.png @@ -0,0 +1 @@ +unicode/e345.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/aquarius.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/aquarius.png new file mode 120000 index 00000000000..76be534b8b2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/aquarius.png @@ -0,0 +1 @@ +unicode/e249.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/aries.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/aries.png new file mode 120000 index 00000000000..3a854e2d4eb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/aries.png @@ -0,0 +1 @@ +unicode/e23f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_backward.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_backward.png new file mode 120000 index 00000000000..dd09a363d3e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_backward.png @@ -0,0 +1 @@ +unicode/e23b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_double_down.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_double_down.png new file mode 120000 index 00000000000..548e4250744 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_double_down.png @@ -0,0 +1 @@ +unicode/23ec.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_double_up.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_double_up.png new file mode 120000 index 00000000000..0c01270502e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_double_up.png @@ -0,0 +1 @@ +unicode/23eb.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_down.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_down.png new file mode 120000 index 00000000000..09dd3822f16 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_down.png @@ -0,0 +1 @@ +unicode/e233.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_down_small.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_down_small.png new file mode 120000 index 00000000000..8431dfccce6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_down_small.png @@ -0,0 +1 @@ +unicode/1f53d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_forward.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_forward.png new file mode 120000 index 00000000000..fdff72795ed --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_forward.png @@ -0,0 +1 @@ +unicode/e23a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_heading_down.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_heading_down.png new file mode 120000 index 00000000000..934028fe2e3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_heading_down.png @@ -0,0 +1 @@ +unicode/2935.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_heading_up.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_heading_up.png new file mode 120000 index 00000000000..25bfe6ac52c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_heading_up.png @@ -0,0 +1 @@ +unicode/2934.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_left.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_left.png new file mode 120000 index 00000000000..07709c746ed --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_left.png @@ -0,0 +1 @@ +unicode/e235.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_lower_left.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_lower_left.png new file mode 120000 index 00000000000..8b1ef8f3f6b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_lower_left.png @@ -0,0 +1 @@ +unicode/e239.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_lower_right.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_lower_right.png new file mode 120000 index 00000000000..08356e2496d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_lower_right.png @@ -0,0 +1 @@ +unicode/e238.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_right.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_right.png new file mode 120000 index 00000000000..b05099c5383 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_right.png @@ -0,0 +1 @@ +unicode/e234.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_right_hook.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_right_hook.png new file mode 120000 index 00000000000..5a3039cee5e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_right_hook.png @@ -0,0 +1 @@ +unicode/21aa.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_up.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_up.png new file mode 120000 index 00000000000..4c0158cbcb5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_up.png @@ -0,0 +1 @@ +unicode/e232.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_up_down.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_up_down.png new file mode 120000 index 00000000000..5e0689401e6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_up_down.png @@ -0,0 +1 @@ +unicode/2195.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_up_small.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_up_small.png new file mode 120000 index 00000000000..9973a430b72 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_up_small.png @@ -0,0 +1 @@ +unicode/1f53c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_upper_left.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_upper_left.png new file mode 120000 index 00000000000..a732a868668 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_upper_left.png @@ -0,0 +1 @@ +unicode/e237.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_upper_right.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_upper_right.png new file mode 120000 index 00000000000..8db975ecc45 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrow_upper_right.png @@ -0,0 +1 @@ +unicode/e236.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrows_clockwise.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrows_clockwise.png new file mode 120000 index 00000000000..ba9d84f4abc --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrows_clockwise.png @@ -0,0 +1 @@ +unicode/1f503.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrows_counterclockwise.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrows_counterclockwise.png new file mode 120000 index 00000000000..6ed37bd08a8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/arrows_counterclockwise.png @@ -0,0 +1 @@ +unicode/1f504.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/art.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/art.png new file mode 120000 index 00000000000..bbd011e79e7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/art.png @@ -0,0 +1 @@ +unicode/e502.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/articulated_lorry.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/articulated_lorry.png new file mode 120000 index 00000000000..eb1bd5e5e26 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/articulated_lorry.png @@ -0,0 +1 @@ +unicode/1f69b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/astonished.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/astonished.png new file mode 120000 index 00000000000..01272fbb5b4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/astonished.png @@ -0,0 +1 @@ +unicode/e410.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/atm.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/atm.png new file mode 120000 index 00000000000..301cfbbc856 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/atm.png @@ -0,0 +1 @@ +unicode/e154.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/b.png new file mode 120000 index 00000000000..2774806d23a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/b.png @@ -0,0 +1 @@ +unicode/e533.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby.png new file mode 120000 index 00000000000..449c965c35d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby.png @@ -0,0 +1 @@ +unicode/e51a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby_bottle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby_bottle.png new file mode 120000 index 00000000000..45ebf95c95d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby_bottle.png @@ -0,0 +1 @@ +unicode/1f37c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby_chick.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby_chick.png new file mode 120000 index 00000000000..6bebf227709 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby_chick.png @@ -0,0 +1 @@ +unicode/e523.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby_symbol.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby_symbol.png new file mode 120000 index 00000000000..d6d82911f42 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baby_symbol.png @@ -0,0 +1 @@ +unicode/e13a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baggage_claim.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baggage_claim.png new file mode 120000 index 00000000000..f658d3a6bd5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baggage_claim.png @@ -0,0 +1 @@ +unicode/1f6c4.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/balloon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/balloon.png new file mode 120000 index 00000000000..20acd83c785 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/balloon.png @@ -0,0 +1 @@ +unicode/e310.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ballot_box_with_check.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ballot_box_with_check.png new file mode 120000 index 00000000000..b695327b841 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ballot_box_with_check.png @@ -0,0 +1 @@ +unicode/2611.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bamboo.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bamboo.png new file mode 120000 index 00000000000..45774b29c1e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bamboo.png @@ -0,0 +1 @@ +unicode/e436.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/banana.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/banana.png new file mode 120000 index 00000000000..b68fc18b1de --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/banana.png @@ -0,0 +1 @@ +unicode/1f34c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bangbang.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bangbang.png new file mode 120000 index 00000000000..12fbcfcbbbe --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bangbang.png @@ -0,0 +1 @@ +unicode/203c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bank.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bank.png new file mode 120000 index 00000000000..c0d58d1a561 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bank.png @@ -0,0 +1 @@ +unicode/e14d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bar_chart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bar_chart.png new file mode 120000 index 00000000000..9df86b6e17f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bar_chart.png @@ -0,0 +1 @@ +unicode/1f4ca.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/barber.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/barber.png new file mode 120000 index 00000000000..31c1eba5614 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/barber.png @@ -0,0 +1 @@ +unicode/e320.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baseball.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baseball.png new file mode 120000 index 00000000000..46bac8cf846 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/baseball.png @@ -0,0 +1 @@ +unicode/e016.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/basketball.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/basketball.png new file mode 120000 index 00000000000..7db047789b5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/basketball.png @@ -0,0 +1 @@ +unicode/e42a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bath.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bath.png new file mode 120000 index 00000000000..fff86b57166 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bath.png @@ -0,0 +1 @@ +unicode/e13f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bathtub.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bathtub.png new file mode 120000 index 00000000000..c1e9bd9eb65 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bathtub.png @@ -0,0 +1 @@ +unicode/1f6c1.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/battery.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/battery.png new file mode 120000 index 00000000000..afbf9d7db65 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/battery.png @@ -0,0 +1 @@ +unicode/1f50b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bear.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bear.png new file mode 120000 index 00000000000..463ff0bc903 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bear.png @@ -0,0 +1 @@ +unicode/e051.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bee.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bee.png new file mode 120000 index 00000000000..d1768eca5e1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bee.png @@ -0,0 +1 @@ +unicode/1f41d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beer.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beer.png new file mode 120000 index 00000000000..ecd27c1ba42 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beer.png @@ -0,0 +1 @@ +unicode/e047.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beers.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beers.png new file mode 120000 index 00000000000..5241f082c1d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beers.png @@ -0,0 +1 @@ +unicode/e30c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beetle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beetle.png new file mode 120000 index 00000000000..3ef684fcd7e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beetle.png @@ -0,0 +1 @@ +unicode/1f41e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beginner.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beginner.png new file mode 120000 index 00000000000..2d52a91a4af --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/beginner.png @@ -0,0 +1 @@ +unicode/e209.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bell.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bell.png new file mode 120000 index 00000000000..fe0ff85d5f0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bell.png @@ -0,0 +1 @@ +unicode/e325.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bento.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bento.png new file mode 120000 index 00000000000..6a0f66a0157 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bento.png @@ -0,0 +1 @@ +unicode/e34c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bicyclist.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bicyclist.png new file mode 120000 index 00000000000..8481c53663f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bicyclist.png @@ -0,0 +1 @@ +unicode/1f6b4.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bike.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bike.png new file mode 120000 index 00000000000..4cf589bfe09 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bike.png @@ -0,0 +1 @@ +unicode/e136.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bikini.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bikini.png new file mode 120000 index 00000000000..6d551bca35f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bikini.png @@ -0,0 +1 @@ +unicode/e322.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bird.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bird.png new file mode 120000 index 00000000000..085de8cc2ad --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bird.png @@ -0,0 +1 @@ +unicode/e521.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/birthday.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/birthday.png new file mode 120000 index 00000000000..8131869195d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/birthday.png @@ -0,0 +1 @@ +unicode/e34b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_circle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_circle.png new file mode 120000 index 00000000000..99487ce5c11 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_circle.png @@ -0,0 +1 @@ +unicode/26ab.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_joker.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_joker.png new file mode 120000 index 00000000000..f9ece1e8be2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_joker.png @@ -0,0 +1 @@ +unicode/1f0cf.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_nib.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_nib.png new file mode 120000 index 00000000000..7f1b3747819 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_nib.png @@ -0,0 +1 @@ +unicode/2712.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_square.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_square.png new file mode 120000 index 00000000000..f269a894540 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/black_square.png @@ -0,0 +1 @@ +unicode/e21a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blossom.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blossom.png new file mode 120000 index 00000000000..f96cde049be --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blossom.png @@ -0,0 +1 @@ +unicode/1f33c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blowfish.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blowfish.png new file mode 120000 index 00000000000..a346e5d1e3c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blowfish.png @@ -0,0 +1 @@ +unicode/1f421.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blue_book.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blue_book.png new file mode 120000 index 00000000000..26b023c7a9d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blue_book.png @@ -0,0 +1 @@ +unicode/1f4d8.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blue_car.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blue_car.png new file mode 120000 index 00000000000..10e48ebac57 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blue_car.png @@ -0,0 +1 @@ +unicode/e42e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blue_heart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blue_heart.png new file mode 120000 index 00000000000..2d47f6b5a4a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blue_heart.png @@ -0,0 +1 @@ +unicode/e32a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blush.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blush.png new file mode 120000 index 00000000000..bc307278563 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/blush.png @@ -0,0 +1 @@ +unicode/e056.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boar.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boar.png new file mode 120000 index 00000000000..6fcc577e5bb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boar.png @@ -0,0 +1 @@ +unicode/e52f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boat.png new file mode 120000 index 00000000000..9cc5881ea22 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boat.png @@ -0,0 +1 @@ +unicode/e01c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bomb.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bomb.png new file mode 120000 index 00000000000..8156e7537c7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bomb.png @@ -0,0 +1 @@ +unicode/e311.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/book.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/book.png new file mode 120000 index 00000000000..6c3c0063ea3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/book.png @@ -0,0 +1 @@ +unicode/e148.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bookmark.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bookmark.png new file mode 120000 index 00000000000..ed7288f4f2b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bookmark.png @@ -0,0 +1 @@ +unicode/1f516.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bookmark_tabs.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bookmark_tabs.png new file mode 120000 index 00000000000..9dbad631b4d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bookmark_tabs.png @@ -0,0 +1 @@ +unicode/1f4d1.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/books.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/books.png new file mode 120000 index 00000000000..d023c04422b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/books.png @@ -0,0 +1 @@ +unicode/1f4da.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boom.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boom.png new file mode 120000 index 00000000000..ac298e5b3a0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boom.png @@ -0,0 +1 @@ +unicode/1f4a5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boot.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boot.png new file mode 120000 index 00000000000..5f243df3c9c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boot.png @@ -0,0 +1 @@ +unicode/e31b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bouquet.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bouquet.png new file mode 120000 index 00000000000..361a8ca4dda --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bouquet.png @@ -0,0 +1 @@ +unicode/e306.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bow.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bow.png new file mode 120000 index 00000000000..582d3973616 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bow.png @@ -0,0 +1 @@ +unicode/e426.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bowling.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bowling.png new file mode 120000 index 00000000000..1d1b3f320e6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bowling.png @@ -0,0 +1 @@ +unicode/1f3b3.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bowtie.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bowtie.png new file mode 100644 index 00000000000..28ff0c787d5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bowtie.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boy.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boy.png new file mode 120000 index 00000000000..e3f406fa6c6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/boy.png @@ -0,0 +1 @@ +unicode/e001.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bread.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bread.png new file mode 120000 index 00000000000..97124236e56 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bread.png @@ -0,0 +1 @@ +unicode/e339.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bride_with_veil.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bride_with_veil.png new file mode 120000 index 00000000000..92f4fb37a70 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bride_with_veil.png @@ -0,0 +1 @@ +unicode/1f470.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bridge_at_night.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bridge_at_night.png new file mode 120000 index 00000000000..fb57a388b13 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bridge_at_night.png @@ -0,0 +1 @@ +unicode/1f309.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/briefcase.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/briefcase.png new file mode 120000 index 00000000000..e6382e5cec1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/briefcase.png @@ -0,0 +1 @@ +unicode/e11e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/broken_heart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/broken_heart.png new file mode 120000 index 00000000000..fc973347dfb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/broken_heart.png @@ -0,0 +1 @@ +unicode/e023.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bug.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bug.png new file mode 120000 index 00000000000..ccfae6b328f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bug.png @@ -0,0 +1 @@ +unicode/e525.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bulb.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bulb.png new file mode 120000 index 00000000000..54cf80ec975 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bulb.png @@ -0,0 +1 @@ +unicode/e10f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bullettrain_front.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bullettrain_front.png new file mode 120000 index 00000000000..610447dd8ce --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bullettrain_front.png @@ -0,0 +1 @@ +unicode/e01f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bullettrain_side.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bullettrain_side.png new file mode 120000 index 00000000000..506bc89cde7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bullettrain_side.png @@ -0,0 +1 @@ +unicode/e435.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bus.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bus.png new file mode 120000 index 00000000000..21a1e63d013 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bus.png @@ -0,0 +1 @@ +unicode/e159.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/busstop.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/busstop.png new file mode 120000 index 00000000000..9a65492aa57 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/busstop.png @@ -0,0 +1 @@ +unicode/e150.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bust_in_silhouette.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bust_in_silhouette.png new file mode 120000 index 00000000000..9d9bf34cd97 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/bust_in_silhouette.png @@ -0,0 +1 @@ +unicode/1f464.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/busts_in_silhouette.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/busts_in_silhouette.png new file mode 120000 index 00000000000..b36b340fe0a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/busts_in_silhouette.png @@ -0,0 +1 @@ +unicode/1f465.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cactus.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cactus.png new file mode 120000 index 00000000000..a8afbc3cb7d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cactus.png @@ -0,0 +1 @@ +unicode/e308.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cake.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cake.png new file mode 120000 index 00000000000..0bf945fbc37 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cake.png @@ -0,0 +1 @@ +unicode/e046.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/calendar.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/calendar.png new file mode 120000 index 00000000000..fa75c5cd71a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/calendar.png @@ -0,0 +1 @@ +unicode/1f4c6.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/calling.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/calling.png new file mode 120000 index 00000000000..64074a86f03 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/calling.png @@ -0,0 +1 @@ +unicode/e104.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/camel.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/camel.png new file mode 120000 index 00000000000..a55d667337f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/camel.png @@ -0,0 +1 @@ +unicode/e530.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/camera.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/camera.png new file mode 120000 index 00000000000..55a19dd2d07 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/camera.png @@ -0,0 +1 @@ +unicode/e008.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cancer.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cancer.png new file mode 120000 index 00000000000..abfeaddcb36 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cancer.png @@ -0,0 +1 @@ +unicode/e242.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/candy.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/candy.png new file mode 120000 index 00000000000..d2db024ebb6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/candy.png @@ -0,0 +1 @@ +unicode/1f36c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/capital_abcd.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/capital_abcd.png new file mode 120000 index 00000000000..2df445b130e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/capital_abcd.png @@ -0,0 +1 @@ +unicode/1f520.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/capricorn.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/capricorn.png new file mode 120000 index 00000000000..d35fd97bd7f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/capricorn.png @@ -0,0 +1 @@ +unicode/e248.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/car.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/car.png new file mode 120000 index 00000000000..e0301c9abfb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/car.png @@ -0,0 +1 @@ +unicode/e01b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/card_index.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/card_index.png new file mode 120000 index 00000000000..ed12eadefa9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/card_index.png @@ -0,0 +1 @@ +unicode/1f4c7.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/carousel_horse.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/carousel_horse.png new file mode 120000 index 00000000000..714231536bf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/carousel_horse.png @@ -0,0 +1 @@ +unicode/1f3a0.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cat.png new file mode 120000 index 00000000000..aadd7527cb0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cat.png @@ -0,0 +1 @@ +unicode/e04f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cat2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cat2.png new file mode 120000 index 00000000000..5b63192d41d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cat2.png @@ -0,0 +1 @@ +unicode/1f408.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cd.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cd.png new file mode 120000 index 00000000000..d0aab5ca1d4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cd.png @@ -0,0 +1 @@ +unicode/e126.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chart.png new file mode 120000 index 00000000000..f81351b23e4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chart.png @@ -0,0 +1 @@ +unicode/e14a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chart_with_downwards_trend.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chart_with_downwards_trend.png new file mode 120000 index 00000000000..ed6490a910c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chart_with_downwards_trend.png @@ -0,0 +1 @@ +unicode/1f4c9.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chart_with_upwards_trend.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chart_with_upwards_trend.png new file mode 120000 index 00000000000..e50ad380b9c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chart_with_upwards_trend.png @@ -0,0 +1 @@ +unicode/1f4c8.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/checkered_flag.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/checkered_flag.png new file mode 120000 index 00000000000..80cc73da717 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/checkered_flag.png @@ -0,0 +1 @@ +unicode/e132.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cherries.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cherries.png new file mode 120000 index 00000000000..4c3c2fc43f4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cherries.png @@ -0,0 +1 @@ +unicode/1f352.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cherry_blossom.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cherry_blossom.png new file mode 120000 index 00000000000..36127ee4ff0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cherry_blossom.png @@ -0,0 +1 @@ +unicode/e030.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chestnut.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chestnut.png new file mode 120000 index 00000000000..4d6ca48afab --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chestnut.png @@ -0,0 +1 @@ +unicode/1f330.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chicken.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chicken.png new file mode 120000 index 00000000000..73c6c8d2776 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chicken.png @@ -0,0 +1 @@ +unicode/e52e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/children_crossing.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/children_crossing.png new file mode 120000 index 00000000000..f91803a5d8d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/children_crossing.png @@ -0,0 +1 @@ +unicode/1f6b8.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chocolate_bar.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chocolate_bar.png new file mode 120000 index 00000000000..fc35f401739 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/chocolate_bar.png @@ -0,0 +1 @@ +unicode/1f36b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/christmas_tree.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/christmas_tree.png new file mode 120000 index 00000000000..056e97f8353 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/christmas_tree.png @@ -0,0 +1 @@ +unicode/e033.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/church.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/church.png new file mode 120000 index 00000000000..53e5086907b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/church.png @@ -0,0 +1 @@ +unicode/e037.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cinema.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cinema.png new file mode 120000 index 00000000000..ee3707964ca --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cinema.png @@ -0,0 +1 @@ +unicode/e507.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/circus_tent.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/circus_tent.png new file mode 120000 index 00000000000..c481d05f235 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/circus_tent.png @@ -0,0 +1 @@ +unicode/1f3aa.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/city_sunrise.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/city_sunrise.png new file mode 120000 index 00000000000..764e18d2f43 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/city_sunrise.png @@ -0,0 +1 @@ +unicode/e44a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/city_sunset.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/city_sunset.png new file mode 120000 index 00000000000..5b698cb9808 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/city_sunset.png @@ -0,0 +1 @@ +unicode/e146.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cl.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cl.png new file mode 120000 index 00000000000..b891eb94947 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cl.png @@ -0,0 +1 @@ +unicode/1f191.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clap.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clap.png new file mode 120000 index 00000000000..a2e79fbef9e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clap.png @@ -0,0 +1 @@ +unicode/e41f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clapper.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clapper.png new file mode 120000 index 00000000000..a0a5895d617 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clapper.png @@ -0,0 +1 @@ +unicode/e324.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clipboard.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clipboard.png new file mode 120000 index 00000000000..bed675bb064 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clipboard.png @@ -0,0 +1 @@ +unicode/1f4cb.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1.png new file mode 120000 index 00000000000..6642c8ddbbd --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1.png @@ -0,0 +1 @@ +unicode/e024.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock10.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock10.png new file mode 120000 index 00000000000..279644d7e6d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock10.png @@ -0,0 +1 @@ +unicode/e02d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1030.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1030.png new file mode 120000 index 00000000000..832d8ede029 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1030.png @@ -0,0 +1 @@ +unicode/1f565.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock11.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock11.png new file mode 120000 index 00000000000..ff309d3b298 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock11.png @@ -0,0 +1 @@ +unicode/e02e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1130.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1130.png new file mode 120000 index 00000000000..7bd6ac61f19 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1130.png @@ -0,0 +1 @@ +unicode/1f566.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock12.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock12.png new file mode 120000 index 00000000000..a6fa0965885 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock12.png @@ -0,0 +1 @@ +unicode/e02f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1230.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1230.png new file mode 120000 index 00000000000..b8b0dd7a7a9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock1230.png @@ -0,0 +1 @@ +unicode/1f567.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock130.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock130.png new file mode 120000 index 00000000000..d58c12b71a5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock130.png @@ -0,0 +1 @@ +unicode/1f55c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock2.png new file mode 120000 index 00000000000..c5cdad50de9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock2.png @@ -0,0 +1 @@ +unicode/e025.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock230.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock230.png new file mode 120000 index 00000000000..c4bf1af15c9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock230.png @@ -0,0 +1 @@ +unicode/1f55d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock3.png new file mode 120000 index 00000000000..b8802593dc9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock3.png @@ -0,0 +1 @@ +unicode/e026.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock330.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock330.png new file mode 120000 index 00000000000..68c67d03f9d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock330.png @@ -0,0 +1 @@ +unicode/1f55e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock4.png new file mode 120000 index 00000000000..ac27d05859c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock4.png @@ -0,0 +1 @@ +unicode/e027.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock430.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock430.png new file mode 120000 index 00000000000..725b1327a82 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock430.png @@ -0,0 +1 @@ +unicode/1f55f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock5.png new file mode 120000 index 00000000000..0909c2a5d8c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock5.png @@ -0,0 +1 @@ +unicode/e028.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock530.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock530.png new file mode 120000 index 00000000000..59f0f4077fb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock530.png @@ -0,0 +1 @@ +unicode/1f560.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock6.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock6.png new file mode 120000 index 00000000000..9cbe1cea268 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock6.png @@ -0,0 +1 @@ +unicode/e029.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock630.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock630.png new file mode 120000 index 00000000000..b452a61526e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock630.png @@ -0,0 +1 @@ +unicode/1f561.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock7.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock7.png new file mode 120000 index 00000000000..339b20a34da --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock7.png @@ -0,0 +1 @@ +unicode/e02a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock730.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock730.png new file mode 120000 index 00000000000..fc8551a853e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock730.png @@ -0,0 +1 @@ +unicode/1f562.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock8.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock8.png new file mode 120000 index 00000000000..6a03c1fd90d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock8.png @@ -0,0 +1 @@ +unicode/e02b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock830.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock830.png new file mode 120000 index 00000000000..3ebfd1a8fe2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock830.png @@ -0,0 +1 @@ +unicode/1f563.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock9.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock9.png new file mode 120000 index 00000000000..18299cea476 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock9.png @@ -0,0 +1 @@ +unicode/e02c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock930.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock930.png new file mode 120000 index 00000000000..7071800dd49 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clock930.png @@ -0,0 +1 @@ +unicode/1f564.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/closed_book.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/closed_book.png new file mode 120000 index 00000000000..a2d7786f7bf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/closed_book.png @@ -0,0 +1 @@ +unicode/1f4d5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/closed_lock_with_key.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/closed_lock_with_key.png new file mode 120000 index 00000000000..49be232101c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/closed_lock_with_key.png @@ -0,0 +1 @@ +unicode/1f510.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/closed_umbrella.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/closed_umbrella.png new file mode 120000 index 00000000000..19f02c89ceb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/closed_umbrella.png @@ -0,0 +1 @@ +unicode/e43c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cloud.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cloud.png new file mode 120000 index 00000000000..76f78c8b64a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cloud.png @@ -0,0 +1 @@ +unicode/e049.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clubs.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clubs.png new file mode 120000 index 00000000000..1b52b3fdabc --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/clubs.png @@ -0,0 +1 @@ +unicode/e20f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cn.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cn.png new file mode 120000 index 00000000000..5e1cd10f6a5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cn.png @@ -0,0 +1 @@ +unicode/e513.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cocktail.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cocktail.png new file mode 120000 index 00000000000..8033bd07b47 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cocktail.png @@ -0,0 +1 @@ +unicode/e044.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/coffee.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/coffee.png new file mode 120000 index 00000000000..59755278c9b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/coffee.png @@ -0,0 +1 @@ +unicode/e045.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cold_sweat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cold_sweat.png new file mode 120000 index 00000000000..d7e6ac47548 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cold_sweat.png @@ -0,0 +1 @@ +unicode/e40f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/collision.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/collision.png new file mode 120000 index 00000000000..ac298e5b3a0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/collision.png @@ -0,0 +1 @@ +unicode/1f4a5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/computer.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/computer.png new file mode 120000 index 00000000000..b28392c778c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/computer.png @@ -0,0 +1 @@ +unicode/e00c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/confetti_ball.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/confetti_ball.png new file mode 120000 index 00000000000..4e45ed75521 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/confetti_ball.png @@ -0,0 +1 @@ +unicode/1f38a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/confounded.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/confounded.png new file mode 120000 index 00000000000..0afac8a26b4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/confounded.png @@ -0,0 +1 @@ +unicode/e407.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/congratulations.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/congratulations.png new file mode 120000 index 00000000000..7ad1fdc5434 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/congratulations.png @@ -0,0 +1 @@ +unicode/e30d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/construction.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/construction.png new file mode 120000 index 00000000000..145c9caac8b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/construction.png @@ -0,0 +1 @@ +unicode/e137.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/construction_worker.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/construction_worker.png new file mode 120000 index 00000000000..73b875a9739 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/construction_worker.png @@ -0,0 +1 @@ +unicode/e51b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/convenience_store.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/convenience_store.png new file mode 120000 index 00000000000..b41435bdb17 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/convenience_store.png @@ -0,0 +1 @@ +unicode/e156.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cookie.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cookie.png new file mode 120000 index 00000000000..e26336948b2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cookie.png @@ -0,0 +1 @@ +unicode/1f36a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cool.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cool.png new file mode 120000 index 00000000000..fdca01bed09 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cool.png @@ -0,0 +1 @@ +unicode/e214.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cop.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cop.png new file mode 120000 index 00000000000..2d5714e1dfd --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cop.png @@ -0,0 +1 @@ +unicode/e152.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/copyright.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/copyright.png new file mode 120000 index 00000000000..0cda43663db --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/copyright.png @@ -0,0 +1 @@ +unicode/e24e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/corn.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/corn.png new file mode 120000 index 00000000000..9cb7d3ef73d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/corn.png @@ -0,0 +1 @@ +unicode/1f33d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/couple.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/couple.png new file mode 120000 index 00000000000..5d06285e897 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/couple.png @@ -0,0 +1 @@ +unicode/e428.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/couple_with_heart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/couple_with_heart.png new file mode 120000 index 00000000000..0d08a0a2fcb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/couple_with_heart.png @@ -0,0 +1 @@ +unicode/e425.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/couplekiss.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/couplekiss.png new file mode 120000 index 00000000000..8ae0662c5b2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/couplekiss.png @@ -0,0 +1 @@ +unicode/e111.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cow.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cow.png new file mode 120000 index 00000000000..6b726fa233a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cow.png @@ -0,0 +1 @@ +unicode/e52b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cow2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cow2.png new file mode 120000 index 00000000000..66e42f9db31 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cow2.png @@ -0,0 +1 @@ +unicode/1f404.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/credit_card.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/credit_card.png new file mode 120000 index 00000000000..f1b2040ff81 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/credit_card.png @@ -0,0 +1 @@ +unicode/1f4b3.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crocodile.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crocodile.png new file mode 120000 index 00000000000..74edced8a39 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crocodile.png @@ -0,0 +1 @@ +unicode/1f40a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crossed_flags.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crossed_flags.png new file mode 120000 index 00000000000..a08591c9f3a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crossed_flags.png @@ -0,0 +1 @@ +unicode/e143.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crown.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crown.png new file mode 120000 index 00000000000..2c6af598c0b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crown.png @@ -0,0 +1 @@ +unicode/e10e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cry.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cry.png new file mode 120000 index 00000000000..0f039b960b6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cry.png @@ -0,0 +1 @@ +unicode/e413.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crying_cat_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crying_cat_face.png new file mode 120000 index 00000000000..59cc6d46bcd --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crying_cat_face.png @@ -0,0 +1 @@ +unicode/1f63f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crystal_ball.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crystal_ball.png new file mode 120000 index 00000000000..db7f0298c2d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/crystal_ball.png @@ -0,0 +1 @@ +unicode/1f52e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cupid.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cupid.png new file mode 120000 index 00000000000..6e1387e259f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cupid.png @@ -0,0 +1 @@ +unicode/e329.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/curly_loop.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/curly_loop.png new file mode 120000 index 00000000000..edd47cb2c85 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/curly_loop.png @@ -0,0 +1 @@ +unicode/27b0.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/currency_exchange.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/currency_exchange.png new file mode 120000 index 00000000000..ddae57e3580 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/currency_exchange.png @@ -0,0 +1 @@ +unicode/e149.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/curry.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/curry.png new file mode 120000 index 00000000000..8f657bc8478 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/curry.png @@ -0,0 +1 @@ +unicode/e341.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/custard.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/custard.png new file mode 120000 index 00000000000..25b31e9c12f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/custard.png @@ -0,0 +1 @@ +unicode/1f36e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/customs.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/customs.png new file mode 120000 index 00000000000..486f2789ca1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/customs.png @@ -0,0 +1 @@ +unicode/1f6c3.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cyclone.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cyclone.png new file mode 120000 index 00000000000..db025517efb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/cyclone.png @@ -0,0 +1 @@ +unicode/e443.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dancer.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dancer.png new file mode 120000 index 00000000000..f02e1b69a59 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dancer.png @@ -0,0 +1 @@ +unicode/e51f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dancers.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dancers.png new file mode 120000 index 00000000000..16f3cd63e39 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dancers.png @@ -0,0 +1 @@ +unicode/e429.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dango.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dango.png new file mode 120000 index 00000000000..74e1d4175b4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dango.png @@ -0,0 +1 @@ +unicode/e33c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dart.png new file mode 120000 index 00000000000..9c082ef6538 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dart.png @@ -0,0 +1 @@ +unicode/e130.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dash.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dash.png new file mode 120000 index 00000000000..001a9a903e1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dash.png @@ -0,0 +1 @@ +unicode/e330.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/date.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/date.png new file mode 120000 index 00000000000..9b2460cf238 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/date.png @@ -0,0 +1 @@ +unicode/1f4c5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/de.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/de.png new file mode 120000 index 00000000000..8715387ded7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/de.png @@ -0,0 +1 @@ +unicode/e50e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/deciduous_tree.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/deciduous_tree.png new file mode 120000 index 00000000000..f84c1cd43ad --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/deciduous_tree.png @@ -0,0 +1 @@ +unicode/1f333.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/department_store.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/department_store.png new file mode 120000 index 00000000000..2a5a7435dc9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/department_store.png @@ -0,0 +1 @@ +unicode/e504.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/diamond_shape_with_a_dot_inside.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/diamond_shape_with_a_dot_inside.png new file mode 120000 index 00000000000..9ca70fc936c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/diamond_shape_with_a_dot_inside.png @@ -0,0 +1 @@ +unicode/1f4a0.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/diamonds.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/diamonds.png new file mode 120000 index 00000000000..952ed51bec1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/diamonds.png @@ -0,0 +1 @@ +unicode/e20d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/disappointed.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/disappointed.png new file mode 120000 index 00000000000..29113ad8330 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/disappointed.png @@ -0,0 +1 @@ +unicode/e058.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dizzy.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dizzy.png new file mode 120000 index 00000000000..d8f02d9b40c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dizzy.png @@ -0,0 +1 @@ +unicode/1f4ab.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dizzy_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dizzy_face.png new file mode 120000 index 00000000000..6c1725d0e02 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dizzy_face.png @@ -0,0 +1 @@ +unicode/1f635.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/do_not_litter.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/do_not_litter.png new file mode 120000 index 00000000000..2aad4879c49 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/do_not_litter.png @@ -0,0 +1 @@ +unicode/1f6af.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dog.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dog.png new file mode 120000 index 00000000000..00c1cd35740 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dog.png @@ -0,0 +1 @@ +unicode/e052.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dog2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dog2.png new file mode 120000 index 00000000000..e447911e29f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dog2.png @@ -0,0 +1 @@ +unicode/1f415.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dollar.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dollar.png new file mode 120000 index 00000000000..5f57b39b6d3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dollar.png @@ -0,0 +1 @@ +unicode/1f4b5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dolls.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dolls.png new file mode 120000 index 00000000000..152feec82c1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dolls.png @@ -0,0 +1 @@ +unicode/e438.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dolphin.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dolphin.png new file mode 120000 index 00000000000..58f333034b7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dolphin.png @@ -0,0 +1 @@ +unicode/e520.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/door.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/door.png new file mode 120000 index 00000000000..2892c68070d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/door.png @@ -0,0 +1 @@ +unicode/1f6aa.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/doughnut.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/doughnut.png new file mode 120000 index 00000000000..3f4be9b306a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/doughnut.png @@ -0,0 +1 @@ +unicode/1f369.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dragon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dragon.png new file mode 120000 index 00000000000..72564c6535e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dragon.png @@ -0,0 +1 @@ +unicode/1f409.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dragon_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dragon_face.png new file mode 120000 index 00000000000..91a1c3ae08f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dragon_face.png @@ -0,0 +1 @@ +unicode/1f432.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dress.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dress.png new file mode 120000 index 00000000000..611aa97a42c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dress.png @@ -0,0 +1 @@ +unicode/e319.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dromedary_camel.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dromedary_camel.png new file mode 120000 index 00000000000..cb20f59c155 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dromedary_camel.png @@ -0,0 +1 @@ +unicode/1f42a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/droplet.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/droplet.png new file mode 120000 index 00000000000..cfac4591e03 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/droplet.png @@ -0,0 +1 @@ +unicode/1f4a7.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dvd.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dvd.png new file mode 120000 index 00000000000..66a5d267bc1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/dvd.png @@ -0,0 +1 @@ +unicode/e127.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/e-mail.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/e-mail.png new file mode 120000 index 00000000000..06849d1877b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/e-mail.png @@ -0,0 +1 @@ +unicode/1f4e7.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ear.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ear.png new file mode 120000 index 00000000000..e7634c60c6d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ear.png @@ -0,0 +1 @@ +unicode/e41b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ear_of_rice.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ear_of_rice.png new file mode 120000 index 00000000000..a2e284c9c06 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ear_of_rice.png @@ -0,0 +1 @@ +unicode/e444.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/earth_africa.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/earth_africa.png new file mode 120000 index 00000000000..f8c52577bc9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/earth_africa.png @@ -0,0 +1 @@ +unicode/1f30d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/earth_americas.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/earth_americas.png new file mode 120000 index 00000000000..6caf59dac59 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/earth_americas.png @@ -0,0 +1 @@ +unicode/1f30e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/earth_asia.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/earth_asia.png new file mode 120000 index 00000000000..2934b85aa66 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/earth_asia.png @@ -0,0 +1 @@ +unicode/1f30f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/egg.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/egg.png new file mode 120000 index 00000000000..96470990182 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/egg.png @@ -0,0 +1 @@ +unicode/e147.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eggplant.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eggplant.png new file mode 120000 index 00000000000..ed61d8f0e77 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eggplant.png @@ -0,0 +1 @@ +unicode/e34a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eight.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eight.png new file mode 120000 index 00000000000..3292a496d0e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eight.png @@ -0,0 +1 @@ +unicode/e223.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eight_pointed_black_star.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eight_pointed_black_star.png new file mode 120000 index 00000000000..04a7707f08e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eight_pointed_black_star.png @@ -0,0 +1 @@ +unicode/e205.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eight_spoked_asterisk.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eight_spoked_asterisk.png new file mode 120000 index 00000000000..501e076bbce --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eight_spoked_asterisk.png @@ -0,0 +1 @@ +unicode/e206.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/electric_plug.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/electric_plug.png new file mode 120000 index 00000000000..1f9b2bb0555 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/electric_plug.png @@ -0,0 +1 @@ +unicode/1f50c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/elephant.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/elephant.png new file mode 120000 index 00000000000..70618e7f618 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/elephant.png @@ -0,0 +1 @@ +unicode/e526.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/email.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/email.png new file mode 120000 index 00000000000..ac2242c0cfb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/email.png @@ -0,0 +1 @@ +unicode/e103.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/end.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/end.png new file mode 120000 index 00000000000..8350d8c305a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/end.png @@ -0,0 +1 @@ +unicode/1f51a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/envelope.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/envelope.png new file mode 120000 index 00000000000..d4e66d388ca --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/envelope.png @@ -0,0 +1 @@ +unicode/2709.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/es.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/es.png new file mode 120000 index 00000000000..9ed925b5797 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/es.png @@ -0,0 +1 @@ +unicode/e511.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/euro.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/euro.png new file mode 120000 index 00000000000..3645ce124e4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/euro.png @@ -0,0 +1 @@ +unicode/1f4b6.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/european_castle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/european_castle.png new file mode 120000 index 00000000000..339c13f74d8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/european_castle.png @@ -0,0 +1 @@ +unicode/e506.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/european_post_office.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/european_post_office.png new file mode 120000 index 00000000000..431839cc9f5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/european_post_office.png @@ -0,0 +1 @@ +unicode/1f3e4.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/evergreen_tree.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/evergreen_tree.png new file mode 120000 index 00000000000..3484dccf5f4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/evergreen_tree.png @@ -0,0 +1 @@ +unicode/1f332.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/exclamation.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/exclamation.png new file mode 120000 index 00000000000..69cfdb30c1b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/exclamation.png @@ -0,0 +1 @@ +unicode/e021.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eyeglasses.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eyeglasses.png new file mode 120000 index 00000000000..8a3815a68b6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eyeglasses.png @@ -0,0 +1 @@ +unicode/1f453.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eyes.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eyes.png new file mode 120000 index 00000000000..695e723c03d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/eyes.png @@ -0,0 +1 @@ +unicode/e419.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/facepunch.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/facepunch.png new file mode 120000 index 00000000000..0c4a662d1ee --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/facepunch.png @@ -0,0 +1 @@ +unicode/e00d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/factory.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/factory.png new file mode 120000 index 00000000000..2b644dd3219 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/factory.png @@ -0,0 +1 @@ +unicode/e508.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fallen_leaf.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fallen_leaf.png new file mode 120000 index 00000000000..5346d8b28c0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fallen_leaf.png @@ -0,0 +1 @@ +unicode/e119.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/family.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/family.png new file mode 120000 index 00000000000..94c3a5a3c78 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/family.png @@ -0,0 +1 @@ +unicode/1f46a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fast_forward.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fast_forward.png new file mode 120000 index 00000000000..7d8ffd547d8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fast_forward.png @@ -0,0 +1 @@ +unicode/e23c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fax.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fax.png new file mode 120000 index 00000000000..bec0df1ecfb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fax.png @@ -0,0 +1 @@ +unicode/e00b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fearful.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fearful.png new file mode 120000 index 00000000000..24211284819 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fearful.png @@ -0,0 +1 @@ +unicode/e40b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/feelsgood.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/feelsgood.png new file mode 100644 index 00000000000..bad80a6b1b9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/feelsgood.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/feet.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/feet.png new file mode 120000 index 00000000000..6afc8d9011b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/feet.png @@ -0,0 +1 @@ +unicode/e536.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ferris_wheel.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ferris_wheel.png new file mode 120000 index 00000000000..63004bb33d7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ferris_wheel.png @@ -0,0 +1 @@ +unicode/e124.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/file_folder.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/file_folder.png new file mode 120000 index 00000000000..9c3f43fdae5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/file_folder.png @@ -0,0 +1 @@ +unicode/1f4c1.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/finnadie.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/finnadie.png new file mode 100644 index 00000000000..05ba8ac5ef9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/finnadie.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fire.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fire.png new file mode 120000 index 00000000000..16dec1787be --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fire.png @@ -0,0 +1 @@ +unicode/e11d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fire_engine.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fire_engine.png new file mode 120000 index 00000000000..7451843798d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fire_engine.png @@ -0,0 +1 @@ +unicode/e430.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fireworks.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fireworks.png new file mode 120000 index 00000000000..15b73ab7fcf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fireworks.png @@ -0,0 +1 @@ +unicode/e117.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/first_quarter_moon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/first_quarter_moon.png new file mode 120000 index 00000000000..07b1c4d7000 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/first_quarter_moon.png @@ -0,0 +1 @@ +unicode/1f313.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/first_quarter_moon_with_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/first_quarter_moon_with_face.png new file mode 120000 index 00000000000..fec7b969fe2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/first_quarter_moon_with_face.png @@ -0,0 +1 @@ +unicode/1f31b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fish.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fish.png new file mode 120000 index 00000000000..9e715ed6fa9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fish.png @@ -0,0 +1 @@ +unicode/e019.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fish_cake.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fish_cake.png new file mode 120000 index 00000000000..090d80978ab --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fish_cake.png @@ -0,0 +1 @@ +unicode/1f365.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fishing_pole_and_fish.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fishing_pole_and_fish.png new file mode 120000 index 00000000000..89abe1b6a15 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fishing_pole_and_fish.png @@ -0,0 +1 @@ +unicode/1f3a3.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fist.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fist.png new file mode 120000 index 00000000000..bfdaa20fb73 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fist.png @@ -0,0 +1 @@ +unicode/e010.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/five.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/five.png new file mode 120000 index 00000000000..d9b5537b0c3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/five.png @@ -0,0 +1 @@ +unicode/e220.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flags.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flags.png new file mode 120000 index 00000000000..b9ed0bb71fe --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flags.png @@ -0,0 +1 @@ +unicode/e43b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flashlight.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flashlight.png new file mode 120000 index 00000000000..662d68ae21f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flashlight.png @@ -0,0 +1 @@ +unicode/1f526.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/floppy_disk.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/floppy_disk.png new file mode 120000 index 00000000000..09b4bf4a594 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/floppy_disk.png @@ -0,0 +1 @@ +unicode/1f4be.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flower_playing_cards.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flower_playing_cards.png new file mode 120000 index 00000000000..78ab229c2f8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flower_playing_cards.png @@ -0,0 +1 @@ +unicode/1f3b4.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flushed.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flushed.png new file mode 120000 index 00000000000..888bd6bb267 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/flushed.png @@ -0,0 +1 @@ +unicode/e40d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/foggy.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/foggy.png new file mode 120000 index 00000000000..f0db32efbbc --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/foggy.png @@ -0,0 +1 @@ +unicode/1f301.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/football.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/football.png new file mode 120000 index 00000000000..590290c900f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/football.png @@ -0,0 +1 @@ +unicode/e42b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fork_and_knife.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fork_and_knife.png new file mode 120000 index 00000000000..f618167ad2e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fork_and_knife.png @@ -0,0 +1 @@ +unicode/e043.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fountain.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fountain.png new file mode 120000 index 00000000000..b783a2d8117 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fountain.png @@ -0,0 +1 @@ +unicode/e121.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/four.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/four.png new file mode 120000 index 00000000000..82fa4fe1baa --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/four.png @@ -0,0 +1 @@ +unicode/e21f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/four_leaf_clover.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/four_leaf_clover.png new file mode 120000 index 00000000000..0bf8938a7b5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/four_leaf_clover.png @@ -0,0 +1 @@ +unicode/e110.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fr.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fr.png new file mode 120000 index 00000000000..31ed4ab57c0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fr.png @@ -0,0 +1 @@ +unicode/e50d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/free.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/free.png new file mode 120000 index 00000000000..6ee820ecbef --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/free.png @@ -0,0 +1 @@ +unicode/1f193.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fried_shrimp.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fried_shrimp.png new file mode 120000 index 00000000000..00c55f30db5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fried_shrimp.png @@ -0,0 +1 @@ +unicode/1f364.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fries.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fries.png new file mode 120000 index 00000000000..02aefb1875d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fries.png @@ -0,0 +1 @@ +unicode/e33b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/frog.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/frog.png new file mode 120000 index 00000000000..d0cac850c4b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/frog.png @@ -0,0 +1 @@ +unicode/e531.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fuelpump.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fuelpump.png new file mode 120000 index 00000000000..7aeb1bbf83d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/fuelpump.png @@ -0,0 +1 @@ +unicode/e03a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/full_moon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/full_moon.png new file mode 120000 index 00000000000..13b0a191cef --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/full_moon.png @@ -0,0 +1 @@ +unicode/1f315.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/full_moon_with_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/full_moon_with_face.png new file mode 120000 index 00000000000..37f8fe7f24e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/full_moon_with_face.png @@ -0,0 +1 @@ +unicode/1f31d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/game_die.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/game_die.png new file mode 120000 index 00000000000..be998c49f2e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/game_die.png @@ -0,0 +1 @@ +unicode/1f3b2.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gb.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gb.png new file mode 120000 index 00000000000..20d8b968e3b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gb.png @@ -0,0 +1 @@ +unicode/e510.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gem.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gem.png new file mode 120000 index 00000000000..56e647a0484 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gem.png @@ -0,0 +1 @@ +unicode/e035.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gemini.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gemini.png new file mode 120000 index 00000000000..b2b45a5067d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gemini.png @@ -0,0 +1 @@ +unicode/e241.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ghost.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ghost.png new file mode 120000 index 00000000000..8db8a2d3e19 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ghost.png @@ -0,0 +1 @@ +unicode/e11b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gift.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gift.png new file mode 120000 index 00000000000..c5d8538afb8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gift.png @@ -0,0 +1 @@ +unicode/e112.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gift_heart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gift_heart.png new file mode 120000 index 00000000000..10400dd56fa --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gift_heart.png @@ -0,0 +1 @@ +unicode/e437.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/girl.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/girl.png new file mode 120000 index 00000000000..f605a9ee560 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/girl.png @@ -0,0 +1 @@ +unicode/e002.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/globe_with_meridians.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/globe_with_meridians.png new file mode 120000 index 00000000000..ce26e094baf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/globe_with_meridians.png @@ -0,0 +1 @@ +unicode/1f310.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/goat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/goat.png new file mode 120000 index 00000000000..684cf9f0b3c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/goat.png @@ -0,0 +1 @@ +unicode/1f410.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/goberserk.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/goberserk.png new file mode 100644 index 00000000000..59a742aaaa5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/goberserk.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/godmode.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/godmode.png new file mode 100644 index 00000000000..7e75ab2081b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/godmode.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/golf.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/golf.png new file mode 120000 index 00000000000..71384080b65 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/golf.png @@ -0,0 +1 @@ +unicode/e014.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grapes.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grapes.png new file mode 120000 index 00000000000..992b8bedb57 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grapes.png @@ -0,0 +1 @@ +unicode/1f347.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/green_apple.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/green_apple.png new file mode 120000 index 00000000000..976a7540141 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/green_apple.png @@ -0,0 +1 @@ +unicode/1f34f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/green_book.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/green_book.png new file mode 120000 index 00000000000..3dc3c270c6d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/green_book.png @@ -0,0 +1 @@ +unicode/1f4d7.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/green_heart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/green_heart.png new file mode 120000 index 00000000000..ea7ac834857 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/green_heart.png @@ -0,0 +1 @@ +unicode/e32b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grey_exclamation.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grey_exclamation.png new file mode 120000 index 00000000000..b4c5368a8c6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grey_exclamation.png @@ -0,0 +1 @@ +unicode/e337.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grey_question.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grey_question.png new file mode 120000 index 00000000000..e3233cd7580 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grey_question.png @@ -0,0 +1 @@ +unicode/e336.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grin.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grin.png new file mode 120000 index 00000000000..506d71c5283 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/grin.png @@ -0,0 +1 @@ +unicode/e404.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/guardsman.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/guardsman.png new file mode 120000 index 00000000000..7efeb8cf7cf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/guardsman.png @@ -0,0 +1 @@ +unicode/e51e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/guitar.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/guitar.png new file mode 120000 index 00000000000..b9bdc045161 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/guitar.png @@ -0,0 +1 @@ +unicode/e041.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gun.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gun.png new file mode 120000 index 00000000000..bc384de81d6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/gun.png @@ -0,0 +1 @@ +unicode/e113.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/haircut.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/haircut.png new file mode 120000 index 00000000000..b0dee93e858 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/haircut.png @@ -0,0 +1 @@ +unicode/e31f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hamburger.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hamburger.png new file mode 120000 index 00000000000..0ff93bc26c1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hamburger.png @@ -0,0 +1 @@ +unicode/e120.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hammer.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hammer.png new file mode 120000 index 00000000000..6a5196e6bb2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hammer.png @@ -0,0 +1 @@ +unicode/e116.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hamster.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hamster.png new file mode 120000 index 00000000000..df4bb20f431 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hamster.png @@ -0,0 +1 @@ +unicode/e524.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hand.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hand.png new file mode 120000 index 00000000000..3264129221d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hand.png @@ -0,0 +1 @@ +unicode/e012.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/handbag.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/handbag.png new file mode 120000 index 00000000000..4cd88dfa407 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/handbag.png @@ -0,0 +1 @@ +unicode/e323.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hankey.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hankey.png new file mode 120000 index 00000000000..1ffab6df5cb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hankey.png @@ -0,0 +1 @@ +unicode/e05a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hash.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hash.png new file mode 120000 index 00000000000..21e68d211d4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hash.png @@ -0,0 +1 @@ +unicode/e210.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hatched_chick.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hatched_chick.png new file mode 120000 index 00000000000..e71635571b1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hatched_chick.png @@ -0,0 +1 @@ +unicode/1f425.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hatching_chick.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hatching_chick.png new file mode 120000 index 00000000000..3a72c178b73 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hatching_chick.png @@ -0,0 +1 @@ +unicode/1f423.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/headphones.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/headphones.png new file mode 120000 index 00000000000..7ee4040992d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/headphones.png @@ -0,0 +1 @@ +unicode/e30a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hear_no_evil.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hear_no_evil.png new file mode 120000 index 00000000000..ecb7fa8122b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hear_no_evil.png @@ -0,0 +1 @@ +unicode/1f649.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart.png new file mode 120000 index 00000000000..3e652ba4901 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart.png @@ -0,0 +1 @@ +unicode/e022.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart_decoration.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart_decoration.png new file mode 120000 index 00000000000..3b3b938acea --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart_decoration.png @@ -0,0 +1 @@ +unicode/e204.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart_eyes.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart_eyes.png new file mode 120000 index 00000000000..9ed4e54d3d6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart_eyes.png @@ -0,0 +1 @@ +unicode/e106.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart_eyes_cat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart_eyes_cat.png new file mode 120000 index 00000000000..55eca64e110 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heart_eyes_cat.png @@ -0,0 +1 @@ +unicode/1f63b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heartbeat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heartbeat.png new file mode 120000 index 00000000000..4568126ae4a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heartbeat.png @@ -0,0 +1 @@ +unicode/e327.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heartpulse.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heartpulse.png new file mode 120000 index 00000000000..932031e8801 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heartpulse.png @@ -0,0 +1 @@ +unicode/e328.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hearts.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hearts.png new file mode 120000 index 00000000000..fa0857f7652 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hearts.png @@ -0,0 +1 @@ +unicode/e20c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_check_mark.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_check_mark.png new file mode 120000 index 00000000000..30e9f06b7c6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_check_mark.png @@ -0,0 +1 @@ +unicode/2714.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_division_sign.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_division_sign.png new file mode 120000 index 00000000000..6b593c8c028 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_division_sign.png @@ -0,0 +1 @@ +unicode/2797.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_dollar_sign.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_dollar_sign.png new file mode 120000 index 00000000000..5cf869bd0ed --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_dollar_sign.png @@ -0,0 +1 @@ +unicode/1f4b2.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_exclamation_mark.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_exclamation_mark.png new file mode 120000 index 00000000000..f085c97778d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_exclamation_mark.png @@ -0,0 +1 @@ +unicode/2757.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_minus_sign.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_minus_sign.png new file mode 120000 index 00000000000..8e032c1e20c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_minus_sign.png @@ -0,0 +1 @@ +unicode/2796.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_multiplication_x.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_multiplication_x.png new file mode 120000 index 00000000000..ac6e4c9d819 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_multiplication_x.png @@ -0,0 +1 @@ +unicode/2716.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_plus_sign.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_plus_sign.png new file mode 120000 index 00000000000..820107fff6b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/heavy_plus_sign.png @@ -0,0 +1 @@ +unicode/2795.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/helicopter.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/helicopter.png new file mode 120000 index 00000000000..6dc29b813b9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/helicopter.png @@ -0,0 +1 @@ +unicode/1f681.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/herb.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/herb.png new file mode 120000 index 00000000000..5bd41d8d3d7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/herb.png @@ -0,0 +1 @@ +unicode/1f33f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hibiscus.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hibiscus.png new file mode 120000 index 00000000000..55d120720f4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hibiscus.png @@ -0,0 +1 @@ +unicode/e303.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/high_brightness.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/high_brightness.png new file mode 120000 index 00000000000..e9b01dcc74e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/high_brightness.png @@ -0,0 +1 @@ +unicode/1f506.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/high_heel.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/high_heel.png new file mode 120000 index 00000000000..2b0263b9b84 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/high_heel.png @@ -0,0 +1 @@ +unicode/e13e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hocho.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hocho.png new file mode 120000 index 00000000000..fd4eefe1319 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hocho.png @@ -0,0 +1 @@ +unicode/1f52a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/honey_pot.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/honey_pot.png new file mode 120000 index 00000000000..bf38a2598c1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/honey_pot.png @@ -0,0 +1 @@ +unicode/1f36f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/honeybee.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/honeybee.png new file mode 120000 index 00000000000..d1768eca5e1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/honeybee.png @@ -0,0 +1 @@ +unicode/1f41d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/horse.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/horse.png new file mode 120000 index 00000000000..7a287933165 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/horse.png @@ -0,0 +1 @@ +unicode/e01a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/horse_racing.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/horse_racing.png new file mode 120000 index 00000000000..3d98145e4de --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/horse_racing.png @@ -0,0 +1 @@ +unicode/1f3c7.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hospital.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hospital.png new file mode 120000 index 00000000000..ec0a9322591 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hospital.png @@ -0,0 +1 @@ +unicode/e155.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hotel.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hotel.png new file mode 120000 index 00000000000..c1c00eb93b1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hotel.png @@ -0,0 +1 @@ +unicode/e158.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hotsprings.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hotsprings.png new file mode 120000 index 00000000000..d1507d87bc9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hotsprings.png @@ -0,0 +1 @@ +unicode/e123.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hourglass.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hourglass.png new file mode 120000 index 00000000000..2563dece9af --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hourglass.png @@ -0,0 +1 @@ +unicode/231b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/house.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/house.png new file mode 120000 index 00000000000..07a175f2a22 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/house.png @@ -0,0 +1 @@ +unicode/e036.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hurtrealbad.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hurtrealbad.png new file mode 100644 index 00000000000..146ef1a6a87 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/hurtrealbad.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ice_cream.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ice_cream.png new file mode 120000 index 00000000000..bfe04923516 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ice_cream.png @@ -0,0 +1 @@ +unicode/1f368.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/icecream.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/icecream.png new file mode 120000 index 00000000000..09bb852c1e5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/icecream.png @@ -0,0 +1 @@ +unicode/e33a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/id.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/id.png new file mode 120000 index 00000000000..1fa7690c251 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/id.png @@ -0,0 +1 @@ +unicode/e229.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ideograph_advantage.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ideograph_advantage.png new file mode 120000 index 00000000000..09d884608f4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ideograph_advantage.png @@ -0,0 +1 @@ +unicode/e226.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/imp.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/imp.png new file mode 120000 index 00000000000..ac0768744bd --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/imp.png @@ -0,0 +1 @@ +unicode/e11a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/inbox_tray.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/inbox_tray.png new file mode 120000 index 00000000000..1f6523edcc9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/inbox_tray.png @@ -0,0 +1 @@ +unicode/1f4e5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/incoming_envelope.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/incoming_envelope.png new file mode 120000 index 00000000000..8ec90adce60 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/incoming_envelope.png @@ -0,0 +1 @@ +unicode/1f4e8.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/information_desk_person.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/information_desk_person.png new file mode 120000 index 00000000000..010c1b41962 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/information_desk_person.png @@ -0,0 +1 @@ +unicode/e253.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/information_source.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/information_source.png new file mode 120000 index 00000000000..b25c3cbd761 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/information_source.png @@ -0,0 +1 @@ +unicode/2139.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/innocent.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/innocent.png new file mode 120000 index 00000000000..87c9e8c58f6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/innocent.png @@ -0,0 +1 @@ +unicode/1f607.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/interrobang.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/interrobang.png new file mode 120000 index 00000000000..ac24b741530 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/interrobang.png @@ -0,0 +1 @@ +unicode/2049.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/iphone.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/iphone.png new file mode 120000 index 00000000000..5dcdd0b2176 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/iphone.png @@ -0,0 +1 @@ +unicode/e00a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/it.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/it.png new file mode 120000 index 00000000000..d488ab15436 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/it.png @@ -0,0 +1 @@ +unicode/e50f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/izakaya_lantern.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/izakaya_lantern.png new file mode 120000 index 00000000000..a00faa3541e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/izakaya_lantern.png @@ -0,0 +1 @@ +unicode/1f3ee.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/jack_o_lantern.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/jack_o_lantern.png new file mode 120000 index 00000000000..9803d2c8123 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/jack_o_lantern.png @@ -0,0 +1 @@ +unicode/e445.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japan.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japan.png new file mode 120000 index 00000000000..4587899c6ab --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japan.png @@ -0,0 +1 @@ +unicode/1f5fe.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japanese_castle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japanese_castle.png new file mode 120000 index 00000000000..ef896c663b3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japanese_castle.png @@ -0,0 +1 @@ +unicode/e505.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japanese_goblin.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japanese_goblin.png new file mode 120000 index 00000000000..74c5aaced56 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japanese_goblin.png @@ -0,0 +1 @@ +unicode/1f47a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japanese_ogre.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japanese_ogre.png new file mode 120000 index 00000000000..1a6082f418f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/japanese_ogre.png @@ -0,0 +1 @@ +unicode/1f479.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/jeans.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/jeans.png new file mode 120000 index 00000000000..cec43bf66b7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/jeans.png @@ -0,0 +1 @@ +unicode/1f456.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/joy.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/joy.png new file mode 120000 index 00000000000..36c8110db7f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/joy.png @@ -0,0 +1 @@ +unicode/e412.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/joy_cat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/joy_cat.png new file mode 120000 index 00000000000..86a6a3fa479 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/joy_cat.png @@ -0,0 +1 @@ +unicode/1f639.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/jp.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/jp.png new file mode 120000 index 00000000000..9eb43ac4707 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/jp.png @@ -0,0 +1 @@ +unicode/e50b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/key.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/key.png new file mode 120000 index 00000000000..ca1447abb49 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/key.png @@ -0,0 +1 @@ +unicode/e03f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/keycap_ten.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/keycap_ten.png new file mode 120000 index 00000000000..d1ed03a8186 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/keycap_ten.png @@ -0,0 +1 @@ +unicode/1f51f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kimono.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kimono.png new file mode 120000 index 00000000000..9fcfe907b98 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kimono.png @@ -0,0 +1 @@ +unicode/e321.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kiss.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kiss.png new file mode 120000 index 00000000000..12df5d0e54f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kiss.png @@ -0,0 +1 @@ +unicode/e003.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kissing_cat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kissing_cat.png new file mode 120000 index 00000000000..38f289f1da0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kissing_cat.png @@ -0,0 +1 @@ +unicode/1f63d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kissing_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kissing_face.png new file mode 120000 index 00000000000..aa6d984c367 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kissing_face.png @@ -0,0 +1 @@ +unicode/e417.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kissing_heart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kissing_heart.png new file mode 120000 index 00000000000..da26045d4ac --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kissing_heart.png @@ -0,0 +1 @@ +unicode/e418.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/koala.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/koala.png new file mode 120000 index 00000000000..35d2756c7bc --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/koala.png @@ -0,0 +1 @@ +unicode/e527.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/koko.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/koko.png new file mode 120000 index 00000000000..4def761a2c9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/koko.png @@ -0,0 +1 @@ +unicode/e203.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kr.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kr.png new file mode 120000 index 00000000000..e189be99b63 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/kr.png @@ -0,0 +1 @@ +unicode/e514.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/large_blue_circle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/large_blue_circle.png new file mode 120000 index 00000000000..d40ba77c029 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/large_blue_circle.png @@ -0,0 +1 @@ +unicode/1f535.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/large_blue_diamond.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/large_blue_diamond.png new file mode 120000 index 00000000000..ef7a174ef47 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/large_blue_diamond.png @@ -0,0 +1 @@ +unicode/1f537.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/large_orange_diamond.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/large_orange_diamond.png new file mode 120000 index 00000000000..199a588f00e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/large_orange_diamond.png @@ -0,0 +1 @@ +unicode/1f536.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/last_quarter_moon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/last_quarter_moon.png new file mode 120000 index 00000000000..ef89f660321 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/last_quarter_moon.png @@ -0,0 +1 @@ +unicode/1f317.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/last_quarter_moon_with_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/last_quarter_moon_with_face.png new file mode 120000 index 00000000000..e1ef1cf41ff --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/last_quarter_moon_with_face.png @@ -0,0 +1 @@ +unicode/1f31c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/laughing.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/laughing.png new file mode 120000 index 00000000000..7332e390781 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/laughing.png @@ -0,0 +1 @@ +unicode/1f606.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leaves.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leaves.png new file mode 120000 index 00000000000..a6bc22c66be --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leaves.png @@ -0,0 +1 @@ +unicode/e447.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ledger.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ledger.png new file mode 120000 index 00000000000..72bd6c33c7f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ledger.png @@ -0,0 +1 @@ +unicode/1f4d2.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/left_luggage.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/left_luggage.png new file mode 120000 index 00000000000..7d6ca72d90d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/left_luggage.png @@ -0,0 +1 @@ +unicode/1f6c5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/left_right_arrow.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/left_right_arrow.png new file mode 120000 index 00000000000..70d9adbf1c8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/left_right_arrow.png @@ -0,0 +1 @@ +unicode/2194.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leftwards_arrow_with_hook.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leftwards_arrow_with_hook.png new file mode 120000 index 00000000000..4bf1b424f14 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leftwards_arrow_with_hook.png @@ -0,0 +1 @@ +unicode/21a9.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lemon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lemon.png new file mode 120000 index 00000000000..8f68b3e0487 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lemon.png @@ -0,0 +1 @@ +unicode/1f34b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leo.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leo.png new file mode 120000 index 00000000000..19a9f14bae2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leo.png @@ -0,0 +1 @@ +unicode/e243.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leopard.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leopard.png new file mode 120000 index 00000000000..415e3b4d179 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/leopard.png @@ -0,0 +1 @@ +unicode/1f406.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/libra.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/libra.png new file mode 120000 index 00000000000..de59fa5ed5b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/libra.png @@ -0,0 +1 @@ +unicode/e245.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/light_rail.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/light_rail.png new file mode 120000 index 00000000000..448409bc2d8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/light_rail.png @@ -0,0 +1 @@ +unicode/1f688.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/link.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/link.png new file mode 120000 index 00000000000..4e82b7668cf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/link.png @@ -0,0 +1 @@ +unicode/1f517.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lips.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lips.png new file mode 120000 index 00000000000..306fd5a1b40 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lips.png @@ -0,0 +1 @@ +unicode/e41c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lipstick.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lipstick.png new file mode 120000 index 00000000000..d86008093a5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lipstick.png @@ -0,0 +1 @@ +unicode/e31c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lock.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lock.png new file mode 120000 index 00000000000..dd0811ec1bf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lock.png @@ -0,0 +1 @@ +unicode/e144.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lock_with_ink_pen.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lock_with_ink_pen.png new file mode 120000 index 00000000000..823ba53a286 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lock_with_ink_pen.png @@ -0,0 +1 @@ +unicode/1f50f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lollipop.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lollipop.png new file mode 120000 index 00000000000..ea983b520ce --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/lollipop.png @@ -0,0 +1 @@ +unicode/1f36d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/loop.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/loop.png new file mode 120000 index 00000000000..3c5a2ae3f20 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/loop.png @@ -0,0 +1 @@ +unicode/e211.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/loudspeaker.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/loudspeaker.png new file mode 120000 index 00000000000..fcdc5864bf7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/loudspeaker.png @@ -0,0 +1 @@ +unicode/e142.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/love_hotel.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/love_hotel.png new file mode 120000 index 00000000000..fbc8951bf0e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/love_hotel.png @@ -0,0 +1 @@ +unicode/e501.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/love_letter.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/love_letter.png new file mode 120000 index 00000000000..bbdee84084f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/love_letter.png @@ -0,0 +1 @@ +unicode/1f48c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/low_brightness.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/low_brightness.png new file mode 120000 index 00000000000..6a90c2e8ce4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/low_brightness.png @@ -0,0 +1 @@ +unicode/1f505.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/m.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/m.png new file mode 120000 index 00000000000..1423f803623 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/m.png @@ -0,0 +1 @@ +unicode/24c2.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mag.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mag.png new file mode 120000 index 00000000000..5517243f0e8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mag.png @@ -0,0 +1 @@ +unicode/e114.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mag_right.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mag_right.png new file mode 120000 index 00000000000..81eb76fb065 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mag_right.png @@ -0,0 +1 @@ +unicode/1f50e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mahjong.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mahjong.png new file mode 120000 index 00000000000..d200d6b7001 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mahjong.png @@ -0,0 +1 @@ +unicode/e12d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox.png new file mode 120000 index 00000000000..5ed2a01277a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox.png @@ -0,0 +1 @@ +unicode/e101.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox_closed.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox_closed.png new file mode 120000 index 00000000000..678bf987f40 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox_closed.png @@ -0,0 +1 @@ +unicode/1f4ea.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox_with_mail.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox_with_mail.png new file mode 120000 index 00000000000..77ecb292d03 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox_with_mail.png @@ -0,0 +1 @@ +unicode/1f4ec.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox_with_no_mail.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox_with_no_mail.png new file mode 120000 index 00000000000..4801a95afc9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mailbox_with_no_mail.png @@ -0,0 +1 @@ +unicode/1f4ed.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/man.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/man.png new file mode 120000 index 00000000000..daa52c239a5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/man.png @@ -0,0 +1 @@ +unicode/e004.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/man_with_gua_pi_mao.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/man_with_gua_pi_mao.png new file mode 120000 index 00000000000..33c1a7ef0ab --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/man_with_gua_pi_mao.png @@ -0,0 +1 @@ +unicode/e516.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/man_with_turban.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/man_with_turban.png new file mode 120000 index 00000000000..8baf5d11a7b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/man_with_turban.png @@ -0,0 +1 @@ +unicode/e517.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mans_shoe.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mans_shoe.png new file mode 120000 index 00000000000..cb54be7b1d3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mans_shoe.png @@ -0,0 +1 @@ +unicode/1f45e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/maple_leaf.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/maple_leaf.png new file mode 120000 index 00000000000..9cca0905b88 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/maple_leaf.png @@ -0,0 +1 @@ +unicode/e118.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mask.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mask.png new file mode 120000 index 00000000000..d26023c078d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mask.png @@ -0,0 +1 @@ +unicode/e40c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/massage.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/massage.png new file mode 120000 index 00000000000..7c1e0c5c84b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/massage.png @@ -0,0 +1 @@ +unicode/e31e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/meat_on_bone.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/meat_on_bone.png new file mode 120000 index 00000000000..acc34def9a6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/meat_on_bone.png @@ -0,0 +1 @@ +unicode/1f356.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mega.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mega.png new file mode 120000 index 00000000000..2a28358b41c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mega.png @@ -0,0 +1 @@ +unicode/e317.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/melon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/melon.png new file mode 120000 index 00000000000..6682e14e15e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/melon.png @@ -0,0 +1 @@ +unicode/1f348.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/memo.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/memo.png new file mode 120000 index 00000000000..c59f82561d5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/memo.png @@ -0,0 +1 @@ +unicode/e301.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mens.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mens.png new file mode 120000 index 00000000000..3167875e268 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mens.png @@ -0,0 +1 @@ +unicode/e138.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/metal.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/metal.png new file mode 100644 index 00000000000..94f1fda2241 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/metal.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/metro.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/metro.png new file mode 120000 index 00000000000..b522b4d7973 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/metro.png @@ -0,0 +1 @@ +unicode/e434.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/microphone.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/microphone.png new file mode 120000 index 00000000000..774b1b97ab1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/microphone.png @@ -0,0 +1 @@ +unicode/e03c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/microscope.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/microscope.png new file mode 120000 index 00000000000..a41c40cdcb0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/microscope.png @@ -0,0 +1 @@ +unicode/1f52c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/milky_way.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/milky_way.png new file mode 120000 index 00000000000..4ae45e67134 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/milky_way.png @@ -0,0 +1 @@ +unicode/1f30c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/minibus.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/minibus.png new file mode 120000 index 00000000000..dda7931b808 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/minibus.png @@ -0,0 +1 @@ +unicode/1f690.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/minidisc.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/minidisc.png new file mode 120000 index 00000000000..a95c8d77e09 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/minidisc.png @@ -0,0 +1 @@ +unicode/e316.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mobile_phone_off.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mobile_phone_off.png new file mode 120000 index 00000000000..a41783afbdb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mobile_phone_off.png @@ -0,0 +1 @@ +unicode/e251.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/money_with_wings.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/money_with_wings.png new file mode 120000 index 00000000000..d1c8c762371 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/money_with_wings.png @@ -0,0 +1 @@ +unicode/1f4b8.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/moneybag.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/moneybag.png new file mode 120000 index 00000000000..b0b09b18747 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/moneybag.png @@ -0,0 +1 @@ +unicode/e12f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/monkey.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/monkey.png new file mode 120000 index 00000000000..ff8a3c53e1d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/monkey.png @@ -0,0 +1 @@ +unicode/e528.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/monkey_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/monkey_face.png new file mode 120000 index 00000000000..2aed4c64a11 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/monkey_face.png @@ -0,0 +1 @@ +unicode/e109.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/monorail.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/monorail.png new file mode 120000 index 00000000000..af2fababc7a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/monorail.png @@ -0,0 +1 @@ +unicode/1f69d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/moon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/moon.png new file mode 120000 index 00000000000..93516fff51a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/moon.png @@ -0,0 +1 @@ +unicode/e04c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mortar_board.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mortar_board.png new file mode 120000 index 00000000000..1bbd68cad67 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mortar_board.png @@ -0,0 +1 @@ +unicode/e439.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mount_fuji.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mount_fuji.png new file mode 120000 index 00000000000..88c24f836ab --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mount_fuji.png @@ -0,0 +1 @@ +unicode/e03b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mountain_bicyclist.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mountain_bicyclist.png new file mode 120000 index 00000000000..8ac95b66c52 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mountain_bicyclist.png @@ -0,0 +1 @@ +unicode/1f6b5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mountain_cableway.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mountain_cableway.png new file mode 120000 index 00000000000..fad5d102f54 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mountain_cableway.png @@ -0,0 +1 @@ +unicode/1f6a0.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mountain_railway.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mountain_railway.png new file mode 120000 index 00000000000..9eb2ff6a76b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mountain_railway.png @@ -0,0 +1 @@ +unicode/1f69e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mouse.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mouse.png new file mode 120000 index 00000000000..0d7e834c3bc --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mouse.png @@ -0,0 +1 @@ +unicode/e053.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mouse2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mouse2.png new file mode 120000 index 00000000000..6d0e846938a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mouse2.png @@ -0,0 +1 @@ +unicode/1f401.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/movie_camera.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/movie_camera.png new file mode 120000 index 00000000000..b0e44161fc1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/movie_camera.png @@ -0,0 +1 @@ +unicode/e03d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/moyai.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/moyai.png new file mode 120000 index 00000000000..0e6686365a8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/moyai.png @@ -0,0 +1 @@ +unicode/1f5ff.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/muscle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/muscle.png new file mode 120000 index 00000000000..4898ae941a4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/muscle.png @@ -0,0 +1 @@ +unicode/e14c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mushroom.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mushroom.png new file mode 120000 index 00000000000..cb8f696c7d7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mushroom.png @@ -0,0 +1 @@ +unicode/1f344.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/musical_keyboard.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/musical_keyboard.png new file mode 120000 index 00000000000..0607cc6805f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/musical_keyboard.png @@ -0,0 +1 @@ +unicode/1f3b9.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/musical_note.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/musical_note.png new file mode 120000 index 00000000000..8273007858f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/musical_note.png @@ -0,0 +1 @@ +unicode/e03e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/musical_score.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/musical_score.png new file mode 120000 index 00000000000..14e0b39fe20 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/musical_score.png @@ -0,0 +1 @@ +unicode/1f3bc.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mute.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mute.png new file mode 120000 index 00000000000..3d6fbe2a36e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/mute.png @@ -0,0 +1 @@ +unicode/1f507.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nail_care.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nail_care.png new file mode 120000 index 00000000000..85c544d1b98 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nail_care.png @@ -0,0 +1 @@ +unicode/e31d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/name_badge.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/name_badge.png new file mode 120000 index 00000000000..a0f94467285 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/name_badge.png @@ -0,0 +1 @@ +unicode/1f4db.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/neckbeard.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/neckbeard.png new file mode 100644 index 00000000000..15108fc97da Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/neckbeard.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/necktie.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/necktie.png new file mode 120000 index 00000000000..adc92c42cfc --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/necktie.png @@ -0,0 +1 @@ +unicode/e302.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/negative_squared_cross_mark.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/negative_squared_cross_mark.png new file mode 120000 index 00000000000..584c2abc03c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/negative_squared_cross_mark.png @@ -0,0 +1 @@ +unicode/274e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/neutral_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/neutral_face.png new file mode 120000 index 00000000000..0c8163cd5de --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/neutral_face.png @@ -0,0 +1 @@ +unicode/1f610.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/new.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/new.png new file mode 120000 index 00000000000..158655bf42c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/new.png @@ -0,0 +1 @@ +unicode/e212.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/new_moon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/new_moon.png new file mode 120000 index 00000000000..3a3fa145d14 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/new_moon.png @@ -0,0 +1 @@ +unicode/1f311.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/new_moon_with_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/new_moon_with_face.png new file mode 120000 index 00000000000..4cf7b983724 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/new_moon_with_face.png @@ -0,0 +1 @@ +unicode/1f31a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/newspaper.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/newspaper.png new file mode 120000 index 00000000000..c293be595ad --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/newspaper.png @@ -0,0 +1 @@ +unicode/1f4f0.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ng.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ng.png new file mode 120000 index 00000000000..a0569253563 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ng.png @@ -0,0 +1 @@ +unicode/1f196.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nine.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nine.png new file mode 120000 index 00000000000..ffc53ed8c5b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nine.png @@ -0,0 +1 @@ +unicode/e224.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_bell.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_bell.png new file mode 120000 index 00000000000..639986ef4b2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_bell.png @@ -0,0 +1 @@ +unicode/1f515.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_bicycles.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_bicycles.png new file mode 120000 index 00000000000..2611e3d7c20 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_bicycles.png @@ -0,0 +1 @@ +unicode/1f6b3.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_entry.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_entry.png new file mode 120000 index 00000000000..bd28b894dad --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_entry.png @@ -0,0 +1 @@ +unicode/26d4.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_entry_sign.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_entry_sign.png new file mode 120000 index 00000000000..b3d86696ce0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_entry_sign.png @@ -0,0 +1 @@ +unicode/1f6ab.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_good.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_good.png new file mode 120000 index 00000000000..ef8e431a473 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_good.png @@ -0,0 +1 @@ +unicode/e423.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_mobile_phones.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_mobile_phones.png new file mode 120000 index 00000000000..97d727dab28 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_mobile_phones.png @@ -0,0 +1 @@ +unicode/1f4f5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_mouth.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_mouth.png new file mode 120000 index 00000000000..99800d2db0d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_mouth.png @@ -0,0 +1 @@ +unicode/1f636.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_pedestrians.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_pedestrians.png new file mode 120000 index 00000000000..dd66e6bb709 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_pedestrians.png @@ -0,0 +1 @@ +unicode/1f6b7.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_smoking.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_smoking.png new file mode 120000 index 00000000000..c4cb22d11cd --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/no_smoking.png @@ -0,0 +1 @@ +unicode/e208.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/non-potable_water.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/non-potable_water.png new file mode 120000 index 00000000000..5afedf60d45 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/non-potable_water.png @@ -0,0 +1 @@ +unicode/1f6b1.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nose.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nose.png new file mode 120000 index 00000000000..fba2468c48d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nose.png @@ -0,0 +1 @@ +unicode/e41a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/notebook.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/notebook.png new file mode 120000 index 00000000000..5be93b3e44c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/notebook.png @@ -0,0 +1 @@ +unicode/1f4d3.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/notebook_with_decorative_cover.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/notebook_with_decorative_cover.png new file mode 120000 index 00000000000..3aac1dd6a38 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/notebook_with_decorative_cover.png @@ -0,0 +1 @@ +unicode/1f4d4.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/notes.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/notes.png new file mode 120000 index 00000000000..14b4a485bed --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/notes.png @@ -0,0 +1 @@ +unicode/e326.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nut_and_bolt.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nut_and_bolt.png new file mode 120000 index 00000000000..7530f4e231c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/nut_and_bolt.png @@ -0,0 +1 @@ +unicode/1f529.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/o.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/o.png new file mode 120000 index 00000000000..2d34a3e2a0a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/o.png @@ -0,0 +1 @@ +unicode/e332.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/o2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/o2.png new file mode 120000 index 00000000000..72a901e2a03 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/o2.png @@ -0,0 +1 @@ +unicode/e535.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ocean.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ocean.png new file mode 120000 index 00000000000..9ba5335f4c6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ocean.png @@ -0,0 +1 @@ +unicode/e43e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/octocat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/octocat.png new file mode 100644 index 00000000000..9d74d902aed Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/octocat.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/octopus.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/octopus.png new file mode 120000 index 00000000000..5e5df5ff636 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/octopus.png @@ -0,0 +1 @@ +unicode/e10a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oden.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oden.png new file mode 120000 index 00000000000..236e7fe72a6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oden.png @@ -0,0 +1 @@ +unicode/e343.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/office.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/office.png new file mode 120000 index 00000000000..060dcf433b1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/office.png @@ -0,0 +1 @@ +unicode/e038.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ok.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ok.png new file mode 120000 index 00000000000..6683d0f4d41 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ok.png @@ -0,0 +1 @@ +unicode/e24d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ok_hand.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ok_hand.png new file mode 120000 index 00000000000..4471ec98cf7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ok_hand.png @@ -0,0 +1 @@ +unicode/e420.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ok_woman.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ok_woman.png new file mode 120000 index 00000000000..c050396bf79 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ok_woman.png @@ -0,0 +1 @@ +unicode/e424.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/older_man.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/older_man.png new file mode 120000 index 00000000000..120b4208855 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/older_man.png @@ -0,0 +1 @@ +unicode/e518.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/older_woman.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/older_woman.png new file mode 120000 index 00000000000..6bd7ea6349c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/older_woman.png @@ -0,0 +1 @@ +unicode/e519.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/on.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/on.png new file mode 120000 index 00000000000..f16f702e35d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/on.png @@ -0,0 +1 @@ +unicode/1f51b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_automobile.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_automobile.png new file mode 120000 index 00000000000..9c65b63602d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_automobile.png @@ -0,0 +1 @@ +unicode/1f698.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_bus.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_bus.png new file mode 120000 index 00000000000..6c9981c7fea --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_bus.png @@ -0,0 +1 @@ +unicode/1f68d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_police_car.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_police_car.png new file mode 120000 index 00000000000..6fbae18580e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_police_car.png @@ -0,0 +1 @@ +unicode/1f694.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_taxi.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_taxi.png new file mode 120000 index 00000000000..d56b95af630 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/oncoming_taxi.png @@ -0,0 +1 @@ +unicode/1f696.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/one.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/one.png new file mode 120000 index 00000000000..a98cf330362 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/one.png @@ -0,0 +1 @@ +unicode/e21c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/open_file_folder.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/open_file_folder.png new file mode 120000 index 00000000000..64c9f9b801d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/open_file_folder.png @@ -0,0 +1 @@ +unicode/1f4c2.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/open_hands.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/open_hands.png new file mode 120000 index 00000000000..18e69429d7e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/open_hands.png @@ -0,0 +1 @@ +unicode/e422.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ophiuchus.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ophiuchus.png new file mode 120000 index 00000000000..b0ac1f94809 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ophiuchus.png @@ -0,0 +1 @@ +unicode/e24b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/orange_book.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/orange_book.png new file mode 120000 index 00000000000..056958e9a98 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/orange_book.png @@ -0,0 +1 @@ +unicode/1f4d9.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/outbox_tray.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/outbox_tray.png new file mode 120000 index 00000000000..02e496e9005 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/outbox_tray.png @@ -0,0 +1 @@ +unicode/1f4e4.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ox.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ox.png new file mode 120000 index 00000000000..51a3aa6662c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ox.png @@ -0,0 +1 @@ +unicode/1f402.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/page_facing_up.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/page_facing_up.png new file mode 120000 index 00000000000..4163893650c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/page_facing_up.png @@ -0,0 +1 @@ +unicode/1f4c4.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/page_with_curl.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/page_with_curl.png new file mode 120000 index 00000000000..5ca7256099e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/page_with_curl.png @@ -0,0 +1 @@ +unicode/1f4c3.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pager.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pager.png new file mode 120000 index 00000000000..aa013af26cd --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pager.png @@ -0,0 +1 @@ +unicode/1f4df.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/palm_tree.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/palm_tree.png new file mode 120000 index 00000000000..a7328c7c78a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/palm_tree.png @@ -0,0 +1 @@ +unicode/e307.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/panda_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/panda_face.png new file mode 120000 index 00000000000..54581cc7a94 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/panda_face.png @@ -0,0 +1 @@ +unicode/1f43c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/paperclip.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/paperclip.png new file mode 120000 index 00000000000..c189521a209 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/paperclip.png @@ -0,0 +1 @@ +unicode/1f4ce.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/parking.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/parking.png new file mode 120000 index 00000000000..9a1ff6af972 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/parking.png @@ -0,0 +1 @@ +unicode/e14f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/part_alternation_mark.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/part_alternation_mark.png new file mode 120000 index 00000000000..e0118cc67d5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/part_alternation_mark.png @@ -0,0 +1 @@ +unicode/e12c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/partly_sunny.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/partly_sunny.png new file mode 120000 index 00000000000..aae87e0fb48 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/partly_sunny.png @@ -0,0 +1 @@ +unicode/26c5.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/passport_control.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/passport_control.png new file mode 120000 index 00000000000..c14ffc60ca4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/passport_control.png @@ -0,0 +1 @@ +unicode/1f6c2.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/paw_prints.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/paw_prints.png new file mode 120000 index 00000000000..bbb76f06be8 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/paw_prints.png @@ -0,0 +1 @@ +unicode/1f43e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/peach.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/peach.png new file mode 120000 index 00000000000..c6932993a64 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/peach.png @@ -0,0 +1 @@ +unicode/1f351.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pear.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pear.png new file mode 120000 index 00000000000..973048cc6ab --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pear.png @@ -0,0 +1 @@ +unicode/1f350.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pencil.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pencil.png new file mode 120000 index 00000000000..c59f82561d5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pencil.png @@ -0,0 +1 @@ +unicode/e301.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pencil2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pencil2.png new file mode 120000 index 00000000000..a32c1634d19 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pencil2.png @@ -0,0 +1 @@ +unicode/270f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/penguin.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/penguin.png new file mode 120000 index 00000000000..ab18629b19a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/penguin.png @@ -0,0 +1 @@ +unicode/e055.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pensive.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pensive.png new file mode 120000 index 00000000000..aea514c5a24 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pensive.png @@ -0,0 +1 @@ +unicode/e403.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/performing_arts.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/performing_arts.png new file mode 120000 index 00000000000..a1ec9abcfd5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/performing_arts.png @@ -0,0 +1 @@ +unicode/1f3ad.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/persevere.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/persevere.png new file mode 120000 index 00000000000..67b0e8e37ba --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/persevere.png @@ -0,0 +1 @@ +unicode/e406.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/person_frowning.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/person_frowning.png new file mode 120000 index 00000000000..dba4617b842 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/person_frowning.png @@ -0,0 +1 @@ +unicode/1f64d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/person_with_blond_hair.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/person_with_blond_hair.png new file mode 120000 index 00000000000..085c46b7d91 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/person_with_blond_hair.png @@ -0,0 +1 @@ +unicode/e515.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/person_with_pouting_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/person_with_pouting_face.png new file mode 120000 index 00000000000..c59a4b26883 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/person_with_pouting_face.png @@ -0,0 +1 @@ +unicode/1f64e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/phone.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/phone.png new file mode 120000 index 00000000000..c30df23ec68 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/phone.png @@ -0,0 +1 @@ +unicode/e009.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pig.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pig.png new file mode 120000 index 00000000000..12240c9f65a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pig.png @@ -0,0 +1 @@ +unicode/e10b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pig2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pig2.png new file mode 120000 index 00000000000..b8f3054e336 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pig2.png @@ -0,0 +1 @@ +unicode/1f416.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pig_nose.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pig_nose.png new file mode 120000 index 00000000000..67a14dd630b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pig_nose.png @@ -0,0 +1 @@ +unicode/1f43d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pill.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pill.png new file mode 120000 index 00000000000..d3dd200a645 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pill.png @@ -0,0 +1 @@ +unicode/e30f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pineapple.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pineapple.png new file mode 120000 index 00000000000..b51c51c6c36 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pineapple.png @@ -0,0 +1 @@ +unicode/1f34d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pisces.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pisces.png new file mode 120000 index 00000000000..14394e9a0ad --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pisces.png @@ -0,0 +1 @@ +unicode/e24a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pizza.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pizza.png new file mode 120000 index 00000000000..0fc4d8d1a7e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pizza.png @@ -0,0 +1 @@ +unicode/1f355.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_down.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_down.png new file mode 120000 index 00000000000..76f239098c1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_down.png @@ -0,0 +1 @@ +unicode/e22f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_left.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_left.png new file mode 120000 index 00000000000..e45df3a97e5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_left.png @@ -0,0 +1 @@ +unicode/e230.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_right.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_right.png new file mode 120000 index 00000000000..7df2662fca7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_right.png @@ -0,0 +1 @@ +unicode/e231.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_up.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_up.png new file mode 120000 index 00000000000..f37b1bd2f95 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_up.png @@ -0,0 +1 @@ +unicode/e00f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_up_2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_up_2.png new file mode 120000 index 00000000000..e7fca858ce5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/point_up_2.png @@ -0,0 +1 @@ +unicode/e22e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/police_car.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/police_car.png new file mode 120000 index 00000000000..056bcd3fcd2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/police_car.png @@ -0,0 +1 @@ +unicode/e432.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/poodle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/poodle.png new file mode 120000 index 00000000000..8bb4520ac03 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/poodle.png @@ -0,0 +1 @@ +unicode/1f429.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/poop.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/poop.png new file mode 120000 index 00000000000..1ffab6df5cb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/poop.png @@ -0,0 +1 @@ +unicode/e05a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/post_office.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/post_office.png new file mode 120000 index 00000000000..f2138cd61e5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/post_office.png @@ -0,0 +1 @@ +unicode/e153.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/postal_horn.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/postal_horn.png new file mode 120000 index 00000000000..ade3f6cc14d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/postal_horn.png @@ -0,0 +1 @@ +unicode/1f4ef.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/postbox.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/postbox.png new file mode 120000 index 00000000000..63b1faa9738 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/postbox.png @@ -0,0 +1 @@ +unicode/e102.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/potable_water.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/potable_water.png new file mode 120000 index 00000000000..dab0e117fb9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/potable_water.png @@ -0,0 +1 @@ +unicode/1f6b0.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pouch.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pouch.png new file mode 120000 index 00000000000..0fdd953a197 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pouch.png @@ -0,0 +1 @@ +unicode/1f45d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/poultry_leg.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/poultry_leg.png new file mode 120000 index 00000000000..7e3d37f108c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/poultry_leg.png @@ -0,0 +1 @@ +unicode/1f357.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pound.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pound.png new file mode 120000 index 00000000000..74d086bb9de --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pound.png @@ -0,0 +1 @@ +unicode/1f4b7.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pouting_cat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pouting_cat.png new file mode 120000 index 00000000000..9f7aff80a0b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pouting_cat.png @@ -0,0 +1 @@ +unicode/1f63e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pray.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pray.png new file mode 120000 index 00000000000..f5c4d825d45 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pray.png @@ -0,0 +1 @@ +unicode/e41d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/princess.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/princess.png new file mode 120000 index 00000000000..c526dc395c4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/princess.png @@ -0,0 +1 @@ +unicode/e51c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/punch.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/punch.png new file mode 120000 index 00000000000..0c4a662d1ee --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/punch.png @@ -0,0 +1 @@ +unicode/e00d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/purple_heart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/purple_heart.png new file mode 120000 index 00000000000..9fa9cd3c3c5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/purple_heart.png @@ -0,0 +1 @@ +unicode/e32d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/purse.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/purse.png new file mode 120000 index 00000000000..edb70ffc8cb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/purse.png @@ -0,0 +1 @@ +unicode/1f45b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pushpin.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pushpin.png new file mode 120000 index 00000000000..9a99e83c2e1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/pushpin.png @@ -0,0 +1 @@ +unicode/1f4cc.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/put_litter_in_its_place.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/put_litter_in_its_place.png new file mode 120000 index 00000000000..45a9ed49849 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/put_litter_in_its_place.png @@ -0,0 +1 @@ +unicode/1f6ae.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/question.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/question.png new file mode 120000 index 00000000000..ac256d06a51 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/question.png @@ -0,0 +1 @@ +unicode/e020.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rabbit.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rabbit.png new file mode 120000 index 00000000000..8f6d30d5b28 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rabbit.png @@ -0,0 +1 @@ +unicode/e52c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rabbit2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rabbit2.png new file mode 120000 index 00000000000..6abf31f2fa3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rabbit2.png @@ -0,0 +1 @@ +unicode/1f407.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/racehorse.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/racehorse.png new file mode 120000 index 00000000000..eef465fd2e9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/racehorse.png @@ -0,0 +1 @@ +unicode/e134.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/radio.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/radio.png new file mode 120000 index 00000000000..263b6c68053 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/radio.png @@ -0,0 +1 @@ +unicode/e128.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/radio_button.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/radio_button.png new file mode 120000 index 00000000000..fb91e155c5e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/radio_button.png @@ -0,0 +1 @@ +unicode/1f518.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage.png new file mode 120000 index 00000000000..27c145f69ec --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage.png @@ -0,0 +1 @@ +unicode/e416.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage1.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage1.png new file mode 100644 index 00000000000..dd2c84f9235 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage1.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage2.png new file mode 100644 index 00000000000..f792e063b49 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage2.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage3.png new file mode 100644 index 00000000000..58764cbcb3b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage3.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage4.png new file mode 100644 index 00000000000..c726c94a295 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rage4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/railway_car.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/railway_car.png new file mode 120000 index 00000000000..856483debc0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/railway_car.png @@ -0,0 +1 @@ +unicode/1f683.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rainbow.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rainbow.png new file mode 120000 index 00000000000..5385f33b438 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rainbow.png @@ -0,0 +1 @@ +unicode/e44c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/raised_hand.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/raised_hand.png new file mode 120000 index 00000000000..9ec42be0957 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/raised_hand.png @@ -0,0 +1 @@ +unicode/1f64b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/raised_hands.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/raised_hands.png new file mode 120000 index 00000000000..db84768624c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/raised_hands.png @@ -0,0 +1 @@ +unicode/e427.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ram.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ram.png new file mode 120000 index 00000000000..f1be38c0b36 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ram.png @@ -0,0 +1 @@ +unicode/1f40f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ramen.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ramen.png new file mode 120000 index 00000000000..56b568203d9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ramen.png @@ -0,0 +1 @@ +unicode/e340.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rat.png new file mode 120000 index 00000000000..800b1e0f58b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rat.png @@ -0,0 +1 @@ +unicode/1f400.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/recycle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/recycle.png new file mode 120000 index 00000000000..54db0b3cdd6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/recycle.png @@ -0,0 +1 @@ +unicode/267b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/red_car.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/red_car.png new file mode 120000 index 00000000000..e0301c9abfb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/red_car.png @@ -0,0 +1 @@ +unicode/e01b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/red_circle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/red_circle.png new file mode 120000 index 00000000000..98f4449df58 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/red_circle.png @@ -0,0 +1 @@ +unicode/e219.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/registered.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/registered.png new file mode 120000 index 00000000000..7562fca08f5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/registered.png @@ -0,0 +1 @@ +unicode/e24f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/relaxed.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/relaxed.png new file mode 120000 index 00000000000..702209d4f05 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/relaxed.png @@ -0,0 +1 @@ +unicode/e414.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/relieved.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/relieved.png new file mode 120000 index 00000000000..7fd5ae52e10 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/relieved.png @@ -0,0 +1 @@ +unicode/e401.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/repeat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/repeat.png new file mode 120000 index 00000000000..b27d6969abf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/repeat.png @@ -0,0 +1 @@ +unicode/1f501.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/repeat_one.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/repeat_one.png new file mode 120000 index 00000000000..22541207a23 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/repeat_one.png @@ -0,0 +1 @@ +unicode/1f502.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/restroom.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/restroom.png new file mode 120000 index 00000000000..21c7505c8b5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/restroom.png @@ -0,0 +1 @@ +unicode/e151.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/revolving_hearts.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/revolving_hearts.png new file mode 120000 index 00000000000..081b988d4a4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/revolving_hearts.png @@ -0,0 +1 @@ +unicode/1f49e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rewind.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rewind.png new file mode 120000 index 00000000000..3873d8f6544 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rewind.png @@ -0,0 +1 @@ +unicode/e23d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ribbon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ribbon.png new file mode 120000 index 00000000000..33acca346c4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ribbon.png @@ -0,0 +1 @@ +unicode/e314.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice.png new file mode 120000 index 00000000000..69b98ffd85d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice.png @@ -0,0 +1 @@ +unicode/e33e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice_ball.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice_ball.png new file mode 120000 index 00000000000..383a1a8749b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice_ball.png @@ -0,0 +1 @@ +unicode/e342.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice_cracker.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice_cracker.png new file mode 120000 index 00000000000..8d768835829 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice_cracker.png @@ -0,0 +1 @@ +unicode/e33d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice_scene.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice_scene.png new file mode 120000 index 00000000000..e9fc2ccf46f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rice_scene.png @@ -0,0 +1 @@ +unicode/e446.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ring.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ring.png new file mode 120000 index 00000000000..a770310c08f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ring.png @@ -0,0 +1 @@ +unicode/e034.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rocket.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rocket.png new file mode 120000 index 00000000000..a7b3644b088 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rocket.png @@ -0,0 +1 @@ +unicode/e10d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/roller_coaster.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/roller_coaster.png new file mode 120000 index 00000000000..77b02a04ecb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/roller_coaster.png @@ -0,0 +1 @@ +unicode/e433.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rooster.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rooster.png new file mode 120000 index 00000000000..68d41628879 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rooster.png @@ -0,0 +1 @@ +unicode/1f413.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rose.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rose.png new file mode 120000 index 00000000000..9c31434ec56 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rose.png @@ -0,0 +1 @@ +unicode/e032.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rotating_light.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rotating_light.png new file mode 120000 index 00000000000..3df341839f0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rotating_light.png @@ -0,0 +1 @@ +unicode/1f6a8.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/round_pushpin.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/round_pushpin.png new file mode 120000 index 00000000000..bf066c15b80 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/round_pushpin.png @@ -0,0 +1 @@ +unicode/1f4cd.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rowboat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rowboat.png new file mode 120000 index 00000000000..6a1d209ef9b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rowboat.png @@ -0,0 +1 @@ +unicode/1f6a3.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ru.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ru.png new file mode 120000 index 00000000000..65ae80ce867 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ru.png @@ -0,0 +1 @@ +unicode/e512.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rugby_football.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rugby_football.png new file mode 120000 index 00000000000..4036943094b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/rugby_football.png @@ -0,0 +1 @@ +unicode/1f3c9.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/runner.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/runner.png new file mode 120000 index 00000000000..054af956066 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/runner.png @@ -0,0 +1 @@ +unicode/e115.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/running.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/running.png new file mode 120000 index 00000000000..054af956066 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/running.png @@ -0,0 +1 @@ +unicode/e115.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/running_shirt_with_sash.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/running_shirt_with_sash.png new file mode 120000 index 00000000000..ed68ed7fd03 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/running_shirt_with_sash.png @@ -0,0 +1 @@ +unicode/1f3bd.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sa.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sa.png new file mode 120000 index 00000000000..2ad0d024f70 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sa.png @@ -0,0 +1 @@ +unicode/e228.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sagittarius.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sagittarius.png new file mode 120000 index 00000000000..b5a20c0a7c1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sagittarius.png @@ -0,0 +1 @@ +unicode/e247.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sailboat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sailboat.png new file mode 120000 index 00000000000..9cc5881ea22 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sailboat.png @@ -0,0 +1 @@ +unicode/e01c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sake.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sake.png new file mode 120000 index 00000000000..b9a2e89e6f2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sake.png @@ -0,0 +1 @@ +unicode/e30b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sandal.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sandal.png new file mode 120000 index 00000000000..22683556aec --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sandal.png @@ -0,0 +1 @@ +unicode/e31a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/santa.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/santa.png new file mode 120000 index 00000000000..8477c87b316 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/santa.png @@ -0,0 +1 @@ +unicode/e448.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/satellite.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/satellite.png new file mode 120000 index 00000000000..430dca0914d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/satellite.png @@ -0,0 +1 @@ +unicode/e14b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/satisfied.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/satisfied.png new file mode 120000 index 00000000000..1efd305e814 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/satisfied.png @@ -0,0 +1 @@ +unicode/e40a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/saxophone.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/saxophone.png new file mode 120000 index 00000000000..18e49ccb976 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/saxophone.png @@ -0,0 +1 @@ +unicode/e040.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/school.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/school.png new file mode 120000 index 00000000000..a59e8e922b3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/school.png @@ -0,0 +1 @@ +unicode/e157.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/school_satchel.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/school_satchel.png new file mode 120000 index 00000000000..efbca7e0bf5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/school_satchel.png @@ -0,0 +1 @@ +unicode/e43a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scissors.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scissors.png new file mode 120000 index 00000000000..42c87594fc6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scissors.png @@ -0,0 +1 @@ +unicode/e313.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scorpius.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scorpius.png new file mode 120000 index 00000000000..6b7166f3b4d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scorpius.png @@ -0,0 +1 @@ +unicode/e246.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scream.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scream.png new file mode 120000 index 00000000000..7664028a46e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scream.png @@ -0,0 +1 @@ +unicode/e107.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scream_cat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scream_cat.png new file mode 120000 index 00000000000..819f2ccc888 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scream_cat.png @@ -0,0 +1 @@ +unicode/1f640.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scroll.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scroll.png new file mode 120000 index 00000000000..4934e3c5518 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/scroll.png @@ -0,0 +1 @@ +unicode/1f4dc.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/seat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/seat.png new file mode 120000 index 00000000000..d1519a4a06c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/seat.png @@ -0,0 +1 @@ +unicode/e11f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/secret.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/secret.png new file mode 120000 index 00000000000..3095fd07808 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/secret.png @@ -0,0 +1 @@ +unicode/e315.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/see_no_evil.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/see_no_evil.png new file mode 120000 index 00000000000..c26c37613ad --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/see_no_evil.png @@ -0,0 +1 @@ +unicode/1f648.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/seedling.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/seedling.png new file mode 120000 index 00000000000..2f5b12a4dc4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/seedling.png @@ -0,0 +1 @@ +unicode/1f331.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/seven.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/seven.png new file mode 120000 index 00000000000..3cddec5deaa --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/seven.png @@ -0,0 +1 @@ +unicode/e222.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shaved_ice.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shaved_ice.png new file mode 120000 index 00000000000..c1e329a48eb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shaved_ice.png @@ -0,0 +1 @@ +unicode/e43f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sheep.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sheep.png new file mode 120000 index 00000000000..44c7de6efed --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sheep.png @@ -0,0 +1 @@ +unicode/e529.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shell.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shell.png new file mode 120000 index 00000000000..9be5f35b69b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shell.png @@ -0,0 +1 @@ +unicode/e441.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ship.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ship.png new file mode 120000 index 00000000000..c8cb996161b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ship.png @@ -0,0 +1 @@ +unicode/e202.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shipit.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shipit.png new file mode 100644 index 00000000000..a58a47f62f9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shipit.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shirt.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shirt.png new file mode 120000 index 00000000000..f09c86d2558 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shirt.png @@ -0,0 +1 @@ +unicode/e006.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shit.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shit.png new file mode 120000 index 00000000000..1ffab6df5cb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shit.png @@ -0,0 +1 @@ +unicode/e05a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shoe.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shoe.png new file mode 120000 index 00000000000..16941ea0a1e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shoe.png @@ -0,0 +1 @@ +unicode/e007.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shower.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shower.png new file mode 120000 index 00000000000..0d8bdc0d317 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/shower.png @@ -0,0 +1 @@ +unicode/1f6bf.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/signal_strength.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/signal_strength.png new file mode 120000 index 00000000000..be2776dfcb5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/signal_strength.png @@ -0,0 +1 @@ +unicode/e20b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/six.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/six.png new file mode 120000 index 00000000000..320a345f45d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/six.png @@ -0,0 +1 @@ +unicode/e221.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/six_pointed_star.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/six_pointed_star.png new file mode 120000 index 00000000000..23fc400fb13 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/six_pointed_star.png @@ -0,0 +1 @@ +unicode/e23e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ski.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ski.png new file mode 120000 index 00000000000..83976ae240e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ski.png @@ -0,0 +1 @@ +unicode/e013.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/skull.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/skull.png new file mode 120000 index 00000000000..58496b466e6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/skull.png @@ -0,0 +1 @@ +unicode/e11c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sleepy.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sleepy.png new file mode 120000 index 00000000000..cc8e40d2a4b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sleepy.png @@ -0,0 +1 @@ +unicode/e408.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/slot_machine.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/slot_machine.png new file mode 120000 index 00000000000..d6052a38283 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/slot_machine.png @@ -0,0 +1 @@ +unicode/e133.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_blue_diamond.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_blue_diamond.png new file mode 120000 index 00000000000..95b4b9fc6d6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_blue_diamond.png @@ -0,0 +1 @@ +unicode/1f539.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_orange_diamond.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_orange_diamond.png new file mode 120000 index 00000000000..e85dbee309b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_orange_diamond.png @@ -0,0 +1 @@ +unicode/1f538.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_red_triangle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_red_triangle.png new file mode 120000 index 00000000000..53ab4364fe9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_red_triangle.png @@ -0,0 +1 @@ +unicode/1f53a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_red_triangle_down.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_red_triangle_down.png new file mode 120000 index 00000000000..62231574e00 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/small_red_triangle_down.png @@ -0,0 +1 @@ +unicode/1f53b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smile.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smile.png new file mode 120000 index 00000000000..9cc4abfb66a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smile.png @@ -0,0 +1 @@ +unicode/e415.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smile_cat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smile_cat.png new file mode 120000 index 00000000000..d0fc083b08f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smile_cat.png @@ -0,0 +1 @@ +unicode/1f638.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smiley.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smiley.png new file mode 120000 index 00000000000..fcb859b3493 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smiley.png @@ -0,0 +1 @@ +unicode/e057.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smiley_cat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smiley_cat.png new file mode 120000 index 00000000000..bbb10516d92 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smiley_cat.png @@ -0,0 +1 @@ +unicode/1f63a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smiling_imp.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smiling_imp.png new file mode 120000 index 00000000000..6327d19dbe2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smiling_imp.png @@ -0,0 +1 @@ +unicode/1f608.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smirk.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smirk.png new file mode 120000 index 00000000000..6294846d743 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smirk.png @@ -0,0 +1 @@ +unicode/e402.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smirk_cat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smirk_cat.png new file mode 120000 index 00000000000..e95f6580d87 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smirk_cat.png @@ -0,0 +1 @@ +unicode/1f63c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smoking.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smoking.png new file mode 120000 index 00000000000..80ec60ff528 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/smoking.png @@ -0,0 +1 @@ +unicode/e30e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snail.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snail.png new file mode 120000 index 00000000000..a1f472956bb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snail.png @@ -0,0 +1 @@ +unicode/1f40c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snake.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snake.png new file mode 120000 index 00000000000..ce9646df28d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snake.png @@ -0,0 +1 @@ +unicode/e52d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snowboarder.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snowboarder.png new file mode 120000 index 00000000000..6fabb940ea2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snowboarder.png @@ -0,0 +1 @@ +unicode/1f3c2.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snowflake.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snowflake.png new file mode 120000 index 00000000000..0bd4292cb26 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snowflake.png @@ -0,0 +1 @@ +unicode/2744.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snowman.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snowman.png new file mode 120000 index 00000000000..067aaebc455 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/snowman.png @@ -0,0 +1 @@ +unicode/e048.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sob.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sob.png new file mode 120000 index 00000000000..b9296721571 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sob.png @@ -0,0 +1 @@ +unicode/e411.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/soccer.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/soccer.png new file mode 120000 index 00000000000..8501a595d49 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/soccer.png @@ -0,0 +1 @@ +unicode/e018.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/soon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/soon.png new file mode 120000 index 00000000000..1da86c8f670 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/soon.png @@ -0,0 +1 @@ +unicode/1f51c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sos.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sos.png new file mode 120000 index 00000000000..5b52ee755f0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sos.png @@ -0,0 +1 @@ +unicode/1f198.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sound.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sound.png new file mode 120000 index 00000000000..ae848083649 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sound.png @@ -0,0 +1 @@ +unicode/1f509.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/space_invader.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/space_invader.png new file mode 120000 index 00000000000..0776e6f485f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/space_invader.png @@ -0,0 +1 @@ +unicode/e12b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/spades.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/spades.png new file mode 120000 index 00000000000..ec99920de26 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/spades.png @@ -0,0 +1 @@ +unicode/e20e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/spaghetti.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/spaghetti.png new file mode 120000 index 00000000000..431daf28a43 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/spaghetti.png @@ -0,0 +1 @@ +unicode/e33f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sparkler.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sparkler.png new file mode 120000 index 00000000000..d2834dd4f72 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sparkler.png @@ -0,0 +1 @@ +unicode/e440.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sparkles.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sparkles.png new file mode 120000 index 00000000000..d1e6deffecd --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sparkles.png @@ -0,0 +1 @@ +unicode/e32e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speak_no_evil.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speak_no_evil.png new file mode 120000 index 00000000000..c64efd7fcca --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speak_no_evil.png @@ -0,0 +1 @@ +unicode/1f64a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speaker.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speaker.png new file mode 120000 index 00000000000..ca50455c0b1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speaker.png @@ -0,0 +1 @@ +unicode/e141.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speech_balloon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speech_balloon.png new file mode 120000 index 00000000000..978d8aeaeea --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speech_balloon.png @@ -0,0 +1 @@ +unicode/1f4ac.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speedboat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speedboat.png new file mode 120000 index 00000000000..dfb0dbd126b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/speedboat.png @@ -0,0 +1 @@ +unicode/e135.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/squirrel.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/squirrel.png new file mode 100644 index 00000000000..a58a47f62f9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/squirrel.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/star.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/star.png new file mode 120000 index 00000000000..638d043e67f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/star.png @@ -0,0 +1 @@ +unicode/e32f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/star2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/star2.png new file mode 120000 index 00000000000..19b56bb4b26 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/star2.png @@ -0,0 +1 @@ +unicode/e335.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/stars.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/stars.png new file mode 120000 index 00000000000..b31e0ff4b75 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/stars.png @@ -0,0 +1 @@ +unicode/e44b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/station.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/station.png new file mode 120000 index 00000000000..256b2c54836 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/station.png @@ -0,0 +1 @@ +unicode/e039.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/statue_of_liberty.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/statue_of_liberty.png new file mode 120000 index 00000000000..7820b0cc742 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/statue_of_liberty.png @@ -0,0 +1 @@ +unicode/e51d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/steam_locomotive.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/steam_locomotive.png new file mode 120000 index 00000000000..b7e90514fcf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/steam_locomotive.png @@ -0,0 +1 @@ +unicode/1f682.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/stew.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/stew.png new file mode 120000 index 00000000000..7db64f19029 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/stew.png @@ -0,0 +1 @@ +unicode/e34d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/straight_ruler.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/straight_ruler.png new file mode 120000 index 00000000000..fffdaa0c902 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/straight_ruler.png @@ -0,0 +1 @@ +unicode/1f4cf.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/strawberry.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/strawberry.png new file mode 120000 index 00000000000..e10bf214dc6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/strawberry.png @@ -0,0 +1 @@ +unicode/e347.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sun_with_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sun_with_face.png new file mode 120000 index 00000000000..9f3ca0790bf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sun_with_face.png @@ -0,0 +1 @@ +unicode/1f31e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunflower.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunflower.png new file mode 120000 index 00000000000..0944037e464 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunflower.png @@ -0,0 +1 @@ +unicode/e305.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunglasses.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunglasses.png new file mode 120000 index 00000000000..d5789358c06 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunglasses.png @@ -0,0 +1 @@ +unicode/1f60e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunny.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunny.png new file mode 120000 index 00000000000..7917a264b03 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunny.png @@ -0,0 +1 @@ +unicode/e04a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunrise.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunrise.png new file mode 120000 index 00000000000..f4f29cb4c7e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunrise.png @@ -0,0 +1 @@ +unicode/e449.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunrise_over_mountains.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunrise_over_mountains.png new file mode 120000 index 00000000000..b457a13da1a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sunrise_over_mountains.png @@ -0,0 +1 @@ +unicode/e04d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/surfer.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/surfer.png new file mode 120000 index 00000000000..ade4a4265bb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/surfer.png @@ -0,0 +1 @@ +unicode/e017.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sushi.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sushi.png new file mode 120000 index 00000000000..bc17968f88f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sushi.png @@ -0,0 +1 @@ +unicode/e344.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/suspect.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/suspect.png new file mode 100644 index 00000000000..58e8921c0a7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/suspect.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/suspension_railway.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/suspension_railway.png new file mode 120000 index 00000000000..6d6b3ea3a6a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/suspension_railway.png @@ -0,0 +1 @@ +unicode/1f69f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweat.png new file mode 120000 index 00000000000..367f420784f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweat.png @@ -0,0 +1 @@ +unicode/e108.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweat_drops.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweat_drops.png new file mode 120000 index 00000000000..84b3ca04c23 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweat_drops.png @@ -0,0 +1 @@ +unicode/e331.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweat_smile.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweat_smile.png new file mode 120000 index 00000000000..2999db35639 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweat_smile.png @@ -0,0 +1 @@ +unicode/1f605.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweet_potato.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweet_potato.png new file mode 120000 index 00000000000..1e62e2d0dc3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/sweet_potato.png @@ -0,0 +1 @@ +unicode/1f360.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/swimmer.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/swimmer.png new file mode 120000 index 00000000000..1a696c85790 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/swimmer.png @@ -0,0 +1 @@ +unicode/e42d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/symbols.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/symbols.png new file mode 120000 index 00000000000..c63eb5bb6cb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/symbols.png @@ -0,0 +1 @@ +unicode/1f523.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/syringe.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/syringe.png new file mode 120000 index 00000000000..516895501a5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/syringe.png @@ -0,0 +1 @@ +unicode/e13b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tada.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tada.png new file mode 120000 index 00000000000..3acd7e245ba --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tada.png @@ -0,0 +1 @@ +unicode/e312.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tanabata_tree.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tanabata_tree.png new file mode 120000 index 00000000000..e4c849b1416 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tanabata_tree.png @@ -0,0 +1 @@ +unicode/1f38b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tangerine.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tangerine.png new file mode 120000 index 00000000000..bdd392d694c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tangerine.png @@ -0,0 +1 @@ +unicode/e346.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/taurus.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/taurus.png new file mode 120000 index 00000000000..3b8131ed882 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/taurus.png @@ -0,0 +1 @@ +unicode/e240.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/taxi.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/taxi.png new file mode 120000 index 00000000000..3bdf32f4891 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/taxi.png @@ -0,0 +1 @@ +unicode/e15a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tea.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tea.png new file mode 120000 index 00000000000..d59100014f6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tea.png @@ -0,0 +1 @@ +unicode/e338.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/telephone.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/telephone.png new file mode 120000 index 00000000000..c30df23ec68 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/telephone.png @@ -0,0 +1 @@ +unicode/e009.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/telephone_receiver.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/telephone_receiver.png new file mode 120000 index 00000000000..ad7d8367146 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/telephone_receiver.png @@ -0,0 +1 @@ +unicode/1f4de.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/telescope.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/telescope.png new file mode 120000 index 00000000000..504b45368cd --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/telescope.png @@ -0,0 +1 @@ +unicode/1f52d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tennis.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tennis.png new file mode 120000 index 00000000000..96db84c0182 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tennis.png @@ -0,0 +1 @@ +unicode/e015.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tent.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tent.png new file mode 120000 index 00000000000..e9d29f6aa66 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tent.png @@ -0,0 +1 @@ +unicode/e122.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/thought_balloon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/thought_balloon.png new file mode 120000 index 00000000000..7999771a483 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/thought_balloon.png @@ -0,0 +1 @@ +unicode/1f4ad.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/three.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/three.png new file mode 120000 index 00000000000..48d598b74af --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/three.png @@ -0,0 +1 @@ +unicode/e21e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/thumbsdown.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/thumbsdown.png new file mode 120000 index 00000000000..3605da79d7f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/thumbsdown.png @@ -0,0 +1 @@ +unicode/e421.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/thumbsup.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/thumbsup.png new file mode 120000 index 00000000000..366a049dfa7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/thumbsup.png @@ -0,0 +1 @@ +unicode/e00e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ticket.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ticket.png new file mode 120000 index 00000000000..b0b0de1713b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/ticket.png @@ -0,0 +1 @@ +unicode/e125.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tiger.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tiger.png new file mode 120000 index 00000000000..bfbafa06b9d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tiger.png @@ -0,0 +1 @@ +unicode/e050.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tiger2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tiger2.png new file mode 120000 index 00000000000..475fc74c422 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tiger2.png @@ -0,0 +1 @@ +unicode/1f405.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tired_face.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tired_face.png new file mode 120000 index 00000000000..ccc7a1150e2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tired_face.png @@ -0,0 +1 @@ +unicode/1f62b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tm.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tm.png new file mode 120000 index 00000000000..11c09bfd263 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tm.png @@ -0,0 +1 @@ +unicode/e537.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/toilet.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/toilet.png new file mode 120000 index 00000000000..80cf1656ea7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/toilet.png @@ -0,0 +1 @@ +unicode/e140.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tokyo_tower.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tokyo_tower.png new file mode 120000 index 00000000000..2858d88e97b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tokyo_tower.png @@ -0,0 +1 @@ +unicode/e509.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tomato.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tomato.png new file mode 120000 index 00000000000..5d19eaa0b30 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tomato.png @@ -0,0 +1 @@ +unicode/e349.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tongue.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tongue.png new file mode 120000 index 00000000000..7b32872c290 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tongue.png @@ -0,0 +1 @@ +unicode/e409.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tongue2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tongue2.png new file mode 120000 index 00000000000..ef622328042 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tongue2.png @@ -0,0 +1 @@ +unicode/1f445.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/top.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/top.png new file mode 120000 index 00000000000..91b40d4094e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/top.png @@ -0,0 +1 @@ +unicode/e24c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tophat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tophat.png new file mode 120000 index 00000000000..a353b3a6b76 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tophat.png @@ -0,0 +1 @@ +unicode/e503.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tractor.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tractor.png new file mode 120000 index 00000000000..5ab84e76df5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tractor.png @@ -0,0 +1 @@ +unicode/1f69c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/traffic_light.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/traffic_light.png new file mode 120000 index 00000000000..cf2989f309f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/traffic_light.png @@ -0,0 +1 @@ +unicode/e14e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/train.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/train.png new file mode 120000 index 00000000000..1c627be268f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/train.png @@ -0,0 +1 @@ +unicode/e01e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/train2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/train2.png new file mode 120000 index 00000000000..12438cf5041 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/train2.png @@ -0,0 +1 @@ +unicode/1f686.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tram.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tram.png new file mode 120000 index 00000000000..b27dea5daf7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tram.png @@ -0,0 +1 @@ +unicode/1f68a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/triangular_flag_on_post.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/triangular_flag_on_post.png new file mode 120000 index 00000000000..e73bd8b8d6a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/triangular_flag_on_post.png @@ -0,0 +1 @@ +unicode/1f6a9.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/triangular_ruler.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/triangular_ruler.png new file mode 120000 index 00000000000..3d9485127ad --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/triangular_ruler.png @@ -0,0 +1 @@ +unicode/1f4d0.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trident.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trident.png new file mode 120000 index 00000000000..5d9b1a2d24e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trident.png @@ -0,0 +1 @@ +unicode/e031.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/triumph.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/triumph.png new file mode 120000 index 00000000000..9b8ec141304 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/triumph.png @@ -0,0 +1 @@ +unicode/1f624.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trolleybus.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trolleybus.png new file mode 120000 index 00000000000..7748498e707 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trolleybus.png @@ -0,0 +1 @@ +unicode/1f68e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trollface.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trollface.png new file mode 100644 index 00000000000..cce7c75858d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trollface.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trophy.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trophy.png new file mode 120000 index 00000000000..25fc3f447ae --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trophy.png @@ -0,0 +1 @@ +unicode/e131.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tropical_drink.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tropical_drink.png new file mode 120000 index 00000000000..a15a3cd87f0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tropical_drink.png @@ -0,0 +1 @@ +unicode/1f379.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tropical_fish.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tropical_fish.png new file mode 120000 index 00000000000..f4250b375db --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tropical_fish.png @@ -0,0 +1 @@ +unicode/e522.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/truck.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/truck.png new file mode 120000 index 00000000000..c0deb9d0b5a --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/truck.png @@ -0,0 +1 @@ +unicode/e42f.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trumpet.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trumpet.png new file mode 120000 index 00000000000..8011962f4f9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/trumpet.png @@ -0,0 +1 @@ +unicode/e042.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tshirt.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tshirt.png new file mode 120000 index 00000000000..f09c86d2558 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tshirt.png @@ -0,0 +1 @@ +unicode/e006.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tulip.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tulip.png new file mode 120000 index 00000000000..4de4f32f504 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tulip.png @@ -0,0 +1 @@ +unicode/e304.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/turtle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/turtle.png new file mode 120000 index 00000000000..d1e091a7664 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/turtle.png @@ -0,0 +1 @@ +unicode/1f422.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tv.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tv.png new file mode 120000 index 00000000000..31d51e297f1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/tv.png @@ -0,0 +1 @@ +unicode/e12a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/twisted_rightwards_arrows.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/twisted_rightwards_arrows.png new file mode 120000 index 00000000000..87ccda233ef --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/twisted_rightwards_arrows.png @@ -0,0 +1 @@ +unicode/1f500.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two.png new file mode 120000 index 00000000000..08df6c49beb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two.png @@ -0,0 +1 @@ +unicode/e21d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two_hearts.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two_hearts.png new file mode 120000 index 00000000000..c51e30afa18 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two_hearts.png @@ -0,0 +1 @@ +unicode/1f495.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two_men_holding_hands.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two_men_holding_hands.png new file mode 120000 index 00000000000..b46ebc125a7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two_men_holding_hands.png @@ -0,0 +1 @@ +unicode/1f46c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two_women_holding_hands.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two_women_holding_hands.png new file mode 120000 index 00000000000..3385e6aab4d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/two_women_holding_hands.png @@ -0,0 +1 @@ +unicode/1f46d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u5272.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u5272.png new file mode 120000 index 00000000000..95255dcda4c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u5272.png @@ -0,0 +1 @@ +unicode/e227.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u5408.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u5408.png new file mode 120000 index 00000000000..1e6f46f3517 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u5408.png @@ -0,0 +1 @@ +unicode/1f234.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u55b6.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u55b6.png new file mode 120000 index 00000000000..da14532149c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u55b6.png @@ -0,0 +1 @@ +unicode/e22d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6307.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6307.png new file mode 120000 index 00000000000..195b3809ab9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6307.png @@ -0,0 +1 @@ +unicode/e22c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6708.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6708.png new file mode 120000 index 00000000000..a7f6f1e26e5 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6708.png @@ -0,0 +1 @@ +unicode/e217.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6709.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6709.png new file mode 120000 index 00000000000..0e01a4c9c22 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6709.png @@ -0,0 +1 @@ +unicode/e215.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6e80.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6e80.png new file mode 120000 index 00000000000..c06c77cc6a1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u6e80.png @@ -0,0 +1 @@ +unicode/e22a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7121.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7121.png new file mode 120000 index 00000000000..c1abd32be92 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7121.png @@ -0,0 +1 @@ +unicode/e216.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7533.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7533.png new file mode 120000 index 00000000000..6e94a7513b7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7533.png @@ -0,0 +1 @@ +unicode/e218.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7981.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7981.png new file mode 120000 index 00000000000..ffe13e4991d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7981.png @@ -0,0 +1 @@ +unicode/1f232.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7a7a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7a7a.png new file mode 120000 index 00000000000..a0b31184839 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/u7a7a.png @@ -0,0 +1 @@ +unicode/e22b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/uk.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/uk.png new file mode 120000 index 00000000000..20d8b968e3b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/uk.png @@ -0,0 +1 @@ +unicode/e510.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/umbrella.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/umbrella.png new file mode 120000 index 00000000000..f069c57b088 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/umbrella.png @@ -0,0 +1 @@ +unicode/e04b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unamused.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unamused.png new file mode 120000 index 00000000000..9567f6ec9e9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unamused.png @@ -0,0 +1 @@ +unicode/e40e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/underage.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/underage.png new file mode 120000 index 00000000000..e198bcfe075 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/underage.png @@ -0,0 +1 @@ +unicode/e207.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f0cf.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f0cf.png new file mode 100644 index 00000000000..4c78f3614d7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f0cf.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f191.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f191.png new file mode 100644 index 00000000000..15ac67525aa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f191.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f193.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f193.png new file mode 100644 index 00000000000..c886cf2494c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f193.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f196.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f196.png new file mode 100644 index 00000000000..2ca180ae397 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f196.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f198.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f198.png new file mode 100644 index 00000000000..e3e16ef73f8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f198.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f232.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f232.png new file mode 100644 index 00000000000..f550a573da7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f232.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f234.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f234.png new file mode 100644 index 00000000000..03ab0d8746e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f234.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f251.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f251.png new file mode 100644 index 00000000000..2d200903188 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f251.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f301.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f301.png new file mode 100644 index 00000000000..3c7b8b04b95 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f301.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f309.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f309.png new file mode 100644 index 00000000000..495b06c3dfe Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f309.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30b.png new file mode 100644 index 00000000000..9b434539b05 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30c.png new file mode 100644 index 00000000000..901090a1265 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30d.png new file mode 100644 index 00000000000..44ce5ecb621 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30e.png new file mode 100644 index 00000000000..97d71767136 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30f.png new file mode 100644 index 00000000000..95ec357ca87 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f30f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f310.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f310.png new file mode 100644 index 00000000000..b198646670c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f310.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f311.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f311.png new file mode 100644 index 00000000000..540239b1f3e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f311.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f312.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f312.png new file mode 100644 index 00000000000..c8f13dd31c8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f312.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f313.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f313.png new file mode 100644 index 00000000000..f38c236937f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f313.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f314.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f314.png new file mode 100644 index 00000000000..dd8c4845896 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f314.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f315.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f315.png new file mode 100644 index 00000000000..8ff657a2593 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f315.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f316.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f316.png new file mode 100644 index 00000000000..8e324ec5f7f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f316.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f317.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f317.png new file mode 100644 index 00000000000..355e3c3f79f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f317.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f318.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f318.png new file mode 100644 index 00000000000..30387780fec Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f318.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31a.png new file mode 100644 index 00000000000..b9aff7a0683 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31b.png new file mode 100644 index 00000000000..85ae2ce72dc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31c.png new file mode 100644 index 00000000000..9ece82dfec6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31d.png new file mode 100644 index 00000000000..94395a4080b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31e.png new file mode 100644 index 00000000000..ee276636fa4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f31e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f330.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f330.png new file mode 100644 index 00000000000..066fb6bf6df Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f330.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f331.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f331.png new file mode 100644 index 00000000000..f0eb5a6b99a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f331.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f332.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f332.png new file mode 100644 index 00000000000..ae8ad103763 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f332.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f333.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f333.png new file mode 100644 index 00000000000..9bb16bdfecb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f333.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f33c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f33c.png new file mode 100644 index 00000000000..55a97353b47 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f33c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f33d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f33d.png new file mode 100644 index 00000000000..fe5d8b1287e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f33d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f33f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f33f.png new file mode 100644 index 00000000000..de1ff1b73bf Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f33f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f344.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f344.png new file mode 100644 index 00000000000..5eeed8e7900 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f344.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f347.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f347.png new file mode 100644 index 00000000000..0f9f007a12f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f347.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f348.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f348.png new file mode 100644 index 00000000000..11c13cbbd44 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f348.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34b.png new file mode 100644 index 00000000000..9814dc95989 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34c.png new file mode 100644 index 00000000000..a0563afb958 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34d.png new file mode 100644 index 00000000000..d6f8e287692 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34f.png new file mode 100644 index 00000000000..337205cd125 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f34f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f350.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f350.png new file mode 100644 index 00000000000..f24aca8c0a8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f350.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f351.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f351.png new file mode 100644 index 00000000000..ee2139ecb88 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f351.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f352.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f352.png new file mode 100644 index 00000000000..8d3e044f2f5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f352.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f355.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f355.png new file mode 100644 index 00000000000..460367d02cd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f355.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f356.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f356.png new file mode 100644 index 00000000000..d6b311b6b24 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f356.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f357.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f357.png new file mode 100644 index 00000000000..43ad8596518 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f357.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f360.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f360.png new file mode 100644 index 00000000000..32117fa9c7f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f360.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f364.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f364.png new file mode 100644 index 00000000000..c8c284bf14a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f364.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f365.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f365.png new file mode 100644 index 00000000000..a8f22614d62 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f365.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f368.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f368.png new file mode 100644 index 00000000000..190be01650e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f368.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f369.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f369.png new file mode 100644 index 00000000000..ccf86912960 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f369.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36a.png new file mode 100644 index 00000000000..653edb258c6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36b.png new file mode 100644 index 00000000000..c7ec19d0796 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36c.png new file mode 100644 index 00000000000..33722f236e9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36d.png new file mode 100644 index 00000000000..ba55e7093f1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36e.png new file mode 100644 index 00000000000..9f843b4c130 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36f.png new file mode 100644 index 00000000000..73278898a4c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f36f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f377.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f377.png new file mode 100644 index 00000000000..82b0f00057d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f377.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f379.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f379.png new file mode 100644 index 00000000000..55ca9eeda75 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f379.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f37c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f37c.png new file mode 100644 index 00000000000..1b2cfe5e301 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f37c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f38a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f38a.png new file mode 100644 index 00000000000..bd293e3d874 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f38a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f38b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f38b.png new file mode 100644 index 00000000000..473346410f6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f38b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3a0.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3a0.png new file mode 100644 index 00000000000..765d2c0a8bd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3a0.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3a3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3a3.png new file mode 100644 index 00000000000..d84609c3b7b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3a3.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3aa.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3aa.png new file mode 100644 index 00000000000..4af8719aa03 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3aa.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3ad.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3ad.png new file mode 100644 index 00000000000..899fbe5a791 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3ad.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3ae.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3ae.png new file mode 100644 index 00000000000..59d45baeabb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3ae.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b2.png new file mode 100644 index 00000000000..4136e78ec98 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b2.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b3.png new file mode 100644 index 00000000000..13d8ece2ee5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b3.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b4.png new file mode 100644 index 00000000000..cc46a6a1fa2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b9.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b9.png new file mode 100644 index 00000000000..93647a4a32d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3b9.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3bb.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3bb.png new file mode 100644 index 00000000000..0dba5ba2b66 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3bb.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3bc.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3bc.png new file mode 100644 index 00000000000..0c927d32fa4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3bc.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3bd.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3bd.png new file mode 100644 index 00000000000..0d68bba0910 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3bd.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3c2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3c2.png new file mode 100644 index 00000000000..aeda5c8d872 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3c2.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3c7.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3c7.png new file mode 100644 index 00000000000..e3bbaec1d6c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3c7.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3c9.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3c9.png new file mode 100644 index 00000000000..f8db67d7018 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3c9.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3e4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3e4.png new file mode 100644 index 00000000000..0f65b145305 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3e4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3ee.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3ee.png new file mode 100644 index 00000000000..18730ad5597 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f3ee.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f400.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f400.png new file mode 100644 index 00000000000..1c463dfde64 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f400.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f401.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f401.png new file mode 100644 index 00000000000..2d777e5e1ac Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f401.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f402.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f402.png new file mode 100644 index 00000000000..f7669802480 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f402.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f403.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f403.png new file mode 100644 index 00000000000..3bcde3edd95 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f403.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f404.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f404.png new file mode 100644 index 00000000000..594c92155bc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f404.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f405.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f405.png new file mode 100644 index 00000000000..b0c7d8dc3ec Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f405.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f406.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f406.png new file mode 100644 index 00000000000..8abfc4a2729 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f406.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f407.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f407.png new file mode 100644 index 00000000000..5bc993e799c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f407.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f408.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f408.png new file mode 100644 index 00000000000..977c992c526 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f408.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f409.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f409.png new file mode 100644 index 00000000000..e399d60e1d8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f409.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40a.png new file mode 100644 index 00000000000..7435d5ab3c4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40b.png new file mode 100644 index 00000000000..4af657b2fdc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40c.png new file mode 100644 index 00000000000..e75e69a84d3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40f.png new file mode 100644 index 00000000000..5ea7bfbc0d8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f40f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f410.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f410.png new file mode 100644 index 00000000000..4be9cf30404 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f410.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f413.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f413.png new file mode 100644 index 00000000000..fab23ad3625 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f413.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f415.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f415.png new file mode 100644 index 00000000000..c7f6a24ac80 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f415.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f416.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f416.png new file mode 100644 index 00000000000..fec3374d709 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f416.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f41c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f41c.png new file mode 100644 index 00000000000..b92d1cc14bd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f41c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f41d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f41d.png new file mode 100644 index 00000000000..f53733953af Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f41d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f41e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f41e.png new file mode 100644 index 00000000000..222577ca7ea Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f41e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f421.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f421.png new file mode 100644 index 00000000000..a1d47cb7e69 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f421.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f422.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f422.png new file mode 100644 index 00000000000..04d1d968470 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f422.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f423.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f423.png new file mode 100644 index 00000000000..005a55519f1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f423.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f425.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f425.png new file mode 100644 index 00000000000..39c25bc7ccd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f425.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f429.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f429.png new file mode 100644 index 00000000000..adac80bd97a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f429.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f42a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f42a.png new file mode 100644 index 00000000000..c8c7b9ffa0f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f42a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f432.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f432.png new file mode 100644 index 00000000000..e5e556bd105 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f432.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f43c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f43c.png new file mode 100644 index 00000000000..a794fb17f67 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f43c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f43d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f43d.png new file mode 100644 index 00000000000..38d612446eb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f43d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f43e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f43e.png new file mode 100644 index 00000000000..89b9fec9efa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f43e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f445.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f445.png new file mode 100644 index 00000000000..b0bab12078f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f445.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f453.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f453.png new file mode 100644 index 00000000000..a3cf75a27a1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f453.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f456.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f456.png new file mode 100644 index 00000000000..d721cea54c3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f456.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45a.png new file mode 100644 index 00000000000..aa297c7b65e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45b.png new file mode 100644 index 00000000000..8f06a2b932c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45d.png new file mode 100644 index 00000000000..0bc5879fcbb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45e.png new file mode 100644 index 00000000000..ecba9ba7d04 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f45e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f464.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f464.png new file mode 100644 index 00000000000..d1313986925 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f464.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f465.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f465.png new file mode 100644 index 00000000000..1f3aabcff60 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f465.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f46a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f46a.png new file mode 100644 index 00000000000..b4b365f3a5c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f46a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f46c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f46c.png new file mode 100644 index 00000000000..d1099f21ffe Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f46c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f46d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f46d.png new file mode 100644 index 00000000000..619646c4e02 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f46d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f470.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f470.png new file mode 100644 index 00000000000..dd0b0cfdad1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f470.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f479.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f479.png new file mode 100644 index 00000000000..e9f5471c9a2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f479.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f47a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f47a.png new file mode 100644 index 00000000000..bd21b187570 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f47a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f48c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f48c.png new file mode 100644 index 00000000000..e29981f4453 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f48c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f495.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f495.png new file mode 100644 index 00000000000..b189e9aea82 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f495.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f49e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f49e.png new file mode 100644 index 00000000000..ea3317c47fb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f49e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4a0.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4a0.png new file mode 100644 index 00000000000..dfd1098b394 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4a0.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4a5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4a5.png new file mode 100644 index 00000000000..bddeb8f49f8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4a5.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4a7.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4a7.png new file mode 100644 index 00000000000..9eff46339f8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4a7.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ab.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ab.png new file mode 100644 index 00000000000..55213d2ddee Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ab.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ac.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ac.png new file mode 100644 index 00000000000..2896c278886 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ac.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ad.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ad.png new file mode 100644 index 00000000000..701bdf0f64b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ad.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ae.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ae.png new file mode 100644 index 00000000000..c0929d0dd99 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ae.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4af.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4af.png new file mode 100644 index 00000000000..bce9ab14f59 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4af.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b2.png new file mode 100644 index 00000000000..361e26aef8b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b2.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b3.png new file mode 100644 index 00000000000..be1c1dd3063 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b3.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b4.png new file mode 100644 index 00000000000..139bc936e0f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b5.png new file mode 100644 index 00000000000..63de8849519 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b5.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b6.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b6.png new file mode 100644 index 00000000000..1c5904b7144 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b6.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b7.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b7.png new file mode 100644 index 00000000000..f8be91d7a4b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b7.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b8.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b8.png new file mode 100644 index 00000000000..135e3981ed1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4b8.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4be.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4be.png new file mode 100644 index 00000000000..4ad56315ae6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4be.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c1.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c1.png new file mode 100644 index 00000000000..4d8bebf8a90 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c1.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c2.png new file mode 100644 index 00000000000..2bbbbf5e7cd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c2.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c3.png new file mode 100644 index 00000000000..bf8f979d31c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c3.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c4.png new file mode 100644 index 00000000000..64cd2e1b2a2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c5.png new file mode 100644 index 00000000000..6ad2efa5fdc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c5.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c6.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c6.png new file mode 100644 index 00000000000..900b868bb94 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c6.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c7.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c7.png new file mode 100644 index 00000000000..374e94e9e84 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c7.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c8.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c8.png new file mode 100644 index 00000000000..de3e9ba7b57 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c8.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c9.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c9.png new file mode 100644 index 00000000000..65b82f04413 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4c9.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ca.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ca.png new file mode 100644 index 00000000000..7871cc60323 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ca.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cb.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cb.png new file mode 100644 index 00000000000..e2c74e6df82 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cb.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cc.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cc.png new file mode 100644 index 00000000000..540c4ecb885 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cc.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cd.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cd.png new file mode 100644 index 00000000000..e498e92cf6a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cd.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ce.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ce.png new file mode 100644 index 00000000000..774412dc10f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ce.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cf.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cf.png new file mode 100644 index 00000000000..af8cb4bcffa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4cf.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d0.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d0.png new file mode 100644 index 00000000000..383677cb74c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d0.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d1.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d1.png new file mode 100644 index 00000000000..0c4e3bf17df Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d1.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d2.png new file mode 100644 index 00000000000..e4f72aceacf Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d2.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d3.png new file mode 100644 index 00000000000..07ea6087ed4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d3.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d4.png new file mode 100644 index 00000000000..4f3b14c85f3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d5.png new file mode 100644 index 00000000000..484029c5ebc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d5.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d7.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d7.png new file mode 100644 index 00000000000..e86651e5c5c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d7.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d8.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d8.png new file mode 100644 index 00000000000..e2b9e8c797a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d8.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d9.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d9.png new file mode 100644 index 00000000000..49650d59e59 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4d9.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4da.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4da.png new file mode 100644 index 00000000000..dca06a1ad99 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4da.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4db.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4db.png new file mode 100644 index 00000000000..2b712dcd55a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4db.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4dc.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4dc.png new file mode 100644 index 00000000000..c5a10e6b8f7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4dc.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4de.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4de.png new file mode 100644 index 00000000000..36e21e0123d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4de.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4df.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4df.png new file mode 100644 index 00000000000..e3e1fc44ee5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4df.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e4.png new file mode 100644 index 00000000000..7ad15e649de Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e5.png new file mode 100644 index 00000000000..e2df0f89705 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e5.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e7.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e7.png new file mode 100644 index 00000000000..176a8e1e825 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e7.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e8.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e8.png new file mode 100644 index 00000000000..afc82712510 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4e8.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ea.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ea.png new file mode 100644 index 00000000000..a5982b69bb5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ea.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ec.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ec.png new file mode 100644 index 00000000000..dae34594367 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ec.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ed.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ed.png new file mode 100644 index 00000000000..59f15c5d7da Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ed.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ef.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ef.png new file mode 100644 index 00000000000..e9b713bbeca Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4ef.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4f0.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4f0.png new file mode 100644 index 00000000000..d171394e6a8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4f0.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4f5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4f5.png new file mode 100644 index 00000000000..41df57cf827 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4f5.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4f9.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4f9.png new file mode 100644 index 00000000000..274cecdd6d4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f4f9.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f500.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f500.png new file mode 100644 index 00000000000..25cde18b250 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f500.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f501.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f501.png new file mode 100644 index 00000000000..80113b6929b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f501.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f502.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f502.png new file mode 100644 index 00000000000..3c47bcc1f33 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f502.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f503.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f503.png new file mode 100644 index 00000000000..5f84d7e72b7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f503.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f504.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f504.png new file mode 100644 index 00000000000..1933ae18b90 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f504.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f505.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f505.png new file mode 100644 index 00000000000..ea15bde4f0d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f505.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f506.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f506.png new file mode 100644 index 00000000000..ba9de7d409c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f506.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f507.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f507.png new file mode 100644 index 00000000000..4cf67c367d3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f507.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f509.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f509.png new file mode 100644 index 00000000000..6aa4dbff4c0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f509.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50b.png new file mode 100644 index 00000000000..aa7eedce4bb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50c.png new file mode 100644 index 00000000000..7a3d6cee683 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50e.png new file mode 100644 index 00000000000..6e6cf11e6d7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50f.png new file mode 100644 index 00000000000..375e67e8253 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f50f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f510.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f510.png new file mode 100644 index 00000000000..e6fdf6cb204 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f510.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f515.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f515.png new file mode 100644 index 00000000000..613b81cd21e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f515.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f516.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f516.png new file mode 100644 index 00000000000..dbee45c605b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f516.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f517.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f517.png new file mode 100644 index 00000000000..ffb8f62ceca Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f517.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f518.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f518.png new file mode 100644 index 00000000000..63755eec258 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f518.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51a.png new file mode 100644 index 00000000000..edb0bda2450 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51b.png new file mode 100644 index 00000000000..3595387fb63 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51c.png new file mode 100644 index 00000000000..9386615a324 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51f.png new file mode 100644 index 00000000000..71dac1c1cc0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f51f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f520.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f520.png new file mode 100644 index 00000000000..ffc0cba4b43 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f520.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f521.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f521.png new file mode 100644 index 00000000000..5218470b63c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f521.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f522.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f522.png new file mode 100644 index 00000000000..c47c2e1f9f0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f522.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f523.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f523.png new file mode 100644 index 00000000000..16bc1da921f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f523.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f524.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f524.png new file mode 100644 index 00000000000..505d40a1557 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f524.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f526.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f526.png new file mode 100644 index 00000000000..215940aa8f1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f526.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f527.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f527.png new file mode 100644 index 00000000000..a87072ad132 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f527.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f529.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f529.png new file mode 100644 index 00000000000..bddfa72a7d3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f529.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52a.png new file mode 100644 index 00000000000..18eade0acfa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52c.png new file mode 100644 index 00000000000..f11d54c010a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52d.png new file mode 100644 index 00000000000..51fd8a07fae Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52e.png new file mode 100644 index 00000000000..6d2c6c42d44 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f52e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f535.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f535.png new file mode 100644 index 00000000000..a5b4ad4aaa2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f535.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f536.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f536.png new file mode 100644 index 00000000000..46d52e5cb6d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f536.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f537.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f537.png new file mode 100644 index 00000000000..f4598ec0f20 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f537.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f538.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f538.png new file mode 100644 index 00000000000..04941d37b63 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f538.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f539.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f539.png new file mode 100644 index 00000000000..5a7b5d555a5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f539.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53a.png new file mode 100644 index 00000000000..8c4428da8fa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53b.png new file mode 100644 index 00000000000..94832f060c4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53c.png new file mode 100644 index 00000000000..12173319772 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53d.png new file mode 100644 index 00000000000..f7f2d510137 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f53d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55c.png new file mode 100644 index 00000000000..df939201900 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55d.png new file mode 100644 index 00000000000..f12c6912af7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55e.png new file mode 100644 index 00000000000..1dc9628ea24 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55f.png new file mode 100644 index 00000000000..7726aaea1bc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f55f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f560.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f560.png new file mode 100644 index 00000000000..e08d4ad2bac Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f560.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f561.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f561.png new file mode 100644 index 00000000000..46f0681f1c4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f561.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f562.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f562.png new file mode 100644 index 00000000000..18aab22fd8c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f562.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f563.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f563.png new file mode 100644 index 00000000000..ec3e382dd4c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f563.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f564.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f564.png new file mode 100644 index 00000000000..fd35221428f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f564.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f565.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f565.png new file mode 100644 index 00000000000..84a3bc8fbd0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f565.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f566.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f566.png new file mode 100644 index 00000000000..415999ec838 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f566.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f567.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f567.png new file mode 100644 index 00000000000..a6527154d1f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f567.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f5fe.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f5fe.png new file mode 100644 index 00000000000..45932803597 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f5fe.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f5ff.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f5ff.png new file mode 100644 index 00000000000..61a1a9c21a4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f5ff.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f605.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f605.png new file mode 100644 index 00000000000..3903f717f31 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f605.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f606.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f606.png new file mode 100644 index 00000000000..11c91eb22e6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f606.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f607.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f607.png new file mode 100644 index 00000000000..503b614f8dc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f607.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f608.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f608.png new file mode 100644 index 00000000000..d904049309c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f608.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f60b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f60b.png new file mode 100644 index 00000000000..fc39637ecd8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f60b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f60e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f60e.png new file mode 100644 index 00000000000..1c468a1c91e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f60e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f610.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f610.png new file mode 100644 index 00000000000..682a1ba066d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f610.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f624.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f624.png new file mode 100644 index 00000000000..92f93bd1025 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f624.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f629.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f629.png new file mode 100644 index 00000000000..0c5475411c1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f629.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f62b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f62b.png new file mode 100644 index 00000000000..3a8eefe565d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f62b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f635.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f635.png new file mode 100644 index 00000000000..8001d6ff8f0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f635.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f636.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f636.png new file mode 100644 index 00000000000..d9ec7ca7d79 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f636.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f638.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f638.png new file mode 100644 index 00000000000..ad333ba3b6b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f638.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f639.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f639.png new file mode 100644 index 00000000000..6c60cb0efc8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f639.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63a.png new file mode 100644 index 00000000000..dbf1b0276ab Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63b.png new file mode 100644 index 00000000000..eeba240e533 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63c.png new file mode 100644 index 00000000000..351565e2461 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63d.png new file mode 100644 index 00000000000..adc62fbe3ce Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63e.png new file mode 100644 index 00000000000..4325fd48dd7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63f.png new file mode 100644 index 00000000000..42d4c27cabf Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f63f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f640.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f640.png new file mode 100644 index 00000000000..d94cd34ff5d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f640.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f648.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f648.png new file mode 100644 index 00000000000..0890a622279 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f648.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f649.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f649.png new file mode 100644 index 00000000000..f97a1f9a090 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f649.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64a.png new file mode 100644 index 00000000000..87944c4de54 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64b.png new file mode 100644 index 00000000000..e1741a40e74 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64d.png new file mode 100644 index 00000000000..6f34d5e159d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64e.png new file mode 100644 index 00000000000..c4a95c3b2a2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f64e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f681.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f681.png new file mode 100644 index 00000000000..8e82a0d5876 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f681.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f682.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f682.png new file mode 100644 index 00000000000..5495077667b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f682.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f683.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f683.png new file mode 100644 index 00000000000..22361158fb3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f683.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f686.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f686.png new file mode 100644 index 00000000000..9c0d3ab6407 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f686.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f688.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f688.png new file mode 100644 index 00000000000..bcfe801eec6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f688.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f68a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f68a.png new file mode 100644 index 00000000000..5eb29fb71cd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f68a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f68d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f68d.png new file mode 100644 index 00000000000..3695f762353 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f68d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f68e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f68e.png new file mode 100644 index 00000000000..b9740a53f87 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f68e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f690.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f690.png new file mode 100644 index 00000000000..c52cef23407 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f690.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f694.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f694.png new file mode 100644 index 00000000000..af20e7eff03 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f694.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f696.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f696.png new file mode 100644 index 00000000000..f78cf3103b8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f696.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f698.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f698.png new file mode 100644 index 00000000000..cb46de22cbb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f698.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69b.png new file mode 100644 index 00000000000..81ec1f91741 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69c.png new file mode 100644 index 00000000000..058fd3eda55 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69d.png new file mode 100644 index 00000000000..913d3002462 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69e.png new file mode 100644 index 00000000000..1f3d1aab56c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69f.png new file mode 100644 index 00000000000..aaa45f61f1f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f69f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a0.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a0.png new file mode 100644 index 00000000000..5688bb239a7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a0.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a1.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a1.png new file mode 100644 index 00000000000..38f6dfe2334 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a1.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a3.png new file mode 100644 index 00000000000..fe8ae3ecdab Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a3.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a6.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a6.png new file mode 100644 index 00000000000..7a5ba35f09d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a6.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a8.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a8.png new file mode 100644 index 00000000000..6cf4a775e0a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a8.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a9.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a9.png new file mode 100644 index 00000000000..f9a3f32d711 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6a9.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6aa.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6aa.png new file mode 100644 index 00000000000..83c819ae466 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6aa.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6ab.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6ab.png new file mode 100644 index 00000000000..a8444d18d2a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6ab.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6ae.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6ae.png new file mode 100644 index 00000000000..c2e350c2dc6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6ae.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6af.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6af.png new file mode 100644 index 00000000000..38c7ae7af23 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6af.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b0.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b0.png new file mode 100644 index 00000000000..e9fd56079ca Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b0.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b1.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b1.png new file mode 100644 index 00000000000..1b29d35b98b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b1.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b3.png new file mode 100644 index 00000000000..4b262166455 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b3.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b4.png new file mode 100644 index 00000000000..4e3e0549c21 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b5.png new file mode 100644 index 00000000000..b698897566a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b5.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b7.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b7.png new file mode 100644 index 00000000000..c35f530b220 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b7.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b8.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b8.png new file mode 100644 index 00000000000..b0302ae6258 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6b8.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6bf.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6bf.png new file mode 100644 index 00000000000..94f82aac02e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6bf.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c1.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c1.png new file mode 100644 index 00000000000..1c3f844ab26 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c1.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c2.png new file mode 100644 index 00000000000..675b76d378c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c2.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c3.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c3.png new file mode 100644 index 00000000000..92691e3117c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c3.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c4.png new file mode 100644 index 00000000000..59ae044a45e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c5.png new file mode 100644 index 00000000000..1c08b464db1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/1f6c5.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/203c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/203c.png new file mode 100644 index 00000000000..7270f0afe6e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/203c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2049.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2049.png new file mode 100644 index 00000000000..64304b9f5fb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2049.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2139.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2139.png new file mode 100644 index 00000000000..9cb8b09b249 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2139.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2194.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2194.png new file mode 100644 index 00000000000..b9fd11c5158 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2194.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2195.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2195.png new file mode 100644 index 00000000000..b718c214582 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2195.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/21a9.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/21a9.png new file mode 100644 index 00000000000..bc45dfefd4a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/21a9.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/21aa.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/21aa.png new file mode 100644 index 00000000000..8b4ea6e1720 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/21aa.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/231a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/231a.png new file mode 100644 index 00000000000..d503bb87c22 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/231a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/231b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/231b.png new file mode 100644 index 00000000000..405aab41beb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/231b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/23eb.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/23eb.png new file mode 100644 index 00000000000..d42979d4bf6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/23eb.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/23ec.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/23ec.png new file mode 100644 index 00000000000..2ecbebcda13 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/23ec.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/23f0.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/23f0.png new file mode 100644 index 00000000000..86ca8c8ed45 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/23f0.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/24c2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/24c2.png new file mode 100644 index 00000000000..7424665e2bb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/24c2.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2611.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2611.png new file mode 100644 index 00000000000..f07a466c778 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2611.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/267b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/267b.png new file mode 100644 index 00000000000..99104c0e9cd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/267b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2693.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2693.png new file mode 100644 index 00000000000..0c5192e6473 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2693.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26aa.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26aa.png new file mode 100644 index 00000000000..da782ae297f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26aa.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26ab.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26ab.png new file mode 100644 index 00000000000..e46f9df615f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26ab.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26c5.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26c5.png new file mode 100644 index 00000000000..020dd5ff698 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26c5.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26d4.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26d4.png new file mode 100644 index 00000000000..cf2086a8e74 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/26d4.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2705.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2705.png new file mode 100644 index 00000000000..61dc0583cfa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2705.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2709.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2709.png new file mode 100644 index 00000000000..3631861bbfd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2709.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/270f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/270f.png new file mode 100644 index 00000000000..e624373b491 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/270f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2712.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2712.png new file mode 100644 index 00000000000..29f6994c11a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2712.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2714.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2714.png new file mode 100644 index 00000000000..336d2626d0c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2714.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2716.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2716.png new file mode 100644 index 00000000000..13d66607865 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2716.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2744.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2744.png new file mode 100644 index 00000000000..54b68ff4f13 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2744.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/274e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/274e.png new file mode 100644 index 00000000000..b47a0cece5c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/274e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2757.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2757.png new file mode 100644 index 00000000000..4c560f5e3f4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2757.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2795.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2795.png new file mode 100644 index 00000000000..61595387bb6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2795.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2796.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2796.png new file mode 100644 index 00000000000..b8d3d82f2cd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2796.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2797.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2797.png new file mode 100644 index 00000000000..ac757a238ec Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2797.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/27b0.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/27b0.png new file mode 100644 index 00000000000..8f051aca43e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/27b0.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2934.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2934.png new file mode 100644 index 00000000000..c8f670a1ef0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2934.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2935.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2935.png new file mode 100644 index 00000000000..56dd3b9d3c8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/2935.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/3030.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/3030.png new file mode 100644 index 00000000000..77f626cc5cd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/3030.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e001.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e001.png new file mode 100644 index 00000000000..f79f1f29807 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e001.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e002.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e002.png new file mode 100644 index 00000000000..ea4126941f7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e002.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e003.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e003.png new file mode 100644 index 00000000000..4ae2c2b5d05 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e003.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e004.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e004.png new file mode 100644 index 00000000000..d9bfa26a674 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e004.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e005.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e005.png new file mode 100644 index 00000000000..6bf0d2b129c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e005.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e006.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e006.png new file mode 100644 index 00000000000..297a6d63ed3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e006.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e007.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e007.png new file mode 100644 index 00000000000..45b82e61cf2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e007.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e008.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e008.png new file mode 100644 index 00000000000..397d03b3935 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e008.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e009.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e009.png new file mode 100644 index 00000000000..87d2559b552 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e009.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00a.png new file mode 100644 index 00000000000..df007103b0b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00b.png new file mode 100644 index 00000000000..62be2c958f4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00c.png new file mode 100644 index 00000000000..d4d2687627e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00d.png new file mode 100644 index 00000000000..2d41fd37e8d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00e.png new file mode 100644 index 00000000000..3a43ecae295 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00f.png new file mode 100644 index 00000000000..01896e214aa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e00f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e010.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e010.png new file mode 100644 index 00000000000..ecc8874c2fd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e010.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e011.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e011.png new file mode 100644 index 00000000000..f61267c281d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e011.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e012.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e012.png new file mode 100644 index 00000000000..5e45c25a56c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e012.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e013.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e013.png new file mode 100644 index 00000000000..c97de3ed92d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e013.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e014.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e014.png new file mode 100644 index 00000000000..cba2116a7e2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e014.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e015.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e015.png new file mode 100644 index 00000000000..278d904ee20 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e015.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e016.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e016.png new file mode 100644 index 00000000000..da004e2ead0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e016.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e017.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e017.png new file mode 100644 index 00000000000..b067e8cb323 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e017.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e018.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e018.png new file mode 100644 index 00000000000..1e118b5b184 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e018.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e019.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e019.png new file mode 100644 index 00000000000..dc2a3f52d98 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e019.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01a.png new file mode 100644 index 00000000000..78d580ad3e9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01b.png new file mode 100644 index 00000000000..d70a2f06263 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01c.png new file mode 100644 index 00000000000..ff656dc62bb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01d.png new file mode 100644 index 00000000000..8407cb67575 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01e.png new file mode 100644 index 00000000000..3202d80ea9f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01f.png new file mode 100644 index 00000000000..16651acff8e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e01f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e020.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e020.png new file mode 100644 index 00000000000..63fd7f83722 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e020.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e021.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e021.png new file mode 100644 index 00000000000..77bbdeabcf4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e021.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e022.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e022.png new file mode 100644 index 00000000000..7d7790ce4df Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e022.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e023.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e023.png new file mode 100644 index 00000000000..a1bc850ecb4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e023.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e024.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e024.png new file mode 100644 index 00000000000..ca34e897516 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e024.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e025.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e025.png new file mode 100644 index 00000000000..1a12524ee44 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e025.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e026.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e026.png new file mode 100644 index 00000000000..cd99bb155df Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e026.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e027.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e027.png new file mode 100644 index 00000000000..7274e8b0728 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e027.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e028.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e028.png new file mode 100644 index 00000000000..3ed5a81af40 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e028.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e029.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e029.png new file mode 100644 index 00000000000..ac38cb92608 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e029.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02a.png new file mode 100644 index 00000000000..6a138dfdeac Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02b.png new file mode 100644 index 00000000000..6690cd74eaa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02c.png new file mode 100644 index 00000000000..c4ad74609ff Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02d.png new file mode 100644 index 00000000000..f710bef5c4f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02e.png new file mode 100644 index 00000000000..fbc165b995b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02f.png new file mode 100644 index 00000000000..c1ca82f395d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e02f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e030.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e030.png new file mode 100644 index 00000000000..e0315549990 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e030.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e031.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e031.png new file mode 100644 index 00000000000..d79a7b4cce5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e031.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e032.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e032.png new file mode 100644 index 00000000000..3479fbcbbd4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e032.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e033.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e033.png new file mode 100644 index 00000000000..d813b9593dc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e033.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e034.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e034.png new file mode 100644 index 00000000000..8a57fd68bac Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e034.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e035.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e035.png new file mode 100644 index 00000000000..8a5d8dad5c3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e035.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e036.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e036.png new file mode 100644 index 00000000000..95b9ee09480 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e036.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e037.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e037.png new file mode 100644 index 00000000000..4c07c6b9ea5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e037.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e038.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e038.png new file mode 100644 index 00000000000..3f20b564228 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e038.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e039.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e039.png new file mode 100644 index 00000000000..e77daa8a75f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e039.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03a.png new file mode 100644 index 00000000000..54c29aeb1db Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03b.png new file mode 100644 index 00000000000..4c313e583f0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03c.png new file mode 100644 index 00000000000..ce19a2bb66a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03d.png new file mode 100644 index 00000000000..9c143840925 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03e.png new file mode 100644 index 00000000000..68b261bcba6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03f.png new file mode 100644 index 00000000000..34673213f64 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e03f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e040.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e040.png new file mode 100644 index 00000000000..011559a7673 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e040.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e041.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e041.png new file mode 100644 index 00000000000..2b7fa43c941 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e041.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e042.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e042.png new file mode 100644 index 00000000000..8d4703fc22a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e042.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e043.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e043.png new file mode 100644 index 00000000000..8ba4bc6535e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e043.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e044.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e044.png new file mode 100644 index 00000000000..28b45ea5145 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e044.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e045.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e045.png new file mode 100644 index 00000000000..57e1adcb04a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e045.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e046.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e046.png new file mode 100644 index 00000000000..efeb9b4b214 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e046.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e047.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e047.png new file mode 100644 index 00000000000..cd78bed7440 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e047.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e048.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e048.png new file mode 100644 index 00000000000..a97902e5304 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e048.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e049.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e049.png new file mode 100644 index 00000000000..b31c08c0b88 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e049.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04a.png new file mode 100644 index 00000000000..d23c095e080 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04b.png new file mode 100644 index 00000000000..1db722fa661 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04c.png new file mode 100644 index 00000000000..afdb450d1df Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04d.png new file mode 100644 index 00000000000..ebc3db14680 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04e.png new file mode 100644 index 00000000000..da52c310c64 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04f.png new file mode 100644 index 00000000000..09b9ef79a7d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e04f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e050.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e050.png new file mode 100644 index 00000000000..d6cc84a3ba9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e050.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e051.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e051.png new file mode 100644 index 00000000000..f5afe920e8e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e051.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e052.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e052.png new file mode 100644 index 00000000000..389a02bf282 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e052.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e053.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e053.png new file mode 100644 index 00000000000..8ff162e2dbb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e053.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e054.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e054.png new file mode 100644 index 00000000000..5bb113e4289 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e054.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e055.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e055.png new file mode 100644 index 00000000000..d8edbcb8fa9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e055.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e056.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e056.png new file mode 100644 index 00000000000..1e9021cb6fe Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e056.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e057.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e057.png new file mode 100644 index 00000000000..77b581d68fa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e057.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e058.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e058.png new file mode 100644 index 00000000000..82552008719 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e058.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e059.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e059.png new file mode 100644 index 00000000000..34174f5e5c4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e059.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e05a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e05a.png new file mode 100644 index 00000000000..73a4dc84008 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e05a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e101.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e101.png new file mode 100644 index 00000000000..8351e70760c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e101.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e102.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e102.png new file mode 100644 index 00000000000..ce04b7008ba Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e102.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e103.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e103.png new file mode 100644 index 00000000000..0e01fd5f052 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e103.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e104.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e104.png new file mode 100644 index 00000000000..837897f261b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e104.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e105.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e105.png new file mode 100644 index 00000000000..6ae9d497d30 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e105.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e106.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e106.png new file mode 100644 index 00000000000..0e5794270ea Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e106.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e107.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e107.png new file mode 100644 index 00000000000..76bfc6b8a65 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e107.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e108.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e108.png new file mode 100644 index 00000000000..e894b769960 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e108.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e109.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e109.png new file mode 100644 index 00000000000..6964cf4d51a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e109.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10a.png new file mode 100644 index 00000000000..52ce64b4687 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10b.png new file mode 100644 index 00000000000..f7f273c733b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10c.png new file mode 100644 index 00000000000..e3fd76a78da Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10d.png new file mode 100644 index 00000000000..783078d3798 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10e.png new file mode 100644 index 00000000000..39da1d52873 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10f.png new file mode 100644 index 00000000000..23afca1c73f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e10f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e110.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e110.png new file mode 100644 index 00000000000..f2014bea44f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e110.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e111.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e111.png new file mode 100644 index 00000000000..d02790822ea Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e111.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e112.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e112.png new file mode 100644 index 00000000000..552cfdc2b98 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e112.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e113.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e113.png new file mode 100644 index 00000000000..c49dc52c6cb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e113.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e114.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e114.png new file mode 100644 index 00000000000..aa5b1d7c46f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e114.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e115.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e115.png new file mode 100644 index 00000000000..1ecfd9059d8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e115.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e116.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e116.png new file mode 100644 index 00000000000..6b75bc37b39 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e116.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e117.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e117.png new file mode 100644 index 00000000000..b4eccd5775b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e117.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e118.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e118.png new file mode 100644 index 00000000000..4e9b47207de Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e118.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e119.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e119.png new file mode 100644 index 00000000000..d49f9c1757d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e119.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11a.png new file mode 100644 index 00000000000..48e570105d1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11b.png new file mode 100644 index 00000000000..671dd0c9e2e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11c.png new file mode 100644 index 00000000000..bd4ee38297a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11d.png new file mode 100644 index 00000000000..f2a3149bbfd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11e.png new file mode 100644 index 00000000000..46e82b0010c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11f.png new file mode 100644 index 00000000000..d1cb864b4bf Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e11f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e120.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e120.png new file mode 100644 index 00000000000..9f1a3fdff6e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e120.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e121.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e121.png new file mode 100644 index 00000000000..da126e6486e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e121.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e122.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e122.png new file mode 100644 index 00000000000..5c0d20e48b6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e122.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e123.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e123.png new file mode 100644 index 00000000000..a0bc9d75f21 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e123.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e124.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e124.png new file mode 100644 index 00000000000..54a1dcfa1ef Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e124.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e125.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e125.png new file mode 100644 index 00000000000..cdacf1a70be Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e125.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e126.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e126.png new file mode 100644 index 00000000000..baff835c489 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e126.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e127.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e127.png new file mode 100644 index 00000000000..363c83d01c5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e127.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e128.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e128.png new file mode 100644 index 00000000000..ea589efe32c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e128.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e129.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e129.png new file mode 100644 index 00000000000..881081c1778 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e129.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12a.png new file mode 100644 index 00000000000..803dc3d412f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12b.png new file mode 100644 index 00000000000..38404916747 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12c.png new file mode 100644 index 00000000000..45dc9b851a1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12d.png new file mode 100644 index 00000000000..f51ce65fdde Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12e.png new file mode 100644 index 00000000000..863638850e1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12f.png new file mode 100644 index 00000000000..5546c04bad4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e12f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e130.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e130.png new file mode 100644 index 00000000000..0438fe54f99 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e130.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e131.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e131.png new file mode 100644 index 00000000000..95d3b63f524 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e131.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e132.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e132.png new file mode 100644 index 00000000000..ead4a68dd37 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e132.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e133.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e133.png new file mode 100644 index 00000000000..26f114830b8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e133.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e134.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e134.png new file mode 100644 index 00000000000..4d09c64de7e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e134.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e135.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e135.png new file mode 100644 index 00000000000..da6689b3be7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e135.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e136.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e136.png new file mode 100644 index 00000000000..65738602722 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e136.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e137.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e137.png new file mode 100644 index 00000000000..523e9f10bf6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e137.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e138.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e138.png new file mode 100644 index 00000000000..abccfc9f2c6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e138.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e139.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e139.png new file mode 100644 index 00000000000..518b76a6d28 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e139.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13a.png new file mode 100644 index 00000000000..2e58725cf56 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13b.png new file mode 100644 index 00000000000..e7e7ab6e395 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13c.png new file mode 100644 index 00000000000..30be04655af Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13d.png new file mode 100644 index 00000000000..260c531b9e2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13e.png new file mode 100644 index 00000000000..525b6a0dd69 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13f.png new file mode 100644 index 00000000000..8f75d1d2499 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e13f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e140.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e140.png new file mode 100644 index 00000000000..e5cc4119a15 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e140.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e141.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e141.png new file mode 100644 index 00000000000..c884bd4f6c7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e141.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e142.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e142.png new file mode 100644 index 00000000000..752385e523d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e142.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e143.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e143.png new file mode 100644 index 00000000000..2ffbb2627ad Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e143.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e144.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e144.png new file mode 100644 index 00000000000..4892b023558 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e144.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e145.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e145.png new file mode 100644 index 00000000000..22b429cd021 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e145.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e146.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e146.png new file mode 100644 index 00000000000..7cb178a2cc6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e146.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e147.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e147.png new file mode 100644 index 00000000000..c3de6ae4ea0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e147.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e148.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e148.png new file mode 100644 index 00000000000..8b698415c3d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e148.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e149.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e149.png new file mode 100644 index 00000000000..d5ee21fc68e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e149.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14a.png new file mode 100644 index 00000000000..ac2c4bb093e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14b.png new file mode 100644 index 00000000000..3481cc2ef4a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14c.png new file mode 100644 index 00000000000..19f92efb66e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14d.png new file mode 100644 index 00000000000..1faa8777e42 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14e.png new file mode 100644 index 00000000000..42eaf70912a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14f.png new file mode 100644 index 00000000000..c24af81ccf6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e14f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e150.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e150.png new file mode 100644 index 00000000000..99af2322ad8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e150.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e151.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e151.png new file mode 100644 index 00000000000..312ca3dc2db Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e151.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e152.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e152.png new file mode 100644 index 00000000000..43a5a84f821 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e152.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e153.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e153.png new file mode 100644 index 00000000000..43b59e30ec2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e153.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e154.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e154.png new file mode 100644 index 00000000000..c2846e79218 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e154.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e155.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e155.png new file mode 100644 index 00000000000..c05c49377fe Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e155.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e156.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e156.png new file mode 100644 index 00000000000..671696c2dfd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e156.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e157.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e157.png new file mode 100644 index 00000000000..afd922bf137 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e157.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e158.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e158.png new file mode 100644 index 00000000000..d29f276a180 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e158.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e159.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e159.png new file mode 100644 index 00000000000..823aa39e49d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e159.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e15a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e15a.png new file mode 100644 index 00000000000..60a50d365a4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e15a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e201.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e201.png new file mode 100644 index 00000000000..7a2bfacfc98 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e201.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e202.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e202.png new file mode 100644 index 00000000000..5d2d8b602bb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e202.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e203.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e203.png new file mode 100644 index 00000000000..3bef28c9fdb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e203.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e204.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e204.png new file mode 100644 index 00000000000..b40a4867588 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e204.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e205.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e205.png new file mode 100644 index 00000000000..73dc6a0c93f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e205.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e206.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e206.png new file mode 100644 index 00000000000..946a20333a2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e206.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e207.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e207.png new file mode 100644 index 00000000000..a789b3c6200 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e207.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e208.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e208.png new file mode 100644 index 00000000000..eb11d79115a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e208.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e209.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e209.png new file mode 100644 index 00000000000..1f022d175da Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e209.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20a.png new file mode 100644 index 00000000000..eddcdd7977a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20b.png new file mode 100644 index 00000000000..a4bd23ebf70 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20c.png new file mode 100644 index 00000000000..e8947153857 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20d.png new file mode 100644 index 00000000000..fe0827758b1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20e.png new file mode 100644 index 00000000000..133a1aba8a3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20f.png new file mode 100644 index 00000000000..bfab5365695 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e20f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e210.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e210.png new file mode 100644 index 00000000000..6765d7d3c2e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e210.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e211.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e211.png new file mode 100644 index 00000000000..ef34df3a404 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e211.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e212.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e212.png new file mode 100644 index 00000000000..28d1570e0a6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e212.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e213.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e213.png new file mode 100644 index 00000000000..829219a868a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e213.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e214.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e214.png new file mode 100644 index 00000000000..937dcd79210 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e214.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e215.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e215.png new file mode 100644 index 00000000000..cd8fb3f62a5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e215.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e216.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e216.png new file mode 100644 index 00000000000..25f694ed3ff Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e216.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e217.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e217.png new file mode 100644 index 00000000000..e4dfe5aa762 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e217.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e218.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e218.png new file mode 100644 index 00000000000..fc4a9901b46 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e218.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e219.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e219.png new file mode 100644 index 00000000000..b391289b203 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e219.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21a.png new file mode 100644 index 00000000000..71da10de81c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21b.png new file mode 100644 index 00000000000..60cb19a1371 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21c.png new file mode 100644 index 00000000000..2d1f9f8c49d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21d.png new file mode 100644 index 00000000000..c191f8a3221 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21e.png new file mode 100644 index 00000000000..55644c9900c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21f.png new file mode 100644 index 00000000000..14782ba23b9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e21f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e220.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e220.png new file mode 100644 index 00000000000..794321aa22a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e220.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e221.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e221.png new file mode 100644 index 00000000000..56880556577 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e221.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e222.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e222.png new file mode 100644 index 00000000000..354e89ae75a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e222.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e223.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e223.png new file mode 100644 index 00000000000..7bdb422327c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e223.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e224.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e224.png new file mode 100644 index 00000000000..8006cc909f3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e224.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e225.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e225.png new file mode 100644 index 00000000000..15e7446c812 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e225.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e226.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e226.png new file mode 100644 index 00000000000..e79af78442e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e226.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e227.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e227.png new file mode 100644 index 00000000000..2148253fc10 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e227.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e228.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e228.png new file mode 100644 index 00000000000..387f098b99c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e228.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e229.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e229.png new file mode 100644 index 00000000000..47437a76d39 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e229.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22a.png new file mode 100644 index 00000000000..5df1cb878f7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22b.png new file mode 100644 index 00000000000..c05f5cff73b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22c.png new file mode 100644 index 00000000000..6557f5672fb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22d.png new file mode 100644 index 00000000000..ba946d3f339 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22e.png new file mode 100644 index 00000000000..196d109a877 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22f.png new file mode 100644 index 00000000000..658c6d91875 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e22f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e230.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e230.png new file mode 100644 index 00000000000..fee9cac4da0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e230.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e231.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e231.png new file mode 100644 index 00000000000..b04e2849d07 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e231.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e232.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e232.png new file mode 100644 index 00000000000..565ce2952af Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e232.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e233.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e233.png new file mode 100644 index 00000000000..3956eb399fe Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e233.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e234.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e234.png new file mode 100644 index 00000000000..e5cca853daa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e234.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e235.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e235.png new file mode 100644 index 00000000000..9d7d1b5687b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e235.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e236.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e236.png new file mode 100644 index 00000000000..0daf4e9408c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e236.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e237.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e237.png new file mode 100644 index 00000000000..12aebd9a7db Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e237.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e238.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e238.png new file mode 100644 index 00000000000..2a15cc7ccca Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e238.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e239.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e239.png new file mode 100644 index 00000000000..a4438cb6e72 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e239.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23a.png new file mode 100644 index 00000000000..fbfe711b64d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23b.png new file mode 100644 index 00000000000..2be422ba39e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23c.png new file mode 100644 index 00000000000..b94a1172623 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23d.png new file mode 100644 index 00000000000..13ba866adaa Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23e.png new file mode 100644 index 00000000000..010f8f5f95f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23f.png new file mode 100644 index 00000000000..d676fd3920e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e23f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e240.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e240.png new file mode 100644 index 00000000000..6af582f69d2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e240.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e241.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e241.png new file mode 100644 index 00000000000..d926f6e88e9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e241.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e242.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e242.png new file mode 100644 index 00000000000..ea43a4a2a04 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e242.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e243.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e243.png new file mode 100644 index 00000000000..e025933b2f8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e243.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e244.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e244.png new file mode 100644 index 00000000000..72e1763f573 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e244.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e245.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e245.png new file mode 100644 index 00000000000..c9062dd2eeb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e245.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e246.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e246.png new file mode 100644 index 00000000000..67fcea1658a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e246.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e247.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e247.png new file mode 100644 index 00000000000..8b5435baaa9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e247.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e248.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e248.png new file mode 100644 index 00000000000..f2044e78935 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e248.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e249.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e249.png new file mode 100644 index 00000000000..cbff66edcf3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e249.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24a.png new file mode 100644 index 00000000000..5a2da0a0599 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24b.png new file mode 100644 index 00000000000..4eef715bc28 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24c.png new file mode 100644 index 00000000000..5aa4dd442da Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24d.png new file mode 100644 index 00000000000..6433d1a90a9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24e.png new file mode 100644 index 00000000000..d59f580a947 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24f.png new file mode 100644 index 00000000000..e5394109ace Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e24f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e250.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e250.png new file mode 100644 index 00000000000..a716e96c635 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e250.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e251.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e251.png new file mode 100644 index 00000000000..fa16c763c94 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e251.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e252.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e252.png new file mode 100644 index 00000000000..466658d99a8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e252.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e253.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e253.png new file mode 100644 index 00000000000..52c0a50a3f6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e253.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e301.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e301.png new file mode 100644 index 00000000000..fc97ddbc92b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e301.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e302.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e302.png new file mode 100644 index 00000000000..80461c66f3a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e302.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e303.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e303.png new file mode 100644 index 00000000000..32a3774c098 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e303.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e304.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e304.png new file mode 100644 index 00000000000..b3ee1102a53 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e304.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e305.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e305.png new file mode 100644 index 00000000000..d9bad194a21 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e305.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e306.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e306.png new file mode 100644 index 00000000000..ce637832e17 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e306.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e307.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e307.png new file mode 100644 index 00000000000..d534785ef96 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e307.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e308.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e308.png new file mode 100644 index 00000000000..5a2c3cc725e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e308.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e309.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e309.png new file mode 100644 index 00000000000..dfe84d2a73a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e309.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30a.png new file mode 100644 index 00000000000..ad83000e687 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30b.png new file mode 100644 index 00000000000..1f69907e58a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30c.png new file mode 100644 index 00000000000..cc5e4ab5aa9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30d.png new file mode 100644 index 00000000000..dcbb1d229ed Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30e.png new file mode 100644 index 00000000000..4aad6cbd7c4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30f.png new file mode 100644 index 00000000000..cd84a78ff75 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e30f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e310.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e310.png new file mode 100644 index 00000000000..a4d3207b8ef Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e310.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e311.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e311.png new file mode 100644 index 00000000000..3289787dcf9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e311.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e312.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e312.png new file mode 100644 index 00000000000..7411b5266a0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e312.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e313.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e313.png new file mode 100644 index 00000000000..020e0522443 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e313.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e314.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e314.png new file mode 100644 index 00000000000..63ee5ba5af2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e314.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e315.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e315.png new file mode 100644 index 00000000000..82e383a60d1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e315.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e316.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e316.png new file mode 100644 index 00000000000..e19cc5d0150 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e316.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e317.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e317.png new file mode 100644 index 00000000000..5d9319e72d5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e317.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e318.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e318.png new file mode 100644 index 00000000000..4cb2e6a6934 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e318.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e319.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e319.png new file mode 100644 index 00000000000..6434e2e2f39 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e319.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31a.png new file mode 100644 index 00000000000..aa62cca5d65 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31b.png new file mode 100644 index 00000000000..58d0fdbcd0c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31c.png new file mode 100644 index 00000000000..82f990c5679 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31d.png new file mode 100644 index 00000000000..6a66e63d2ad Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31e.png new file mode 100644 index 00000000000..dd30d159755 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31f.png new file mode 100644 index 00000000000..902d273f6c4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e31f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e320.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e320.png new file mode 100644 index 00000000000..a10cb232286 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e320.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e321.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e321.png new file mode 100644 index 00000000000..34ffe137dcd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e321.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e322.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e322.png new file mode 100644 index 00000000000..4ff63b40f88 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e322.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e323.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e323.png new file mode 100644 index 00000000000..d7adf04ddf2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e323.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e324.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e324.png new file mode 100644 index 00000000000..4e1dc111d76 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e324.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e325.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e325.png new file mode 100644 index 00000000000..69acceb286e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e325.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e326.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e326.png new file mode 100644 index 00000000000..a13147faedc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e326.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e327.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e327.png new file mode 100644 index 00000000000..b6628f6fa70 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e327.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e328.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e328.png new file mode 100644 index 00000000000..a7491cbeae6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e328.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e329.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e329.png new file mode 100644 index 00000000000..4987284767c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e329.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32a.png new file mode 100644 index 00000000000..baa29b31bcd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32b.png new file mode 100644 index 00000000000..7289cb8147c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32c.png new file mode 100644 index 00000000000..fa41ce78ac4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32d.png new file mode 100644 index 00000000000..d5f875043f0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32e.png new file mode 100644 index 00000000000..92138828df0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32f.png new file mode 100644 index 00000000000..1bfddc86255 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e32f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e330.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e330.png new file mode 100644 index 00000000000..dc2c0a8f468 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e330.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e331.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e331.png new file mode 100644 index 00000000000..a83b3e960cd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e331.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e332.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e332.png new file mode 100644 index 00000000000..0ededebe312 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e332.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e333.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e333.png new file mode 100644 index 00000000000..b84f63557a0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e333.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e334.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e334.png new file mode 100644 index 00000000000..6fb4dca1854 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e334.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e335.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e335.png new file mode 100644 index 00000000000..8b40ff4c8c8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e335.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e336.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e336.png new file mode 100644 index 00000000000..57db41ead4c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e336.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e337.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e337.png new file mode 100644 index 00000000000..a50d265e9d7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e337.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e338.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e338.png new file mode 100644 index 00000000000..3ece0b708af Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e338.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e339.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e339.png new file mode 100644 index 00000000000..7e7c63753d3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e339.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33a.png new file mode 100644 index 00000000000..871ce097689 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33b.png new file mode 100644 index 00000000000..cfef66966a7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33c.png new file mode 100644 index 00000000000..2d042aebeb5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33d.png new file mode 100644 index 00000000000..954c901e935 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33e.png new file mode 100644 index 00000000000..f4773edec8f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33f.png new file mode 100644 index 00000000000..08de243f554 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e33f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e340.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e340.png new file mode 100644 index 00000000000..78dc7d537fb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e340.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e341.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e341.png new file mode 100644 index 00000000000..7983c706a40 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e341.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e342.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e342.png new file mode 100644 index 00000000000..04f8a88067c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e342.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e343.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e343.png new file mode 100644 index 00000000000..73add1c73cf Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e343.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e344.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e344.png new file mode 100644 index 00000000000..0d179bd9756 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e344.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e345.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e345.png new file mode 100644 index 00000000000..08aa17b9513 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e345.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e346.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e346.png new file mode 100644 index 00000000000..fc9d4f82ad9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e346.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e347.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e347.png new file mode 100644 index 00000000000..13eb827ab87 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e347.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e348.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e348.png new file mode 100644 index 00000000000..fc212be7844 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e348.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e349.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e349.png new file mode 100644 index 00000000000..a129700bbb5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e349.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34a.png new file mode 100644 index 00000000000..566d6a844c2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34b.png new file mode 100644 index 00000000000..36e8edcbec4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34c.png new file mode 100644 index 00000000000..c6d99e89b67 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34d.png new file mode 100644 index 00000000000..6e80b4a9c4f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e34d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e401.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e401.png new file mode 100644 index 00000000000..fa5f9e7f9f9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e401.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e402.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e402.png new file mode 100644 index 00000000000..bc6e5082c8c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e402.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e403.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e403.png new file mode 100644 index 00000000000..2f3bad9453b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e403.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e404.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e404.png new file mode 100644 index 00000000000..591cfcef8bb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e404.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e405.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e405.png new file mode 100644 index 00000000000..756766dd3e9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e405.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e406.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e406.png new file mode 100644 index 00000000000..c7e433e8ec8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e406.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e407.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e407.png new file mode 100644 index 00000000000..a5877a0a796 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e407.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e408.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e408.png new file mode 100644 index 00000000000..df4f55efd9a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e408.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e409.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e409.png new file mode 100644 index 00000000000..333716ee1fe Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e409.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40a.png new file mode 100644 index 00000000000..820cf315a14 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40b.png new file mode 100644 index 00000000000..513fce47b68 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40c.png new file mode 100644 index 00000000000..05887e99c6b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40d.png new file mode 100644 index 00000000000..9b49410c0ce Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40e.png new file mode 100644 index 00000000000..3722e6f5753 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40f.png new file mode 100644 index 00000000000..b9e39bc60fb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e40f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e410.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e410.png new file mode 100644 index 00000000000..858a83484a8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e410.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e411.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e411.png new file mode 100644 index 00000000000..7d433183aa1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e411.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e412.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e412.png new file mode 100644 index 00000000000..47df693d424 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e412.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e413.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e413.png new file mode 100644 index 00000000000..6d0d9afd284 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e413.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e414.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e414.png new file mode 100644 index 00000000000..bbab82d3bb5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e414.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e415.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e415.png new file mode 100644 index 00000000000..81a83968996 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e415.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e416.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e416.png new file mode 100644 index 00000000000..c65ddff552a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e416.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e417.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e417.png new file mode 100644 index 00000000000..449de197048 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e417.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e418.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e418.png new file mode 100644 index 00000000000..af9a80b7f09 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e418.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e419.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e419.png new file mode 100644 index 00000000000..dc2216f63d6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e419.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41a.png new file mode 100644 index 00000000000..ad17c16c29e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41b.png new file mode 100644 index 00000000000..2bbbf10c9ef Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41c.png new file mode 100644 index 00000000000..826ed1102dc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41d.png new file mode 100644 index 00000000000..f86c992d5a7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41e.png new file mode 100644 index 00000000000..e78402eb086 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41f.png new file mode 100644 index 00000000000..d01c982a75a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e41f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e420.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e420.png new file mode 100644 index 00000000000..3177439dcc0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e420.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e421.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e421.png new file mode 100644 index 00000000000..e44c04219ec Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e421.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e422.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e422.png new file mode 100644 index 00000000000..2cc25bd41a4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e422.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e423.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e423.png new file mode 100644 index 00000000000..d459a35bc1f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e423.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e424.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e424.png new file mode 100644 index 00000000000..e8b98194edb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e424.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e425.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e425.png new file mode 100644 index 00000000000..c503f40a931 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e425.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e426.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e426.png new file mode 100644 index 00000000000..024cb610492 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e426.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e427.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e427.png new file mode 100644 index 00000000000..e03142bdce9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e427.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e428.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e428.png new file mode 100644 index 00000000000..9e51f40e16e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e428.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e429.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e429.png new file mode 100644 index 00000000000..2dfb451a73a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e429.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42a.png new file mode 100644 index 00000000000..ef694bec4c9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42b.png new file mode 100644 index 00000000000..0e4e168fa8f Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42c.png new file mode 100644 index 00000000000..c2c710d4501 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42d.png new file mode 100644 index 00000000000..d3878a06525 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42e.png new file mode 100644 index 00000000000..978291e087d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42f.png new file mode 100644 index 00000000000..3f25ba1f92a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e42f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e430.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e430.png new file mode 100644 index 00000000000..9e6c59c9976 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e430.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e431.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e431.png new file mode 100644 index 00000000000..b740f45dba2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e431.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e432.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e432.png new file mode 100644 index 00000000000..b8f17275ee1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e432.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e433.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e433.png new file mode 100644 index 00000000000..9180b9861dc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e433.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e434.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e434.png new file mode 100644 index 00000000000..7f34f6be345 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e434.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e435.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e435.png new file mode 100644 index 00000000000..8eca368458a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e435.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e436.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e436.png new file mode 100644 index 00000000000..fc858d0fc2c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e436.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e437.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e437.png new file mode 100644 index 00000000000..f31c26a3fcc Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e437.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e438.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e438.png new file mode 100644 index 00000000000..47ce33900ca Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e438.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e439.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e439.png new file mode 100644 index 00000000000..2e811b097a1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e439.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43a.png new file mode 100644 index 00000000000..edfb19aec91 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43b.png new file mode 100644 index 00000000000..540164e84e4 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43c.png new file mode 100644 index 00000000000..072c5c217a8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43d.png new file mode 100644 index 00000000000..ead19d52cfb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43e.png new file mode 100644 index 00000000000..f8d520cd490 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43f.png new file mode 100644 index 00000000000..0d0b382c22b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e43f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e440.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e440.png new file mode 100644 index 00000000000..4aabd7e0ed3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e440.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e441.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e441.png new file mode 100644 index 00000000000..3145b564963 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e441.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e442.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e442.png new file mode 100644 index 00000000000..efacf5dd4be Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e442.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e443.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e443.png new file mode 100644 index 00000000000..6c49f64b2f3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e443.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e444.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e444.png new file mode 100644 index 00000000000..a9bba5c2c14 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e444.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e445.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e445.png new file mode 100644 index 00000000000..1f7667ea458 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e445.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e446.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e446.png new file mode 100644 index 00000000000..14361988db7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e446.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e447.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e447.png new file mode 100644 index 00000000000..801e578e66c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e447.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e448.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e448.png new file mode 100644 index 00000000000..a2240c07e7a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e448.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e449.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e449.png new file mode 100644 index 00000000000..ec58dcc94ff Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e449.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e44a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e44a.png new file mode 100644 index 00000000000..91ca2a40b69 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e44a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e44b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e44b.png new file mode 100644 index 00000000000..097a84241c1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e44b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e44c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e44c.png new file mode 100644 index 00000000000..6b1faa03793 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e44c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e501.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e501.png new file mode 100644 index 00000000000..44d7db828ad Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e501.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e502.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e502.png new file mode 100644 index 00000000000..d45212b0340 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e502.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e503.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e503.png new file mode 100644 index 00000000000..7d27134d6a5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e503.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e504.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e504.png new file mode 100644 index 00000000000..68d959c507d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e504.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e505.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e505.png new file mode 100644 index 00000000000..f225ab217c0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e505.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e506.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e506.png new file mode 100644 index 00000000000..8229b8a8a94 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e506.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e507.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e507.png new file mode 100644 index 00000000000..a990ccf99c2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e507.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e508.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e508.png new file mode 100644 index 00000000000..6404634793e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e508.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e509.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e509.png new file mode 100644 index 00000000000..e1cbd7a3c5d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e509.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50a.png new file mode 100644 index 00000000000..74b9d5d38cd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50b.png new file mode 100644 index 00000000000..b786efbbd8a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50c.png new file mode 100644 index 00000000000..38137669aa9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50d.png new file mode 100644 index 00000000000..6311c91159e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50e.png new file mode 100644 index 00000000000..16a28548c9e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50f.png new file mode 100644 index 00000000000..70bc9f32463 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e50f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e510.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e510.png new file mode 100644 index 00000000000..2a62c7a0810 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e510.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e511.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e511.png new file mode 100644 index 00000000000..71b30bff352 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e511.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e512.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e512.png new file mode 100644 index 00000000000..55fcf3549e2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e512.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e513.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e513.png new file mode 100644 index 00000000000..b30dcc53df9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e513.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e514.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e514.png new file mode 100644 index 00000000000..b4c0c1b673d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e514.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e515.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e515.png new file mode 100644 index 00000000000..c144301cbb8 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e515.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e516.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e516.png new file mode 100644 index 00000000000..7aad74b55e3 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e516.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e517.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e517.png new file mode 100644 index 00000000000..036604caf2a Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e517.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e518.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e518.png new file mode 100644 index 00000000000..149f0cfb8e1 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e518.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e519.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e519.png new file mode 100644 index 00000000000..f839565f478 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e519.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51a.png new file mode 100644 index 00000000000..3b29da40b60 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51b.png new file mode 100644 index 00000000000..4d648604786 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51c.png new file mode 100644 index 00000000000..1ebb2ce9b13 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51d.png new file mode 100644 index 00000000000..9ad90280689 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51e.png new file mode 100644 index 00000000000..b67b335d687 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51f.png new file mode 100644 index 00000000000..6885a0bc3d5 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e51f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e520.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e520.png new file mode 100644 index 00000000000..9326077a927 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e520.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e521.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e521.png new file mode 100644 index 00000000000..e6be8c02786 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e521.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e522.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e522.png new file mode 100644 index 00000000000..a6d734987bb Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e522.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e523.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e523.png new file mode 100644 index 00000000000..9be8d293006 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e523.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e524.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e524.png new file mode 100644 index 00000000000..ada9c3108e9 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e524.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e525.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e525.png new file mode 100644 index 00000000000..c2eaf7a708d Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e525.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e526.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e526.png new file mode 100644 index 00000000000..5ca04570e24 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e526.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e527.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e527.png new file mode 100644 index 00000000000..e17bd3cf531 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e527.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e528.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e528.png new file mode 100644 index 00000000000..64070359776 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e528.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e529.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e529.png new file mode 100644 index 00000000000..c7277d2898e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e529.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52a.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52a.png new file mode 100644 index 00000000000..c60c96895f7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52a.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52b.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52b.png new file mode 100644 index 00000000000..12e1ab6c0bd Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52b.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52c.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52c.png new file mode 100644 index 00000000000..5cb3ef6f0c6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52c.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52d.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52d.png new file mode 100644 index 00000000000..ef58933e2b2 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52d.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52e.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52e.png new file mode 100644 index 00000000000..6d25c0ef4ad Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52e.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52f.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52f.png new file mode 100644 index 00000000000..8196ad4a14b Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e52f.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e530.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e530.png new file mode 100644 index 00000000000..496c186ae6c Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e530.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e531.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e531.png new file mode 100644 index 00000000000..cfe11b18ff0 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e531.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e532.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e532.png new file mode 100644 index 00000000000..4908a44fc01 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e532.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e533.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e533.png new file mode 100644 index 00000000000..8742b3d2e3e Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e533.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e534.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e534.png new file mode 100644 index 00000000000..2a522204767 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e534.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e535.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e535.png new file mode 100644 index 00000000000..d85f9fb98c7 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e535.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e536.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e536.png new file mode 100644 index 00000000000..d7a25614f70 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e536.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e537.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e537.png new file mode 100644 index 00000000000..9ba71b75ba6 Binary files /dev/null and b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unicode/e537.png differ diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unlock.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unlock.png new file mode 120000 index 00000000000..b1f0c0cf3b0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/unlock.png @@ -0,0 +1 @@ +unicode/e145.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/up.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/up.png new file mode 120000 index 00000000000..26efdf3ceae --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/up.png @@ -0,0 +1 @@ +unicode/e213.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/us.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/us.png new file mode 120000 index 00000000000..be476480a7d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/us.png @@ -0,0 +1 @@ +unicode/e50c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/v.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/v.png new file mode 120000 index 00000000000..40b1246d5a3 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/v.png @@ -0,0 +1 @@ +unicode/e011.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vertical_traffic_light.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vertical_traffic_light.png new file mode 120000 index 00000000000..a25fff65142 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vertical_traffic_light.png @@ -0,0 +1 @@ +unicode/1f6a6.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vhs.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vhs.png new file mode 120000 index 00000000000..c6ca8b4a5bb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vhs.png @@ -0,0 +1 @@ +unicode/e129.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vibration_mode.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vibration_mode.png new file mode 120000 index 00000000000..84efc21f99b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vibration_mode.png @@ -0,0 +1 @@ +unicode/e250.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/video_camera.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/video_camera.png new file mode 120000 index 00000000000..e4adee991eb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/video_camera.png @@ -0,0 +1 @@ +unicode/1f4f9.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/video_game.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/video_game.png new file mode 120000 index 00000000000..d108a542bfb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/video_game.png @@ -0,0 +1 @@ +unicode/1f3ae.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/violin.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/violin.png new file mode 120000 index 00000000000..d29467f9f8c --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/violin.png @@ -0,0 +1 @@ +unicode/1f3bb.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/virgo.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/virgo.png new file mode 120000 index 00000000000..b484fb46cb9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/virgo.png @@ -0,0 +1 @@ +unicode/e244.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/volcano.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/volcano.png new file mode 120000 index 00000000000..6e4837e5322 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/volcano.png @@ -0,0 +1 @@ +unicode/1f30b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vs.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vs.png new file mode 120000 index 00000000000..cb4f5e33f97 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/vs.png @@ -0,0 +1 @@ +unicode/e12e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/walking.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/walking.png new file mode 120000 index 00000000000..d0529a9e8d0 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/walking.png @@ -0,0 +1 @@ +unicode/e201.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waning_crescent_moon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waning_crescent_moon.png new file mode 120000 index 00000000000..937d72e52ce --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waning_crescent_moon.png @@ -0,0 +1 @@ +unicode/1f318.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waning_gibbous_moon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waning_gibbous_moon.png new file mode 120000 index 00000000000..afe11272535 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waning_gibbous_moon.png @@ -0,0 +1 @@ +unicode/1f316.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/warning.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/warning.png new file mode 120000 index 00000000000..9c811c1a08b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/warning.png @@ -0,0 +1 @@ +unicode/e252.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/watch.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/watch.png new file mode 120000 index 00000000000..ee115707ef1 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/watch.png @@ -0,0 +1 @@ +unicode/231a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/water_buffalo.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/water_buffalo.png new file mode 120000 index 00000000000..e9da9757724 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/water_buffalo.png @@ -0,0 +1 @@ +unicode/1f403.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/watermelon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/watermelon.png new file mode 120000 index 00000000000..414b9613fbb --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/watermelon.png @@ -0,0 +1 @@ +unicode/e348.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wave.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wave.png new file mode 120000 index 00000000000..340eebb71aa --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wave.png @@ -0,0 +1 @@ +unicode/e41e.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wavy_dash.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wavy_dash.png new file mode 120000 index 00000000000..27ebde71c61 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wavy_dash.png @@ -0,0 +1 @@ +unicode/3030.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waxing_crescent_moon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waxing_crescent_moon.png new file mode 120000 index 00000000000..fba84d6dcde --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waxing_crescent_moon.png @@ -0,0 +1 @@ +unicode/1f312.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waxing_gibbous_moon.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waxing_gibbous_moon.png new file mode 120000 index 00000000000..1c589610f6d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/waxing_gibbous_moon.png @@ -0,0 +1 @@ +unicode/1f314.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wc.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wc.png new file mode 120000 index 00000000000..481ffd5b7c2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wc.png @@ -0,0 +1 @@ +unicode/e309.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/weary.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/weary.png new file mode 120000 index 00000000000..02c5636be64 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/weary.png @@ -0,0 +1 @@ +unicode/1f629.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wedding.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wedding.png new file mode 120000 index 00000000000..52461a13a37 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wedding.png @@ -0,0 +1 @@ +unicode/e43d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/whale.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/whale.png new file mode 120000 index 00000000000..41b05aec769 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/whale.png @@ -0,0 +1 @@ +unicode/e054.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/whale2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/whale2.png new file mode 120000 index 00000000000..ae62b29788b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/whale2.png @@ -0,0 +1 @@ +unicode/1f40b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wheelchair.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wheelchair.png new file mode 120000 index 00000000000..25ca66af479 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wheelchair.png @@ -0,0 +1 @@ +unicode/e20a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/white_circle.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/white_circle.png new file mode 120000 index 00000000000..30505e96c1b --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/white_circle.png @@ -0,0 +1 @@ +unicode/26aa.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/white_flower.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/white_flower.png new file mode 120000 index 00000000000..d2a05197fb9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/white_flower.png @@ -0,0 +1 @@ +unicode/1f4ae.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/white_square.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/white_square.png new file mode 120000 index 00000000000..cbdb0ce7a3e --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/white_square.png @@ -0,0 +1 @@ +unicode/e21b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wind_chime.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wind_chime.png new file mode 120000 index 00000000000..5f6579bb6f4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wind_chime.png @@ -0,0 +1 @@ +unicode/e442.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wine_glass.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wine_glass.png new file mode 120000 index 00000000000..dd6a6ad03e7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wine_glass.png @@ -0,0 +1 @@ +unicode/1f377.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wink.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wink.png new file mode 120000 index 00000000000..a633147bce4 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wink.png @@ -0,0 +1 @@ +unicode/e405.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wink2.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wink2.png new file mode 120000 index 00000000000..5e22f979bbf --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wink2.png @@ -0,0 +1 @@ +unicode/e105.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wolf.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wolf.png new file mode 120000 index 00000000000..bffa8e44c84 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wolf.png @@ -0,0 +1 @@ +unicode/e52a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/woman.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/woman.png new file mode 120000 index 00000000000..a1867527cd2 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/woman.png @@ -0,0 +1 @@ +unicode/e005.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/womans_clothes.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/womans_clothes.png new file mode 120000 index 00000000000..dfdf15fadb6 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/womans_clothes.png @@ -0,0 +1 @@ +unicode/1f45a.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/womans_hat.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/womans_hat.png new file mode 120000 index 00000000000..7a76967c301 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/womans_hat.png @@ -0,0 +1 @@ +unicode/e318.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/womens.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/womens.png new file mode 120000 index 00000000000..9952dd9a8aa --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/womens.png @@ -0,0 +1 @@ +unicode/e139.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wrench.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wrench.png new file mode 120000 index 00000000000..5ff3e8c1fd9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/wrench.png @@ -0,0 +1 @@ +unicode/1f527.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/x.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/x.png new file mode 120000 index 00000000000..8d59ef0fcbe --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/x.png @@ -0,0 +1 @@ +unicode/e333.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/yellow_heart.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/yellow_heart.png new file mode 120000 index 00000000000..1b90e4d364d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/yellow_heart.png @@ -0,0 +1 @@ +unicode/e32c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/yen.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/yen.png new file mode 120000 index 00000000000..d2215b588c7 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/yen.png @@ -0,0 +1 @@ +unicode/1f4b4.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/yum.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/yum.png new file mode 120000 index 00000000000..98071348c35 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/yum.png @@ -0,0 +1 @@ +unicode/1f60b.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/zap.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/zap.png new file mode 120000 index 00000000000..3a970e5cb2f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/zap.png @@ -0,0 +1 @@ +unicode/e13d.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/zero.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/zero.png new file mode 120000 index 00000000000..21dd5e4578f --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/zero.png @@ -0,0 +1 @@ +unicode/e225.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/images/emoji/zzz.png b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/zzz.png new file mode 120000 index 00000000000..82d60483cc9 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/images/emoji/zzz.png @@ -0,0 +1 @@ +unicode/e13c.png \ No newline at end of file diff --git a/vendor/gems/discourse_emoji/vendor/assets/javascripts/discourse_emoji.js b/vendor/gems/discourse_emoji/vendor/assets/javascripts/discourse_emoji.js new file mode 100644 index 00000000000..28fdb108802 --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/javascripts/discourse_emoji.js @@ -0,0 +1,75 @@ +(function() { + + var emoji = ["+1","-1","100","109","1234","8ball","a","ab","abc","abcd","accept","aerial_tramway","airplane","alarm_clock","alien","ambulance","anchor","angel","anger","angry","ant","apple","aquarius","aries","arrow_backward","arrow_double_down","arrow_double_up","arrow_down","arrow_down_small","arrow_forward","arrow_heading_down","arrow_heading_up","arrow_left","arrow_lower_left","arrow_lower_right","arrow_right","arrow_right_hook","arrow_up","arrow_up_down","arrow_up_small","arrow_upper_left","arrow_upper_right","arrows_clockwise","arrows_counterclockwise","art","articulated_lorry","astonished","atm","b","baby","baby_bottle","baby_chick","baby_symbol","baggage_claim","balloon","ballot_box_with_check","bamboo","banana","bangbang","bank","bar_chart","barber","baseball","basketball","bath","bathtub","battery","bear","bee","beer","beers","beetle","beginner","bell","bento","bicyclist","bike","bikini","bird","birthday","black_circle","black_joker","black_nib","black_square","blossom","blowfish","blue_book","blue_car","blue_heart","blush","boar","boat","bomb","book","bookmark","bookmark_tabs","books","boom","boot","bouquet","bow","bowling","bowtie","boy","bread","bride_with_veil","bridge_at_night","briefcase","broken_heart","bug","bulb","bullettrain_front","bullettrain_side","bus","busstop","bust_in_silhouette","busts_in_silhouette","cactus","cake","calendar","calling","camel","camera","cancer","candy","capital_abcd","capricorn","car","card_index","carousel_horse","cat","cat2","cd","chart","chart_with_downwards_trend","chart_with_upwards_trend","checkered_flag","cherries","cherry_blossom","chestnut","chicken","children_crossing","chocolate_bar","christmas_tree","church","cinema","circus_tent","city_sunrise","city_sunset","cl","clap","clapper","clipboard","clock1","clock10","clock1030","clock11","clock1130","clock12","clock1230","clock130","clock2","clock230","clock3","clock330","clock4","clock430","clock5","clock530","clock6","clock630","clock7","clock730","clock8","clock830","clock9","clock930","closed_book","closed_lock_with_key","closed_umbrella","cloud","clubs","cn","cocktail","coffee","cold_sweat","collision","computer","confetti_ball","confounded","congratulations","construction","construction_worker","convenience_store","cookie","cool","cop","copyright","corn","couple","couple_with_heart","couplekiss","cow","cow2","credit_card","crocodile","crossed_flags","crown","cry","crying_cat_face","crystal_ball","cupid","curly_loop","currency_exchange","curry","custard","customs","cyclone","dancer","dancers","dango","dart","dash","date","de","deciduous_tree","department_store","diamond_shape_with_a_dot_inside","diamonds","disappointed","dizzy","dizzy_face","do_not_litter","dog","dog2","dollar","dolls","dolphin","door","doughnut","dragon","dragon_face","dress","dromedary_camel","droplet","dvd","e-mail","ear","ear_of_rice","earth_africa","earth_americas","earth_asia","egg","eggplant","eight","eight_pointed_black_star","eight_spoked_asterisk","electric_plug","elephant","email","end","envelope","es","euro","european_castle","european_post_office","evergreen_tree","exclamation","eyeglasses","eyes","facepunch","factory","fallen_leaf","family","fast_forward","fax","fearful","feelsgood","feet","ferris_wheel","file_folder","finnadie","fire","fire_engine","fireworks","first_quarter_moon","first_quarter_moon_with_face","fish","fish_cake","fishing_pole_and_fish","fist","five","flags","flashlight","floppy_disk","flower_playing_cards","flushed","foggy","football","fork_and_knife","fountain","four","four_leaf_clover","fr","free","fried_shrimp","fries","frog","fuelpump","full_moon","full_moon_with_face","game_die","gb","gem","gemini","ghost","gift","gift_heart","girl","globe_with_meridians","goat","goberserk","godmode","golf","grapes","green_apple","green_book","green_heart","grey_exclamation","grey_question","grin","guardsman","guitar","gun","haircut","hamburger","hammer","hamster","hand","handbag","hankey","hash","hatched_chick","hatching_chick","headphones","hear_no_evil","heart","heart_decoration","heart_eyes","heart_eyes_cat","heartbeat","heartpulse","hearts","heavy_check_mark","heavy_division_sign","heavy_dollar_sign","heavy_exclamation_mark","heavy_minus_sign","heavy_multiplication_x","heavy_plus_sign","helicopter","herb","hibiscus","high_brightness","high_heel","hocho","honey_pot","honeybee","horse","horse_racing","hospital","hotel","hotsprings","hourglass","house","hurtrealbad","ice_cream","icecream","id","ideograph_advantage","imp","inbox_tray","incoming_envelope","information_desk_person","information_source","innocent","interrobang","iphone","it","izakaya_lantern","jack_o_lantern","japan","japanese_castle","japanese_goblin","japanese_ogre","jeans","joy","joy_cat","jp","key","keycap_ten","kimono","kiss","kissing_cat","kissing_face","kissing_heart","koala","koko","kr","large_blue_circle","large_blue_diamond","large_orange_diamond","last_quarter_moon","last_quarter_moon_with_face","laughing","leaves","ledger","left_luggage","left_right_arrow","leftwards_arrow_with_hook","lemon","leo","leopard","libra","light_rail","link","lips","lipstick","lock","lock_with_ink_pen","lollipop","loop","loudspeaker","love_hotel","love_letter","low_brightness","m","mag","mag_right","mahjong","mailbox","mailbox_closed","mailbox_with_mail","mailbox_with_no_mail","man","man_with_gua_pi_mao","man_with_turban","mans_shoe","maple_leaf","mask","massage","meat_on_bone","mega","melon","memo","mens","metal","metro","microphone","microscope","milky_way","minibus","minidisc","mobile_phone_off","money_with_wings","moneybag","monkey","monkey_face","monorail","moon","mortar_board","mount_fuji","mountain_bicyclist","mountain_cableway","mountain_railway","mouse","mouse2","movie_camera","moyai","muscle","mushroom","musical_keyboard","musical_note","musical_score","mute","nail_care","name_badge","neckbeard","necktie","negative_squared_cross_mark","neutral_face","new","new_moon","new_moon_with_face","newspaper","ng","nine","no_bell","no_bicycles","no_entry","no_entry_sign","no_good","no_mobile_phones","no_mouth","no_pedestrians","no_smoking","non-potable_water","nose","notebook","notebook_with_decorative_cover","notes","nut_and_bolt","o","o2","ocean","octocat","octopus","oden","office","ok","ok_hand","ok_woman","older_man","older_woman","on","oncoming_automobile","oncoming_bus","oncoming_police_car","oncoming_taxi","one","open_file_folder","open_hands","ophiuchus","orange_book","outbox_tray","ox","page_facing_up","page_with_curl","pager","palm_tree","panda_face","paperclip","parking","part_alternation_mark","partly_sunny","passport_control","paw_prints","peach","pear","pencil","pencil2","penguin","pensive","performing_arts","persevere","person_frowning","person_with_blond_hair","person_with_pouting_face","phone","pig","pig2","pig_nose","pill","pineapple","pisces","pizza","point_down","point_left","point_right","point_up","point_up_2","police_car","poodle","poop","post_office","postal_horn","postbox","potable_water","pouch","poultry_leg","pound","pouting_cat","pray","princess","punch","purple_heart","purse","pushpin","put_litter_in_its_place","question","rabbit","rabbit2","racehorse","radio","radio_button","rage","rage1","rage2","rage3","rage4","railway_car","rainbow","raised_hand","raised_hands","ram","ramen","rat","recycle","red_car","red_circle","registered","relaxed","relieved","repeat","repeat_one","restroom","revolving_hearts","rewind","ribbon","rice","rice_ball","rice_cracker","rice_scene","ring","rocket","roller_coaster","rooster","rose","rotating_light","round_pushpin","rowboat","ru","rugby_football","runner","running","running_shirt_with_sash","sa","sagittarius","sailboat","sake","sandal","santa","satellite","satisfied","saxophone","school","school_satchel","scissors","scorpius","scream","scream_cat","scroll","seat","secret","see_no_evil","seedling","seven","shaved_ice","sheep","shell","ship","shipit","shirt","shit","shoe","shower","signal_strength","six","six_pointed_star","ski","skull","sleepy","slot_machine","small_blue_diamond","small_orange_diamond","small_red_triangle","small_red_triangle_down","smile","smile_cat","smiley","smiley_cat","smiling_imp","smirk","smirk_cat","smoking","snail","snake","snowboarder","snowflake","snowman","sob","soccer","soon","sos","sound","space_invader","spades","spaghetti","sparkler","sparkles","speak_no_evil","speaker","speech_balloon","speedboat","squirrel","star","star2","stars","station","statue_of_liberty","steam_locomotive","stew","straight_ruler","strawberry","sun_with_face","sunflower","sunglasses","sunny","sunrise","sunrise_over_mountains","surfer","sushi","suspect","suspension_railway","sweat","sweat_drops","sweat_smile","sweet_potato","swimmer","symbols","syringe","tada","tanabata_tree","tangerine","taurus","taxi","tea","telephone","telephone_receiver","telescope","tennis","tent","thought_balloon","three","thumbsdown","thumbsup","ticket","tiger","tiger2","tired_face","tm","toilet","tokyo_tower","tomato","tongue","tongue2","top","tophat","tractor","traffic_light","train","train2","tram","triangular_flag_on_post","triangular_ruler","trident","triumph","trolleybus","trollface","trophy","tropical_drink","tropical_fish","truck","trumpet","tshirt","tulip","turtle","tv","twisted_rightwards_arrows","two","two_hearts","two_men_holding_hands","two_women_holding_hands","u5272","u5408","u55b6","u6307","u6708","u6709","u6e80","u7121","u7533","u7981","u7a7a","uk","umbrella","unamused","underage","unlock","up","us","v","vertical_traffic_light","vhs","vibration_mode","video_camera","video_game","violin","virgo","volcano","vs","walking","waning_crescent_moon","waning_gibbous_moon","warning","watch","water_buffalo","watermelon","wave","wavy_dash","waxing_crescent_moon","waxing_gibbous_moon","wc","weary","wedding","whale","whale2","wheelchair","white_circle","white_flower","white_square","wind_chime","wine_glass","wink","wink2","wolf","woman","womans_clothes","womans_hat","womens","wrench","x","yellow_heart","yen","yum","zap","zero","zzz"] + + // Regiest a before cook event + Discourse.Utilities.on("beforeCook", function(event) { + var text = event.detail; + var opts = event.opts; + + style = "" + if (opts && opts.environment === "email") { + // Hard code sizes for email view + style = 'width="20" height="20"'; + } + + this.textResult = text.replace(/\:([a-z\_\+\-0-9]+)\:/g, function (m1, m2) { + return (emoji.indexOf(m2) !== -1) ? + '' + m2 + '' : + m1; + }); + }); + + + if (Discourse && Discourse.ComposerView) { + Discourse.ComposerView.on("initWmdEditor", function(event){ + + template = Handlebars.compile("
            " + + "" + + "
            "); + + $('#wmd-input').autocomplete({ + template: template, + key: ":", + transformComplete: function(v){ return v + ":"; }, + dataSource: function(term, callback){ + + term = term.toLowerCase(); + + if (term == "") { + callback(["smile", "smiley", "wink", "sunny", "blush"]); + return + } + + var options = [] + var i; + for (i=0; i < emoji.length; i++) { + if (emoji[i].indexOf(term) == 0) { + options.push(emoji[i]); + if(options.length > 4) { break; } + } + } + + if (options.length <= 4) { + for (i=0; i < emoji.length; i++) { + if (emoji[i].indexOf(term) > 0) { + options.push(emoji[i]); + if(options.length > 4) { break; } + } + } + } + + callback(options) + } + }); + }); + } +}).call(this); diff --git a/vendor/gems/discourse_emoji/vendor/assets/stylesheets/discourse_emoji.css.sass b/vendor/gems/discourse_emoji/vendor/assets/stylesheets/discourse_emoji.css.sass new file mode 100644 index 00000000000..adafe16109d --- /dev/null +++ b/vendor/gems/discourse_emoji/vendor/assets/stylesheets/discourse_emoji.css.sass @@ -0,0 +1,5 @@ +body + img.emoji + width: 20px + height: 20px + vertical-align: middle \ No newline at end of file diff --git a/vendor/gems/discourse_plugin/Gemfile b/vendor/gems/discourse_plugin/Gemfile new file mode 100644 index 00000000000..4bfb4319ab2 --- /dev/null +++ b/vendor/gems/discourse_plugin/Gemfile @@ -0,0 +1,10 @@ +source 'https://rubygems.org' + +group :test do + gem 'rails' + gem 'rspec' + gem 'mocha' +end + +# Specify your gem's dependencies in rails_multisite.gemspec +gemspec diff --git a/vendor/gems/discourse_plugin/Gemfile.lock b/vendor/gems/discourse_plugin/Gemfile.lock new file mode 100644 index 00000000000..3ae47f0f628 --- /dev/null +++ b/vendor/gems/discourse_plugin/Gemfile.lock @@ -0,0 +1,105 @@ +PATH + remote: . + specs: + discourse_plugin (0.0.1) + +GEM + remote: https://rubygems.org/ + specs: + actionmailer (3.2.8) + actionpack (= 3.2.8) + mail (~> 2.4.4) + actionpack (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + builder (~> 3.0.0) + erubis (~> 2.7.0) + journey (~> 1.0.4) + rack (~> 1.4.0) + rack-cache (~> 1.2) + rack-test (~> 0.6.1) + sprockets (~> 2.1.3) + activemodel (3.2.8) + activesupport (= 3.2.8) + builder (~> 3.0.0) + activerecord (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + arel (~> 3.0.2) + tzinfo (~> 0.3.29) + activeresource (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + activesupport (3.2.8) + i18n (~> 0.6) + multi_json (~> 1.0) + arel (3.0.2) + builder (3.0.3) + diff-lcs (1.1.3) + erubis (2.7.0) + hike (1.2.1) + i18n (0.6.1) + journey (1.0.4) + json (1.7.5) + mail (2.4.4) + i18n (>= 0.4.0) + mime-types (~> 1.16) + treetop (~> 1.4.8) + metaclass (0.0.1) + mime-types (1.19) + mocha (0.12.7) + metaclass (~> 0.0.1) + multi_json (1.3.6) + polyglot (0.3.3) + rack (1.4.1) + rack-cache (1.2) + rack (>= 0.4) + rack-ssl (1.3.2) + rack + rack-test (0.6.2) + rack (>= 1.0) + rails (3.2.8) + actionmailer (= 3.2.8) + actionpack (= 3.2.8) + activerecord (= 3.2.8) + activeresource (= 3.2.8) + activesupport (= 3.2.8) + bundler (~> 1.0) + railties (= 3.2.8) + railties (3.2.8) + actionpack (= 3.2.8) + activesupport (= 3.2.8) + rack-ssl (~> 1.3.2) + rake (>= 0.8.7) + rdoc (~> 3.4) + thor (>= 0.14.6, < 2.0) + rake (0.9.2.2) + rdoc (3.12) + json (~> 1.4) + rspec (2.11.0) + rspec-core (~> 2.11.0) + rspec-expectations (~> 2.11.0) + rspec-mocks (~> 2.11.0) + rspec-core (2.11.1) + rspec-expectations (2.11.3) + diff-lcs (~> 1.1.3) + rspec-mocks (2.11.3) + sprockets (2.1.3) + hike (~> 1.2) + rack (~> 1.0) + tilt (~> 1.1, != 1.3.0) + thor (0.16.0) + tilt (1.3.3) + treetop (1.4.10) + polyglot + polyglot (>= 0.3.1) + tzinfo (0.3.33) + +PLATFORMS + ruby + +DEPENDENCIES + discourse_plugin! + mocha + rails + rspec diff --git a/vendor/gems/discourse_plugin/LICENSE b/vendor/gems/discourse_plugin/LICENSE new file mode 100644 index 00000000000..1561959f642 --- /dev/null +++ b/vendor/gems/discourse_plugin/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2012 Robin Ward + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/vendor/gems/discourse_plugin/README.md b/vendor/gems/discourse_plugin/README.md new file mode 100644 index 00000000000..a582de1fcb5 --- /dev/null +++ b/vendor/gems/discourse_plugin/README.md @@ -0,0 +1,3 @@ +# Discourse Plugin + +The basic stuff a plugin needs to exist in Discourse diff --git a/vendor/gems/discourse_plugin/Rakefile b/vendor/gems/discourse_plugin/Rakefile new file mode 100644 index 00000000000..ecd0cbf9288 --- /dev/null +++ b/vendor/gems/discourse_plugin/Rakefile @@ -0,0 +1,7 @@ +#!/usr/bin/env rake +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:test) do |spec| + spec.pattern = 'spec/*_spec.rb' +end diff --git a/vendor/gems/discourse_plugin/discourse_plugin.gemspec b/vendor/gems/discourse_plugin/discourse_plugin.gemspec new file mode 100644 index 00000000000..4d1a38d1486 --- /dev/null +++ b/vendor/gems/discourse_plugin/discourse_plugin.gemspec @@ -0,0 +1,20 @@ +# -*- encoding: utf-8 -*- +require File.expand_path('../lib/discourse_plugin/version', __FILE__) + +Gem::Specification.new do |gem| + gem.authors = ["Robin Ward"] + gem.email = ["robin.ward@gmail.com"] + gem.description = %q{Toolkit for creating a discourse plugin} + gem.summary = %q{Toolkit for creating a discourse plugin} + gem.homepage = "" + + # when this is extracted comment it back in, prd has no .git + # gem.files = `git ls-files`.split($\) + gem.files = Dir['README*','LICENSE','lib/**/*.rb'] + + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + gem.name = "discourse_plugin" + gem.require_paths = ["lib"] + gem.version = DiscoursePlugin::VERSION +end diff --git a/vendor/gems/discourse_plugin/lib/discourse_event.rb b/vendor/gems/discourse_plugin/lib/discourse_event.rb new file mode 100644 index 00000000000..12ecde64b49 --- /dev/null +++ b/vendor/gems/discourse_plugin/lib/discourse_event.rb @@ -0,0 +1,28 @@ +# This is meant to be used by plugins to trigger and listen to events +# So we can execute code when things happen. +module DiscourseEvent + + class << self + + def trigger(event_name, *params) + + return unless @events + return unless event_list = @events[event_name] + + event_list.each do |ev| + ev.call(*params) + end + end + + def on(event_name, &block) + @events ||= {} + @events[event_name] ||= Set.new + @events[event_name] << block + end + + def clear + @events = {} + end + end + +end diff --git a/vendor/gems/discourse_plugin/lib/discourse_plugin.rb b/vendor/gems/discourse_plugin/lib/discourse_plugin.rb new file mode 100644 index 00000000000..a715dec8969 --- /dev/null +++ b/vendor/gems/discourse_plugin/lib/discourse_plugin.rb @@ -0,0 +1,3 @@ +require 'discourse_event' +require 'discourse_plugin/version' +require 'discourse_plugin/discourse_plugin' \ No newline at end of file diff --git a/vendor/gems/discourse_plugin/lib/discourse_plugin/discourse_plugin.rb b/vendor/gems/discourse_plugin/lib/discourse_plugin/discourse_plugin.rb new file mode 100644 index 00000000000..67d4b01ac0f --- /dev/null +++ b/vendor/gems/discourse_plugin/lib/discourse_plugin/discourse_plugin.rb @@ -0,0 +1,48 @@ +# A basic plugin for Discourse. Meant to be extended and filled in. +# Most work is delegated to a registry. + +class DiscoursePlugin + + attr_reader :registry + + def initialize(registry) + @registry = registry + end + + def setup + # Initialize the plugin here + end + + # Find the modules in our class with the name mixin, then include them in the appropriate places + # automagically. + def self.include_mixins + modules = constants.collect {|const_name| const_get(const_name)}.select {|const| const.class == Module} + unless modules.empty? + modules.each do |m| + original_class = m.to_s.sub("#{self.name}::", '').sub("Mixin", "") + dependency_file_name = original_class.underscore + require_dependency(dependency_file_name) + original_class.constantize.send(:include, m) + end + end + end + + def register_js(file, opts={}) + @registry.register_js(file, opts) + end + + def register_css(file) + @registry.register_css(file) + end + + def register_archetype(name, options={}) + @registry.register_archetype(name, options) + end + + def listen_for(event_name) + return unless self.respond_to?(event_name) + DiscourseEvent.on(event_name, &self.method(event_name)) + end + +end + diff --git a/vendor/gems/discourse_plugin/lib/discourse_plugin/version.rb b/vendor/gems/discourse_plugin/lib/discourse_plugin/version.rb new file mode 100644 index 00000000000..04bedde42f3 --- /dev/null +++ b/vendor/gems/discourse_plugin/lib/discourse_plugin/version.rb @@ -0,0 +1,3 @@ +class DiscoursePlugin + VERSION = "0.0.1" +end diff --git a/vendor/gems/discourse_plugin/spec/discourse_event_spec.rb b/vendor/gems/discourse_plugin/spec/discourse_event_spec.rb new file mode 100644 index 00000000000..0aa334edac4 --- /dev/null +++ b/vendor/gems/discourse_plugin/spec/discourse_event_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' +require 'discourse_event' +require 'ostruct' + +describe DiscourseEvent do + + it "doesn't raise an error if we call an event that doesn't exist" do + DiscourseEvent.trigger(:missing_event) + end + + context 'with an event to call' do + + let(:harvey) { OpenStruct.new(name: 'Harvey Dent', job: 'District Attorney') } + + before do + DiscourseEvent.on(:acid_face) do |user| + user.name = 'Two Face' + end + end + + it "doesn't raise an error" do + DiscourseEvent.trigger(:acid_face, harvey) + end + + it "chnages the name" do + DiscourseEvent.trigger(:acid_face, harvey) + harvey.name.should == 'Two Face' + end + + context 'multiple events' do + before do + DiscourseEvent.on(:acid_face) do |user| + user.job = 'Supervillian' + end + DiscourseEvent.trigger(:acid_face, harvey) + end + + it 'triggerred the email event' do + harvey.job.should == 'Supervillian' + end + + it 'triggerred the username change' do + harvey.name.should == 'Two Face' + end + end + + end + +end diff --git a/vendor/gems/discourse_plugin/spec/discourse_plugin_spec.rb b/vendor/gems/discourse_plugin/spec/discourse_plugin_spec.rb new file mode 100644 index 00000000000..19b0e5d5466 --- /dev/null +++ b/vendor/gems/discourse_plugin/spec/discourse_plugin_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' +require 'discourse_plugin' +require 'ostruct' + +describe DiscoursePlugin do + + class TestPlugin < DiscoursePlugin + end + + let(:registry) { mock } + let(:plugin) { TestPlugin.new(registry) } + + it "delegates adding js to the registry" do + registry.expects(:register_js).with('test.js', any_parameters) + plugin.register_js('test.js') + end + + it "delegates adding css to the registry" do + registry.expects(:register_css).with('test.css') + plugin.register_css('test.css') + end + + it "delegates creating archetypes" do + registry.expects(:register_archetype).with('banana', oh: 'no!') + plugin.register_archetype('banana', oh: 'no!') + end + + context 'registering for callbacks' do + before do + plugin.stubs(:hello) + plugin.listen_for(:hello) + end + + it "calls the method when it is triggered" do + plugin.expects(:hello).with('there') + DiscourseEvent.trigger(:hello, 'there') + end + + end + + +end diff --git a/vendor/gems/discourse_plugin/spec/spec_helper.rb b/vendor/gems/discourse_plugin/spec/spec_helper.rb new file mode 100644 index 00000000000..a79bca981c0 --- /dev/null +++ b/vendor/gems/discourse_plugin/spec/spec_helper.rb @@ -0,0 +1,18 @@ +require 'rubygems' +require 'rails' + +ENV["RAILS_ENV"] ||= 'test' + + +RSpec.configure do |config| + + config.mock_framework = :mocha + config.color_enabled = true + + config.before(:each) do + DiscourseEvent.clear + end + +end + + diff --git a/vendor/gems/discourse_poll/Gemfile b/vendor/gems/discourse_poll/Gemfile new file mode 100644 index 00000000000..fcb7fdc2990 --- /dev/null +++ b/vendor/gems/discourse_poll/Gemfile @@ -0,0 +1,13 @@ +source 'https://rubygems.org' + +group :test do + gem 'rails' + gem 'rspec' + gem 'mocha' +end + +# TODO: We need our own gem server +gem 'discourse_plugin', path: '../discourse_plugin' + +# Specify your gem's dependencies in rails_multisite.gemspec +gemspec diff --git a/vendor/gems/discourse_poll/Gemfile.lock b/vendor/gems/discourse_poll/Gemfile.lock new file mode 100644 index 00000000000..8f3075cc95d --- /dev/null +++ b/vendor/gems/discourse_poll/Gemfile.lock @@ -0,0 +1,111 @@ +PATH + remote: . + specs: + discourse_poll (0.0.1) + +PATH + remote: ../discourse_plugin + specs: + discourse_plugin (0.0.1) + +GEM + remote: https://rubygems.org/ + specs: + actionmailer (3.2.8) + actionpack (= 3.2.8) + mail (~> 2.4.4) + actionpack (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + builder (~> 3.0.0) + erubis (~> 2.7.0) + journey (~> 1.0.4) + rack (~> 1.4.0) + rack-cache (~> 1.2) + rack-test (~> 0.6.1) + sprockets (~> 2.1.3) + activemodel (3.2.8) + activesupport (= 3.2.8) + builder (~> 3.0.0) + activerecord (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + arel (~> 3.0.2) + tzinfo (~> 0.3.29) + activeresource (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + activesupport (3.2.8) + i18n (~> 0.6) + multi_json (~> 1.0) + arel (3.0.2) + builder (3.0.3) + diff-lcs (1.1.3) + erubis (2.7.0) + hike (1.2.1) + i18n (0.6.1) + journey (1.0.4) + json (1.7.5) + mail (2.4.4) + i18n (>= 0.4.0) + mime-types (~> 1.16) + treetop (~> 1.4.8) + metaclass (0.0.1) + mime-types (1.19) + mocha (0.12.7) + metaclass (~> 0.0.1) + multi_json (1.3.6) + polyglot (0.3.3) + rack (1.4.1) + rack-cache (1.2) + rack (>= 0.4) + rack-ssl (1.3.2) + rack + rack-test (0.6.2) + rack (>= 1.0) + rails (3.2.8) + actionmailer (= 3.2.8) + actionpack (= 3.2.8) + activerecord (= 3.2.8) + activeresource (= 3.2.8) + activesupport (= 3.2.8) + bundler (~> 1.0) + railties (= 3.2.8) + railties (3.2.8) + actionpack (= 3.2.8) + activesupport (= 3.2.8) + rack-ssl (~> 1.3.2) + rake (>= 0.8.7) + rdoc (~> 3.4) + thor (>= 0.14.6, < 2.0) + rake (0.9.2.2) + rdoc (3.12) + json (~> 1.4) + rspec (2.11.0) + rspec-core (~> 2.11.0) + rspec-expectations (~> 2.11.0) + rspec-mocks (~> 2.11.0) + rspec-core (2.11.1) + rspec-expectations (2.11.3) + diff-lcs (~> 1.1.3) + rspec-mocks (2.11.3) + sprockets (2.1.3) + hike (~> 1.2) + rack (~> 1.0) + tilt (~> 1.1, != 1.3.0) + thor (0.16.0) + tilt (1.3.3) + treetop (1.4.10) + polyglot + polyglot (>= 0.3.1) + tzinfo (0.3.33) + +PLATFORMS + ruby + +DEPENDENCIES + discourse_plugin! + discourse_poll! + mocha + rails + rspec diff --git a/vendor/gems/discourse_poll/LICENSE b/vendor/gems/discourse_poll/LICENSE new file mode 100644 index 00000000000..1561959f642 --- /dev/null +++ b/vendor/gems/discourse_poll/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2012 Robin Ward + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/vendor/gems/discourse_poll/README.md b/vendor/gems/discourse_poll/README.md new file mode 100644 index 00000000000..4ce99b594b6 --- /dev/null +++ b/vendor/gems/discourse_poll/README.md @@ -0,0 +1,3 @@ +# Discourse Poll Gem + +Include to give Discourse the ability to support polls. diff --git a/vendor/gems/discourse_poll/Rakefile b/vendor/gems/discourse_poll/Rakefile new file mode 100644 index 00000000000..ecd0cbf9288 --- /dev/null +++ b/vendor/gems/discourse_poll/Rakefile @@ -0,0 +1,7 @@ +#!/usr/bin/env rake +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:test) do |spec| + spec.pattern = 'spec/*_spec.rb' +end diff --git a/vendor/gems/discourse_poll/discourse_poll.gemspec b/vendor/gems/discourse_poll/discourse_poll.gemspec new file mode 100644 index 00000000000..4a024a83703 --- /dev/null +++ b/vendor/gems/discourse_poll/discourse_poll.gemspec @@ -0,0 +1,20 @@ +# -*- encoding: utf-8 -*- +require File.expand_path('../lib/discourse_poll/version', __FILE__) + +Gem::Specification.new do |gem| + gem.authors = ["Robin Ward"] + gem.email = ["robin.ward@gmail.com"] + gem.description = %q{TODO: Write a gem description} + gem.summary = %q{TODO: Write a gem summary} + gem.homepage = "" + + # when this is extracted comment it back in, prd has no .git + # gem.files = `git ls-files`.split($\) + gem.files = Dir['README*','LICENSE','lib/**/*.rb'] + + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + gem.name = "discourse_poll" + gem.require_paths = ["lib"] + gem.version = DiscoursePoll::VERSION +end diff --git a/vendor/gems/discourse_poll/lib/discourse_poll.rb b/vendor/gems/discourse_poll/lib/discourse_poll.rb new file mode 100644 index 00000000000..5126027de7c --- /dev/null +++ b/vendor/gems/discourse_poll/lib/discourse_poll.rb @@ -0,0 +1,5 @@ +require 'discourse_poll/version' +require 'discourse_poll/engine' if defined?(Rails) && (!Rails.env.test?) + + +I18n.load_path << "#{File.dirname(__FILE__)}/discourse_poll/locale/en.yml" \ No newline at end of file diff --git a/vendor/gems/discourse_poll/lib/discourse_poll/engine.rb b/vendor/gems/discourse_poll/lib/discourse_poll/engine.rb new file mode 100644 index 00000000000..c3036e0f642 --- /dev/null +++ b/vendor/gems/discourse_poll/lib/discourse_poll/engine.rb @@ -0,0 +1,21 @@ +require 'discourse_poll/plugin' + +module DiscoursePoll + class Engine < Rails::Engine + + engine_name 'discourse_poll' + + initializer "discourse_poll.configure_rails_initialization" do |app| + + app.config.after_initialize do + DiscoursePluginRegistry.setup(DiscoursePoll::Plugin) + end + + app.config.to_prepare do + DiscoursePoll::Plugin.include_mixins + end + + end + + end +end \ No newline at end of file diff --git a/vendor/gems/discourse_poll/lib/discourse_poll/locale/en.yml b/vendor/gems/discourse_poll/lib/discourse_poll/locale/en.yml new file mode 100644 index 00000000000..6f8a86754f0 --- /dev/null +++ b/vendor/gems/discourse_poll/lib/discourse_poll/locale/en.yml @@ -0,0 +1,47 @@ +en: + + js: + poll: + title: "Poll" + description: "This topic is a poll. Vote for posts by clicking the vote button. Posts with the most votes will rise to the top!" + + vote: + voted: 'voted' + title: 'vote' + help: 'vote for this post' + who_voted: 'click to see who voted for this option' + cant: "Sorry, you've already voted." + not_logged_in: "You must be logged in to vote." + + topic_statuses: + poll: + help: "this topic is a poll" + + topic: + reply: + poll: "Add Poll Option" + + post: + archetypes: + poll: + public: "Other users can see what you voted for." + private: "Your vote will be kept private." + single_vote: "You may only vote once." + many_votes: "You may vote for as many options as you like." + + post_action_types: + vote: + title: 'Vote' + description: 'Vote for this post' + long_form: 'voted for this post' + + archetypes: + poll: + title: "Poll Topic" + options: + single_vote: + title: "Only allow one vote" + description: "A user may only vote on one post." + private_poll: + title: "Voting is Private" + description: "Hide who voted for what choice." \ No newline at end of file diff --git a/vendor/gems/discourse_poll/lib/discourse_poll/plugin.rb b/vendor/gems/discourse_poll/lib/discourse_poll/plugin.rb new file mode 100644 index 00000000000..8f907c7f59f --- /dev/null +++ b/vendor/gems/discourse_poll/lib/discourse_poll/plugin.rb @@ -0,0 +1,49 @@ +require 'discourse_plugin' + +module DiscoursePoll + class Plugin < DiscoursePlugin + + MAX_SORT_ORDER = 2147483647 + POLL_OPTIONS = {private_poll: 1, single_vote: 1} + + def setup + + # Add our Assets + register_js('discourse_poll') + register_css('discourse_poll') + + # Create the poll archetype + register_archetype('poll', POLL_OPTIONS) + + # Callbacks + listen_for(:before_create_post) + end + + # Callbacks below + def before_create_post(post) + return unless post.archetype == 'poll' + if post.post_number == 1 + post.sort_order = 1 + else + post.sort_order = DiscoursePoll::Plugin::MAX_SORT_ORDER + end + end + + module TopicViewSerializerMixin + + def self.included(base) + base.attributes :private_poll, :single_vote + end + + def private_poll + object.topic.has_meta_data_boolean?(:private_poll) + end + + def single_vote + object.topic.has_meta_data_boolean?(:single_vote) + end + + end + + end +end diff --git a/vendor/gems/discourse_poll/lib/discourse_poll/version.rb b/vendor/gems/discourse_poll/lib/discourse_poll/version.rb new file mode 100644 index 00000000000..2540fa414ee --- /dev/null +++ b/vendor/gems/discourse_poll/lib/discourse_poll/version.rb @@ -0,0 +1,3 @@ +module DiscoursePoll + VERSION = "0.0.1" +end diff --git a/vendor/gems/discourse_poll/spec/plugin_spec.rb b/vendor/gems/discourse_poll/spec/plugin_spec.rb new file mode 100644 index 00000000000..d93358f4a24 --- /dev/null +++ b/vendor/gems/discourse_poll/spec/plugin_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' +require 'discourse_poll/plugin' +require 'ostruct' + +describe DiscoursePoll::Plugin do + + let(:registry) { stub_everything } + let(:plugin) { DiscoursePoll::Plugin.new(registry) } + + context '.setup' do + + it 'registers its js' do + plugin.expects(:register_js) + plugin.setup + end + + it 'registers its css' do + plugin.expects(:register_css) + plugin.setup + end + + it 'registers a poll archetype' do + plugin.expects(:register_archetype).with('poll', DiscoursePoll::Plugin::POLL_OPTIONS) + plugin.setup + end + + it 'registers a handler on post_create' do + plugin.expects(:listen_for).with(:before_create_post) + plugin.setup + end + end + + + context ".before_create_post" do + + context 'without a poll' do + let(:post) { OpenStruct.new(archetype: 'something-else', post_number: 1000) } + + it "doesn't set the sort order" do + plugin.before_create_post(post) + post.sort_order.should_not == DiscoursePoll::Plugin::MAX_SORT_ORDER + end + + end + + context 'with a poll' do + let(:post) { OpenStruct.new(archetype: 'poll') } + + it 'sets the sort order to 1 when the post_number is 1' do + post.post_number = 1 + plugin.before_create_post(post) + post.sort_order.should == 1 + end + + it 'sets the sort order to MAX_SORT_ORDER when the post_number is not 1' do + post.post_number = 1000 + plugin.before_create_post(post) + post.sort_order.should == DiscoursePoll::Plugin::MAX_SORT_ORDER + end + + end + + end + + +end diff --git a/vendor/gems/discourse_poll/spec/spec_helper.rb b/vendor/gems/discourse_poll/spec/spec_helper.rb new file mode 100644 index 00000000000..820326fdff5 --- /dev/null +++ b/vendor/gems/discourse_poll/spec/spec_helper.rb @@ -0,0 +1,13 @@ +require 'rubygems' +require 'rails' + +ENV["RAILS_ENV"] ||= 'test' + +RSpec.configure do |config| + + config.mock_framework = :mocha + config.color_enabled = true + +end + + diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll.js b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll.js new file mode 100644 index 00000000000..7fd6350629f --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll.js @@ -0,0 +1,3 @@ +//= require_tree ./discourse_poll + + diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/models/post.js b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/models/post.js new file mode 100644 index 00000000000..d50ef8be8e9 --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/models/post.js @@ -0,0 +1,45 @@ +(function() { + window.Discourse.Post.reopen({ + + voteAction: function () { + return this.get('actionByName.vote'); + }.property('actionByName.vote'), + + // We never show "replies below" for polls. + replyBelowUrl: function() { + if (this.get('topic.archetype') === 'poll') return null; + return this.get('replyBelowUrlComputed'); + }.property('replyBelowUrlComputed', 'topic.archetype'), + + // Vote for this post + vote: function() { + voteType = Discourse.get('site.post_action_types').findProperty('name_key', 'vote'); + this.get('voteAction').act(); + Em.run.next(function () { + this.set('topic.voted_in_topic', true); + }.bind(this)); + return false; + }, + + cantVote: function() { + + if (!Discourse.get('currentUser')) { + bootbox.alert(Em.String.i18n('vote.not_logged_in')); + return false; + } + + bootbox.alert(Em.String.i18n('vote.cant')); + return false; + }, + + undoVote: function() { + voteType = Discourse.get('site.post_action_types').findProperty('name_key', 'vote'); + this.get('voteAction').undo(); + Em.run.next(function () { + this.set('topic.voted_in_topic', false); + }.bind(this)); + return false; + } + + }); +}).call(this); diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/models/post_action_type.js b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/models/post_action_type.js new file mode 100644 index 00000000000..1212dc90389 --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/models/post_action_type.js @@ -0,0 +1,9 @@ +(function() { + Discourse.PostActionType.reopen({ + + isVote: function() { + return (this.get('name_key') === 'vote'); + }.property('name_key') + + }); +}).call(this); \ No newline at end of file diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/templates/about_poll.js.handlebars b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/templates/about_poll.js.handlebars new file mode 100644 index 00000000000..8d6a4aeb489 --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/templates/about_poll.js.handlebars @@ -0,0 +1,15 @@ +

            {{i18n poll.title}}

            +

            {{{i18n poll.description}}}

            + +
              +{{#if controller.content.private_poll}} +
            • {{i18n post.archetypes.poll.private}}
            • +{{else}} +
            • {{i18n post.archetypes.poll.public}}
            • +{{/if}} +{{#if controller.content.single_vote}} +
            • {{i18n post.archetypes.poll.single_vote}}
            • +{{else}} +
            • {{i18n post.archetypes.poll.many_votes}}
            • +{{/if}} +
            diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/templates/poll_controls.js.handlebars b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/templates/poll_controls.js.handlebars new file mode 100644 index 00000000000..5373c94f1c3 --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/templates/poll_controls.js.handlebars @@ -0,0 +1,17 @@ +{{#if view.showVotes}} + {{#if view.canSeeWhoVoted}} + {{view.post.voteAction.count}} + {{else}} +
            {{view.post.voteAction.count}}
            + {{/if}} + + {{#if view.showVoteControls}} + + {{else}} + + {{/if}} + + {{#if view.post.voteAction.can_undo}} + undo + {{/if}} +{{/if}} \ No newline at end of file diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/post_view.js b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/post_view.js new file mode 100644 index 00000000000..dda6d6366d2 --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/post_view.js @@ -0,0 +1,18 @@ +(function() { + window.Discourse.PostView.reopen({ + + extraClass: function() { + if (this.get('showVotes')) return 'votes'; + return null; + }.property('showVotes'), + + showVotes: function() { + var post = this.get('post'); + if (post.get('post_number') === 1) return; + if (post.get('post_type') !== Discourse.Post.REGULAR_TYPE) return; + if (post.get('reply_to_post_number')) return; + return (post.get('topic.archetype') === 'poll'); + }.property('post.post_number', 'post.post_type', 'post.reply_to_post_number') + + }) +}).call(this); \ No newline at end of file diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/prepend_post_view.js b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/prepend_post_view.js new file mode 100644 index 00000000000..e9349a5b69e --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/prepend_post_view.js @@ -0,0 +1,12 @@ +(function() { + + Discourse.PrependPostView.prototype.on("prependPostContent", function(event) { + + // Append our template for the poll controls + if (this.get('controller.content.archetype') == 'poll') { + this.get('childViews').pushObject(Discourse.VoteControlsView.create()); + } + + }); + +}).call(this); \ No newline at end of file diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/topic_footer_buttons_view.js b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/topic_footer_buttons_view.js new file mode 100644 index 00000000000..fc47b75e9d3 --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/topic_footer_buttons_view.js @@ -0,0 +1,9 @@ +(function() { + window.Discourse.TopicFooterButtonsView.reopen({ + + replyButtonTextPoll: function() { + return Em.String.i18n("topic.reply.poll"); + }.property() + + }); +}).call(this); \ No newline at end of file diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/topic_information_view.js b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/topic_information_view.js new file mode 100644 index 00000000000..6be29a1ea0d --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/topic_information_view.js @@ -0,0 +1,14 @@ +(function() { + + Discourse.TopicSummaryView.prototype.on("appendSummaryInformation", function(childViews) { + // Add the poll information + if (this.get('topic.archetype') === 'poll') { + childViews.pushObject(Em.View.create({ + tagName: 'section', + classNames: ['information'], + templateName: 'discourse_poll/templates/about_poll' + })); + } + }); + +}).call(this); \ No newline at end of file diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/topic_status_view.js b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/topic_status_view.js new file mode 100644 index 00000000000..a195ddcfb11 --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/topic_status_view.js @@ -0,0 +1,12 @@ +(function() { + + Discourse.TopicStatusView.prototype.on("addCustomIcon", function(buffer) { + + // Add check icon for polls + if (this.get('topic.archetype') === 'poll') { + this.renderIcon(buffer, 'check-empty', 'poll'); + } + + }); + +}).call(this); \ No newline at end of file diff --git a/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/vote_controls_view.js b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/vote_controls_view.js new file mode 100644 index 00000000000..58b21888123 --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/javascripts/discourse_poll/views/vote_controls_view.js @@ -0,0 +1,41 @@ +(function() { + window.Discourse.VoteControlsView = Em.View.extend({ + templateName: 'discourse_poll/templates/poll_controls', + classNameBindings: ['pollControlsClass'], + postBinding: 'parentView.post', + showVotesBinding: 'parentView.parentView.showVotes', + + canSeeWhoVoted: function() { + if (this.get('post.voteAction.count') === 0) return false; + return !this.get('controller.content.private_poll'); + }.property('post.voteAction.count'), + + showVoteControls: function() { + if (!Discourse.get('currentUser')) return false; + if (this.get('post.post_number') === 1) return; + if (this.get('post.topic.single_vote') && this.get('post.topic.voted_in_topic')) return false; + if (this.get('post.topic.archived')) return false; + return true; + }.property('post.post_number', 'post.topic.archived', 'post.topic.single_vote', 'post.topic.voted_in_topic'), + + pollControlsClass: function() { + if (this.get('post.post_number') === 1) return; + if (this.get('post.reply_to_post_number')) return; + return 'poll-controls'; + }.property('showVoteControls'), + + canUndo: function() { + return true; + }.property(), + + voteDisabled: function() { + return !this.get('post.voteAction.can_act'); + }.property('post.voteAction.can_act'), + + voteButtonText: function() { + if (!this.get('post.voteAction.can_act')) return Em.String.i18n("vote.voted"); + return Em.String.i18n("vote.title"); + }.property('post.voteAction.can_act') + + }) +}).call(this); \ No newline at end of file diff --git a/vendor/gems/discourse_poll/vendor/assets/stylesheets/discourse_poll.css.sass b/vendor/gems/discourse_poll/vendor/assets/stylesheets/discourse_poll.css.sass new file mode 100644 index 00000000000..3d3741b3555 --- /dev/null +++ b/vendor/gems/discourse_poll/vendor/assets/stylesheets/discourse_poll.css.sass @@ -0,0 +1,53 @@ +#main + .votes + aside + margin-left: 55px !important + blockquote + margin-left: 0 + + blockquote + margin-left: 55px + .onebox-result + margin-left: 55px + + .topic-body + .contents.votes + min-height: 120px !important + + .poll-controls + width: 50px + float: left + height: 100% + margin-right: 5px + padding-top: 10px + text-align: center + + .undo + font-size: 12px + + .total + color: #000 + font-size: 30px + line-height: 20px + text-decoration: none + margin: 0 + padding: 0 + + .total.chosen + color: #0f0 + + button + margin: 10px 0 0 0 + font-size: 14px + padding: 0 3px + + i + font-size: 17px + margin: 4px 0 0 0 + padding: 0 + display: inline-block + + &:disabled + background-color: #070 + color: #fff + text-shadow: none !important \ No newline at end of file diff --git a/vendor/gems/discourse_task/Gemfile b/vendor/gems/discourse_task/Gemfile new file mode 100644 index 00000000000..fcb7fdc2990 --- /dev/null +++ b/vendor/gems/discourse_task/Gemfile @@ -0,0 +1,13 @@ +source 'https://rubygems.org' + +group :test do + gem 'rails' + gem 'rspec' + gem 'mocha' +end + +# TODO: We need our own gem server +gem 'discourse_plugin', path: '../discourse_plugin' + +# Specify your gem's dependencies in rails_multisite.gemspec +gemspec diff --git a/vendor/gems/discourse_task/Gemfile.lock b/vendor/gems/discourse_task/Gemfile.lock new file mode 100644 index 00000000000..7c7324ecc75 --- /dev/null +++ b/vendor/gems/discourse_task/Gemfile.lock @@ -0,0 +1,111 @@ +PATH + remote: . + specs: + discourse_task (0.0.1) + +PATH + remote: ../discourse_plugin + specs: + discourse_plugin (0.0.1) + +GEM + remote: https://rubygems.org/ + specs: + actionmailer (3.2.8) + actionpack (= 3.2.8) + mail (~> 2.4.4) + actionpack (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + builder (~> 3.0.0) + erubis (~> 2.7.0) + journey (~> 1.0.4) + rack (~> 1.4.0) + rack-cache (~> 1.2) + rack-test (~> 0.6.1) + sprockets (~> 2.1.3) + activemodel (3.2.8) + activesupport (= 3.2.8) + builder (~> 3.0.0) + activerecord (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + arel (~> 3.0.2) + tzinfo (~> 0.3.29) + activeresource (3.2.8) + activemodel (= 3.2.8) + activesupport (= 3.2.8) + activesupport (3.2.8) + i18n (~> 0.6) + multi_json (~> 1.0) + arel (3.0.2) + builder (3.0.3) + diff-lcs (1.1.3) + erubis (2.7.0) + hike (1.2.1) + i18n (0.6.1) + journey (1.0.4) + json (1.7.5) + mail (2.4.4) + i18n (>= 0.4.0) + mime-types (~> 1.16) + treetop (~> 1.4.8) + metaclass (0.0.1) + mime-types (1.19) + mocha (0.12.7) + metaclass (~> 0.0.1) + multi_json (1.3.6) + polyglot (0.3.3) + rack (1.4.1) + rack-cache (1.2) + rack (>= 0.4) + rack-ssl (1.3.2) + rack + rack-test (0.6.2) + rack (>= 1.0) + rails (3.2.8) + actionmailer (= 3.2.8) + actionpack (= 3.2.8) + activerecord (= 3.2.8) + activeresource (= 3.2.8) + activesupport (= 3.2.8) + bundler (~> 1.0) + railties (= 3.2.8) + railties (3.2.8) + actionpack (= 3.2.8) + activesupport (= 3.2.8) + rack-ssl (~> 1.3.2) + rake (>= 0.8.7) + rdoc (~> 3.4) + thor (>= 0.14.6, < 2.0) + rake (0.9.2.2) + rdoc (3.12) + json (~> 1.4) + rspec (2.11.0) + rspec-core (~> 2.11.0) + rspec-expectations (~> 2.11.0) + rspec-mocks (~> 2.11.0) + rspec-core (2.11.1) + rspec-expectations (2.11.3) + diff-lcs (~> 1.1.3) + rspec-mocks (2.11.3) + sprockets (2.1.3) + hike (~> 1.2) + rack (~> 1.0) + tilt (~> 1.1, != 1.3.0) + thor (0.16.0) + tilt (1.3.3) + treetop (1.4.10) + polyglot + polyglot (>= 0.3.1) + tzinfo (0.3.33) + +PLATFORMS + ruby + +DEPENDENCIES + discourse_plugin! + discourse_task! + mocha + rails + rspec diff --git a/vendor/gems/discourse_task/LICENSE b/vendor/gems/discourse_task/LICENSE new file mode 100644 index 00000000000..1561959f642 --- /dev/null +++ b/vendor/gems/discourse_task/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2012 Robin Ward + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/vendor/gems/discourse_task/README.md b/vendor/gems/discourse_task/README.md new file mode 100644 index 00000000000..287c39407f0 --- /dev/null +++ b/vendor/gems/discourse_task/README.md @@ -0,0 +1,3 @@ +# Discourse Task Gem + +Add support for a topic that can be marked as "Closed." diff --git a/vendor/gems/discourse_task/Rakefile b/vendor/gems/discourse_task/Rakefile new file mode 100644 index 00000000000..ecd0cbf9288 --- /dev/null +++ b/vendor/gems/discourse_task/Rakefile @@ -0,0 +1,7 @@ +#!/usr/bin/env rake +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:test) do |spec| + spec.pattern = 'spec/*_spec.rb' +end diff --git a/vendor/gems/discourse_task/config/routes.rb b/vendor/gems/discourse_task/config/routes.rb new file mode 100644 index 00000000000..94c6aa8b117 --- /dev/null +++ b/vendor/gems/discourse_task/config/routes.rb @@ -0,0 +1,5 @@ +Rails.application.routes.draw do + + put 't/:slug/:topic_id/complete' => 'topics#complete', :constraints => {:topic_id => /\d+/} + +end \ No newline at end of file diff --git a/vendor/gems/discourse_task/discourse_task.gemspec b/vendor/gems/discourse_task/discourse_task.gemspec new file mode 100644 index 00000000000..a837093c424 --- /dev/null +++ b/vendor/gems/discourse_task/discourse_task.gemspec @@ -0,0 +1,19 @@ +# -*- encoding: utf-8 -*- +require File.expand_path('../lib/discourse_task/version', __FILE__) + +Gem::Specification.new do |gem| + gem.authors = ["Robin Ward"] + gem.email = ["robin.ward@gmail.com"] + gem.description = %q{This gem add a Task archetype to discourse that can be closed} + gem.summary = %q{This gem add a Task archetype to discourse that can be closed} + gem.homepage = "" + + # when this is extracted comment it back in, prd has no .git + gem.files = Dir['README*','LICENSE','lib/**/*.rb'] + + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + gem.name = "discourse_task" + gem.require_paths = ["lib"] + gem.version = DiscourseTask::VERSION +end diff --git a/vendor/gems/discourse_task/lib/discourse_task.rb b/vendor/gems/discourse_task/lib/discourse_task.rb new file mode 100644 index 00000000000..259afff9a37 --- /dev/null +++ b/vendor/gems/discourse_task/lib/discourse_task.rb @@ -0,0 +1,4 @@ +require 'discourse_task/version' +require 'discourse_task/engine' if defined?(Rails) && (!Rails.env.test?) + +I18n.load_path << "#{File.dirname(__FILE__)}/discourse_task/locale/en.yml" \ No newline at end of file diff --git a/vendor/gems/discourse_task/lib/discourse_task/engine.rb b/vendor/gems/discourse_task/lib/discourse_task/engine.rb new file mode 100644 index 00000000000..11b133ecd3e --- /dev/null +++ b/vendor/gems/discourse_task/lib/discourse_task/engine.rb @@ -0,0 +1,20 @@ +require 'discourse_task/plugin' + +module DiscourseTask + class Engine < Rails::Engine + + engine_name 'discourse_task' + + initializer "discourse_task.configure_rails_initialization" do |app| + + app.config.after_initialize do + DiscoursePluginRegistry.setup(DiscourseTask::Plugin) + end + + app.config.to_prepare do + DiscourseTask::Plugin.include_mixins + end + end + + end +end \ No newline at end of file diff --git a/vendor/gems/discourse_task/lib/discourse_task/locale/en.yml b/vendor/gems/discourse_task/lib/discourse_task/locale/en.yml new file mode 100644 index 00000000000..c12113f1951 --- /dev/null +++ b/vendor/gems/discourse_task/lib/discourse_task/locale/en.yml @@ -0,0 +1,21 @@ +en: + archetypes: + task: + title: "Task" + + task: + completed: "This task has been marked as complete." + reversed: "This task has been re-opened." + + js: + topic_statuses: + task: + help: "this topic is a task" + + task: + title: "Task" + description: "This topic can be marked as complete by its creator or a moderator." + complete_action: "Complete Task" + complete_help: "mark this task as complete" + complete: "This task was marked as complete on {{completed_at}}." + reverse: "Undo Complete" diff --git a/vendor/gems/discourse_task/lib/discourse_task/plugin.rb b/vendor/gems/discourse_task/lib/discourse_task/plugin.rb new file mode 100644 index 00000000000..5ce2fd98326 --- /dev/null +++ b/vendor/gems/discourse_task/lib/discourse_task/plugin.rb @@ -0,0 +1,94 @@ +require 'discourse_plugin' + +module DiscourseTask + + class Plugin < DiscoursePlugin + + def self.archetype + 'task' + end + + def setup + # Add our Assets + register_js('discourse_task') + register_css('discourse_task') + + # Add the archetype + register_archetype(DiscourseTask::Plugin.archetype) + + end + + module TopicViewSerializerMixin + def self.included(base) + base.attributes :can_complete_task, :complete, :completed_at + end + + def can_complete_task + scope.can_complete_task?(object.topic) + end + + def complete + object.topic.has_meta_data_boolean?(:complete) + end + + def completed_at + dt = Date.parse(object.topic.meta_data_string(:completed_at)).strftime("%d %b, %Y") + end + def include_completed_at? + object.topic.meta_data_string(:completed_at).present? + end + + end + + module GuardianMixin + + # We need to be able to determine if a user can complete a task + def can_complete_task?(topic) + return false if @user.blank? + return false if topic.blank? + return false unless topic.archetype == DiscourseTask::Plugin.archetype + return true if @user.moderator? + return true if @user.admin? + + # The OP can complete the topic + return @user == topic.user + end + + end + + module TopicsControllerMixin + + def complete + topic = Topic.where(id: params[:topic_id]).first + guardian.ensure_can_complete_task!(topic) + + Topic.transaction do + if params[:complete] == 'true' + topic.update_meta_data(complete: true, completed_at: Time.now) + topic.add_moderator_post(current_user, I18n.t(:'task.completed')) + else + topic.update_meta_data(complete: false) + topic.add_moderator_post(current_user, I18n.t(:'task.reversed')) + end + + end + + + render nothing: true + end + + end + + module TopicListItemSerializerMixin + def self.included(base) + base.attributes :complete + end + + def complete + object.has_meta_data_boolean?(:complete) + end + end + + end + +end diff --git a/vendor/gems/discourse_task/lib/discourse_task/version.rb b/vendor/gems/discourse_task/lib/discourse_task/version.rb new file mode 100644 index 00000000000..d60d3ac4fa9 --- /dev/null +++ b/vendor/gems/discourse_task/lib/discourse_task/version.rb @@ -0,0 +1,3 @@ +module DiscourseTask + VERSION = "0.0.1" +end diff --git a/vendor/gems/discourse_task/spec/plugin_spec.rb b/vendor/gems/discourse_task/spec/plugin_spec.rb new file mode 100644 index 00000000000..8dacd2cffe4 --- /dev/null +++ b/vendor/gems/discourse_task/spec/plugin_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' +require 'discourse_task/plugin' + +describe DiscourseTask::Plugin do + + let(:registry) { stub_everything } + let(:plugin) { DiscourseTask::Plugin.new(registry) } + + context '.setup' do + + it 'registers its js' do + plugin.expects(:register_js).with('discourse_task') + plugin.setup + end + + it 'registers its css' do + plugin.expects(:register_css).with('discourse_task') + plugin.setup + end + + it 'registers a task archetype' do + plugin.expects(:register_archetype).with('task') + plugin.setup + end + + end + +end diff --git a/vendor/gems/discourse_task/spec/spec_helper.rb b/vendor/gems/discourse_task/spec/spec_helper.rb new file mode 100644 index 00000000000..820326fdff5 --- /dev/null +++ b/vendor/gems/discourse_task/spec/spec_helper.rb @@ -0,0 +1,13 @@ +require 'rubygems' +require 'rails' + +ENV["RAILS_ENV"] ||= 'test' + +RSpec.configure do |config| + + config.mock_framework = :mocha + config.color_enabled = true + +end + + diff --git a/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task.js b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task.js new file mode 100644 index 00000000000..565bbd39256 --- /dev/null +++ b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task.js @@ -0,0 +1 @@ +//= require_tree ./discourse_task \ No newline at end of file diff --git a/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/controllers/topic_controller.js b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/controllers/topic_controller.js new file mode 100644 index 00000000000..fe69294689b --- /dev/null +++ b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/controllers/topic_controller.js @@ -0,0 +1,15 @@ +(function() { + + Discourse.TopicController.reopen({ + + // Allow the user to complete the task + completeTask: function(e) { + this.get('content').toggleComplete(); + return false; + } + + }) + +}).call(this); + + diff --git a/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/models/topic.js b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/models/topic.js new file mode 100644 index 00000000000..fb018ecad2c --- /dev/null +++ b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/models/topic.js @@ -0,0 +1,23 @@ +(function() { + + Discourse.Topic.reopen({ + + // Allow the user to complete the task + toggleComplete: function() { + this.toggleProperty('complete'); + this.set('completed_at', Date.create().format("{d} {Mon}, {yyyy}")); + + jQuery.ajax(this.get('url') + "/complete", { + type: 'PUT', + data: { + complete: this.get('complete') ? 'true' : 'false' + } + + }); + } + + }) + +}).call(this); + + diff --git a/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/templates/about_task.js.handlebars b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/templates/about_task.js.handlebars new file mode 100644 index 00000000000..55dca03e300 --- /dev/null +++ b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/templates/about_task.js.handlebars @@ -0,0 +1,15 @@ +

            {{i18n task.title}}

            +

            {{{i18n task.description}}}

            + +{{#if controller.content.complete}} +

            {{i18n task.complete completed_at="controller.content.completed_at"}}

            + + {{#if controller.content.can_complete_task}} + + {{/if}} +{{else}} + {{#if controller.content.can_complete_task}} + + {{/if}} +{{/if}} + diff --git a/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/views/topic_footer_buttons_view.js b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/views/topic_footer_buttons_view.js new file mode 100644 index 00000000000..37e0c83eaf6 --- /dev/null +++ b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/views/topic_footer_buttons_view.js @@ -0,0 +1,40 @@ +(function() { + + Discourse.TopicFooterButtonsView.prototype.on("additionalButtons", function(childViews) { + var topic = this.get('topic'); + if (topic.get('archetype') == 'task' && topic.get('can_complete_task')) { + + // If we can complete the task: + childViews.addObject(Discourse.ButtonView.createWithMixins({ + + completeBinding: 'controller.content.complete', + + completeChanged: function () { + this.rerender(); + }.observes('complete'), + + renderIcon: function (buffer) { + if (!this.get('complete')) { + buffer.push("") + } + }, + + text: function () { + if (this.get('complete')) { + return Em.String.i18n("task.reverse"); + } else { + return Em.String.i18n("task.complete_action"); + } + }.property('complete'), + + click: function(e) { + this.get('controller').completeTask(); + } + })); + + } + }); + +}).call(this); + + diff --git a/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/views/topic_status_view.js b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/views/topic_status_view.js new file mode 100644 index 00000000000..eab0208373d --- /dev/null +++ b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/views/topic_status_view.js @@ -0,0 +1,21 @@ +(function() { + + Discourse.TopicStatusView.prototype.on("addCustomIcon", function(buffer) { + + // Add icon for closed tasks + var topic = this.get('topic'); + if (topic.get('archetype') === 'task') { + var icon = 'cog'; + if (topic.get('complete')) icon = 'ok'; + this.renderIcon(buffer, icon, 'task'); + } + + }); + + Discourse.TopicStatusView.reopen({ + taskComplete: function() { + this.rerender(); + }.observes('topic.complete') + }) + +}).call(this); \ No newline at end of file diff --git a/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/views/topic_summary_view.js b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/views/topic_summary_view.js new file mode 100644 index 00000000000..b1411ef81cf --- /dev/null +++ b/vendor/gems/discourse_task/vendor/assets/javascripts/discourse_task/views/topic_summary_view.js @@ -0,0 +1,14 @@ +(function() { + + Discourse.TopicSummaryView.prototype.on("appendSummaryInformation", function(childViews) { + // Add the poll information + if (this.get('topic.archetype') === 'task') { + childViews.pushObject(Discourse.View.create({ + tagName: 'section', + classNames: ['information'], + templateName: 'discourse_task/templates/about_task' + })); + } + }); + +}).call(this); \ No newline at end of file diff --git a/vendor/gems/discourse_task/vendor/assets/stylesheets/discourse_task.css.sass b/vendor/gems/discourse_task/vendor/assets/stylesheets/discourse_task.css.sass new file mode 100644 index 00000000000..63848297d92 --- /dev/null +++ b/vendor/gems/discourse_task/vendor/assets/stylesheets/discourse_task.css.sass @@ -0,0 +1,15 @@ +.complete-topic + background-color: #eee + padding: 5px + margin-bottom: 5px + font-size: 13px + clear: both + height: 30px + border-radius: 5px + + p + float: left + padding: 5px 0 0 3px + + button + float: right diff --git a/vendor/gems/message_bus/.gitignore b/vendor/gems/message_bus/.gitignore new file mode 100644 index 00000000000..d87d4be66f4 --- /dev/null +++ b/vendor/gems/message_bus/.gitignore @@ -0,0 +1,17 @@ +*.gem +*.rbc +.bundle +.config +.yardoc +Gemfile.lock +InstalledFiles +_yardoc +coverage +doc/ +lib/bundler/man +pkg +rdoc +spec/reports +test/tmp +test/version_tmp +tmp diff --git a/vendor/gems/message_bus/Gemfile b/vendor/gems/message_bus/Gemfile new file mode 100644 index 00000000000..7fca62e6f46 --- /dev/null +++ b/vendor/gems/message_bus/Gemfile @@ -0,0 +1,16 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in message_bus.gemspec +gemspec + +group :test do + gem 'rspec' + gem 'ZenTest' + gem 'autotest' + gem 'redis' + gem 'rake' + gem 'guard-rspec' + gem 'rb-inotify' + gem 'rack' + gem "rack-test", require: "rack/test" +end diff --git a/vendor/gems/message_bus/Guardfile b/vendor/gems/message_bus/Guardfile new file mode 100644 index 00000000000..9426a7dce88 --- /dev/null +++ b/vendor/gems/message_bus/Guardfile @@ -0,0 +1,7 @@ +guard 'rspec', :focus_on_failed => true do + watch(%r{^spec/.+_spec\.rb$}) + watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } + watch('spec/spec_helper.rb') { "spec" } +end + + diff --git a/vendor/gems/message_bus/LICENSE b/vendor/gems/message_bus/LICENSE new file mode 100644 index 00000000000..f3d7f653bb5 --- /dev/null +++ b/vendor/gems/message_bus/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2012 Sam Saffron + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/vendor/gems/message_bus/README.md b/vendor/gems/message_bus/README.md new file mode 100644 index 00000000000..a9308a20b92 --- /dev/null +++ b/vendor/gems/message_bus/README.md @@ -0,0 +1,27 @@ +# MessageBus + +This is an extraction of the MessageBus used at discourse. + +## Installation + +Add this line to your application's Gemfile: + + gem 'message_bus' + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install message_bus + +## Usage + +## Contributing + +1. Fork it +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Added some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create new Pull Request diff --git a/vendor/gems/message_bus/Rakefile b/vendor/gems/message_bus/Rakefile new file mode 100644 index 00000000000..a56caaa20db --- /dev/null +++ b/vendor/gems/message_bus/Rakefile @@ -0,0 +1,14 @@ +require 'rubygems' +require 'bundler' +require 'bundler/gem_tasks' +require 'bundler/setup' + +Bundler.require(:default, :test) + +task :default => [:spec] + +require 'rspec/core' +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new(:spec) do |spec| + spec.pattern = FileList['spec/**/*_spec.rb'] +end diff --git a/vendor/gems/message_bus/autotest/discover.rb b/vendor/gems/message_bus/autotest/discover.rb new file mode 100644 index 00000000000..cd6892ccbf7 --- /dev/null +++ b/vendor/gems/message_bus/autotest/discover.rb @@ -0,0 +1 @@ +Autotest.add_discovery { "rspec2" } diff --git a/vendor/gems/message_bus/lib/message_bus.rb b/vendor/gems/message_bus/lib/message_bus.rb new file mode 100644 index 00000000000..7b3fefc09df --- /dev/null +++ b/vendor/gems/message_bus/lib/message_bus.rb @@ -0,0 +1,240 @@ +# require 'thin' +# require 'eventmachine' +# require 'rack' +# require 'redis' + +require "message_bus/version" +require "message_bus/message" +require "message_bus/reliable_pub_sub" +require "message_bus/client" +require "message_bus/connection_manager" +require "message_bus/message_handler" +require "message_bus/rack/middleware" + +# we still need to take care of the logger +if defined?(::Rails) + require 'message_bus/rails/railtie' +end + +module MessageBus; end +module MessageBus::Implementation + + def logger=(logger) + @logger = logger + end + + def logger + return @logger if @logger + require 'logger' + @logger = Logger.new(STDOUT) + end + + def sockets_enabled? + @sockets_enabled == false ? false : true + end + + def sockets_enabled=(val) + @sockets_enabled = val + end + + def long_polling_enabled? + @long_polling_enabled == false ? false : true + end + + def long_polling_enabled=(val) + @long_polling_enabled = val + end + + def long_polling_interval=(millisecs) + @long_polling_interval = millisecs + end + + def long_polling_interval + @long_polling_interval || 30 * 1000 + end + + def off + @off = true + end + + def on + @off = false + end + + # Allow us to inject a redis db + def redis_config=(config) + @redis_config = config + end + + def redis_config + @redis_config ||= {} + end + + def site_id_lookup(&blk) + @site_id_lookup ||= blk + end + + def user_id_lookup(&blk) + @user_id_lookup ||= blk + end + + def on_connect(&blk) + @on_connect ||= blk + end + + def on_disconnect(&blk) + @on_disconnect ||= blk + end + + def allow_broadcast=(val) + @allow_broadcast = val + end + + def allow_broadcast? + @allow_broadcast ||= + if defined? ::Rails + ::Rails.env.test? || ::Rails.env.development? + else + false + end + end + + def reliable_pub_sub + @reliable_pub_sub ||= MessageBus::ReliablePubSub.new redis_config + end + + def publish(channel, data, opts = nil) + return if @off + + user_ids = nil + if opts + user_ids = opts[:user_ids] if opts + end + + encoded_data = JSON.dump({ + data: data, + user_ids: user_ids + }) + + reliable_pub_sub.publish(encode_channel_name(channel), encoded_data) + end + + def blocking_subscribe(channel=nil, &blk) + if channel + reliable_pub_sub.subscribe(encode_channel_name(channel), &blk) + else + reliable_pub_sub.global_subscribe(&blk) + end + end + + ENCODE_SITE_TOKEN = "$|$" + + # encode channel name to include site + def encode_channel_name(channel) + if MessageBus.site_id_lookup + raise ArgumentError.new channel if channel.include? ENCODE_SITE_TOKEN + "#{channel}#{ENCODE_SITE_TOKEN}#{MessageBus.site_id_lookup.call}" + else + channel + end + end + + def decode_channel_name(channel) + channel.split(ENCODE_SITE_TOKEN) + end + + def subscribe(channel=nil, &blk) + subscribe_impl(channel, nil, &blk) + end + + # subscribe only on current site + def local_subscribe(channel=nil, &blk) + site_id = MessageBus.site_id_lookup.call if MessageBus.site_id_lookup + subscribe_impl(channel, site_id, &blk) + end + + def backlog(channel=nil, last_id) + old = + if channel + reliable_pub_sub.backlog(encode_channel_name(channel), last_id) + else + reliable_pub_sub.global_backlog(encode_channel_name(channel), last_id) + end + + old.each{ |m| + decode_message!(m) + } + old + end + + + def last_id(channel) + reliable_pub_sub.last_id(encode_channel_name(channel)) + end + + protected + + def decode_message!(msg) + channel, site_id = decode_channel_name(msg.channel) + msg.channel = channel + msg.site_id = site_id + parsed = JSON.parse(msg.data) + msg.data = parsed["data"] + msg.user_ids = parsed["user_ids"] + end + + def subscribe_impl(channel, site_id, &blk) + @subscriptions ||= {} + @subscriptions[site_id] ||= {} + @subscriptions[site_id][channel] ||= [] + @subscriptions[site_id][channel] << blk + ensure_subscriber_thread + end + + def ensure_subscriber_thread + @mutex ||= Mutex.new + @mutex.synchronize do + return if @subscriber_thread + @subscriber_thread = Thread.new do + reliable_pub_sub.global_subscribe do |msg| + begin + decode_message!(msg) + + globals = @subscriptions[nil] + locals = @subscriptions[msg.site_id] if msg.site_id + + global_globals = globals[nil] if globals + local_globals = locals[nil] if locals + + globals = globals[msg.channel] if globals + locals = locals[msg.channel] if locals + + multi_each(globals,locals, global_globals, local_globals) do |c| + c.call msg + end + + rescue => e + MessageBus.logger.warn "failed to process message #{msg.inspect}\n ex: #{e} backtrace: #{e.backtrace}" + end + + end + end + end + end + + def multi_each(*args,&block) + args.each do |a| + a.each(&block) if a + end + end + +end + +module MessageBus + extend MessageBus::Implementation +end + +# allows for multiple buses per app +class MessageBus::Instance + include MessageBus::Implementation +end diff --git a/vendor/gems/message_bus/lib/message_bus/client.rb b/vendor/gems/message_bus/lib/message_bus/client.rb new file mode 100644 index 00000000000..996a773deab --- /dev/null +++ b/vendor/gems/message_bus/lib/message_bus/client.rb @@ -0,0 +1,71 @@ +class MessageBus::Client + attr_accessor :client_id, :user_id, :connect_time, :subscribed_sets, :site_id, :cleanup_timer, :async_response + def initialize(opts) + self.client_id = opts[:client_id] + self.user_id = opts[:user_id] + self.site_id = opts[:site_id] + self.connect_time = Time.now + @subscriptions = {} + end + + def close + return unless @async_response + write_and_close "[]" + end + + def closed + !@async_response + end + + def subscribe(channel, last_seen_id) + last_seen_id ||= MessageBus.last_id(channel) + @subscriptions[channel] = last_seen_id + end + + def subscriptions + @subscriptions + end + + def <<(msg) + write_and_close messages_to_json([msg]) + end + + def subscriptions + @subscriptions + end + + def backlog + r = [] + @subscriptions.each do |k,v| + next if v.to_i < 0 + + messages = MessageBus.backlog(k,v) + messages.each do |msg| + allowed = !msg.user_ids || msg.user_ids.include?(self.user_id) + r << msg if allowed + end + end + # stats message for all newly subscribed + status_message = nil + @subscriptions.each do |k,v| + if v.to_i == -1 + status_message ||= {} + status_message[k] = MessageBus.last_id(k) + end + end + r << MessageBus::Message.new(-1, -1, '/__status', status_message) if status_message + r + end + + protected + + def write_and_close(data) + @async_response << data + @async_response.done + @async_response = nil + end + + def messages_to_json(msgs) + MessageBus::Rack::Middleware.backlog_to_json(msgs) + end +end diff --git a/vendor/gems/message_bus/lib/message_bus/connection_manager.rb b/vendor/gems/message_bus/lib/message_bus/connection_manager.rb new file mode 100644 index 00000000000..446104ad6ab --- /dev/null +++ b/vendor/gems/message_bus/lib/message_bus/connection_manager.rb @@ -0,0 +1,69 @@ +require 'json' unless defined? ::JSON + +class MessageBus::ConnectionManager + + def initialize + @clients = {} + @subscriptions = {} + end + + def notify_clients(msg) + begin + site_subs = @subscriptions[msg.site_id] + subscription = site_subs[msg.channel] if site_subs + + return unless subscription + + subscription.each do |client_id| + client = @clients[client_id] + if client + allowed = !msg.user_ids || msg.user_ids.include?(client.user_id) + if allowed + client << msg + # turns out you can delete from a set while itereating + remove_client(client) + end + end + end + rescue => e + MessageBus.logger.error "notify clients crash #{e} : #{e.backtrace}" + end + end + + def add_client(client) + @clients[client.client_id] = client + @subscriptions[client.site_id] ||= {} + client.subscriptions.each do |k,v| + subscribe_client(client, k) + end + end + + def remove_client(c) + @clients.delete c.client_id + @subscriptions[c.site_id].each do |k, set| + set.delete c.client_id + end + c.cleanup_timer.cancel + end + + def lookup_client(client_id) + @clients[client_id] + end + + def subscribe_client(client,channel) + set = @subscriptions[client.site_id][channel] + unless set + set = Set.new + @subscriptions[client.site_id][channel] = set + end + set << client.client_id + end + + def stats + { + client_count: @clients.length, + subscriptions: @subscriptions + } + end + +end diff --git a/vendor/gems/message_bus/lib/message_bus/message.rb b/vendor/gems/message_bus/lib/message_bus/message.rb new file mode 100644 index 00000000000..681302e8ecb --- /dev/null +++ b/vendor/gems/message_bus/lib/message_bus/message.rb @@ -0,0 +1,17 @@ +class MessageBus::Message < Struct.new(:global_id, :message_id, :channel , :data) + + attr_accessor :site_id, :user_ids + + def self.decode(encoded) + s1 = encoded.index("|") + s2 = encoded.index("|", s1+1) + s3 = encoded.index("|", s2+1) + + MessageBus::Message.new encoded[0..s1].to_i, encoded[s1+1..s2].to_i, encoded[s2+1..s3-1].gsub("$$123$$", "|"), encoded[s3+1..-1] + end + + # only tricky thing to encode is pipes in a channel name ... do a straight replace + def encode + global_id.to_s << "|" << message_id.to_s << "|" << channel.gsub("|","$$123$$") << "|" << data + end +end diff --git a/vendor/gems/message_bus/lib/message_bus/message_handler.rb b/vendor/gems/message_bus/lib/message_bus/message_handler.rb new file mode 100644 index 00000000000..c370d6c2446 --- /dev/null +++ b/vendor/gems/message_bus/lib/message_bus/message_handler.rb @@ -0,0 +1,26 @@ +class MessageBus::MessageHandler + def self.load_handlers(path) + Dir.glob("#{path}/*.rb").each do |f| + load "#{f}" + end + end + + def self.handle(name,&blk) + raise ArgumentError.new("expecting block") unless block_given? + raise ArgumentError.new("name") unless name + + @@handlers ||= {} + @@handlers[name] = blk + end + + def self.call(site_id, name, data, current_user_id) + begin + MessageBus.on_connect.call(site_id) if MessageBus.on_connect + @@handlers[name].call(data,current_user_id) + ensure + MessageBus.on_disconnect.call(site_id) if MessageBus.on_disconnect + end + end + + +end diff --git a/vendor/gems/message_bus/lib/message_bus/rack/middleware.rb b/vendor/gems/message_bus/lib/message_bus/rack/middleware.rb new file mode 100644 index 00000000000..7f9e0ec402f --- /dev/null +++ b/vendor/gems/message_bus/lib/message_bus/rack/middleware.rb @@ -0,0 +1,163 @@ +# our little message bus, accepts long polling and web sockets +require 'thin' +require 'eventmachine' + +module MessageBus::Rack; end + +class MessageBus::Rack::Middleware + + def self.start_listener + unless @started_listener + MessageBus.subscribe do |msg| + EM.next_tick do + @@connection_manager.notify_clients(msg) if @@connection_manager + end + end + @started_listener = true + end + end + + def initialize(app, config = {}) + @app = app + @@connection_manager = MessageBus::ConnectionManager.new + self.class.start_listener + end + + def self.backlog_to_json(backlog) + m = backlog.map do |msg| + { + :global_id => msg.global_id, + :message_id => msg.message_id, + :channel => msg.channel, + :data => msg.data + } + end.to_a + JSON.dump(m) + end + + def call(env) + + return @app.call(env) unless env['PATH_INFO'] =~ /^\/message-bus/ + + # special debug/test route + if ::MessageBus.allow_broadcast? && env['PATH_INFO'] == '/message-bus/broadcast' + parsed = Rack::Request.new(env) + ::MessageBus.publish parsed["channel"], parsed["data"] + return [200,{"Content-Type" => "text/html"},["sent"]] + end + + client_id = env['PATH_INFO'].split("/")[2] + return [404, {}, ["not found"]] unless client_id + + user_id = MessageBus.user_id_lookup.call(env) if MessageBus.user_id_lookup + site_id = MessageBus.site_id_lookup.call(env) if MessageBus.site_id_lookup + + client = MessageBus::Client.new(client_id: client_id, user_id: user_id, site_id: site_id) + + connection = env['em.connection'] + + request = Rack::Request.new(env) + request.POST.each do |k,v| + client.subscribe(k, v) + end + + backlog = client.backlog + headers = {} + headers["Cache-Control"] = "must-revalidate, private, max-age=0" + headers["Content-Type"] ="application/json; charset=utf-8" + + if backlog.length > 0 + [200, headers, [self.class.backlog_to_json(backlog)] ] + elsif MessageBus.long_polling_enabled? && env['QUERY_STRING'] !~ /dlp=t/ + response = Thin::AsyncResponse.new(env) + response.headers["Cache-Control"] = "must-revalidate, private, max-age=0" + response.headers["Content-Type"] ="application/json; charset=utf-8" + response.status = 200 + client.async_response = response + + @@connection_manager.add_client(client) + + client.cleanup_timer = ::EM::Timer.new(MessageBus.long_polling_interval.to_f / 1000) { + client.close + @@connection_manager.remove_client(client) + } + + throw :async + else + [200, headers, ["[]"]] + end + + end +end + +# there is also another in cramp this is from https://github.com/macournoyer/thin_async/blob/master/lib/thin/async.rb +module Thin + unless defined?(DeferrableBody) + # Based on version from James Tucker + class DeferrableBody + include ::EM::Deferrable + + def initialize + @queue = [] + end + + def call(body) + @queue << body + schedule_dequeue + end + + def each(&blk) + @body_callback = blk + schedule_dequeue + end + + private + def schedule_dequeue + return unless @body_callback + ::EM.next_tick do + next unless body = @queue.shift + body.each do |chunk| + @body_callback.call(chunk) + end + schedule_dequeue unless @queue.empty? + end + end + end + end + + # Response whos body is sent asynchronously. + class AsyncResponse + include Rack::Response::Helpers + + attr_reader :headers, :callback, :closed + attr_accessor :status + + def initialize(env, status=200, headers={}) + @callback = env['async.callback'] + @body = DeferrableBody.new + @status = status + @headers = headers + @headers_sent = false + end + + def send_headers + return if @headers_sent + @callback.call [@status, @headers, @body] + @headers_sent = true + end + + def write(body) + send_headers + @body.call(body.respond_to?(:each) ? body : [body]) + end + alias :<< :write + + # Tell Thin the response is complete and the connection can be closed. + def done + @closed = true + send_headers + ::EM.next_tick { @body.succeed } + end + + end +end diff --git a/vendor/gems/message_bus/lib/message_bus/rails/railtie.rb b/vendor/gems/message_bus/lib/message_bus/rails/railtie.rb new file mode 100644 index 00000000000..b57faafe515 --- /dev/null +++ b/vendor/gems/message_bus/lib/message_bus/rails/railtie.rb @@ -0,0 +1,9 @@ +module MessageBus; module Rails; end; end + +class MessageBus::Rails::Railtie < ::Rails::Railtie + initializer "message_bus.configure_init" do |app| + MessageBus::MessageHandler.load_handlers("#{Rails.root}/app/message_handlers") + app.middleware.use MessageBus::Rack::Middleware + MessageBus.logger = Rails.logger + end +end diff --git a/vendor/gems/message_bus/lib/message_bus/reliable_pub_sub.rb b/vendor/gems/message_bus/lib/message_bus/reliable_pub_sub.rb new file mode 100644 index 00000000000..6ea0726dce8 --- /dev/null +++ b/vendor/gems/message_bus/lib/message_bus/reliable_pub_sub.rb @@ -0,0 +1,242 @@ +require 'redis' +# the heart of the message bus, it acts as 2 things +# +# 1. A channel multiplexer +# 2. Backlog storage per-multiplexed channel. +# +# ids are all sequencially increasing numbers starting at 0 +# + + +class MessageBus::ReliablePubSub + + # max_backlog_size is per multiplexed channel + def initialize(redis_config = {}, max_backlog_size = 1000) + @redis_config = redis_config + @max_backlog_size = 1000 + # we can store a ton here ... + @max_global_backlog_size = 100000 + end + + # amount of global backlog we can spin through + def max_global_backlog_size=(val) + @max_global_backlog_size = val + end + + # per channel backlog size + def max_backlog_size=(val) + @max_backlog_size = val + end + + def new_redis_connection + ::Redis.new(@redis_config) + end + + def redis_channel_name + db = @redis_config[:db] || 0 + "discourse_#{db}" + end + + # redis connection used for publishing messages + def pub_redis + @pub_redis ||= new_redis_connection + end + + def offset_key(channel) + "__mb_offset_#{channel}" + end + + def backlog_key(channel) + "__mb_backlog_#{channel}" + end + + def global_id_key + "__mb_global_id" + end + + def global_backlog_key + "__mb_global_backlog" + end + + def global_offset_key + "__mb_global_offset" + end + + # use with extreme care, will nuke all of the data + def reset! + pub_redis.keys("__mb_*").each do |k| + pub_redis.del k + end + end + + def publish(channel, data) + redis = pub_redis + offset_key = offset_key(channel) + backlog_key = backlog_key(channel) + + redis.watch(offset_key, backlog_key, global_id_key, global_backlog_key, global_offset_key) do + offset = redis.get(offset_key).to_i + backlog = redis.llen(backlog_key).to_i + + global_offset = redis.get(global_offset_key).to_i + global_backlog = redis.llen(global_backlog_key).to_i + + global_id = redis.get(global_id_key).to_i + global_id += 1 + + too_big = backlog + 1 > @max_backlog_size + global_too_big = global_backlog + 1 > @max_global_backlog_size + + message_id = backlog + offset + 1 + redis.multi do + if too_big + redis.ltrim backlog_key, (backlog+1) - @max_backlog_size, -1 + offset += (backlog+1) - @max_backlog_size + redis.set(offset_key, offset) + end + + if global_too_big + redis.ltrim global_backlog_key, (global_backlog+1) - @max_global_backlog_size, -1 + global_offset += (global_backlog+1) - @max_global_backlog_size + redis.set(global_offset_key, global_offset) + end + + msg = MessageBus::Message.new global_id, message_id, channel, data + payload = msg.encode + + redis.set global_id_key, global_id + redis.rpush backlog_key, payload + redis.rpush global_backlog_key, message_id.to_s << "|" << channel + redis.publish redis_channel_name, payload + end + + return message_id + end + end + + def last_id(channel) + redis = pub_redis + offset_key = offset_key(channel) + backlog_key = backlog_key(channel) + + offset,len = nil + redis.watch offset_key, backlog_key do + offset = redis.get(offset_key).to_i + len = redis.llen backlog_key + end + offset + len + end + + def backlog(channel, last_id = nil) + redis = pub_redis + offset_key = offset_key(channel) + backlog_key = backlog_key(channel) + + items = nil + + redis.watch offset_key, backlog_key do + offset = redis.get(offset_key).to_i + start_at = last_id.to_i - offset + items = redis.lrange backlog_key, start_at, -1 + end + + items.map do |i| + MessageBus::Message.decode(i) + end + end + + def global_backlog(last_id = nil) + last_id = last_id.to_i + items = nil + redis = pub_redis + + redis.watch global_backlog_key, global_offset_key do + offset = redis.get(global_offset_key).to_i + start_at = last_id.to_i - offset + items = redis.lrange global_backlog_key, start_at, -1 + end + + items.map! do |i| + pipe = i.index "|" + message_id = i[0..pipe].to_i + channel = i[pipe+1..-1] + m = get_message(channel, message_id) + m + end + + items.compact! + + items + end + + def get_message(channel, message_id) + redis = pub_redis + offset_key = offset_key(channel) + backlog_key = backlog_key(channel) + + msg = nil + redis.watch(offset_key, backlog_key) do + offset = redis.get(offset_key).to_i + idx = (message_id-1) - offset + return nil if idx < 0 + msg = redis.lindex(backlog_key, idx) + end + + if msg + msg = MessageBus::Message.decode(msg) + end + msg + end + + def subscribe(channel, last_id = nil) + # trivial implementation for now, + # can cut down on connections if we only have one global subscriber + raise ArgumentError unless block_given? + + global_subscribe(last_id) do |m| + yield m if m.channel == channel + end + end + + def global_subscribe(last_id=nil, &blk) + raise ArgumentError unless block_given? + highest_id = last_id + + clear_backlog = lambda do + global_backlog(highest_id).each do |old| + highest_id = old.global_id + yield old + end + end + + begin + redis = new_redis_connection + + if highest_id + clear_backlog.call(&blk) + end + + redis.subscribe(redis_channel_name) do |on| + on.subscribe do + if highest_id + clear_backlog.call(&blk) + end + end + on.message do |c,m| + m = MessageBus::Message.decode m + if highest_id && m.global_id != highest_id + 1 + clear_backlog.call(&blk) + end + yield m if highest_id.nil? || m.global_id > highest_id + highest_id = m.global_id + end + end + rescue => error + MessageBus.logger.warn "#{error} subscribe failed, reconnecting in 1 second. Call stack #{error.backtrace}" + sleep 1 + retry + end + end + + +end diff --git a/vendor/gems/message_bus/lib/message_bus/version.rb b/vendor/gems/message_bus/lib/message_bus/version.rb new file mode 100644 index 00000000000..c38f5e9f14d --- /dev/null +++ b/vendor/gems/message_bus/lib/message_bus/version.rb @@ -0,0 +1,3 @@ +module MessageBus + VERSION = "0.0.1" +end diff --git a/vendor/gems/message_bus/message_bus.gemspec b/vendor/gems/message_bus/message_bus.gemspec new file mode 100644 index 00000000000..fc8b095d974 --- /dev/null +++ b/vendor/gems/message_bus/message_bus.gemspec @@ -0,0 +1,23 @@ +# -*- encoding: utf-8 -*- +require File.expand_path('../lib/message_bus/version', __FILE__) + +Gem::Specification.new do |gem| + gem.authors = ["Sam Saffron"] + gem.email = ["sam.saffron@gmail.com"] + gem.description = %q{A message bus built on websockets} + gem.summary = %q{} + gem.homepage = "" + + # when this is extracted comment it back in, prd has no .git + # gem.files = `git ls-files`.split($\) + gem.files = Dir['README*','LICENSE','lib/**/*.rb'] + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + gem.name = "message_bus" + gem.require_paths = ["lib"] + gem.version = MessageBus::VERSION + gem.add_runtime_dependency 'rack', '>= 1.1.3' + gem.add_runtime_dependency 'thin' + gem.add_runtime_dependency 'eventmachine' + gem.add_runtime_dependency 'redis' +end diff --git a/vendor/gems/message_bus/spec/lib/client_spec.rb b/vendor/gems/message_bus/spec/lib/client_spec.rb new file mode 100644 index 00000000000..448146d282a --- /dev/null +++ b/vendor/gems/message_bus/spec/lib/client_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' +require 'message_bus' + +describe MessageBus::Client do + + describe "subscriptions" do + + before do + @client = MessageBus::Client.new :client_id => 'abc' + end + + it "should provide a list of subscriptions" do + @client.subscribe('/hello', nil) + @client.subscriptions['/hello'].should_not be_nil + end + + it "should provide backlog for subscribed channel" do + @client.subscribe('/hello', nil) + MessageBus.publish '/hello', 'world' + log = @client.backlog + log.length.should == 1 + log[0].channel.should == '/hello' + log[0].data.should == 'world' + end + end + +end diff --git a/vendor/gems/message_bus/spec/lib/connection_manager_spec.rb b/vendor/gems/message_bus/spec/lib/connection_manager_spec.rb new file mode 100644 index 00000000000..27c74ee26c9 --- /dev/null +++ b/vendor/gems/message_bus/spec/lib/connection_manager_spec.rb @@ -0,0 +1,83 @@ +require 'spec_helper' +require 'message_bus' + +class FakeAsync + + attr_accessor :cleanup_timer + + def <<(val) + @sent ||= "" + @sent << val + end + + def sent; @sent; end + def done; @done = true; end + def done?; @done; end +end + +class FakeTimer + attr_accessor :cancelled + def cancel; @cancelled = true; end +end + +describe MessageBus::ConnectionManager do + + before do + @manager = MessageBus::ConnectionManager.new + @client = MessageBus::Client.new(client_id: "xyz", user_id: 1, site_id: 10) + @resp = FakeAsync.new + @client.async_response = @resp + @client.subscribe('test', -1) + @manager.add_client(@client) + @client.cleanup_timer = FakeTimer.new + end + + it "should cancel the timer after its responds" do + m = MessageBus::Message.new(1,1,"test","data") + m.site_id = 10 + @manager.notify_clients(m) + @client.cleanup_timer.cancelled.should == true + end + + it "should be able to lookup an identical client" do + @manager.lookup_client(@client.client_id).should == @client + end + + it "should be subscribed to a channel" do + @manager.stats[:subscriptions][10]["test"].length == 1 + end + + it "should not notify clients on incorrect site" do + m = MessageBus::Message.new(1,1,"test","data") + m.site_id = 9 + @manager.notify_clients(m) + @resp.sent.should == nil + end + + it "should notify clients on the correct site" do + m = MessageBus::Message.new(1,1,"test","data") + m.site_id = 10 + @manager.notify_clients(m) + @resp.sent.should_not == nil + end + + it "should strip site id and user id from the payload delivered" do + m = MessageBus::Message.new(1,1,"test","data") + m.user_ids = [1] + m.site_id = 10 + @manager.notify_clients(m) + parsed = JSON.parse(@resp.sent) + parsed[0]["site_id"].should == nil + parsed[0]["user_id"].should == nil + end + + it "should not deliver unselected" do + m = MessageBus::Message.new(1,1,"test","data") + m.user_ids = [5] + m.site_id = 10 + @manager.notify_clients(m) + @resp.sent.should == nil + end + + +end diff --git a/vendor/gems/message_bus/spec/lib/handlers/demo_message_handler.rb b/vendor/gems/message_bus/spec/lib/handlers/demo_message_handler.rb new file mode 100644 index 00000000000..ba4daa08ccf --- /dev/null +++ b/vendor/gems/message_bus/spec/lib/handlers/demo_message_handler.rb @@ -0,0 +1,5 @@ +class DemoMessageHandler < MessageBus::MessageHandler + handle "/dupe" do |m, uid| + "#{m}#{m}" + end +end diff --git a/vendor/gems/message_bus/spec/lib/message_bus_spec.rb b/vendor/gems/message_bus/spec/lib/message_bus_spec.rb new file mode 100644 index 00000000000..f4c6cbc58fe --- /dev/null +++ b/vendor/gems/message_bus/spec/lib/message_bus_spec.rb @@ -0,0 +1,78 @@ +require 'spec_helper' +require 'message_bus' +require 'redis' + + +describe MessageBus do + + before do + MessageBus.site_id_lookup do + "magic" + end + MessageBus.redis_config = {} + end + + it "should automatically decode hashed messages" do + data = nil + MessageBus.subscribe("/chuck") do |msg| + data = msg.data + end + MessageBus.publish("/chuck", {:norris => true}) + wait_for(1000){ data } + + data["norris"].should == true + end + + it "should get a message if it subscribes to it" do + @data,@site_id,@channel = nil + + MessageBus.subscribe("/chuck") do |msg| + @data = msg.data + @site_id = msg.site_id + @channel = msg.channel + @user_ids = msg.user_ids + end + + MessageBus.publish("/chuck", "norris", user_ids: [1,2,3]) + + wait_for(1000){@data} + + @data.should == 'norris' + @site_id.should == 'magic' + @channel.should == '/chuck' + @user_ids.should == [1,2,3] + + end + + + it "should get global messages if it subscribes to them" do + @data,@site_id,@channel = nil + + MessageBus.subscribe do |msg| + @data = msg.data + @site_id = msg.site_id + @channel = msg.channel + end + + MessageBus.publish("/chuck", "norris") + + wait_for(1000){@data} + + @data.should == 'norris' + @site_id.should == 'magic' + @channel.should == '/chuck' + end + + it "should have the ability to grab the backlog messages in the correct order" do + id = MessageBus.publish("/chuck", "norris") + MessageBus.publish("/chuck", "foo") + MessageBus.publish("/chuck", "bar") + + r = MessageBus.backlog("/chuck", id) + + wait_for(1000) { r.length == 2 } + + r.map{|i| i.data}.to_a.should == ['foo', 'bar'] + end + +end diff --git a/vendor/gems/message_bus/spec/lib/message_handler_spec.rb b/vendor/gems/message_bus/spec/lib/message_handler_spec.rb new file mode 100644 index 00000000000..74071022404 --- /dev/null +++ b/vendor/gems/message_bus/spec/lib/message_handler_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' +require 'message_bus' + +describe MessageBus::MessageHandler do + + it "should properly register message handlers" do + MessageBus::MessageHandler.handle "/hello" do |m| + m + end + MessageBus::MessageHandler.call("site","/hello", "world", 1).should == "world" + end + + it "should correctly load message handlers" do + MessageBus::MessageHandler.load_handlers("#{File.dirname(__FILE__)}/handlers") + MessageBus::MessageHandler.call("site","/dupe", "1", 1).should == "11" + end + + it "should allow for a connect / disconnect callback" do + MessageBus::MessageHandler.handle "/channel" do |m| + m + end + + connected = false + disconnected = false + + MessageBus.on_connect do |site_id| + connected = true + end + MessageBus.on_disconnect do |site_id| + disconnected = true + end + + MessageBus::MessageHandler.call("site_id", "/channel", "data", 1) + + connected.should == true + disconnected.should == true + + end +end diff --git a/vendor/gems/message_bus/spec/lib/middleware_spec.rb b/vendor/gems/message_bus/spec/lib/middleware_spec.rb new file mode 100644 index 00000000000..1d7bd279797 --- /dev/null +++ b/vendor/gems/message_bus/spec/lib/middleware_spec.rb @@ -0,0 +1,180 @@ +require 'spec_helper' +require 'message_bus' +require 'rack/test' + +describe MessageBus::Rack::Middleware do + include Rack::Test::Methods + + class FakeAsyncMiddleware + + def self.in_async? + @@in_async if defined? @@in_async + end + + def initialize(app,config={}) + @app = app + end + + def call(env) + result = nil + EM.run { + env['async.callback'] = lambda { |r| + # more judo with deferrable body, at this point we just have headers + r[2].callback do + # even more judo cause rack test does not call each like the spec says + body = "" + r[2].each do |m| + body << m + end + r[2] = [body] + result = r + end + } + catch(:async) { + result = @app.call(env) + } + + EM::Timer.new(1) { EM.stop } + + defer = lambda { + if !result + @@in_async = true + EM.next_tick do + defer.call + end + else + EM.next_tick { EM.stop } + end + } + defer.call + } + + @@in_async = false + result || [500, {}, ['timeout']] + end + end + + def app + @app ||= Rack::Builder.new { + use FakeAsyncMiddleware + use MessageBus::Rack::Middleware + run lambda {|env| [500, {'Content-Type' => 'text/html'}, 'should not be called' ]} + }.to_app + end + + describe "long polling" do + before do + MessageBus.sockets_enabled = false + MessageBus.long_polling_enabled = true + end + + it "should respond right away if dlp=t" do + post "/message-bus/ABC?dlp=t", '/foo1' => 0 + FakeAsyncMiddleware.in_async?.should == false + last_response.should be_ok + end + + it "should respond right away to long polls that are polling on -1 with the last_id" do + post "/message-bus/ABC", '/foo' => -1 + last_response.should be_ok + parsed = JSON.parse(last_response.body) + parsed.length.should == 1 + parsed[0]["channel"].should == "/__status" + parsed[0]["data"]["/foo"].should == MessageBus.last_id("/foo") + end + + it "should respond to long polls when data is available" do + + Thread.new do + wait_for(2000) { FakeAsyncMiddleware.in_async? } + MessageBus.publish "/foo", "bar" + end + + post "/message-bus/ABC", '/foo' => nil + + last_response.should be_ok + parsed = JSON.parse(last_response.body) + parsed.length.should == 1 + parsed[0]["data"].should == "bar" + end + + it "should timeout within its alloted slot" do + begin + MessageBus.long_polling_interval = 10 + s = Time.now.to_f * 1000 + post "/message-bus/ABC", '/foo' => nil + (Time.now.to_f * 1000 - s).should < 30 + ensure + MessageBus.long_polling_interval = 5000 + end + + + end + end + + + describe "polling" do + before do + MessageBus.sockets_enabled = false + MessageBus.long_polling_enabled = false + end + + it "should respond with a 200 to a subscribe" do + client_id = "ABCD" + + # client always keeps a list of channels with last message id they got on each + post "/message-bus/#{client_id}", { + '/foo' => nil, + '/bar' => nil + } + last_response.should be_ok + end + + it "should correctly understand that -1 means stuff from now onwards" do + + MessageBus.publish('foo', 'bar') + + post "/message-bus/ABCD", { + '/foo' => -1 + } + last_response.should be_ok + parsed = JSON.parse(last_response.body) + parsed.length.should == 1 + parsed[0]["channel"].should == "/__status" + parsed[0]["data"]["/foo"].should == MessageBus.last_id("/foo") + + end + + it "should respond with the data if messages exist in the backlog" do + id = MessageBus.last_id('/foo') + + MessageBus.publish("/foo", "barbs") + MessageBus.publish("/foo", "borbs") + + client_id = "ABCD" + post "/message-bus/#{client_id}", { + '/foo' => id, + '/bar' => nil + } + + parsed = JSON.parse(last_response.body) + parsed.length.should == 2 + parsed[0]["data"].should == "barbs" + parsed[1]["data"].should == "borbs" + end + + it "should not get consumed messages" do + MessageBus.publish("/foo", "barbs") + id = MessageBus.last_id('/foo') + + client_id = "ABCD" + post "/message-bus/#{client_id}", { + '/foo' => id + } + + parsed = JSON.parse(last_response.body) + parsed.length.should == 0 + end + end + +end diff --git a/vendor/gems/message_bus/spec/lib/reliable_pub_sub_spec.rb b/vendor/gems/message_bus/spec/lib/reliable_pub_sub_spec.rb new file mode 100644 index 00000000000..4a89d58fc34 --- /dev/null +++ b/vendor/gems/message_bus/spec/lib/reliable_pub_sub_spec.rb @@ -0,0 +1,167 @@ +require 'spec_helper' +require 'message_bus' + +describe MessageBus::ReliablePubSub do + + def new_test_bus + MessageBus::ReliablePubSub.new(:db => 10) + end + + before do + @bus = new_test_bus + @bus.reset! + end + + it "should be able to access the backlog" do + @bus.publish "/foo", "bar" + @bus.publish "/foo", "baz" + + @bus.backlog("/foo", 0).to_a.should == [ + MessageBus::Message.new(1,1,'/foo','bar'), + MessageBus::Message.new(2,2,'/foo','baz') + ] + end + + it "should truncate channels correctly" do + @bus.max_backlog_size = 2 + 4.times do |t| + @bus.publish "/foo", t.to_s + end + + @bus.backlog("/foo").to_a.should == [ + MessageBus::Message.new(3,3,'/foo','2'), + MessageBus::Message.new(4,4,'/foo','3'), + ] + end + + it "should be able to grab a message by id" do + id1 = @bus.publish "/foo", "bar" + id2 = @bus.publish "/foo", "baz" + @bus.get_message("/foo", id2).should == MessageBus::Message.new(2, 2, "/foo", "baz") + @bus.get_message("/foo", id1).should == MessageBus::Message.new(1, 1, "/foo", "bar") + end + + it "should be able to access the global backlog" do + @bus.publish "/foo", "bar" + @bus.publish "/hello", "world" + @bus.publish "/foo", "baz" + @bus.publish "/hello", "planet" + + @bus.global_backlog.to_a.should == [ + MessageBus::Message.new(1, 1, "/foo", "bar"), + MessageBus::Message.new(2, 1, "/hello", "world"), + MessageBus::Message.new(3, 2, "/foo", "baz"), + MessageBus::Message.new(4, 2, "/hello", "planet") + ] + end + + it "should correctly omit dropped messages from the global backlog" do + @bus.max_backlog_size = 1 + @bus.publish "/foo", "a" + @bus.publish "/foo", "b" + @bus.publish "/bar", "a" + @bus.publish "/bar", "b" + + @bus.global_backlog.to_a.should == [ + MessageBus::Message.new(2, 2, "/foo", "b"), + MessageBus::Message.new(4, 2, "/bar", "b") + ] + end + + it "should have the correct number of messages for multi threaded access" do + threads = [] + 4.times do + threads << Thread.new do + bus = new_test_bus + 25.times { + bus.publish "/foo", "." + } + end + end + + threads.each{|t| t.join} + @bus.backlog("/foo").length == 100 + end + + it "should be able to subscribe globally with recovery" do + @bus.publish("/foo", "1") + @bus.publish("/bar", "2") + got = [] + + t = Thread.new do + new_test_bus.global_subscribe(0) do |msg| + got << msg + end + end + + @bus.publish("/bar", "3") + + wait_for(100) do + got.length == 3 + end + + t.kill + got.length.should == 3 + + got.map{|m| m.data}.should == ["1","2","3"] + end + + it "should be able to encode and decode messages properly" do + m = MessageBus::Message.new 1,2,'||','||' + MessageBus::Message.decode(m.encode).should == m + end + + it "should handle subscribe on single channel, with recovery" do + @bus.publish("/foo", "1") + @bus.publish("/bar", "2") + got = [] + + t = Thread.new do + new_test_bus.subscribe("/foo",0) do |msg| + got << msg + end + end + + @bus.publish("/foo", "3") + + wait_for(100) do + got.length == 2 + end + + t.kill + + got.map{|m| m.data}.should == ["1","3"] + end + + it "should not get backlog if subscribe is called without params" do + @bus.publish("/foo", "1") + got = [] + + t = Thread.new do + new_test_bus.subscribe("/foo") do |msg| + got << msg + end + end + + # sleep 50ms to allow the bus to correctly subscribe, + # I thought about adding a subscribed callback, but outside of testing it matters less + sleep 0.05 + + @bus.publish("/foo", "2") + + wait_for(100) do + got.length == 1 + end + + t.kill + + got.map{|m| m.data}.should == ["2"] + end + + it "should allow us to get last id on a channel" do + @bus.last_id("/foo").should == 0 + @bus.publish("/foo", "1") + @bus.last_id("/foo").should == 1 + end + +end diff --git a/vendor/gems/message_bus/spec/spec_helper.rb b/vendor/gems/message_bus/spec/spec_helper.rb new file mode 100644 index 00000000000..1836c1f6819 --- /dev/null +++ b/vendor/gems/message_bus/spec/spec_helper.rb @@ -0,0 +1,16 @@ +RSpec.configure do |config| + config.color_enabled = true +end + +def wait_for(timeout_milliseconds) + timeout = (timeout_milliseconds + 0.0) / 1000 + finish = Time.now + timeout + t = Thread.new do + while Time.now < finish && !yield + sleep(0.001) + end + end + t.join +end + + diff --git a/vendor/gems/rails_multisite/.gitignore b/vendor/gems/rails_multisite/.gitignore new file mode 100644 index 00000000000..d87d4be66f4 --- /dev/null +++ b/vendor/gems/rails_multisite/.gitignore @@ -0,0 +1,17 @@ +*.gem +*.rbc +.bundle +.config +.yardoc +Gemfile.lock +InstalledFiles +_yardoc +coverage +doc/ +lib/bundler/man +pkg +rdoc +spec/reports +test/tmp +test/version_tmp +tmp diff --git a/vendor/gems/rails_multisite/Gemfile b/vendor/gems/rails_multisite/Gemfile new file mode 100644 index 00000000000..3a73f5e74c0 --- /dev/null +++ b/vendor/gems/rails_multisite/Gemfile @@ -0,0 +1,11 @@ +source 'https://rubygems.org' + +group :test do + gem 'rails' + gem 'rspec' + gem 'activerecord' + gem 'sqlite3' +end + +# Specify your gem's dependencies in rails_multisite.gemspec +gemspec diff --git a/vendor/gems/rails_multisite/LICENSE b/vendor/gems/rails_multisite/LICENSE new file mode 100644 index 00000000000..f3d7f653bb5 --- /dev/null +++ b/vendor/gems/rails_multisite/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2012 Sam Saffron + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/vendor/gems/rails_multisite/README.md b/vendor/gems/rails_multisite/README.md new file mode 100644 index 00000000000..d7121ffb79d --- /dev/null +++ b/vendor/gems/rails_multisite/README.md @@ -0,0 +1,29 @@ +# RailsMultisite + +TODO: Write a gem description + +## Installation + +Add this line to your application's Gemfile: + + gem 'rails_multisite' + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install rails_multisite + +## Usage + +TODO: Write usage instructions here + +## Contributing + +1. Fork it +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Added some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create new Pull Request diff --git a/vendor/gems/rails_multisite/Rakefile b/vendor/gems/rails_multisite/Rakefile new file mode 100644 index 00000000000..ecd0cbf9288 --- /dev/null +++ b/vendor/gems/rails_multisite/Rakefile @@ -0,0 +1,7 @@ +#!/usr/bin/env rake +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:test) do |spec| + spec.pattern = 'spec/*_spec.rb' +end diff --git a/vendor/gems/rails_multisite/lib/rails_multisite.rb b/vendor/gems/rails_multisite/lib/rails_multisite.rb new file mode 100644 index 00000000000..3b89d335178 --- /dev/null +++ b/vendor/gems/rails_multisite/lib/rails_multisite.rb @@ -0,0 +1,3 @@ +require "rails_multisite/version" +require "rails_multisite/railtie" +require "rails_multisite/connection_management" diff --git a/vendor/gems/rails_multisite/lib/rails_multisite/connection_management.rb b/vendor/gems/rails_multisite/lib/rails_multisite/connection_management.rb new file mode 100644 index 00000000000..41a1452c191 --- /dev/null +++ b/vendor/gems/rails_multisite/lib/rails_multisite/connection_management.rb @@ -0,0 +1,147 @@ +module RailsMultisite + class ConnectionManagement + CONFIG_FILE = 'config/multisite.yml' + + def self.establish_connection(opts) + if opts[:db] == "default" && (!defined?(@@default_spec) || !@@default_spec) + ActiveRecord::Base.establish_connection + else + spec = connection_spec(opts) || @@default_spec + handler = nil + if spec != @@default_spec + handler = @@connection_handlers[spec] + unless handler + handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new + @@connection_handlers[spec] = handler + end + else + handler = @@default_connection_handler + end + ActiveRecord::Base.connection_handler = handler + ActiveRecord::Base.connection_handler.establish_connection("ActiveRecord::Base", spec) + end + end + + def self.each_connection + old = current_db + connected = ActiveRecord::Base.connection_pool.connected? + all_dbs.each do |db| + establish_connection(:db => db) + yield db + ActiveRecord::Base.connection_handler.clear_active_connections! + end + establish_connection(:db => old) + ActiveRecord::Base.connection_handler.clear_active_connections! unless connected + end + + def self.all_dbs + ["default"] + + if defined?(@@db_spec_cache) && @@db_spec_cache + @@db_spec_cache.keys.to_a + else + [] + end + end + + def self.current_db + db = ActiveRecord::Base.connection_pool.spec.config[:db_key] || "default" + end + + def self.config_filename=(config_filename) + @@config_filename = config_filename + end + + def self.config_filename + @@config_filename ||= File.absolute_path(Rails.root.to_s + "/" + RailsMultisite::ConnectionManagement::CONFIG_FILE) + end + + def self.current_hostname + ActiveRecord::Base.connection_pool.spec.config[:host_names].first + end + + + def self.clear_settings! + @@db_spec_cache = nil + @@host_spec_cache = nil + @@default_spec = nil + end + + def self.load_settings! + configs = YAML::load(File.open(self.config_filename)) + configs.each do |k,v| + raise ArgumentError.new("Please do not name any db default!") if k == "default" + v[:db_key] = k + end + + @@db_spec_cache = Hash[*configs.map do |k, data| + [k, ActiveRecord::Base::ConnectionSpecification::Resolver.new(k, configs).spec] + end.flatten] + + @@host_spec_cache = {} + configs.each do |k,v| + next unless v["host_names"] + v["host_names"].each do |host| + @@host_spec_cache[host] = @@db_spec_cache[k] + end + end + + @@default_spec = ActiveRecord::Base::ConnectionSpecification::Resolver.new(Rails.env, ActiveRecord::Base.configurations).spec + ActiveRecord::Base.configurations[Rails.env]["host_names"].each do |host| + @@host_spec_cache[host] = @@default_spec + end + + # inject our connection_handler pool + # WARNING MONKEY PATCH + # + # see: https://github.com/rails/rails/issues/8344#issuecomment-10800848 + # + @@default_connection_handler = ActiveRecord::Base.connection_handler + ActiveRecord::Base.send :include, NewConnectionHandler + ActiveRecord::Base.connection_handler = @@default_connection_handler + @@connection_handlers = {} + end + + module NewConnectionHandler + def self.included(klass) + klass.class_eval do + define_singleton_method :connection_handler do + Thread.current[:connection_handler] || @connection_handler + end + define_singleton_method :connection_handler= do |handler| + @connection_handler ||= handler + Thread.current[:connection_handler] = handler + end + end + end + end + + + def initialize(app, config = nil) + @app = app + end + + def call(env) + request = Rack::Request.new(env) + begin + + #TODO: add a callback so users can simply go to a domain to register it, or something + return [404, {}, ["not found"]] unless @@host_spec_cache[request.host] + + ActiveRecord::Base.connection_handler.clear_active_connections! + self.class.establish_connection(:host => request['__ws'] || request.host) + @app.call(env) + ensure + ActiveRecord::Base.connection_handler.clear_active_connections! + end + end + + def self.connection_spec(opts) + if opts[:host] + @@host_spec_cache[opts[:host]] + else + @@db_spec_cache[opts[:db]] + end + end + + end +end diff --git a/vendor/gems/rails_multisite/lib/rails_multisite/railtie.rb b/vendor/gems/rails_multisite/lib/rails_multisite/railtie.rb new file mode 100644 index 00000000000..6594a4fadc3 --- /dev/null +++ b/vendor/gems/rails_multisite/lib/rails_multisite/railtie.rb @@ -0,0 +1,21 @@ +module RailsMultisite + class Railtie < Rails::Railtie + rake_tasks do + Dir[File.join(File.dirname(__FILE__),'../tasks/*.rake')].each { |f| load f } + end + + initializer "RailsMultisite.init" do |app| + if File.exists?(ConnectionManagement.config_filename) + ConnectionManagement.load_settings! + app.middleware.insert_after(ActiveRecord::ConnectionAdapters::ConnectionManagement, RailsMultisite::ConnectionManagement) + app.middleware.delete(ActiveRecord::ConnectionAdapters::ConnectionManagement) + if ENV['RAILS_DB'] + ConnectionManagement.establish_connection(:db => ENV['RAILS_DB']) + end + end + end + + + end +end + diff --git a/vendor/gems/rails_multisite/lib/rails_multisite/version.rb b/vendor/gems/rails_multisite/lib/rails_multisite/version.rb new file mode 100644 index 00000000000..41ecff99240 --- /dev/null +++ b/vendor/gems/rails_multisite/lib/rails_multisite/version.rb @@ -0,0 +1,3 @@ +module RailsMultisite + VERSION = "0.0.1" +end diff --git a/vendor/gems/rails_multisite/lib/tasks/db.rake b/vendor/gems/rails_multisite/lib/tasks/db.rake new file mode 100644 index 00000000000..e436ea101dc --- /dev/null +++ b/vendor/gems/rails_multisite/lib/tasks/db.rake @@ -0,0 +1,20 @@ +desc "migrate all sites in tier" +task "multisite:migrate" => :environment do + RailsMultisite::ConnectionManagement.each_connection do |db| + puts "Migrating #{db}" + puts "---------------------------------\n" + t = Rake::Task["db:migrate"] + t.reenable + t.invoke + end +end + +task "multisite:seed_fu" => :environment do + RailsMultisite::ConnectionManagement.each_connection do |db| + puts "Seeding #{db}" + puts "---------------------------------\n" + t = Rake::Task["db:seed_fu"] + t.reenable + t.invoke + end +end diff --git a/vendor/gems/rails_multisite/lib/tasks/generators.rake b/vendor/gems/rails_multisite/lib/tasks/generators.rake new file mode 100644 index 00000000000..95e901623ea --- /dev/null +++ b/vendor/gems/rails_multisite/lib/tasks/generators.rake @@ -0,0 +1,26 @@ +desc "generate multisite config file (if missing)" +task "multisite:generate:config" => :environment do + filename = RailsMultisite::ConnectionManagement.config_filename + + if File.exists?(filename) + puts "Config is already generated at #{RailsMultisite::ConnectionManagement::CONFIG_FILE}" + else + puts "Generated config file at #{RailsMultisite::ConnectionManagement::CONFIG_FILE}" + File.open(filename, 'w') do |f| + f.write <<-CONFIG +# site_name: +# adapter: postgresql +# database: db_name +# host: localhost +# pool: 5 +# timeout: 5000 +# db_id: 1 # optionally include other settings you need +# host_names: +# - www.mysite.com +# - www.anothersite.com +CONFIG + + end + + end +end diff --git a/vendor/gems/rails_multisite/rails_multisite.gemspec b/vendor/gems/rails_multisite/rails_multisite.gemspec new file mode 100644 index 00000000000..085179194ba --- /dev/null +++ b/vendor/gems/rails_multisite/rails_multisite.gemspec @@ -0,0 +1,20 @@ +# -*- encoding: utf-8 -*- +require File.expand_path('../lib/rails_multisite/version', __FILE__) + +Gem::Specification.new do |gem| + gem.authors = ["Sam Saffron"] + gem.email = ["sam.saffron@gmail.com"] + gem.description = %q{TODO: Write a gem description} + gem.summary = %q{TODO: Write a gem summary} + gem.homepage = "" + + # when this is extracted comment it back in, prd has no .git + # gem.files = `git ls-files`.split($\) + gem.files = Dir['README*','LICENSE','lib/**/*.rb'] + + gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } + gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) + gem.name = "rails_multisite" + gem.require_paths = ["lib"] + gem.version = RailsMultisite::VERSION +end diff --git a/vendor/gems/rails_multisite/spec/connection_management_rack_spec.rb b/vendor/gems/rails_multisite/spec/connection_management_rack_spec.rb new file mode 100644 index 00000000000..8931c7ac650 --- /dev/null +++ b/vendor/gems/rails_multisite/spec/connection_management_rack_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' +require 'rails_multisite' +require 'rack/test' + +describe RailsMultisite::ConnectionManagement do + include Rack::Test::Methods + + def app + + RailsMultisite::ConnectionManagement.config_filename = 'spec/fixtures/two_dbs.yml' + RailsMultisite::ConnectionManagement.load_settings! + + @app ||= Rack::Builder.new { + use RailsMultisite::ConnectionManagement + map '/html' do + run lambda { |env| [200, {'Content-Type' => 'text/html'}, "

            Hi

            \n \t"] } + end + }.to_app + end + + after do + RailsMultisite::ConnectionManagement.clear_settings! + end + + describe 'with a valid request' do + + before do + end + + it 'returns 200 for valid site' do + get 'http://second.localhost/html' + last_response.should be_ok + end + + it 'returns 200 for valid main site' do + get 'http://default.localhost/html' + last_response.should be_ok + end + + it 'returns 404 for invalid site' do + get '/html' + last_response.should be_not_found + end + end + +end + diff --git a/vendor/gems/rails_multisite/spec/connection_management_spec.rb b/vendor/gems/rails_multisite/spec/connection_management_spec.rb new file mode 100644 index 00000000000..690dffcf5f3 --- /dev/null +++ b/vendor/gems/rails_multisite/spec/connection_management_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' +require 'rails_multisite' + +describe RailsMultisite::ConnectionManagement do + + subject { RailsMultisite::ConnectionManagement } + + context 'default' do + its(:all_dbs) { should == ['default']} + + context 'current' do + before do + subject.establish_connection(db: 'default') + end + + its(:current_db) { should == 'default' } + its(:current_hostname) { should == 'default.localhost' } + end + + end + + context 'two dbs' do + + before do + subject.config_filename = "spec/fixtures/two_dbs.yml" + subject.load_settings! + end + its(:all_dbs) { should == ['default', 'second']} + + context 'second db' do + before do + subject.establish_connection(db: 'second') + end + + its(:current_db) { should == 'second' } + its(:current_hostname) { should == "second.localhost" } + end + + end + +end diff --git a/vendor/gems/rails_multisite/spec/fixtures/database.yml b/vendor/gems/rails_multisite/spec/fixtures/database.yml new file mode 100644 index 00000000000..7c370bf3490 --- /dev/null +++ b/vendor/gems/rails_multisite/spec/fixtures/database.yml @@ -0,0 +1,6 @@ +test: + adapter: sqlite3 + database: rails_multisite + timeout: 5000 + host_names: + - default.localhost diff --git a/vendor/gems/rails_multisite/spec/fixtures/two_dbs.yml b/vendor/gems/rails_multisite/spec/fixtures/two_dbs.yml new file mode 100644 index 00000000000..ab1049b96a4 --- /dev/null +++ b/vendor/gems/rails_multisite/spec/fixtures/two_dbs.yml @@ -0,0 +1,9 @@ +second: + adapter: sqlite3 + database: second_db + username: username + password: password + db_id: 1 + host_names: + - second.localhost + - 2nd.localhost \ No newline at end of file diff --git a/vendor/gems/rails_multisite/spec/spec_helper.rb b/vendor/gems/rails_multisite/spec/spec_helper.rb new file mode 100644 index 00000000000..19b054548d5 --- /dev/null +++ b/vendor/gems/rails_multisite/spec/spec_helper.rb @@ -0,0 +1,17 @@ +require 'rubygems' +require 'rails' +require 'active_record' + + +ENV["RAILS_ENV"] ||= 'test' +RSpec.configure do |config| + + config.color_enabled = true + + config.before(:suite) do + ActiveRecord::Base.configurations['test'] = (YAML::load(File.open("spec/fixtures/database.yml"))['test']) + end + +end + + diff --git a/vendor/gems/simple_handlebars_rails/lib/simple_handlebars_rails.rb b/vendor/gems/simple_handlebars_rails/lib/simple_handlebars_rails.rb new file mode 100644 index 00000000000..784c9c5313d --- /dev/null +++ b/vendor/gems/simple_handlebars_rails/lib/simple_handlebars_rails.rb @@ -0,0 +1,10 @@ +require 'sprockets' +require 'sprockets/engines' +require 'simple_handlebars_rails/simple_handlebars_template' + +module SimpleHandlebarsRails + class Engine < Rails::Engine + end + + Sprockets.register_engine '.shbrs', SimpleHandlebarsTemplate +end \ No newline at end of file diff --git a/vendor/gems/simple_handlebars_rails/lib/simple_handlebars_rails/simple_handlebars_template.rb b/vendor/gems/simple_handlebars_rails/lib/simple_handlebars_rails/simple_handlebars_template.rb new file mode 100644 index 00000000000..0486769a32e --- /dev/null +++ b/vendor/gems/simple_handlebars_rails/lib/simple_handlebars_rails/simple_handlebars_template.rb @@ -0,0 +1,38 @@ +require 'tilt/template' + +module SimpleHandlebarsRails + + # = Sprockets engine for MustacheTemplate templates + class SimpleHandlebarsTemplate < Tilt::Template + def self.default_mime_type + 'application/javascript' + end + + def initialize_engine + end + + def prepare + end + + # Generates Javascript code from a HandlebarsJS template. + # The SC template name is derived from the lowercase logical asset path + # by replacing non-alphanum characheters by underscores. + def evaluate(scope, locals, &block) + + template = data.dup + template.gsub!(/"/, '\\"') + template.gsub!(/\r?\n/, '\\n') + template.gsub!(/\t/, '\\t') + + # TODO: make this an option + templateName = scope.logical_path.downcase.gsub(/[^a-z0-9\/]/, '_') + templateName.gsub!(/^discourse\/templates\//, '') + + # TODO precompile so we can just have handlebars-runtime in prd + + result = "if (typeof HANDLEBARS_TEMPLATES == 'undefined') HANDLEBARS_TEMPLATES = {};\n" + result << "HANDLEBARS_TEMPLATES[\"#{templateName}\"] = Handlebars.compile(\"#{template}\");\n" + result + end + end +end diff --git a/vendor/gems/simple_handlebars_rails/simple_handlebars_rails.gemspec b/vendor/gems/simple_handlebars_rails/simple_handlebars_rails.gemspec new file mode 100644 index 00000000000..0b44ec1a59c --- /dev/null +++ b/vendor/gems/simple_handlebars_rails/simple_handlebars_rails.gemspec @@ -0,0 +1,18 @@ +# -*- encoding: utf-8 -*- +$:.push File.expand_path('../lib', __FILE__) + +Gem::Specification.new do |s| + s.name = "simple_handlebars_rails" + s.version = "0.0.1" + s.authors = ["Robin Ward"] + s.email = ["robin.ward@gmail.com"] + s.homepage = "" + s.summary = %q{Basic Mustache Support for Rails} + s.description = %q{Adds the Mustache plugin and a corresponding Sprockets engine to the asset pipeline in Rails applications.} + + s.add_development_dependency "rails", ["~> 3.1"] + s.add_dependency 'rails', ['~> 3.1'] + + s.files = Dir["lib/**/*"] + s.require_paths = ["lib"] +end \ No newline at end of file