search engine!

This commit is contained in:
joshua stein 2012-07-11 17:20:43 -05:00
parent b01f9e9027
commit abb8392c16
14 changed files with 380 additions and 70 deletions

1
.gitignore vendored
View file

@ -9,6 +9,7 @@
# Ignore the default SQLite database.
/db/*.sqlite3
/db/sphinx
# Ignore all logfiles and tempfiles.
/log/*.log

View file

@ -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"

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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.")

88
app/models/search.rb Normal file
View file

@ -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("&amp;")
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

View file

@ -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?

View file

@ -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)

View file

@ -54,7 +54,7 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<% end %>
<% if defined?(show_story) && show_story %>
| on
| on:
<a href="<%= story.comments_url %>"><%= story.title %></a>
<% end %>
</div>

View file

@ -38,8 +38,8 @@
<a href="/settings"><%= @user.username %> (<%= @user.karma %>)</a>
<%= 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 %>
<a href="/login">Login</a>
<% end %>

View file

@ -0,0 +1,83 @@
<div class="box">
<div class="legend">
Search
</div>
<%= form_tag "/search", :method => :get do %>
<div class="boxline">
<%= text_field_tag "q", @search.q, :size => 40 %>
<input type="submit" value="Search">
</div>
<div class="boxline">
<label class="required">Include:</label>
<%= radio_button_tag "what", "all", @search.what == "all" %>
<label for="search_what_all" class="normal">All</label>
&nbsp;
<%= radio_button_tag "what", "stories", @search.what == "stories" %>
<label for="search_what_stories" class="normal">Stories</label>
&nbsp;
<%= radio_button_tag "what", "comments", @search.what == "comments" %>
<label for="search_what_comments" class="normal">Comments</label>
<br>
<label class="required">Order By:</label>
<%= radio_button_tag "order", "relevance", @search.order == "relevance" %>
<label for="search_order_relevance" class="normal">Relevance</label>
&nbsp;
<%= radio_button_tag "order", "newest", @search.order == "newest" %>
<label for="search_order_newest" class="normal">Newest</label>
&nbsp;
<%= radio_button_tag "order", "points", @search.order == "points" %>
<label for="search_order_points" class="normal">Points</label>
</div>
<% end %>
</div>
<% if @search.results.any? %>
<div class="box">
<p>
<div class="legend">
<%= @search.total_results %> result<%= @search.total_results == 1 ? "" :
"s" %> for "<%= @search.q %>"
</div>
</p>
</div>
<ol class="search_results">
<% @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 %>
</ol>
<% if @search.total_results > @search.per_page %>
<div class="page_link_buttons">
Page:
<% (@search.total_results.to_f / @search.per_page.to_f).ceil.
times do |p| %>
<a href="/search?<%= raw(@search.to_url_params) %>&amp;page=<%= p + 1
%>" class="<%= @search.page == p + 1 ? "cur" : "" %>"><%= p + 1
%></a>
<% end %>
</div>
<% end %>
<% end %>

View file

@ -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"

3
config/sphinx.yml Normal file
View file

@ -0,0 +1,3 @@
production:
address: 127.0.0.1
port: 9313