diff --git a/.gitignore b/.gitignore index 6f74c52..952728c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # Ignore the default SQLite database. /db/*.sqlite3 +/db/sphinx # Ignore all logfiles and tempfiles. /log/*.log diff --git a/Gemfile b/Gemfile index 75c6be3..f4d6cb7 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -gem "rails", "3.2.2" +gem "rails", "3.2.6" # Bundle edge Rails instead: # gem "rails", :git => "git://github.com/rails/rails.git" @@ -27,6 +27,8 @@ gem "htmlentities" gem "rdiscount" +gem "thinking-sphinx", "2.0.12" + group :test, :development do gem "rspec-rails", "~> 2.6" gem "machinist" diff --git a/Gemfile.lock b/Gemfile.lock index f24e5b7..005b853 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,31 +1,31 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (3.2.2) - actionpack (= 3.2.2) - mail (~> 2.4.0) - actionpack (3.2.2) - activemodel (= 3.2.2) - activesupport (= 3.2.2) + actionmailer (3.2.6) + actionpack (= 3.2.6) + mail (~> 2.4.4) + actionpack (3.2.6) + activemodel (= 3.2.6) + activesupport (= 3.2.6) builder (~> 3.0.0) erubis (~> 2.7.0) journey (~> 1.0.1) rack (~> 1.4.0) - rack-cache (~> 1.1) + rack-cache (~> 1.2) rack-test (~> 0.6.1) - sprockets (~> 2.1.2) - activemodel (3.2.2) - activesupport (= 3.2.2) + sprockets (~> 2.1.3) + activemodel (3.2.6) + activesupport (= 3.2.6) builder (~> 3.0.0) - activerecord (3.2.2) - activemodel (= 3.2.2) - activesupport (= 3.2.2) + activerecord (3.2.6) + activemodel (= 3.2.6) + activesupport (= 3.2.6) arel (~> 3.0.2) tzinfo (~> 0.3.29) - activeresource (3.2.2) - activemodel (= 3.2.2) - activesupport (= 3.2.2) - activesupport (3.2.2) + activeresource (3.2.6) + activemodel (= 3.2.6) + activesupport (= 3.2.6) + activesupport (3.2.6) i18n (~> 0.6) multi_json (~> 1.0) arel (3.0.2) @@ -36,26 +36,26 @@ GEM erubis (2.7.0) exception_notification (2.6.1) actionmailer (>= 3.0.4) - execjs (1.3.2) + execjs (1.4.0) multi_json (~> 1.0) hike (1.2.1) htmlentities (4.3.1) i18n (0.6.0) - journey (1.0.3) - jquery-rails (2.0.1) + journey (1.0.4) + jquery-rails (2.0.2) railties (>= 3.2.0, < 5.0) thor (~> 0.14) - json (1.6.5) + json (1.7.3) kgio (2.7.4) machinist (2.0) mail (2.4.4) i18n (>= 0.4.0) mime-types (~> 1.16) treetop (~> 1.4.8) - mime-types (1.18) - multi_json (1.1.0) + mime-types (1.19) + multi_json (1.3.6) mysql2 (0.3.11) - nokogiri (1.5.4) + nokogiri (1.5.5) polyglot (0.3.3) rack (1.4.1) rack-cache (1.2) @@ -64,53 +64,58 @@ GEM rack rack-test (0.6.1) rack (>= 1.0) - rails (3.2.2) - actionmailer (= 3.2.2) - actionpack (= 3.2.2) - activerecord (= 3.2.2) - activeresource (= 3.2.2) - activesupport (= 3.2.2) + rails (3.2.6) + actionmailer (= 3.2.6) + actionpack (= 3.2.6) + activerecord (= 3.2.6) + activeresource (= 3.2.6) + activesupport (= 3.2.6) bundler (~> 1.0) - railties (= 3.2.2) - railties (3.2.2) - actionpack (= 3.2.2) - activesupport (= 3.2.2) + railties (= 3.2.6) + railties (3.2.6) + actionpack (= 3.2.6) + activesupport (= 3.2.6) rack-ssl (~> 1.3.2) rake (>= 0.8.7) rdoc (~> 3.4) - thor (~> 0.14.6) - raindrops (0.9.0) + thor (>= 0.14.6, < 2.0) + raindrops (0.10.0) rake (0.9.2.2) rdiscount (1.6.8) rdoc (3.12) json (~> 1.4) - rspec (2.9.0) - rspec-core (~> 2.9.0) - rspec-expectations (~> 2.9.0) - rspec-mocks (~> 2.9.0) - rspec-core (2.9.0) - rspec-expectations (2.9.0) + riddle (1.5.2) + rspec (2.11.0) + rspec-core (~> 2.11.0) + rspec-expectations (~> 2.11.0) + rspec-mocks (~> 2.11.0) + rspec-core (2.11.0) + rspec-expectations (2.11.1) diff-lcs (~> 1.1.3) - rspec-mocks (2.9.0) - rspec-rails (2.9.0) + rspec-mocks (2.11.1) + rspec-rails (2.11.0) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec (~> 2.9.0) - sprockets (2.1.2) + rspec (~> 2.11.0) + sprockets (2.1.3) hike (~> 1.2) rack (~> 1.0) tilt (~> 1.1, != 1.3.0) sqlite3 (1.3.6) - thor (0.14.6) + thinking-sphinx (2.0.12) + activerecord (>= 3.0.3) + builder (>= 2.1.2) + riddle (>= 1.5.2) + thor (0.15.4) tilt (1.3.3) treetop (1.4.10) polyglot polyglot (>= 0.3.1) - tzinfo (0.3.32) - uglifier (1.2.4) + tzinfo (0.3.33) + uglifier (1.2.6) execjs (>= 0.3.0) - multi_json (>= 1.0.2) + multi_json (~> 1.3) unicorn (4.3.1) kgio (~> 2.6) rack @@ -128,9 +133,10 @@ DEPENDENCIES machinist mysql2 nokogiri - rails (= 3.2.2) + rails (= 3.2.6) rdiscount rspec-rails (~> 2.6) sqlite3 + thinking-sphinx (= 2.0.12) uglifier unicorn diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index f1a270b..0d78554 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -297,7 +297,8 @@ div#footer a { /* stories */ ol.stories, -ol.comments { +ol.comments, +ol.search_results { padding: 0; list-style: none; margin: 0; @@ -321,6 +322,16 @@ ol.comments.preview { padding: 0; } +ol.search_results { + margin-left: 20px; + margin-bottom: 0em; + padding-left: 6px; +} + +ol.search_results li.story { + padding-bottom: 0.75em; +} + div.voters { float: left; margin-top: -4px; @@ -371,13 +382,13 @@ li.downvoted div.voters a.downvoter { border-top-color: gray; } -ol.stories li.story, -ol.comments li.comment { +li.story, +li.comment { clear: both; padding-top: 0.4em; padding-bottom: 0.4em; } -ol.comments li.comment { +li.comment { padding-top: 0.5em; padding-bottom: 0.5em; } @@ -389,21 +400,21 @@ li div.details { padding-top: 0.1em; } -ol.comments li.negative { +li.negative { opacity: 0.7; color: gray !important; } -ol.comments li.negative_3 { +li.negative_3 { opacity: 0.4; } -ol.comments li.negative_5 { +li.negative_5 { opacity: 0.3; } -ol.comments li.negative_7 { +li.negative_7 { opacity: 0.2; } -ol.comments li.highlighted { +li.comment.highlighted { background-color: #ffffbf; } @@ -417,7 +428,7 @@ li .link a { text-decoration: none; } -ol.stories a.tag { +li.story a.tag { vertical-align: middle; } @@ -436,7 +447,7 @@ li .byline { color: #888; font-size: 8.5pt; } -ol.stories li .byline { +li.story .byline { margin-top: 1px; } li .byline a { @@ -461,7 +472,8 @@ div.story_content { margin-bottom: 3em; } -div.morelink { +div.morelink, +div.page_link_buttons { margin-top: 1.5em; margin-left: 40px; } @@ -470,6 +482,24 @@ div.morelink a { font-weight: bold; text-decoration: none; } +div.page_link_buttons { + font-weight: bold; + margin-top: 2em; +} +div.page_link_buttons a { + color: #666; + border: 1px solid #d0d0d0; + background-color: #f3f3f3; + padding: 0.25em 0.5em; + font-weight: bold; + text-decoration: none; + margin-left: 0.5em; +} +div.page_link_buttons a.cur { + background-color: transparent; + border-color: transparent; +} + div.story_text { margin-bottom: 1.5em; diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb new file mode 100644 index 0000000..7b9bb16 --- /dev/null +++ b/app/controllers/search_controller.rb @@ -0,0 +1,24 @@ +class SearchController < ApplicationController + def index + @title = "Search" + @cur_url = "/search" + + @search = Search.new + + if params[:q].present? + @search.q = params[:q] + @search.what = params[:what] + @search.order = params[:order] + + if params[:page] + @search.page = params[:page].to_i + end + + if @search.valid? + @search.search_for_user!(@user) + end + end + + render :action => "index" + end +end diff --git a/app/models/comment.rb b/app/models/comment.rb index 6ec975b..ef019a3 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -17,6 +17,16 @@ class Comment < ActiveRecord::Base MAX_EDIT_MINS = 45 + define_index do + indexes comment + indexes user.username, :as => :author + + has "(upvotes - downvotes)", :as => :score, :type => :integer, + :sortable => true + + has created_at + end + validate do self.comment.to_s.strip == "" && errors.add(:comment, "cannot be blank.") diff --git a/app/models/search.rb b/app/models/search.rb new file mode 100644 index 0000000..0bc9509 --- /dev/null +++ b/app/models/search.rb @@ -0,0 +1,88 @@ +class Search + include ActiveModel::Validations + include ActiveModel::Conversion + include ActiveModel::AttributeMethods + extend ActiveModel::Naming + + attr_accessor :q, :what, :order + attr_accessor :results, :page, :total_results, :per_page + + validates_length_of :q, :minimum => 2 + + def initialize + @q = "" + @what = "all" + @order = "relevance" + + @page = 1 + @per_page = 20 + + @results = [] + @total_results = 0 + end + + def persisted? + false + end + + def to_url_params + [ :q, :what, :order ].map{|p| "#{p}=#{CGI.escape(self.send(p))}" + }.join("&") + end + + def search_for_user!(user) + opts = { + :match_mode => :extended, + :rank_mode => :bm25, + :page => @page, + :per_page => @per_page, + } + + if order == "newest" + opts[:order] = :created_at + opts[:sort_mode] = :desc + elsif order == "points" + opts[:order] = :score + opts[:sort_mode] = :desc + end + + opts[:classes] = [] + if what == "all" + opts[:classes] = [ Story, Comment ] + elsif what == "comments" + opts[:classes] = [ Comment ] + elsif what == "stories" + opts[:classes] = [ Story ] + end + + opts[:include] = [ :story, :user ] + + # go go gadget search + @results = ThinkingSphinx.search @q, opts + @total_results = @results.total_entries + + # bind votes for both types + + if opts[:classes].include?(Comment) && user + votes = Vote.comment_votes_by_user_for_comment_ids_hash(user.id, + @results.select{|r| r.class == Comment }.map{|c| c.id }) + + @results.each do |r| + if r.class == Comment && votes[r.id] + r.current_vote = votes[r.id] + end + end + end + + if opts[:classes].include?(Story) && user + votes = Vote.story_votes_by_user_for_story_ids_hash(user.id, + @results.select{|r| r.class == Story }.map{|s| s.id }) + + @results.each do |r| + if r.class == Story && votes[r.id] + r.vote = votes[r.id][:vote] + end + end + end + end +end diff --git a/app/models/story.rb b/app/models/story.rb index 9d9c73f..03b8f30 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -14,14 +14,32 @@ class Story < ActiveRecord::Base MAX_EDIT_MINS = 30 attr_accessor :_comment_count - attr_accessor :vote, :story_type, :already_posted_story, :fetched_content + attr_accessor :vote, :already_posted_story, :fetched_content attr_accessor :new_tags, :tags_to_add, :tags_to_delete - attr_accessible :title, :description, :story_type, :tags_a + attr_accessible :title, :description, :tags_a before_create :assign_short_id after_create :mark_submitter after_save :deal_with_tags + + define_index do + indexes url + indexes title + indexes description + indexes user.username, :as => :author + indexes tags(:tag), :as => :tags + + has created_at, :sortable => true + has hotness, is_moderated, is_expired + has "(upvotes - downvotes)", :as => :score, :type => :integer, + :sortable => true + + set_property :field_weights => { + :title => 10, + :tags => 5, + } + end validate do if self.url.present? diff --git a/app/models/vote.rb b/app/models/vote.rb index b240e0c..eec8c32 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -23,7 +23,8 @@ class Vote < ActiveRecord::Base attr_accessible nil def self.votes_by_user_for_stories_hash(user, stories) - votes = [] + votes = {} + Vote.where(:user_id => user, :story_id => stories, :comment_id => nil).each do |v| votes[v.story_id] = v.vote @@ -42,6 +43,48 @@ class Vote < ActiveRecord::Base votes end + + def self.story_votes_by_user_for_story_ids_hash(user_id, story_ids) + if !story_ids.any? + return {} + end + + votes = {} + + cond = [ "user_id = ? AND comment_id IS NULL AND story_id IN (", user_id ] + story_ids.each_with_index do |s,x| + cond.push s + cond[0] += (x == 0 ? "" : ",") + "?" + end + cond[0] += ")" + + Vote.find(:all, :conditions => cond).each do |v| + votes[v.story_id] = { :vote => v.vote, :reason => v.reason } + end + + votes + end + + def self.comment_votes_by_user_for_comment_ids_hash(user_id, comment_ids) + if !comment_ids.any? + return {} + end + + votes = {} + + cond = [ "user_id = ? AND comment_id IN (", user_id ] + comment_ids.each_with_index do |c,x| + cond.push c + cond[0] += (x == 0 ? "" : ",") + "?" + end + cond[0] += ")" + + Vote.find(:all, :conditions => cond).each do |v| + votes[v.comment_id] = { :vote => v.vote, :reason => v.reason } + end + + votes + end def self.vote_thusly_on_story_or_comment_for_user_because(vote, story_id, comment_id, user_id, reason, update_counters = true) diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index a8361f5..ba645eb 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -54,7 +54,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ? <% end %> <% if defined?(show_story) && show_story %> - | on + | on: <%= story.title %> <% end %> diff --git a/app/views/global/_header.html.erb b/app/views/global/_header.html.erb index 32e4fd9..84d01e2 100644 --- a/app/views/global/_header.html.erb +++ b/app/views/global/_header.html.erb @@ -38,8 +38,8 @@ <%= @user.username %> (<%= @user.karma %>) <%= link_to "Logout", { :controller => "login", :action => "logout" }, - { :confirm => "Are you sure you want to logout?", - "method" => "post" } %> + :data => { :confirm => "Are you sure you want to logout?" }, + :method => "post" %> <% else %> Login <% end %> diff --git a/app/views/search/index.html.erb b/app/views/search/index.html.erb new file mode 100644 index 0000000..207827b --- /dev/null +++ b/app/views/search/index.html.erb @@ -0,0 +1,83 @@ +
+
+ Search +
+ + <%= form_tag "/search", :method => :get do %> +
+ <%= text_field_tag "q", @search.q, :size => 40 %> + +
+ +
+ + + <%= radio_button_tag "what", "all", @search.what == "all" %> + + +   + + <%= radio_button_tag "what", "stories", @search.what == "stories" %> + + +   + + <%= radio_button_tag "what", "comments", @search.what == "comments" %> + + +
+ + + + <%= radio_button_tag "order", "relevance", @search.order == "relevance" %> + + +   + + <%= radio_button_tag "order", "newest", @search.order == "newest" %> + + +   + + <%= radio_button_tag "order", "points", @search.order == "points" %> + +
+ <% end %> +
+ +<% if @search.results.any? %> +
+

+

+ <%= @search.total_results %> result<%= @search.total_results == 1 ? "" : + "s" %> for "<%= @search.q %>" +
+

+
+ +
    + <% @search.results.each do |res| %> + <% if res.class == Story %> + <%= render :partial => "stories/listdetail", + :locals => { :story => res } %> + <% elsif res.class == Comment %> + <%= render :partial => "comments/comment", + :locals => { :comment => res, :story => res.story, + :show_story => true, :hide_voters => true } %> + <% end %> + <% end %> +
+ + <% if @search.total_results > @search.per_page %> + + <% end %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 3e36061..04e3bb1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,8 @@ Lobsters::Application.routes.draw do match "/t/:tag" => "home#tagged", :as => "tag" match "/t/:tag/page/:page" => "home#tagged" + get "/search" => "search#index" + resources :stories do post "upvote" post "downvote" diff --git a/config/sphinx.yml b/config/sphinx.yml new file mode 100644 index 0000000..402ec2b --- /dev/null +++ b/config/sphinx.yml @@ -0,0 +1,3 @@ +production: + address: 127.0.0.1 + port: 9313