diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index a83a572b6..3a13ddb2c 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -4,7 +4,7 @@ class PostsController < ApplicationController respond_to :html, :xml, :json def index - @post_set = PostSets::Post.new(params[:tags], :page => params[:page], :before_id => params[:before_id]) + @post_set = PostSets::Post.new(params[:tags], params) respond_with(@post_set) end diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb new file mode 100644 index 000000000..c7f2ede0a --- /dev/null +++ b/app/helpers/pagination_helper.rb @@ -0,0 +1,78 @@ +module PaginationHelper + def smart_paginator(set, &block) + if set.page && set.page > 1000 + sequential_paginator(set) + else + numbered_paginator(set, &block) + end + end + + def sequential_paginator(set) + html = "" + + unless set.first_page? + html << '
  • ' + link_to("« Previous", params.merge(:after_id => set.first_id)) + '
  • ' + end + + unless set.last_page? + html << '
  • ' + link_to("Next »", params.merge(:before_id => set.last_id)) + '
  • ' + end + + html << "
    " + html.html_safe + end + + def numbered_paginator(set, &block) + html = "" + window = 3 + if set.total_pages <= (window * 2) + 5 + 1.upto(set.total_pages) do |page| + html << numbered_paginator_item(page, set.current_page, &block) + end + elsif set.current_page <= window + 2 + 1.upto(set.current_page + window) do |page| + html << numbered_paginator_item(page, set.current_page, &block) + end + html << numbered_paginator_item("...", set.current_page, &block) + html << numbered_paginator_final_item(set.total_pages, set.current_page, &block) + + elsif set.current_page >= set.total_pages - (window + 1) + html << numbered_paginator_item(1, set.current_page, &block) + html << numbered_paginator_item("...", set.current_page, &block) + (set.current_page - window).upto(set.total_pages) do |page| + html << numbered_paginator_item(page, set.current_page, &block) + end + else + html << numbered_paginator_item(1, set.current_page, &block) + html << numbered_paginator_item("...", set.current_page, &block) + (set.current_page - window).upto(set.current_page + window) do |page| + html << numbered_paginator_item(page, set.current_page, &block) + end + html << numbered_paginator_item("...", set.current_page, &block) + html << numbered_paginator_final_item(set.total_pages, set.current_page, &block) + end + html << "" + html.html_safe + end + + def numbered_paginator_final_item(total_pages, current_page, &block) + if total_pages <= 1000 + numbered_paginator_item(total_pages, current_page, &block) + else + "" + end + end + + def numbered_paginator_item(page, current_page, &block) + html = "
  • " + if page == "..." + html << "..." + elsif page == current_page + html << page.to_s + else + html << capture(page, &block) + end + html << "
  • " + html.html_safe + end +end diff --git a/app/logical/favorite.rb b/app/logical/favorite.rb index 412fad61e..56b10a99b 100644 --- a/app/logical/favorite.rb +++ b/app/logical/favorite.rb @@ -5,6 +5,20 @@ class Favorite "favorites_#{user_id.to_i % 10}" end + def self.sql_order_clause(post_ids, posts_table_alias = "posts") + if post_ids.empty? + return "#{posts_table_alias}.id desc" + end + + conditions = [] + + post_ids.each_with_index do |post_id, n| + conditions << "when #{post_id} then #{n}" + end + + "case #{posts_table_alias}.id " + conditions.join(" ") + " end" + end + def self.create(attributes) user_id = attributes[:user_id] post_id = attributes[:post_id] @@ -26,6 +40,19 @@ class Favorite destroy_for_post(conditions[:post_id]) end end + + def self.find_post_ids(user_id, options) + limit = options[:limit] || 1 || Danbooru.config.posts_per_page + if options[:before_id] + select_values_sql("SELECT post_id FROM #{table_name_for(user_id)} WHERE id < ? ORDER BY id DESC LIMIT ?", options[:before_id], limit) + elsif options[:after_id] + select_values_sql("SELECT post_id FROM #{table_name_for(user_id)} WHERE id > ? ORDER BY id ASC LIMIT ?", options[:after_id], limit).reverse + elsif options[:offset] + select_values_sql("SELECT post_id FROM #{table_name_for(user_id)} ORDER BY id DESC LIMIT ? OFFSET ?", limit, options[:offset]) + else + select_values_sql("SELECT post_id FROM #{table_name_for(user_id)} ORDER BY id DESC LIMIT ?", limit) + end + end def self.exists?(conditions) if conditions[:user_id] && conditions[:post_id] @@ -39,26 +66,30 @@ class Favorite end end - private - def self.destroy_for_post_and_user(post_id, user_id) - execute_sql("DELETE FROM #{table_name_for(user_id)} WHERE post_id = #{post_id} AND user_id = #{user_id}") - end - - def self.destroy_for_post(post) - 0.upto(9) do |i| - execute_sql("DELETE FROM favorites_#{i} WHERE post_id = #{post.id}") - end - end - - def self.destroy_for_user(user) - execute_sql("DELETE FROM #{table_name_for(user)} WHERE user_id = #{user.id}") - end - - def self.select_value_sql(sql, *params) - ActiveRecord::Base.select_value_sql(sql, *params) - end - - def self.execute_sql(sql, *params) - ActiveRecord::Base.execute_sql(sql, *params) + def self.destroy_for_post_and_user(post_id, user_id) + execute_sql("DELETE FROM #{table_name_for(user_id)} WHERE post_id = #{post_id} AND user_id = #{user_id}") + end + + def self.destroy_for_post(post) + 0.upto(9) do |i| + execute_sql("DELETE FROM favorites_#{i} WHERE post_id = #{post.id}") end + end + + def self.destroy_for_user(user) + execute_sql("DELETE FROM #{table_name_for(user)} WHERE user_id = #{user.id}") + end + + def self.select_value_sql(sql, *params) + ActiveRecord::Base.select_value_sql(sql, *params) + end + + def self.select_values_sql(sql, *params) + ActiveRecord::Base.select_values_sql(sql, *params) + end + + def self.execute_sql(sql, *params) + ActiveRecord::Base.execute_sql(sql, *params) + end end + diff --git a/app/logical/post_sets/base.rb b/app/logical/post_sets/base.rb index 4ab472bb1..d43c11356 100644 --- a/app/logical/post_sets/base.rb +++ b/app/logical/post_sets/base.rb @@ -1,45 +1,78 @@ +# A PostSet represents a paginated slice of posts. It is used in conjunction +# with the helpers to render the paginator. +# +# Usage: +# +# @post_set = PostSets::Base.new(params) +# @post_set.extend(PostSets::Sequential) +# @post_set.extend(PostSets::Post) + module PostSets class Base - attr_accessor :page, :before_id, :count, :posts - - def initialize(options = {}) - @page = options[:page] ? options[:page].to_i : 1 - @before_id = options[:before_id] - load_posts + attr_reader :params, :posts + delegate :to_xml, :to_json, :to => :posts + + def initialize(params) + @params = params end - def has_wiki? - false - end - - def use_sequential_paginator? - !use_numbered_paginator? - end - - def use_numbered_paginator? - before_id.nil? - end - - def load_posts + # Should a return a paginated array of posts. This means it should have + # at most elements. + def posts raise NotImplementedError end - - def to_xml - posts.to_xml + + # Does this post set have a valid wiki page representation? + def has_wiki? + raise NotImplementedError end - - def to_json - posts.to_json + + # Should return an array of strings representing the tags. + def tags + raise NotImplementedError end - + + # Given an ActiveRelation object, perform the necessary pagination to + # extract at most elements. Should return an array. + def slice(relation) + raise NotImplementedError + end + + # For cases where we're not relying on the default pagination + # implementation (for example, if the ids are cached in a string) + # then pass in the offset/before_id/after_id parameters here. + def pagination_options + raise NotImplementedError + end + + # This method should throw an exception if for whatever reason the query + # is invalid or forbidden. + def validate + end + + # Clear out any memoized instance variables. + def reload + @posts = nil + @presenter = nil + @tag_string = nil + end + + def tag_string + @tag_string ||= tags.join(" ") + end + + def is_first_page? + raise NotImplementedError + end + + def is_last_page? + posts.size == 0 + end + def presenter @presenter ||= PostSetPresenter.new(self) end - def offset - ((page < 1) ? 0 : (page - 1)) * count - end - def limit Danbooru.config.posts_per_page end diff --git a/app/logical/post_sets/favorite.rb b/app/logical/post_sets/favorite.rb index 721959c43..e9c25ba5c 100644 --- a/app/logical/post_sets/favorite.rb +++ b/app/logical/post_sets/favorite.rb @@ -1,18 +1,29 @@ module PostSets - class Favorite < Base - attr_accessor :user - - def initialize(user) - @user = user - super() + module Favorite + def user + @user ||= User.find(params[:id]) end def tags - "fav:#{user.name}" + @tags ||= ["fav:#{user.name}"] + end + + def has_wiki? + false + end + + def reload + super + @user = nil + @count = nil + end + + def count + @count ||= Favorite.count(user.id) end - def load_posts - @posts = user.favorite_posts(:before_id => before_id) + def posts + @posts ||= user.favorites(pagination_options) end end end diff --git a/app/logical/post_sets/numbered.rb b/app/logical/post_sets/numbered.rb new file mode 100644 index 000000000..b9457decb --- /dev/null +++ b/app/logical/post_sets/numbered.rb @@ -0,0 +1,35 @@ +module PostSets + module Numbered + attr_reader :page + + def initialize(params) + super + @page = options[:page] ? options[:page].to_i : 1 + end + + def total_pages + @total_pages ||= (count / limit.to_f).ceil.to_i + end + + def reload + super + @total_pages = nil + end + + def slice(relation) + relation.offset(offset).all + end + + def pagination_options + {:offset => offset} + end + + def is_first_page? + offset == 0 + end + + def offset + ((page < 1) ? 0 : (page - 1)) * limit + end + end +end diff --git a/app/logical/post_sets/pool.rb b/app/logical/post_sets/pool.rb index 14e9484d0..44d9517e1 100644 --- a/app/logical/post_sets/pool.rb +++ b/app/logical/post_sets/pool.rb @@ -1,33 +1,30 @@ +# This only works with the numbered paginator because of the way +# the association is stored. module PostSets - class Pool < Base - attr_reader :pool - - def initialize(pool, options = {}) - @pool = pool - @count = pool.post_id_array.size - super(options) + module Pool + def pool + @pool ||= Pool.find(params[:id]) end def tags - "pool:#{pool.name}" + ["pool:#{pool.name}"] + end + + def has_wiki? + true + end + + def count + pool.post_count end - def load_posts - @posts = pool.posts(:limit => limit, :offset => offset).order("posts.id") + def posts + @posts ||= pool.posts(pagination_options) end - def sorted_posts - sort_posts(@posts) - end - - private - def sort_posts(posts) - posts_by_id = posts.inject({}) do |hash, post| - hash[post.id] = post - hash - end - - @pool.post_id_array.map {|x| posts_by_id[x]} + def reload + super + @pool = nil end end end diff --git a/app/logical/post_sets/post.rb b/app/logical/post_sets/post.rb index 37dd01530..af53eafd7 100644 --- a/app/logical/post_sets/post.rb +++ b/app/logical/post_sets/post.rb @@ -1,78 +1,56 @@ module PostSets - class Post < Base + module Post class Error < Exception ; end - attr_accessor :tags, :errors, :count - attr_accessor :wiki_page, :artist, :suggestions - - def initialize(tags, options = {}) - super(options) - @tags = Tag.normalize(tags) - @errors = [] - load_associations - load_suggestions - validate + attr_accessor :tags, :count, :wiki_page, :artist, :suggestions + + def tags + @tags ||= Tag.normalize(params[:tags]) + end + + def count + @count ||= ::Post.fast_count(tag_string) + end + + def posts + @posts ||= slice(::Post.tag_match(tags).limit(limit)) + end + + def reload + super + @tags = nil + @tag_string = nil + @count = nil + @wiki_page = nil + @artist = nil + end + + def wiki_page + @wiki_page ||= ::WikiPage.titled(tag_string).first + end + + def artist + @artist ||= ::Artist.find_by_name(tag_string) end def has_wiki? is_single_tag? end - def has_errors? - errors.any? - end - - def offset - x = (page - 1) * limit - if x < 0 - x = 0 - end - x - end - def is_single_tag? tag_array.size == 1 end - - def date_tag - tag_array.grep(/date:/).first - end - - def load_associations - if is_single_tag? - @wiki_page = ::WikiPage.titled(tags).first - @artist = ::Artist.find_by_name(tags) - end - end - - def load_posts - @count = ::Post.fast_count(tags) - @posts = ::Post.tag_match(tags).before_id(before_id).all(:order => "posts.id desc", :limit => limit, :offset => offset) - end - - def load_suggestions - if count < limit && is_single_tag? - @suggestions = Tag.find_suggestions(tags) - else - @suggestions = [] - end - end - + def tag_array @tag_array ||= Tag.scan_query(tags) end - def tag - tag_array.first - end - def validate + super validate_page validate_query_count - rescue Error => x - @errors << x.to_s end - + def validate_page if page > 1_000 raise Error.new("You cannot explicitly specify the page after page 1000") diff --git a/app/logical/post_sets/sequential.rb b/app/logical/post_sets/sequential.rb new file mode 100644 index 000000000..6201361a9 --- /dev/null +++ b/app/logical/post_sets/sequential.rb @@ -0,0 +1,29 @@ +module PostSets + module Sequential + attr_reader :before_id, :after_id + + def initialize(params) + super + @before_id = params[:before_id] + @after_id = params[:after_id] + end + + def slice(relation) + if before_id + relation.where("id < ?", before_id).all + elsif after_id + relation.where("id > ?", after_id).order("id asc").all.reverse + else + relation.all + end + end + + def pagination_options + {:before_id => before_id, :after_id => after_id} + end + + def is_first_page? + before_id.nil? + end + end +end diff --git a/app/logical/post_sets/wiki_page.rb b/app/logical/post_sets/wiki_page.rb index 100515c4a..c79385a4e 100644 --- a/app/logical/post_sets/wiki_page.rb +++ b/app/logical/post_sets/wiki_page.rb @@ -1,34 +1,29 @@ module PostSets - class WikiPage < Base - attr_reader :tag_name - - def initialize(tag_name) - @tag_name = tag_name - super() + module WikiPage + def wiki_page + @wiki_page ||= begin + if params[:id] + ::WikiPage.find(params[:id]) + elsif params[:tags] + ::WikiPage.titled(params[:tags]).first + end + end end - def load_posts - @posts = ::Post.tag_match(tag_name).all(:order => "posts.id desc", :limit => limit, :offset => offset) - end - - def limit - 8 - end - - def offset - 0 + def has_wiki? + true end def tags - [@tag_name] + @tags ||= Tag.normalize(wiki_page.title) end - - def use_sequential_paginator? - false + + def posts + @posts ||= slice(::Post.tag_match(tag_string)) end - - def use_numbered_paginator? - false + + def count + @count ||= ::Post.fast_count(tag_string) end end end diff --git a/app/models/pool.rb b/app/models/pool.rb index 85ff2c492..357aeff55 100644 --- a/app/models/pool.rb +++ b/app/models/pool.rb @@ -72,9 +72,9 @@ class Pool < ActiveRecord::Base def posts(options = {}) offset = options[:offset] || 0 - limit = options[:limit] || 20 + limit = options[:limit] || Danbooru.config.posts_per_page ids = post_id_array[offset, limit] - Post.where(["id IN (?)", ids]) + Post.where(["id IN (?)", ids]).order(Favorite.sql_order_clause(ids)) end def post_id_array diff --git a/app/models/post.rb b/app/models/post.rb index 8cdf265f1..ae89b1775 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1,6 +1,7 @@ class Post < ActiveRecord::Base class ApprovalError < Exception ; end class DisapprovalError < Exception ; end + class SearchError < Exception ; end attr_accessor :old_tag_string, :old_parent_id after_destroy :delete_files @@ -39,6 +40,7 @@ class Post < ActiveRecord::Base scope :available_for_moderation, lambda {where(["id NOT IN (SELECT pd.post_id FROM post_disapprovals pd WHERE pd.user_id = ?)", CurrentUser.id])} scope :hidden_from_moderation, lambda {where(["id IN (SELECT pd.post_id FROM post_disapprovals pd WHERE pd.user_id = ?)", CurrentUser.id])} scope :before_id, lambda {|id| id.present? ? where(["posts.id < ?", id]) : where("TRUE")} + scope :after_id, lambda {|id| id.present? ? where("posts.id > ?", id) : where("true")} scope :tag_match, lambda {|query| Post.tag_match_helper(query)} search_methods :tag_match @@ -374,36 +376,33 @@ class Post < ActiveRecord::Base Favorite.destroy_for_post(self) end - def add_favorite(user) - if user.is_a?(ActiveRecord::Base) - user_id = user.id - else - user_id = user + def favorited_by?(user_id) + fav_string =~ /(?:\A| )fav:#{user_id}(?:\Z| )/ + end + + def add_favorite(user_id) + if user_id.is_a?(ActiveRecord::Base) + user_id = user_id.id + end + + if favorited_by?(user_id) + return false end - return false if fav_string =~ /(?:\A| )fav:#{user_id}(?:\Z| )/ self.fav_string += " fav:#{user_id}" self.fav_string.strip! - - # in order to avoid rerunning the callbacks, just update through raw sql - execute_sql("UPDATE posts SET fav_string = ? WHERE id = ?", fav_string, id) - + update_attribute(:fav_string, fav_string) Favorite.create(:user_id => user_id, :post_id => id) end - def remove_favorite(user) - if user.is_a?(ActiveRecord::Base) - user_id = user.id - else - user_id = user + def remove_favorite(user_id) + if user_id.is_a?(ActiveRecord::Base) + user_id = user_id.id end self.fav_string.gsub!(/(?:\A| )fav:#{user_id}(?:\Z| )/, " ") self.fav_string.strip! - - # in order to avoid rerunning the callbacks, just update through raw sql - execute_sql("UPDATE posts SET fav_string = ? WHERE id = ?", fav_string, id) - + update_attribute(:fav_string, fav_string) Favorite.destroy(:user_id => user_id, :post_id => id) end @@ -413,9 +412,9 @@ class Post < ActiveRecord::Base end module SearchMethods - class SearchError < Exception ; end - def add_range_relation(arr, field, relation) + return relation if arr.nil? + case arr[0] when :eq relation.where(["#{field} = ?", arr[1]]) @@ -513,10 +512,10 @@ class Post < ActiveRecord::Base relation = add_range_relation(q[:character_tag_count], "posts.tag_count_character", relation) relation = add_range_relation(q[:tag_count], "posts.tag_count", relation) - if q[:md5].any? + if q[:md5] relation = relation.where(["posts.md5 IN (?)", q[:md5]]) end - + if q[:status] == "pending" relation = relation.where("posts.is_pending = TRUE") elsif q[:status] == "flagged" @@ -525,11 +524,11 @@ class Post < ActiveRecord::Base relation = relation.where("posts.is_deleted = TRUE") end - if q[:source].is_a?(String) + if q[:source] relation = relation.where(["posts.source LIKE ? ESCAPE E'\\\\'", q[:source]]) end - if q[:subscriptions].any? + if q[:subscriptions] relation = add_tag_subscription_relation(q[:subscriptions], relation) end diff --git a/app/models/tag.rb b/app/models/tag.rb index 20fb4ad1e..c42564ede 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -208,7 +208,7 @@ class Tag < ActiveRecord::Base end def parse_query(query, options = {}) - q = Hash.new {|h, k| h[k] = []} + q = {} q[:tags] = { :related => [], :include => [], @@ -237,6 +237,7 @@ class Tag < ActiveRecord::Base q[:tags][:related] << "fav:#{User.name_to_id($2)}" when "sub" + q[:subscriptions] ||= [] q[:subscriptions] << $2 when "md5" diff --git a/app/models/user.rb b/app/models/user.rb index 0d8782446..64af81b80 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -117,16 +117,13 @@ class User < ActiveRecord::Base end module FavoriteMethods - def favorite_posts(options = {}) - favorites_table = Favorite.table_name_for(id) - if options[:before_id] - before_id_sql_fragment = ["favorites.id < ?", options[:before_id]] + def favorites(options = {}) + post_ids = Favorite.find_post_ids(id, options) + if post_ids.any? + Post.where("id in (?)", post_ids).order(Favorite.sql_order_clause(post_ids)) else - before_id_sql_fragment = "TRUE" + Post.where("false") end - limit = options[:limit] || 20 - - Post.joins("JOIN #{favorites_table} AS favorites ON favorites.post_id = posts.id").where("favorites.user_id = ?", id).where(before_id_sql_fragment).order("favorite_id DESC").limit(limit).select("posts.*, favorites.id AS favorite_id") end end diff --git a/app/presenters/paginators/numbered.rb b/app/presenters/paginators/numbered.rb new file mode 100644 index 000000000..d848fe170 --- /dev/null +++ b/app/presenters/paginators/numbered.rb @@ -0,0 +1,58 @@ +module Paginators + class Numbered + attr_reader :template, :source + delegate :url, :total_pages, :current_page, :to => :source + + def initialize(template, source) + @template = template + @source = source + end + + def pagination_html + html = "" + window = 3 + if total_pages <= (window * 2) + 5 + 1.upto(total_pages) do |page| + html << pagination_item(page, current_page) + end + elsif current_page <= window + 2 + 1.upto(current_page + window) do |page| + html << pagination_item(page, current_page) + end + html << pagination_item("...", current_page) + html << pagination_item(total_pages, current_page) + + elsif current_page >= total_pages - (window + 1) + html << pagination_item(1, current_page) + html << pagination_item("...", current_page) + (current_page - window).upto(total_pages) do |page| + html << pagination_item(page, current_page) + end + else + html << pagination_item(1, current_page) + html << pagination_item("...", current_page) + (current_page - window).upto(current_page + window) do |page| + html << pagination_item(page, current_page) + end + html << pagination_item("...", current_page) + html << pagination_item(total_pages, current_page) + end + html << "" + html.html_safe + end + + protected + def pagination_item(page, current_page) + html = "
  • " + if page == "..." + html << "..." + elsif page == current_page + html << page.to_s + else + html << template.link_to(page, url(template, :page => page)) + end + html << "
  • " + html.html_safe + end + end +end diff --git a/app/presenters/paginators/post.rb b/app/presenters/paginators/post.rb index fdc426bdf..a7d09f9e3 100644 --- a/app/presenters/paginators/post.rb +++ b/app/presenters/paginators/post.rb @@ -15,6 +15,7 @@ module Paginators [1, post_set.page].max end + # TODO: this is not compatible with paginating favorites def sequential_link(template) template.posts_path(:tags => template.params[:tags], before_id => post_set.posts[-1].id, :page => nil) end diff --git a/app/presenters/paginators/sequential.rb b/app/presenters/paginators/sequential.rb new file mode 100644 index 000000000..35d894f05 --- /dev/null +++ b/app/presenters/paginators/sequential.rb @@ -0,0 +1,29 @@ +module Paginators + class Sequential + attr_reader :template, :source + delegate :url, :to => :source + + def initialize(template, source) + @template = template + @source = source + end + + def pagination_html + html = "" + html << '
  • ' + template.link_to("« Previous", prev_url) + '
  • ' + if next_url + html << '
  • ' + template.link_to("Next »", next_url) + '
  • ' + end + html << "
    " + html.html_safe + end + + def prev_url + template.request.env["HTTP_REFERER"] + end + + def next_url + @next_url ||= url(template) + end + end +end diff --git a/app/views/posts/index.html.erb b/app/views/posts/index.html.erb index 263bd93ec..34afe46fd 100644 --- a/app/views/posts/index.html.erb +++ b/app/views/posts/index.html.erb @@ -1,11 +1,5 @@
    - <% if @post_set.suggestions.any? %> -
    - Maybe you meant: <%= @post_set.suggestions.map {|x| link_to(x, posts_path(:tags => x), :class => "tag-type-#{Tag.type_name(x)}" )}.to_sentence(:last_word_connector => ", or ", :two_words_connector => " or ") %> -
    - <% end %> -