diff --git a/Gemfile b/Gemfile index 1c8ec24..8f525bf 100644 --- a/Gemfile +++ b/Gemfile @@ -12,8 +12,16 @@ gem 'railties' gem 'coffee-rails' gem 'bootstrap-sass', "2.3.2.0" gem 'uglifier' - gem 'thin' +gem 'devise', '3.0.0.rc' +gem 'jquery-rails' +gem 'acts-as-taggable-on' +gem 'rails-jquery-tokeninput' +gem 'protected_attributes' + +source 'https://rails-assets.org' do + gem 'rails-assets-chosen' +end # Language detection gem "github-linguist", "~> 2.3.4" , require: "linguist" @@ -21,5 +29,4 @@ gem "github-linguist", "~> 2.3.4" , require: "linguist" # Syntax highlighter gem "pygments.rb", git: "https://github.com/tmm1/pygments.rb", branch: "master" gem 'diffy' -gem 'jquery-rails' -gem 'devise', '3.0.0.rc' + diff --git a/Gemfile.lock b/Gemfile.lock index 4981f2a..aaa3b8d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,6 +9,7 @@ GIT GEM remote: https://rubygems.org/ + remote: https://rails-assets.org/ specs: actionmailer (4.0.1) actionpack (= 4.0.1) @@ -34,6 +35,8 @@ GEM multi_json (~> 1.3) thread_safe (~> 0.1) tzinfo (~> 0.3.37) + acts-as-taggable-on (3.5.0) + activerecord (>= 3.2, < 5) arel (4.0.1) atomic (1.1.14) bcrypt-ruby (3.1.2) @@ -79,6 +82,8 @@ GEM orm_adapter (0.4.0) polyglot (0.3.3) posix-spawn (0.3.6) + protected_attributes (1.1.3) + activemodel (>= 4.0.1, < 5.0) rack (1.5.2) rack-test (0.6.2) rack (>= 1.0) @@ -90,6 +95,11 @@ GEM bundler (>= 1.3.0, < 2.0) railties (= 4.0.1) sprockets-rails (~> 2.0.0) + rails-assets-chosen (1.6.1) + rails-assets-jquery (>= 1.4.4) + rails-assets-jquery (3.1.0) + rails-jquery-tokeninput (0.2.6) + jquery-rails (>= 2) railties (4.0.1) actionpack (= 4.0.1) activesupport (= 4.0.1) @@ -135,14 +145,18 @@ PLATFORMS ruby DEPENDENCIES + acts-as-taggable-on bootstrap-sass (= 2.3.2.0) coffee-rails devise (= 3.0.0.rc) diffy github-linguist (~> 2.3.4) jquery-rails + protected_attributes pygments.rb! rails (~> 4.0.1) + rails-assets-chosen! + rails-jquery-tokeninput railties redcarpet sass-rails diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 5dcb43d..f2ae6c6 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -12,8 +12,13 @@ // //= require jquery //= require jquery_ujs +//= require jquery.tokeninput //= require_tree . // Loads all Bootstrap javascripts //= require bootstrap // + +//Needed for tag auto completition +//= require chosen +// diff --git a/app/assets/javascripts/posts.js b/app/assets/javascripts/posts.js index f890d83..8fe6e9c 100644 --- a/app/assets/javascripts/posts.js +++ b/app/assets/javascripts/posts.js @@ -82,4 +82,11 @@ $(document).ready(function() { }, 100); }); + $('#post_tags').tokenInput("/posts/tags.json", { + tokenValue: "name", + allowFreeTagging: true, + preventDuplicates: true, + prePopulate: $("#post_tags").data("pre") + }); }); + diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index fb3e57f..f49e327 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -9,7 +9,9 @@ * compiled file, but it's generally better to create a new file per style scope. * *= require_self + *= require token-input *= require_tree . + *= require chosen */ /* compensate the overlap caused by bootstrap top navigation, min. 40px at the top! */ diff --git a/app/assets/stylesheets/posts.css.scss b/app/assets/stylesheets/posts.css.scss index 1e1f0d0..9750d9a 100644 --- a/app/assets/stylesheets/posts.css.scss +++ b/app/assets/stylesheets/posts.css.scss @@ -86,6 +86,10 @@ padding-bottom: 20px; } +.set-lower-25 { + padding-top: 25px; +} + input.input-sm { height: 12px; width: 32px; diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index deb7973..6ed4ab9 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -30,9 +30,9 @@ def show @post = Post.find(params[:id]) respond_to do |format| - format.html # show.html.erb - format.text { render :text => @post.content } - end + format.html # show.html.erb + format.text { render :text => @post.content } + end end # GET /posts/new @@ -252,6 +252,17 @@ def parentlist end end + def tags + @tags = ActsAsTaggableOn::Tag.where("tags.name LIKE ?", "%#{params[:q]}%") + respond_to do |format| + format.json {render :json => @tags.map{|t| {:id => t.name, :name => t.name }}} + end + end + + def search_by_tag + @posts = Post.tagged_with(params[:tag_name]) + end + private def post_params diff --git a/app/models/post.rb b/app/models/post.rb index bcccf31..10da91e 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1,5 +1,6 @@ class Post < ActiveRecord::Base require 'tempfile' + #require 'acts-as-taggable-on' def self.MaxUploadSize 102400 # 100kb @@ -22,6 +23,10 @@ def self.MaxUploadSize :foreign_key => :parent_id, :dependent => :destroy belongs_to :parent, :class_name => "Post" + acts_as_taggable + + attr_accessible :tag_list, :title, :author, :content_type, :content + attr_accessor :uploaded_file def self.file_extensions @@ -63,11 +68,28 @@ def all_comments end def create_version(params) - child = self.class.new(params) - child.parent_id = self.id - self.newest = false - self.save - return child + if self.newest != true + children_ids = self.children.map{|x| x.id} + newest_post = Post.where(:id => children_ids, "newest" => true).first + former_newest_params = newest_post.serializable_hash + former_newest_params.delete("id") + former_newest_params["newest"] = false + former_newest = self.class.new(former_newest_params) + former_newest.save + newest_post.update_attributes(params) + newest_post.parent_id = former_newest.id + return newest_post + else + old_params = self.serializable_hash + old_params.delete("id") + parent = self.class.new(old_params) + parent.newest = false + self.newest = true + parent.save + self.parent_id = parent.id + self.update_attributes(params) + return self + end end def self.new_from_file(params, data) @@ -136,8 +158,18 @@ def diff_to_parent(post_id = nil) end end + def children + child = Post.where(:parent_id => self.id).first + return [] if child.nil? + return child.children + [child] + end + def parents return [] if parent_id.nil? - return parent.parents + [parent] + return [parent] + parent.parents + end + + def tag_list_tokens=(tokens) + self.tag_list = tokens.gsub("'", "") end end diff --git a/app/views/posts/_form.html.erb b/app/views/posts/_form.html.erb index 84ac167..a73b514 100644 --- a/app/views/posts/_form.html.erb +++ b/app/views/posts/_form.html.erb @@ -45,7 +45,8 @@ :id => "inputContentType")%> - + +
@@ -60,7 +61,7 @@
-
+
-
+
+
- <%= f.submit :class => "btn pull-right" %> + +
+ <%= f.text_field :tag_list, :id => "post_tags", + "data-pre" => @post.tags.map(&:attributes).to_json %> +
+
+ +
+ <%= f.submit :class => "btn pull-right" %>
diff --git a/app/views/posts/_options.html.erb b/app/views/posts/_options.html.erb index 8be4cd4..bd39d0a 100644 --- a/app/views/posts/_options.html.erb +++ b/app/views/posts/_options.html.erb @@ -16,10 +16,11 @@ <% end %> <% end %> - <% if @post.parent.present? && - !current_page?(root_url) && !current_page?(posts_path) %> + <% if !current_page?(root_url) && !current_page?(posts_path) %> + <%= !@post.parent.nil? ? parent_placeholder = + @post.parent.id.to_s : parent_placeholder = "" %> <%= text_field_tag(:diff_id, nil, :class => "input-sm", - :placeholder => @post.parent.id.to_s) %> + :placeholder => parent_placeholder) %> <%= button_tag(:type => "submit", :class => "btn btn-mini", :title => "Diff") do %> @@ -30,13 +31,20 @@ <% end %> @@ -58,8 +66,8 @@ <% end %> <% end %> - <%= link_to post_path(@post, :format => :json), - :method => :delete, :remote => true, + <%= link_to post_path(@post), + :method => :delete, data: { confirm: 'Are you sure?' }, :class => "btn btn-mini", :id => "delete_trigger", :title => "Delete" do %> diff --git a/app/views/posts/_post.html.erb b/app/views/posts/_post.html.erb index 993e27c..8834aed 100644 --- a/app/views/posts/_post.html.erb +++ b/app/views/posts/_post.html.erb @@ -19,7 +19,7 @@ - <%= @post.updated_at.strftime('%a, %d %b %Y %H:%M:%S') %> -<%= render 'options' %> +<%= render 'posts/options' %>

diff --git a/app/views/posts/parentlist.html.erb b/app/views/posts/parentlist.html.erb index 869f60a..8b94fb1 100644 --- a/app/views/posts/parentlist.html.erb +++ b/app/views/posts/parentlist.html.erb @@ -1,6 +1,15 @@
- <% @post.parents.each.with_index(1) do |parent, id| %> -
  • <%= link_to(diff_post_path(@post, :diff_id => parent.id)){"Version: #{id}"} %>
  • + <% children = @post.children %> + <% parents = @post.parents %> + <% children.each.with_index(0) do |child, id| %> +
  • <%= link_to(diff_post_path(@post, + :diff_id => child.id)){"Version: #{parents.size + 1 + children.size - id}"} %>
  • + <% end %> +
  • <%= link_to(diff_post_path(@post, + :diff_id => @post.id)) { "Version: #{parents.size + 1} (self)"} %>
  • + <% parents.each.with_index(0) do |parent, id| %> +
  • <%= link_to(diff_post_path(@post, + :diff_id => parent.id)){"Version: #{parents.size - id}"} %>
  • <% end %>
    diff --git a/app/views/posts/search_by_tag.html.erb b/app/views/posts/search_by_tag.html.erb new file mode 100644 index 0000000..aebbd64 --- /dev/null +++ b/app/views/posts/search_by_tag.html.erb @@ -0,0 +1,5 @@ +<% unless @posts.blank? %> +
    + <%= render :partial => "post", :collection => @posts %> +
    +<% end %> diff --git a/app/views/posts/show.html.erb b/app/views/posts/show.html.erb index 642b7db..311a28a 100644 --- a/app/views/posts/show.html.erb +++ b/app/views/posts/show.html.erb @@ -14,6 +14,12 @@ +
    + <% @post.tag_counts_on(:tags).map(&:name).each do |tag| %> + <%= link_to tag, {:action => 'search_by_tag', :tag_name => tag}, + :class => "btn btn-mini" %> + <% end %> +

    <%= render 'like_dislike' %> <%= render 'comments/comment' %> diff --git a/config/application.rb b/config/application.rb index e13407b..7fa32c3 100644 --- a/config/application.rb +++ b/config/application.rb @@ -18,7 +18,7 @@ class Application < Rails::Application # Custom directories with classes and modules you want to be autoloadable. # config.autoload_paths += %W(#{config.root}/extras) - # Only load the plugins named here, in the order given (default is alphabetical). + # 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 ] diff --git a/config/routes.rb b/config/routes.rb index af2f4eb..2cfb6ff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,8 @@ # note that the helper methods are still called with 'posts' instead of 'p' # for legibility purposes + get "posts/tags" => "posts#tags", :as => :tags + resources :posts, :path => :p do resources :comments resources :linecomments @@ -12,8 +14,10 @@ get :dislike get :markdown get :parentlist - end + get :search_by_tag + end end + resources :posts, :as => :p get '/help' => 'posts#help', :as => :help diff --git a/db/migrate/20160707134101_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb b/db/migrate/20160707134101_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..6bbd559 --- /dev/null +++ b/db/migrate/20160707134101_acts_as_taggable_on_migration.acts_as_taggable_on_engine.rb @@ -0,0 +1,31 @@ +# This migration comes from acts_as_taggable_on_engine (originally 1) +class ActsAsTaggableOnMigration < ActiveRecord::Migration + def self.up + create_table :tags do |t| + t.string :name + end + + create_table :taggings do |t| + t.references :tag + + # You should make sure that the column created is + # long enough to store the required class names. + t.references :taggable, polymorphic: true + t.references :tagger, polymorphic: true + + # Limit is created to prevent MySQL error on index + # length for MyISAM table type: http://bit.ly/vgW2Ql + t.string :context, limit: 128 + + t.datetime :created_at + end + + add_index :taggings, :tag_id + add_index :taggings, [:taggable_id, :taggable_type, :context] + end + + def self.down + drop_table :taggings + drop_table :tags + end +end diff --git a/db/migrate/20160707134102_add_missing_unique_indices.acts_as_taggable_on_engine.rb b/db/migrate/20160707134102_add_missing_unique_indices.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..4ca676f --- /dev/null +++ b/db/migrate/20160707134102_add_missing_unique_indices.acts_as_taggable_on_engine.rb @@ -0,0 +1,20 @@ +# This migration comes from acts_as_taggable_on_engine (originally 2) +class AddMissingUniqueIndices < ActiveRecord::Migration + def self.up + add_index :tags, :name, unique: true + + remove_index :taggings, :tag_id + remove_index :taggings, [:taggable_id, :taggable_type, :context] + add_index :taggings, + [:tag_id, :taggable_id, :taggable_type, :context, :tagger_id, :tagger_type], + unique: true, name: 'taggings_idx' + end + + def self.down + remove_index :tags, :name + + remove_index :taggings, name: 'taggings_idx' + add_index :taggings, :tag_id + add_index :taggings, [:taggable_id, :taggable_type, :context] + end +end diff --git a/db/migrate/20160707134103_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb b/db/migrate/20160707134103_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..8edb508 --- /dev/null +++ b/db/migrate/20160707134103_add_taggings_counter_cache_to_tags.acts_as_taggable_on_engine.rb @@ -0,0 +1,15 @@ +# This migration comes from acts_as_taggable_on_engine (originally 3) +class AddTaggingsCounterCacheToTags < ActiveRecord::Migration + def self.up + add_column :tags, :taggings_count, :integer, default: 0 + + ActsAsTaggableOn::Tag.reset_column_information + ActsAsTaggableOn::Tag.find_each do |tag| + ActsAsTaggableOn::Tag.reset_counters(tag.id, :taggings) + end + end + + def self.down + remove_column :tags, :taggings_count + end +end diff --git a/db/migrate/20160707134104_add_missing_taggable_index.acts_as_taggable_on_engine.rb b/db/migrate/20160707134104_add_missing_taggable_index.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..71f2d7f --- /dev/null +++ b/db/migrate/20160707134104_add_missing_taggable_index.acts_as_taggable_on_engine.rb @@ -0,0 +1,10 @@ +# This migration comes from acts_as_taggable_on_engine (originally 4) +class AddMissingTaggableIndex < ActiveRecord::Migration + def self.up + add_index :taggings, [:taggable_id, :taggable_type, :context] + end + + def self.down + remove_index :taggings, [:taggable_id, :taggable_type, :context] + end +end diff --git a/db/migrate/20160707134105_change_collation_for_tag_names.acts_as_taggable_on_engine.rb b/db/migrate/20160707134105_change_collation_for_tag_names.acts_as_taggable_on_engine.rb new file mode 100644 index 0000000..bfb06bc --- /dev/null +++ b/db/migrate/20160707134105_change_collation_for_tag_names.acts_as_taggable_on_engine.rb @@ -0,0 +1,10 @@ +# This migration comes from acts_as_taggable_on_engine (originally 5) +# This migration is added to circumvent issue #623 and have special characters +# work properly +class ChangeCollationForTagNames < ActiveRecord::Migration + def up + if ActsAsTaggableOn::Utils.using_mysql? + execute("ALTER TABLE tags MODIFY name varchar(255) CHARACTER SET utf8 COLLATE utf8_bin;") + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6287fcd..33810df 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20140202010251) do +ActiveRecord::Schema.define(version: 20160707134105) do create_table "apikeys", force: true do |t| t.string "key" @@ -60,6 +60,26 @@ t.string "content_type", default: "None" end + create_table "taggings", force: true do |t| + t.integer "tag_id" + t.integer "taggable_id" + t.string "taggable_type" + t.integer "tagger_id" + t.string "tagger_type" + t.string "context", limit: 128 + t.datetime "created_at" + end + + add_index "taggings", ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true + add_index "taggings", ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" + + create_table "tags", force: true do |t| + t.string "name" + t.integer "taggings_count", default: 0 + end + + add_index "tags", ["name"], name: "index_tags_on_name", unique: true + create_table "users", force: true do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false diff --git a/test/functional/posts_controller_test.rb b/test/functional/posts_controller_test.rb index ee2452d..1c6b950 100644 --- a/test/functional/posts_controller_test.rb +++ b/test/functional/posts_controller_test.rb @@ -80,18 +80,18 @@ class PostsControllerTest < ActionController::TestCase assert_nil assigns(:post).parent_id end - test "should update post with a new post and old post as parent" do + test "should update post with an old post and a new post as parent" do put :update, id: @post, post: { content: @post.content + "new", title: @post.title } - assert_redirected_to post_path(assigns(:post)) - assert_not_equal assigns(:post).id, @post.id - assert_equal assigns(:post).parent_id, @post.id - @post.reload - assert_equal false, @post.newest + assert_redirected_to post_path(@post) + assert_equal assigns(:post).id, @post.id + assert_not_equal assigns(:post).parent_id, @post.parent_id + assigns(:post).reload + assert_equal true, @post.newest assert_equal true, assigns(:post).newest end - test "should update post with a new post from file and old post as parent" do + test "should update post with an old post from file and a new post as parent" do test_image = "test/fixtures/test.txt" file = Rack::Test::UploadedFile.new(test_image, "text/plain") @@ -101,11 +101,11 @@ class PostsControllerTest < ActionController::TestCase author: @post.author, upload_file: file } - assert_redirected_to post_path(assigns(:post)) - assert_not_equal assigns(:post).id, @post.id - assert_equal assigns(:post).parent_id, @post.id - @post.reload - assert_equal false, @post.newest + assert_redirected_to post_path(@post) + assert_equal assigns(:post).id, @post.id + assert_not_equal assigns(:post).parent_id, @post.parent_id + assigns(:post).reload + assert_equal true, @post.newest assert_equal true, assigns(:post).newest end diff --git a/test/unit/post_test.rb b/test/unit/post_test.rb index e2d800a..6d6b6ad 100644 --- a/test/unit/post_test.rb +++ b/test/unit/post_test.rb @@ -64,15 +64,16 @@ class PostTest < ActiveSupport::TestCase assert_equal parent, child.parent end - test "should make new version of post" do - assert_not_nil parent = Post.find(1) - assert_not_nil child = parent.create_version({ + test "should stay the same post after creating new version" do + assert_not_nil old_post = Post.find(1) + content_before = old_post.content + old_post.content = old_post.content + "new" + assert_not_nil new_post = old_post.create_version({ :content => "far out"}) - assert_not_equal child, parent - assert child.save - assert_equal parent.id, child.parent_id - assert_not_equal true, parent.newest - assert_equal true, child.newest + assert_equal new_post, old_post + assert_not_equal old_post.content, content_before + assert_not_equal new_post.content, content_before + assert_not_equal new_post.content, new_post.parent.content end test "collect all parent ids" do diff --git a/vendor/assets/javascripts/jquery.tokeninput.js b/vendor/assets/javascripts/jquery.tokeninput.js new file mode 100755 index 0000000..4b69d82 --- /dev/null +++ b/vendor/assets/javascripts/jquery.tokeninput.js @@ -0,0 +1,1106 @@ +/* + * jQuery Plugin: Tokenizing Autocomplete Text Entry + * Version 1.6.2 + * + * Copyright (c) 2009 James Smith (http://loopj.com) + * Licensed jointly under the GPL and MIT licenses, + * choose which one suits your project best! + * + */ +;(function ($) { + var DEFAULT_SETTINGS = { + // Search settings + method: "GET", + queryParam: "q", + searchDelay: 300, + minChars: 1, + propertyToSearch: "name", + jsonContainer: null, + contentType: "json", + excludeCurrent: false, + excludeCurrentParameter: "x", + + // Prepopulation settings + prePopulate: null, + processPrePopulate: false, + + // Display settings + hintText: "Type in a search term", + noResultsText: "No results", + searchingText: "Searching...", + deleteText: "×", + animateDropdown: true, + placeholder: null, + theme: null, + zindex: 999, + resultsLimit: null, + + enableHTML: false, + + resultsFormatter: function(item) { + var string = item[this.propertyToSearch]; + return "
  • " + (this.enableHTML ? string : _escapeHTML(string)) + "
  • "; + }, + + tokenFormatter: function(item) { + var string = item[this.propertyToSearch]; + return "
  • " + (this.enableHTML ? string : _escapeHTML(string)) + "

  • "; + }, + + // Tokenization settings + tokenLimit: null, + tokenDelimiter: ",", + preventDuplicates: false, + tokenValue: "id", + + // Behavioral settings + allowFreeTagging: false, + allowTabOut: false, + autoSelectFirstResult: false, + + // Callbacks + onResult: null, + onCachedResult: null, + onAdd: null, + onFreeTaggingAdd: null, + onDelete: null, + onReady: null, + + // Other settings + idPrefix: "token-input-", + + // Keep track if the input is currently in disabled mode + disabled: false + }; + + // Default classes to use when theming + var DEFAULT_CLASSES = { + tokenList : "token-input-list", + token : "token-input-token", + tokenReadOnly : "token-input-token-readonly", + tokenDelete : "token-input-delete-token", + selectedToken : "token-input-selected-token", + highlightedToken : "token-input-highlighted-token", + dropdown : "token-input-dropdown", + dropdownItem : "token-input-dropdown-item", + dropdownItem2 : "token-input-dropdown-item2", + selectedDropdownItem : "token-input-selected-dropdown-item", + inputToken : "token-input-input-token", + focused : "token-input-focused", + disabled : "token-input-disabled" + }; + + // Input box position "enum" + var POSITION = { + BEFORE : 0, + AFTER : 1, + END : 2 + }; + + // Keys "enum" + var KEY = { + BACKSPACE : 8, + TAB : 9, + ENTER : 13, + ESCAPE : 27, + SPACE : 32, + PAGE_UP : 33, + PAGE_DOWN : 34, + END : 35, + HOME : 36, + LEFT : 37, + UP : 38, + RIGHT : 39, + DOWN : 40, + NUMPAD_ENTER : 108, + COMMA : 188 + }; + + var HTML_ESCAPES = { + '&' : '&', + '<' : '<', + '>' : '>', + '"' : '"', + "'" : ''', + '/' : '/' + }; + + var HTML_ESCAPE_CHARS = /[&<>"'\/]/g; + + function coerceToString(val) { + return String((val === null || val === undefined) ? '' : val); + } + + function _escapeHTML(text) { + return coerceToString(text).replace(HTML_ESCAPE_CHARS, function(match) { + return HTML_ESCAPES[match]; + }); + } + + // Additional public (exposed) methods + var methods = { + init: function(url_or_data_or_function, options) { + var settings = $.extend({}, DEFAULT_SETTINGS, options || {}); + + return this.each(function () { + $(this).data("settings", settings); + $(this).data("tokenInputObject", new $.TokenList(this, url_or_data_or_function, settings)); + }); + }, + clear: function() { + this.data("tokenInputObject").clear(); + return this; + }, + add: function(item) { + this.data("tokenInputObject").add(item); + return this; + }, + remove: function(item) { + this.data("tokenInputObject").remove(item); + return this; + }, + get: function() { + return this.data("tokenInputObject").getTokens(); + }, + toggleDisabled: function(disable) { + this.data("tokenInputObject").toggleDisabled(disable); + return this; + }, + setOptions: function(options){ + $(this).data("settings", $.extend({}, $(this).data("settings"), options || {})); + return this; + }, + destroy: function () { + if (this.data("tokenInputObject")) { + this.data("tokenInputObject").clear(); + var tmpInput = this; + var closest = this.parent(); + closest.empty(); + tmpInput.show(); + closest.append(tmpInput); + return tmpInput; + } + } + }; + + // Expose the .tokenInput function to jQuery as a plugin + $.fn.tokenInput = function (method) { + // Method calling and initialization logic + if (methods[method]) { + return methods[method].apply(this, Array.prototype.slice.call(arguments, 1)); + } else { + return methods.init.apply(this, arguments); + } + }; + + // TokenList class for each input + $.TokenList = function (input, url_or_data, settings) { + // + // Initialization + // + + // Configure the data source + if (typeof(url_or_data) === "string" || typeof(url_or_data) === "function") { + // Set the url to query against + $(input).data("settings").url = url_or_data; + + // If the URL is a function, evaluate it here to do our initalization work + var url = computeURL(); + + // Make a smart guess about cross-domain if it wasn't explicitly specified + if ($(input).data("settings").crossDomain === undefined && typeof url === "string") { + if(url.indexOf("://") === -1) { + $(input).data("settings").crossDomain = false; + } else { + $(input).data("settings").crossDomain = (location.href.split(/\/+/g)[1] !== url.split(/\/+/g)[1]); + } + } + } else if (typeof(url_or_data) === "object") { + // Set the local data to search through + $(input).data("settings").local_data = url_or_data; + } + + // Build class names + if($(input).data("settings").classes) { + // Use custom class names + $(input).data("settings").classes = $.extend({}, DEFAULT_CLASSES, $(input).data("settings").classes); + } else if($(input).data("settings").theme) { + // Use theme-suffixed default class names + $(input).data("settings").classes = {}; + $.each(DEFAULT_CLASSES, function(key, value) { + $(input).data("settings").classes[key] = value + "-" + $(input).data("settings").theme; + }); + } else { + $(input).data("settings").classes = DEFAULT_CLASSES; + } + + // Save the tokens + var saved_tokens = []; + + // Keep track of the number of tokens in the list + var token_count = 0; + + // Basic cache to save on db hits + var cache = new $.TokenList.Cache(); + + // Keep track of the timeout, old vals + var timeout; + var input_val; + + // Create a new text input an attach keyup events + var input_box = $("") + .css({ + outline: "none" + }) + .attr("id", $(input).data("settings").idPrefix + input.id) + .focus(function () { + if ($(input).data("settings").disabled) { + return false; + } else + if ($(input).data("settings").tokenLimit === null || $(input).data("settings").tokenLimit !== token_count) { + show_dropdown_hint(); + } + token_list.addClass($(input).data("settings").classes.focused); + }) + .blur(function () { + hide_dropdown(); + + if ($(input).data("settings").allowFreeTagging) { + add_freetagging_tokens(); + } + + $(this).val(""); + token_list.removeClass($(input).data("settings").classes.focused); + }) + .bind("keyup keydown blur update", resize_input) + .keydown(function (event) { + var previous_token; + var next_token; + + switch(event.keyCode) { + case KEY.LEFT: + case KEY.RIGHT: + case KEY.UP: + case KEY.DOWN: + if(this.value.length === 0) { + previous_token = input_token.prev(); + next_token = input_token.next(); + + if((previous_token.length && previous_token.get(0) === selected_token) || + (next_token.length && next_token.get(0) === selected_token)) { + // Check if there is a previous/next token and it is selected + if(event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) { + deselect_token($(selected_token), POSITION.BEFORE); + } else { + deselect_token($(selected_token), POSITION.AFTER); + } + } else if((event.keyCode === KEY.LEFT || event.keyCode === KEY.UP) && previous_token.length) { + // We are moving left, select the previous token if it exists + select_token($(previous_token.get(0))); + } else if((event.keyCode === KEY.RIGHT || event.keyCode === KEY.DOWN) && next_token.length) { + // We are moving right, select the next token if it exists + select_token($(next_token.get(0))); + } + } else { + var dropdown_item = null; + + if (event.keyCode === KEY.DOWN || event.keyCode === KEY.RIGHT) { + dropdown_item = $(dropdown).find('li').first(); + + if (selected_dropdown_item) { + dropdown_item = $(selected_dropdown_item).next(); + } + } else { + dropdown_item = $(dropdown).find('li').last(); + + if (selected_dropdown_item) { + dropdown_item = $(selected_dropdown_item).prev(); + } + } + + select_dropdown_item(dropdown_item); + } + + break; + + case KEY.BACKSPACE: + previous_token = input_token.prev(); + + if (this.value.length === 0) { + if (selected_token) { + delete_token($(selected_token)); + hiddenInput.change(); + } else if(previous_token.length) { + select_token($(previous_token.get(0))); + } + + return false; + } else if($(this).val().length === 1) { + hide_dropdown(); + } else { + // set a timeout just long enough to let this function finish. + setTimeout(function(){ do_search(); }, 5); + } + break; + + case KEY.TAB: + case KEY.ENTER: + case KEY.NUMPAD_ENTER: + case KEY.COMMA: + if(selected_dropdown_item) { + add_token($(selected_dropdown_item).data("tokeninput")); + hiddenInput.change(); + } else { + if ($(input).data("settings").allowFreeTagging) { + if($(input).data("settings").allowTabOut && $(this).val() === "") { + return true; + } else { + add_freetagging_tokens(); + } + } else { + $(this).val(""); + if($(input).data("settings").allowTabOut) { + return true; + } + } + event.stopPropagation(); + event.preventDefault(); + } + return false; + + case KEY.ESCAPE: + hide_dropdown(); + return true; + + default: + if (String.fromCharCode(event.which)) { + // set a timeout just long enough to let this function finish. + setTimeout(function(){ do_search(); }, 5); + } + break; + } + }); + + // Keep reference for placeholder + if (settings.placeholder) { + input_box.attr("placeholder", settings.placeholder); + } + + // Keep a reference to the original input box + var hiddenInput = $(input) + .hide() + .val("") + .focus(function () { + focusWithTimeout(input_box); + }) + .blur(function () { + input_box.blur(); + + //return the object to this can be referenced in the callback functions. + return hiddenInput; + }) + ; + + // Keep a reference to the selected token and dropdown item + var selected_token = null; + var selected_token_index = 0; + var selected_dropdown_item = null; + + // The list to store the token items in + var token_list = $("