diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb new file mode 100644 index 00000000..7d5e2169 --- /dev/null +++ b/app/controllers/admin_controller.rb @@ -0,0 +1,85 @@ +class AdminController < ApplicationController + layout "default" + before_filter :admin_only, :except => [:cache_stats] + + def index + set_title "Admin" + end + + def edit_user + if request.post? + @user = User.find_by_name(params[:user][:name]) + if @user.nil? + flash[:notice] = "User not found" + redirect_to :action => "edit_user" + return + end + @user.level = params[:user][:level] + + if @user.save + flash[:notice] = "User updated" + redirect_to :action => "edit_user" + else + render_error(@user) + end + end + end + + def reset_password + if request.post? + @user = User.find_by_name(params[:user][:name]) + + if @user + new_password = @user.reset_password + flash[:notice] = "Password reset to #{new_password}" + + unless @user.email.blank? + UserMailer.deliver_new_password(@user, new_password) + end + else + flash[:notice] = "That account does not exist" + redirect_to :action => "reset_password" + end + else + @user = User.new + end + end + + def cache_stats + keys = [] + [0, 20, 30, 35, 40, 50].each do |level| + keys << "stats/count/level=#{level}" + + [0, 1, 2, 3, 4, 5].each do |tag_count| + keys << "stats/tags/level=#{level}&tags=#{tag_count}" + end + + keys << "stats/page/level=#{level}&page=0-10" + keys << "stats/page/level=#{level}&page=10-20" + keys << "stats/page/level=#{level}&page=20+" + end + + @post_stats = keys.inject({}) {|h, k| h[k] = Cache.get(k); h} + end + + def reset_post_stats + keys = [] + [0, 20, 30, 35, 40].each do |level| + keys << "stats/count/level=#{level}" + + [0, 1, 2, 3, 4, 5].each do |tag_count| + keys << "stats/tags/level=#{level}&tags=#{tag_count}" + end + + keys << "stats/page/level=#{level}&page=0-10" + keys << "stats/page/level=#{level}&page=10-20" + keys << "stats/page/level=#{level}&page=20+" + end + + keys.each do |key| + CACHE.set(key, 0) + end + + redirect_to :action => "cache_stats" + end +end diff --git a/app/controllers/advertisement_controller.rb b/app/controllers/advertisement_controller.rb new file mode 100644 index 00000000..653b66b4 --- /dev/null +++ b/app/controllers/advertisement_controller.rb @@ -0,0 +1,20 @@ +class AdvertisementController < ApplicationController + layout "bare" + before_filter :admin_only, :only => [:reset_stats] + + def redirect_ad + ad = Advertisement.find(params[:id]) + ad.increment!(:hit_count) + redirect_to ad.referral_url + end + + def show_stats + @ads = Advertisement.find(:all, :order => "id") + render :layout => "default" + end + + def reset_stats + Advertisement.update_all("hit_count = 0") + redirect_to :action => "show_stats" + end +end diff --git a/app/controllers/application.rb b/app/controllers/application.rb new file mode 100644 index 00000000..48063468 --- /dev/null +++ b/app/controllers/application.rb @@ -0,0 +1,345 @@ +require 'digest/md5' + +class ApplicationController < ActionController::Base + # This is a proxy class to make various nil checks unnecessary + class AnonymousUser + def id + nil + end + + def level + 0 + end + + def name + "Anonymous" + end + + def pretty_name + "Anonymous" + end + + def is_anonymous? + true + end + + def has_permission?(obj, foreign_key = :user_id) + false + end + + def can_change?(record, attribute) + method = "can_change_#{attribute.to_s}?" + if record.respond_to?(method) + record.__send__(method, self) + elsif record.respond_to?(:can_change?) + record.can_change?(self, attribute) + else + false + end + end + + def show_samples? + true + end + + def has_avatar? + false + end + + CONFIG["user_levels"].each do |name, value| + normalized_name = name.downcase.gsub(/ /, "_") + + define_method("is_#{normalized_name}?") do + false + end + + define_method("is_#{normalized_name}_or_higher?") do + false + end + + define_method("is_#{normalized_name}_or_lower?") do + true + end + end + end + + module LoginSystem + protected + def access_denied + previous_url = params[:url] || request.request_uri + + respond_to do |fmt| + fmt.html {flash[:notice] = "Access denied"; redirect_to(:controller => "user", :action => "login", :url => previous_url)} + fmt.xml {render :xml => {:success => false, :reason => "access denied"}.to_xml(:root => "response"), :status => 403} + fmt.json {render :json => {:success => false, :reason => "access denied"}.to_json, :status => 403} + end + end + + def set_current_user + if RAILS_ENV == "test" && session[:user_id] + @current_user = User.find_by_id(session[:user_id]) + end + + if @current_user == nil && session[:user_id] + @current_user = User.find_by_id(session[:user_id]) + end + + if @current_user == nil && cookies[:login] && cookies[:pass_hash] + @current_user = User.authenticate_hash(cookies[:login], cookies[:pass_hash]) + end + + if @current_user == nil && params[:login] && params[:password_hash] + @current_user = User.authenticate_hash(params[:login], params[:password_hash]) + end + + if @current_user == nil && params[:user] + @current_user = User.authenticate(params[:user][:name], params[:user][:password]) + end + + if @current_user + if @current_user.is_blocked? && @current_user.ban && @current_user.ban.expires_at < Time.now + @current_user.update_attribute(:level, CONFIG["starting_level"]) + Ban.destroy_all("user_id = #{@current_user.id}") + end + + session[:user_id] = @current_user.id + else + @current_user = AnonymousUser.new + end + + # For convenient access in activerecord models + Thread.current["danbooru-user"] = @current_user + Thread.current["danbooru-user_id"] = @current_user.id + Thread.current["danbooru-ip_addr"] = request.remote_ip + + # Hash the user's IP to seed things like mirror selection. + Thread.current["danbooru-ip_addr_seed"] = Digest::MD5.hexdigest(request.remote_ip)[0..7].hex + + ActiveRecord::Base.init_history + + UserLog.access(@current_user, request) + end + + CONFIG["user_levels"].each do |name, value| + normalized_name = name.downcase.gsub(/ /, "_") + + define_method("#{normalized_name}_only") do + if @current_user.__send__("is_#{normalized_name}_or_higher?") + return true + else + access_denied() + end + end + + # For many actions, GET invokes the HTML UI, and a POST actually invokes + # the action, so we often want to require higher access for POST (so the UI + # can invoke the login dialog). + define_method("post_#{normalized_name}_only") do + return true unless request.post? + + if @current_user.__send__("is_#{normalized_name}_or_higher?") + return true + else + access_denied() + end + end + end + end + + module RespondToHelpers + protected + def respond_to_success(notice, redirect_to_params, options = {}) + extra_api_params = options[:api] || {} + + respond_to do |fmt| + fmt.html {flash[:notice] = notice ; redirect_to(redirect_to_params)} + fmt.json {render :json => extra_api_params.merge(:success => true).to_json} + fmt.xml {render :xml => extra_api_params.merge(:success => true).to_xml(:root => "response")} + end + end + + def respond_to_error(obj, redirect_to_params, options = {}) + extra_api_params = options[:api] || {} + status = options[:status] || 500 + + if obj.is_a?(ActiveRecord::Base) + obj = obj.errors.full_messages.join(", ") + status = 420 + end + + case status + when 420 + status = "420 Invalid Record" + + when 421 + status = "421 User Throttled" + + when 422 + status = "422 Locked" + + when 423 + status = "423 Already Exists" + + when 424 + status = "424 Invalid Parameters" + end + + respond_to do |fmt| + fmt.html {flash[:notice] = "Error: #{obj}" ; redirect_to(redirect_to_params)} + fmt.json {render :json => extra_api_params.merge(:success => false, :reason => obj).to_json, :status => status} + fmt.xml {render :xml => extra_api_params.merge(:success => false, :reason => obj).to_xml(:root => "response"), :status => status} + end + end + + def respond_to_list(inst_var_name) + inst_var = instance_variable_get("@#{inst_var_name}") + + respond_to do |fmt| + fmt.html + fmt.json {render :json => inst_var.to_json} + fmt.xml {render :xml => inst_var.to_xml(:root => inst_var_name)} + end + end + + def render_error(record) + @record = record + render :status => 500, :layout => "bare", :inline => "<%= error_messages_for('record') %>" + end + + end + + include LoginSystem + include RespondToHelpers + #include ExceptionNotifiable + include CacheHelper + #local_addresses.clear + + before_filter :set_title + before_filter :set_current_user + before_filter :check_ip_ban + after_filter :init_cookies + + protected :build_cache_key + protected :get_cache_key + + def get_ip_ban() + ban = IpBans.find(:first, :conditions => ["? <<= ip_addr", request.remote_ip]) + if not ban then return nil end + return ban + end + + protected + def check_ip_ban + if request.parameters[:controller] == "banned" and request.parameters[:action] == "index" then + return + end + + ban = get_ip_ban() + if not ban then + return + end + + if ban.expires_at && ban.expires_at < Time.now then + IpBans.destroy_all("ip_addr = '#{request.remote_ip}'") + return + end + + redirect_to :controller => "banned", :action => "index" + end + + def check_load_average + current_load = Sys::CPU.load_avg[1] + + if request.get? && request.env["HTTP_USER_AGENT"] !~ /Google/ && current_load > CONFIG["load_average_threshold"] && @current_user.is_member_or_lower? + render :file => "#{RAILS_ROOT}/public/503.html", :status => 503 + return false + end + end + + def set_title(title = CONFIG["app_name"]) + @page_title = CGI.escapeHTML(title) + end + + def save_tags_to_cookie + if params[:tags] || (params[:post] && params[:post][:tags]) + tags = TagAlias.to_aliased((params[:tags] || params[:post][:tags]).downcase.scan(/\S+/)) + tags += cookies["recent_tags"].to_s.scan(/\S+/) + cookies["recent_tags"] = tags.slice(0, 20).join(" ") + end + end + + def set_cache_headers + response.headers["Cache-Control"] = "max-age=300" + end + + def cache_action + if request.method == :get && request.env !~ /Googlebot/ && params[:format] != "xml" && params[:format] != "json" + key, expiry = get_cache_key(controller_name, action_name, params, :user => @current_user) + + if key && key.size < 200 + cached = Cache.get(key) + + unless cached.blank? + render :text => cached, :layout => false + return + end + end + + yield + + if key && response.headers['Status'] =~ /^200/ + Cache.put(key, response.body, expiry) + end + else + yield + end + end + + def init_cookies + unless @current_user.is_anonymous? + if @current_user.has_mail? + cookies["has_mail"] = "1" + else + cookies["has_mail"] = "0" + end + + if @current_user.is_privileged_or_higher? && ForumPost.updated?(@current_user) + cookies["forum_updated"] = "1" + else + cookies["forum_updated"] = "0" + end + + if @current_user.is_privileged_or_higher? && Comment.updated?(@current_user) + cookies["comments_updated"] = "1" + else + cookies["comments_updated"] = "0" + end + + if @current_user.is_blocked? + if @current_user.ban + cookies["block_reason"] = "You have been blocked. Reason: #{@current_user.ban.reason}. Expires: #{@current_user.ban.expires_at.strftime('%Y-%m-%d')}" + else + cookies["block_reason"] = "You have been blocked." + end + else + cookies["block_reason"] = "" + end + + if @current_user.always_resize_images? + cookies["resize_image"] = "1" + else + cookies["resize_image"] = "0" + end + + cookies["my_tags"] = @current_user.my_tags + cookies["blacklisted_tags"] = @current_user.blacklisted_tags_array + cookies["held_post_count"] = @current_user.held_post_count.to_s + else + cookies["blacklisted_tags"] = CONFIG["default_blacklists"] + end + + if flash[:notice] then + cookies["notice"] = flash[:notice] + end + end +end diff --git a/app/controllers/artist_controller.rb b/app/controllers/artist_controller.rb new file mode 100644 index 00000000..d90ba2b3 --- /dev/null +++ b/app/controllers/artist_controller.rb @@ -0,0 +1,103 @@ +class ArtistController < ApplicationController + layout "default" + + before_filter :post_member_only, :only => [:create, :update] + before_filter :post_privileged_only, :only => [:destroy] + helper :post, :wiki + + def preview + render :inline => "

Preview

<%= format_text(params[:artist][:notes]) %>" + end + + def destroy + @artist = Artist.find(params[:id]) + + if request.post? + if params[:commit] == "Yes" + @artist.destroy + respond_to_success("Artist deleted", :action => "index", :page => params[:page]) + else + redirect_to :action => "index", :page => params[:page] + end + end + end + + def update + if request.post? + if params[:commit] == "Cancel" + redirect_to :action => "show", :id => params[:id] + return + end + + artist = Artist.find(params[:id]) + artist.update_attributes(params[:artist].merge(:updater_ip_addr => request.remote_ip, :updater_id => @current_user.id)) + + if artist.errors.empty? + respond_to_success("Artist updated", :action => "show", :id => artist.id) + else + respond_to_error(artist, :action => "update", :id => artist.id) + end + else + @artist = Artist.find(params["id"]) + end + end + + def create + if request.post? + artist = Artist.create(params[:artist].merge(:updater_ip_addr => request.remote_ip, :updater_id => @current_user.id)) + + if artist.errors.empty? + respond_to_success("Artist created", :action => "show", :id => artist.id) + else + respond_to_error(artist, :action => "create", :alias_id => params[:alias_id]) + end + else + @artist = Artist.new + + if params[:name] + @artist.name = params[:name] + + post = Post.find(:first, :conditions => ["id IN (SELECT post_id FROM posts_tags WHERE tag_id = (SELECT id FROM tags WHERE name = ?)) AND source LIKE 'http%'", params[:name]]) + unless post == nil || post.source.blank? + @artist.urls = post.source + end + end + + if params[:alias_id] + @artist.alias_id = params[:alias_id] + end + end + end + + def index + if params[:name] + @artists = Artist.paginate Artist.generate_sql(params[:name]).merge(:per_page => 50, :page => params[:page], :order => "name") + elsif params[:url] + @artists = Artist.paginate Artist.generate_sql(params[:url]).merge(:per_page => 50, :page => params[:page], :order => "name") + else + if params[:order] == "date" + order = "updated_at DESC" + else + order = "name" + end + + @artists = Artist.paginate :order => order, :per_page => 25, :page => params[:page] + end + + respond_to_list("artists") + end + + def show + if params[:name] + @artist = Artist.find_by_name(params[:name]) + else + @artist = Artist.find(params[:id]) + end + + if @artist.nil? + redirect_to :action => "create", :name => params[:name] + else + redirect_to :controller => "wiki", :action => "show", :title => @artist.name + end + end +end diff --git a/app/controllers/banned_controller.rb b/app/controllers/banned_controller.rb new file mode 100644 index 00000000..f0890fa3 --- /dev/null +++ b/app/controllers/banned_controller.rb @@ -0,0 +1,11 @@ +class BannedController < ApplicationController + layout 'bare' + def index + @ban = get_ip_ban() + if not @ban + redirect_to :controller => "static", :action => "index" + return + end + end + +end diff --git a/app/controllers/blocks_controller.rb b/app/controllers/blocks_controller.rb new file mode 100644 index 00000000..3cad5a2b --- /dev/null +++ b/app/controllers/blocks_controller.rb @@ -0,0 +1,31 @@ +class CanNotBanSelf < Exception +end + +class BlocksController < ApplicationController + before_filter :mod_only, :only => [:block_ip, :unblock_ip] + + def block_ip + if request.post? + begin + IpBans.transaction do + ban = IpBans.create(params[:ban].merge(:banned_by => @current_user.id)) + if IpBans.find(:first, :conditions => ["id = ? and inet ? <<= ip_addr", ban.id, request.remote_ip]) then + raise CanNotBanSelf + end + end + rescue CanNotBanSelf => e + flash[:notice] = "You can not ban yourself" + end + redirect_to :controller => "user", :action => "show_blocked_users" + end + end + + def unblock_ip + params[:ip_ban].keys.each do |ban_id| + IpBans.destroy_all(["id = ?", ban_id]) + end + + redirect_to :controller => "user", :action => "show_blocked_users" + end +end + diff --git a/app/controllers/comment_controller.rb b/app/controllers/comment_controller.rb new file mode 100644 index 00000000..94ca8517 --- /dev/null +++ b/app/controllers/comment_controller.rb @@ -0,0 +1,128 @@ +class CommentController < ApplicationController + layout "default" + helper :avatar + + verify :method => :post, :only => [:create, :destroy, :update, :mark_as_spam] + before_filter :member_only, :only => [:create, :destroy, :update] + before_filter :janitor_only, :only => [:moderate] + helper :post + + def edit + @comment = Comment.find(params[:id]) + end + + def update + comment = Comment.find(params[:id]) + if @current_user.has_permission?(comment) + comment.update_attributes(params[:comment]) + respond_to_success("Comment updated", {:action => "index"}) + else + access_denied() + end + end + + def destroy + comment = Comment.find(params[:id]) + if @current_user.has_permission?(comment) + comment.destroy + respond_to_success("Comment deleted", :controller => "post", :action => "show", :id => comment.post_id) + else + access_denied() + end + end + + def create + if @current_user.is_member_or_lower? && params[:commit] == "Post" && Comment.count(:conditions => ["user_id = ? AND created_at > ?", @current_user.id, 1.hour.ago]) >= CONFIG["member_comment_limit"] + # TODO: move this to the model + respond_to_error("Hourly limit exceeded", {:action => "index"}, :status => 421) + return + end + + user_id = session[:user_id] + + comment = Comment.new(params[:comment].merge(:ip_addr => request.remote_ip, :user_id => user_id)) + if params[:commit] == "Post without bumping" + comment.do_not_bump_post = true + end + + if comment.save + respond_to_success("Comment created", :action => "index") + else + respond_to_error(comment, :action => "index") + end + end + + def show + set_title "Comment" + @comment = Comment.find(params[:id]) + + respond_to_list("comment") + end + + def index + set_title "Comments" + + if params[:format] == "json" || params[:format] == "xml" + @comments = Comment.paginate(Comment.generate_sql(params).merge(:per_page => 25, :page => params[:page], :order => "id DESC")) + respond_to_list("comments") + else + @posts = Post.paginate :order => "last_commented_at DESC", :conditions => "last_commented_at IS NOT NULL", :per_page => 10, :page => params[:page] + + comments = [] + @posts.each { |post| comments.push(*post.recent_comments) } + + newest_comment = comments.max {|a,b| a.created_at <=> b.created_at } + if !@current_user.is_anonymous? && newest_comment && @current_user.last_comment_read_at < newest_comment.created_at + @current_user.update_attribute(:last_comment_read_at, newest_comment.created_at) + end + + @posts = @posts.select {|x| x.can_be_seen_by?(@current_user, {:show_deleted => true})} + end + + @votes = {} + if !@current_user.is_anonymous? + @posts.each { |post| + vote = PostVotes.find_by_ids(@current_user.id, post.id) + @votes[post.id] = vote.score rescue 0 + } + end + end + + def search + options = { :order => "id desc", :per_page => 25, :page => params[:page] } + if params[:query] + query = params[:query].scan(/\S+/).join(" & ") + options[:conditions] = ["text_search_index @@ plainto_tsquery(?)", query] + end + @comments = Comment.paginate options + end + + def moderate + set_title "Moderate Comments" + + if request.post? + ids = params["c"].keys + coms = Comment.find(:all, :conditions => ["id IN (?)", ids]) + + if params["commit"] == "Delete" + coms.each do |c| + c.destroy + end + elsif params["commit"] == "Approve" + coms.each do |c| + c.update_attribute(:is_spam, false) + end + end + + redirect_to :action => "moderate" + else + @comments = Comment.find(:all, :conditions => "is_spam = TRUE", :order => "id DESC") + end + end + + def mark_as_spam + @comment = Comment.find(params[:id]) + @comment.update_attributes(:is_spam => true) + respond_to_success("Comment marked as spam", :action => "index") + end +end diff --git a/app/controllers/dmail_controller.rb b/app/controllers/dmail_controller.rb new file mode 100644 index 00000000..44f2c98e --- /dev/null +++ b/app/controllers/dmail_controller.rb @@ -0,0 +1,63 @@ +class DmailController < ApplicationController + before_filter :blocked_only + layout "default" + + def auto_complete_for_dmail_to_name + @users = User.find(:all, :order => "lower(name)", :conditions => ["name ilike ? escape '\\\\'", params[:dmail][:to_name] + "%"]) + render :layout => false, :text => "" + end + + def show_previous_messages + @dmails = Dmail.find(:all, :conditions => ["(to_id = ? or from_id = ?) and parent_id = ? and id < ?", @current_user.id, @current_user.id, params[:parent_id], params[:id]], :order => "id asc") + render :layout => false + end + + def compose + @dmail = Dmail.new + end + + def create + @dmail = Dmail.create(params[:dmail].merge(:from_id => @current_user.id)) + + if @dmail.errors.empty? + flash[:notice] = "Message sent to #{params[:dmail][:to_name]}" + redirect_to :action => "inbox" + else + flash[:notice] = "Error: " + @dmail.errors.full_messages.join(", ") + render :action => "compose" + end + end + + def inbox + @dmails = Dmail.paginate :conditions => ["to_id = ? or from_id = ?", @current_user.id, @current_user.id], :order => "created_at desc", :per_page => 25, :page => params[:page] + end + + def show + @dmail = Dmail.find(params[:id]) + + if @dmail.to_id != @current_user.id && @dmail.from_id != @current_user.id + flash[:notice] = "Access denied" + redirect_to :controller => "user", :action => "login" + return + end + + if @dmail.to_id == @current_user.id + @dmail.mark_as_read!(@current_user) + end + end + + def mark_all_read + if request.post? + if params[:commit] == "Yes" + Dmail.find(:all, :conditions => ["to_id = ? and has_seen = false", @current_user.id]).each do |dmail| + dmail.update_attribute(:has_seen, true) + end + + @current_user.update_attribute(:has_mail, false) + respond_to_success("All messages marked as read", {:action => "inbox"}) + else + redirect_to :action => "inbox" + end + end + end +end diff --git a/app/controllers/favorite_controller.rb b/app/controllers/favorite_controller.rb new file mode 100644 index 00000000..54d814cb --- /dev/null +++ b/app/controllers/favorite_controller.rb @@ -0,0 +1,20 @@ +class FavoriteController < ApplicationController + layout "default" + before_filter :blocked_only, :only => [:create, :destroy] + verify :method => :post, :only => [:create, :destroy] + helper :post + + def list_users + @post = Post.find(params[:id]) + respond_to do |fmt| + fmt.json do + render :json => {:favorited_users => @post.favorited_by.map(&:name).join(",")}.to_json + end + end + end + +protected + def favorited_users_for_post(post) + post.favorited_by.map {|x| x.name}.uniq.join(",") + end +end diff --git a/app/controllers/forum_controller.rb b/app/controllers/forum_controller.rb new file mode 100644 index 00000000..817c5f6e --- /dev/null +++ b/app/controllers/forum_controller.rb @@ -0,0 +1,150 @@ +class ForumController < ApplicationController + layout "default" + helper :avatar + verify :method => :post, :only => [:create, :destroy, :update, :stick, :unstick, :lock, :unlock] + before_filter :mod_only, :only => [:stick, :unstick, :lock, :unlock] + before_filter :member_only, :only => [:destroy, :update, :edit, :add, :mark_all_read] + before_filter :post_member_only, :only => [:create] + + def stick + ForumPost.stick!(params[:id]) + flash[:notice] = "Topic stickied" + redirect_to :action => "show", :id => params[:id] + end + + def unstick + ForumPost.unstick!(params[:id]) + flash[:notice] = "Topic unstickied" + redirect_to :action => "show", :id => params[:id] + end + + def preview + render :inline => "

Preview

<%= format_text(params[:forum_post][:body]) %>" + end + + def new + @forum_post = ForumPost.new + + if params[:type] == "alias" + @forum_post.title = "Tag Alias: " + @forum_post.body = "Aliasing ___ to ___.\n\nReason: " + elsif params[:type] == "impl" + @forum_post.title = "Tag Implication: " + @forum_post.body = "Implicating ___ to ___.\n\nReason: " + end + end + + def create + @forum_post = ForumPost.create(params[:forum_post].merge(:creator_id => session[:user_id])) + + if @forum_post.errors.empty? + if params[:forum_post][:parent_id].to_i == 0 + flash[:notice] = "Forum topic created" + redirect_to :action => "show", :id => @forum_post.root_id + else + flash[:notice] = "Response posted" + redirect_to :action => "show", :id => @forum_post.root_id, :page => (@forum_post.root.response_count / 30.0).ceil + end + else + render_error(@forum_post) + end + end + + def add + end + + def destroy + @forum_post = ForumPost.find(params[:id]) + + if @current_user.has_permission?(@forum_post, :creator_id) + @forum_post.destroy + flash[:notice] = "Post destroyed" + + if @forum_post.is_parent? + redirect_to :action => "index" + else + redirect_to :action => "show", :id => @forum_post.root_id + end + else + flash[:notice] = "Access denied" + redirect_to :action => "show", :id => @forum_post.root_id + end + end + + def edit + @forum_post = ForumPost.find(params[:id]) + + if !@current_user.has_permission?(@forum_post, :creator_id) + access_denied() + end + end + + def update + @forum_post = ForumPost.find(params[:id]) + + if !@current_user.has_permission?(@forum_post, :creator_id) + access_denied() + return + end + + @forum_post.attributes = params[:forum_post] + if @forum_post.save + flash[:notice] = "Post updated" + redirect_to :action => "show", :id => @forum_post.root_id, :page => (@forum_post.root.response_count / 30.0).ceil + else + render_error(@forum_post) + end + end + + def show + @forum_post = ForumPost.find(params[:id]) + set_title @forum_post.title + @children = ForumPost.paginate :order => "id", :per_page => 30, :conditions => ["parent_id = ?", params[:id]], :page => params[:page] + + if !@current_user.is_anonymous? && @current_user.last_forum_topic_read_at < @forum_post.updated_at && @forum_post.updated_at < 3.seconds.ago + @current_user.update_attribute(:last_forum_topic_read_at, @forum_post.updated_at) + end + + respond_to_list("forum_post") + end + + def index + set_title CONFIG["app_name"] + " Forum" + + if params[:parent_id] + @forum_posts = ForumPost.paginate :order => "is_sticky desc, updated_at DESC", :per_page => 100, :conditions => ["parent_id = ?", params[:parent_id]], :page => params[:page] + else + @forum_posts = ForumPost.paginate :order => "is_sticky desc, updated_at DESC", :per_page => 30, :conditions => "parent_id IS NULL", :page => params[:page] + end + + respond_to_list("forum_posts") + end + + def search + if params[:query] + query = params[:query].scan(/\S+/).join(" & ") + @forum_posts = ForumPost.paginate :order => "id desc", :per_page => 30, :conditions => ["text_search_index @@ plainto_tsquery(?)", query], :page => params[:page] + else + @forum_posts = ForumPost.paginate :order => "id desc", :per_page => 30, :page => params[:page] + end + + respond_to_list("forum_posts") + end + + def lock + ForumPost.lock!(params[:id]) + flash[:notice] = "Topic locked" + redirect_to :action => "show", :id => params[:id] + end + + def unlock + ForumPost.unlock!(params[:id]) + flash[:notice] = "Topic unlocked" + redirect_to :action => "show", :id => params[:id] + end + + def mark_all_read + @current_user.update_attribute(:last_forum_topic_read_at, Time.now) + render :nothing => true + end +end diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb new file mode 100644 index 00000000..99765284 --- /dev/null +++ b/app/controllers/help_controller.rb @@ -0,0 +1,3 @@ +class HelpController < ApplicationController + layout "default" +end diff --git a/app/controllers/history_controller.rb b/app/controllers/history_controller.rb new file mode 100644 index 00000000..ae96a48e --- /dev/null +++ b/app/controllers/history_controller.rb @@ -0,0 +1,129 @@ +class HistoryController < ApplicationController + layout 'default' +# before_filter :member_only + verify :method => :post, :only => [:undo] + + def index + @params = params + if params[:action] == "index" + @type = "all" + else + @type = params[:action].pluralize + end + + conds = [] + cond_params = [] + # :specific_history => showing only one history + # :specific_table => showing changes only for a particular table + # :show_all_tags => don't omit post tags that didn't change + @options = { + :show_all_tags => @params[:show_all_tags] == "1" + } + if params[:search] + # If a search specifies a table name, it overrides the action. + search_type, param = + if params[:search] =~ /^(.+?):(.*)/ + search_type = $1 + param = $2 + + if search_type == "user" + user = User.find_by_name(param) + if user + conds << "histories.user_id = ?" + cond_params << user.id + else + conds << "false" + end + elsif search_type == "change" + @type = "all" + @options[:specific_history] = true + conds << "histories.id = ?" + cond_params << param.to_i + + set_type_to_result = true + else + @options[:specific_table] = true + @type = search_type.pluralize + conds << "histories.group_by_id = ?" + cond_params << param.to_i + end + end + end + + if @type != "all" + conds << "histories.group_by_table = ?" + cond_params << @type + end + + @options[:show_name] = false + if @type != "all" + begin + obj = Object.const_get(@type.classify) + @options[:show_name] = obj.method_defined?("pretty_name") + rescue NameError => e + end + end + + @changes = History.paginate(History.generate_sql(params).merge( + :order => "id DESC", :per_page => 20, :select => "*", :page => params[:page], + :conditions => [conds.join(" AND "), *cond_params], + :include => [:history_changes] + )) + + # If we're searching for a specific change, force the display to the + # type of the change we found. + if set_type_to_result && !@changes.empty? + @type = @changes.first.group_by_table.pluralize + end + + render :action => :index + end + + alias_method :post, :index + alias_method :pool, :index + alias_method :tag, :index + + def undo + ids = params[:id].split(/,/) + + @changes = [] + ids.each do |id| + @changes += HistoryChange.find(:all, :conditions => ["id = ?", id]) + end + + histories = {} + total_histories = 0 + @changes.each { |change| + next if histories[change.history_id] + histories[change.history_id] = true + total_histories += 1 + } + + if total_histories > 1 && !@current_user.is_privileged_or_higher? + respond_to_error("Only privileged users can undo more than one change at once", :status => 403) + return + end + + errors = {} + History.undo(@changes, @current_user, params[:redo] == "1", errors) + + error_texts = [] + successful = 0 + failed = 0 + @changes.each { |change| + if not errors[change] + successful += 1 + next + end + failed += 1 + + case errors[change] + when :denied + error_texts << "Some changes were not made because you do not have access to make them." + end + } + error_texts.uniq! + + respond_to_success("Changes made.", {:action => "index"}, :api => {:successful=>successful, :failed=>failed, :errors=>error_texts}) + end +end diff --git a/app/controllers/inline_controller.rb b/app/controllers/inline_controller.rb new file mode 100644 index 00000000..a78b2b97 --- /dev/null +++ b/app/controllers/inline_controller.rb @@ -0,0 +1,167 @@ +class InlineController < ApplicationController + layout "default" + before_filter :member_only, :only => [:create, :copy] + verify :method => :post, :only => [:delete_image, :delete, :update, :copy] + + def create + # If this user already has an inline with no images, use it. + inline = Inline.find(:first, :conditions => ["(SELECT count(*) FROM inline_images WHERE inline_images.inline_id = inlines.id) = 0 AND user_id = ?", @current_user.id]) + inline ||= Inline.create(:user_id => @current_user.id) + redirect_to :action => "edit", :id => inline.id + end + + def index + order = [] + if not @current_user.is_anonymous? + order << ["user_id = #{@current_user.id} DESC"] + end + order << ["created_at desc"] + + options = { + :per_page => 20, + :page => params[:page], + # Mods can view all inlines; sort the user's own inlines first. + :order => order.join(", ") + } + + @inlines = Inline.paginate options + + respond_to_list("inlines") + end + + def delete + @inline = Inline.find_by_id(params[:id]) + if @inline.nil? + redirect_to "/404" + return + end + + unless @current_user.has_permission?(@inline) + access_denied() + return + end + + @inline.destroy + respond_to_success("Image group deleted", :action => "index") + end + + def add_image + @inline = Inline.find_by_id(params[:id]) + if @inline.nil? + redirect_to "/404" + return + end + + unless @current_user.has_permission?(@inline) + access_denied() + return + end + + if request.post? + new_image = InlineImage.create(params[:image].merge(:inline_id => @inline.id)) + if not new_image.errors.empty? + respond_to_error(new_image, :action => "edit", :id => @inline.id) + return + end + + redirect_to :action => "edit", :id => @inline.id + return + end + end + + def delete_image + image = InlineImage.find_by_id(params[:id]) + if image.nil? + redirect_to "/404" + return + end + + inline = image.inline + unless @current_user.has_permission?(inline) + access_denied() + return + end + + image.destroy + redirect_to :action => "edit", :id => inline.id + end + + def update + inline = Inline.find_by_id(params[:id].to_i) + if inline.nil? + redirect_to "/404" + return + end + unless @current_user.has_permission?(inline) + access_denied() + return + end + + inline.update_attributes(params[:inline]) + params[:image] ||= [] + params[:image].each do |id, p| + image = InlineImage.find(:first, :conditions => ["id = ? AND inline_id = ?", id, inline.id]) + image.update_attributes(:description => p[:description]) if p[:description] + image.update_attributes(:sequence => p[:sequence]) if p[:sequence] + end + + inline.reload + inline.renumber_sequences + + flash[:notice] = "Image updated" + redirect_to :action => "edit", :id => inline.id + end + + # Create a copy of an inline image and all of its images. Allow copying from images + # owned by someone else. + def copy + inline = Inline.find_by_id(params[:id]) + if inline.nil? + redirect_to "/404" + return + end + + new_inline = Inline.create(:user_id => @current_user.id) + new_inline.update_attributes(:description => inline.description) + + for image in inline.inline_images do + new_attributes = image.attributes.merge(:inline_id => new_inline.id) + new_attributes.delete(:id) + new_image = InlineImage.create(new_attributes) + new_image.save! + end + + respond_to_success("Image copied", :action => "edit", :id => new_inline.id) + end + + def edit + @inline = Inline.find_by_id(params[:id]) + if @inline.nil? + redirect_to "/404" + return + end + end + + def crop + @inline = Inline.find_by_id(params[:id]) + @image = @inline.inline_images[0] rescue nil + if @inline.nil? || @image.nil? + redirect_to "/404" + return + end + unless @current_user.has_permission?(@inline) + access_denied() + return + end + + if request.post? + if @inline.crop(params) then + redirect_to :action => "edit", :id => @inline.id + else + respond_to_error(@inline, :action => "edit", :id => @inline.id) + end + end + + @params = params + end +end diff --git a/app/controllers/job_task_controller.rb b/app/controllers/job_task_controller.rb new file mode 100644 index 00000000..b46a5d7d --- /dev/null +++ b/app/controllers/job_task_controller.rb @@ -0,0 +1,24 @@ +class JobTaskController < ApplicationController + layout "default" + + def index + @job_tasks = JobTask.paginate(:per_page => 25, :order => "id DESC", :page => params[:page]) + end + + def show + @job_task = JobTask.find(params[:id]) + + if @job_task.task_type == "upload_post" && @job_task.status == "finished" + redirect_to :controller => "post", :action => "show", :id => @job_task.status_message + end + end + + def retry + @job_task = JobTask.find(params[:id]) + + if request.post? + @job_task.update_attributes(:status => "pending", :status_message => "") + redirect_to :action => "show", :id => @job_task.id + end + end +end diff --git a/app/controllers/note_controller.rb b/app/controllers/note_controller.rb new file mode 100644 index 00000000..e06930fd --- /dev/null +++ b/app/controllers/note_controller.rb @@ -0,0 +1,89 @@ +class NoteController < ApplicationController + layout 'default', :only => [:index, :history, :search] + before_filter :post_member_only, :only => [:destroy, :update, :revert] + verify :method => :post, :only => [:update, :revert, :destroy] + helper :post + + def search + if params[:query] + query = params[:query].scan(/\S+/).join(" & ") + @notes = Note.paginate :order => "id asc", :per_page => 25, :conditions => ["text_search_index @@ plainto_tsquery(?)", query], :page => params[:page] + + respond_to_list("notes") + end + end + + def index + set_title "Notes" + + if params[:post_id] + @posts = Post.paginate :order => "last_noted_at DESC", :conditions => ["id = ?", params[:post_id]], :per_page => 100, :page => params[:page] + else + @posts = Post.paginate :order => "last_noted_at DESC", :conditions => "last_noted_at IS NOT NULL", :per_page => 16, :page => params[:page] + end + + respond_to do |fmt| + fmt.html + fmt.xml {render :xml => @posts.map {|x| x.notes}.flatten.to_xml(:root => "notes")} + fmt.json {render :json => @posts.map {|x| x.notes}.flatten.to_json} + end + end + + def history + set_title "Note History" + + if params[:id] + @notes = NoteVersion.paginate(:page => params[:page], :per_page => 25, :order => "id DESC", :conditions => ["note_id = ?", params[:id]]) + elsif params[:post_id] + @notes = NoteVersion.paginate(:page => params[:page], :per_page => 50, :order => "id DESC", :conditions => ["post_id = ?", params[:post_id]]) + elsif params[:user_id] + @notes = NoteVersion.paginate(:page => params[:page], :per_page => 50, :order => "id DESC", :conditions => ["user_id = ?", params[:user_id]]) + else + @notes = NoteVersion.paginate(:page => params[:page], :per_page => 25, :order => "id DESC") + end + + respond_to_list("notes") + end + + def revert + note = Note.find(params[:id]) + + if note.is_locked? + respond_to_error("Post is locked", {:action => "history", :id => note.id}, :status => 422) + return + end + + note.revert_to(params[:version]) + note.ip_addr = request.remote_ip + note.user_id = @current_user.id + + if note.save + respond_to_success("Note reverted", :action => "history", :id => note.id) + else + render_error(note) + end + end + + def update + if params[:note][:post_id] + note = Note.new(:post_id => params[:note][:post_id]) + else + note = Note.find(params[:id]) + end + + if note.is_locked? + respond_to_error("Post is locked", {:controller => "post", :action => "show", :id => note.post_id}, :status => 422) + return + end + + note.attributes = params[:note] + note.user_id = @current_user.id + note.ip_addr = request.remote_ip + + if note.save + respond_to_success("Note updated", {:action => "index"}, :api => {:new_id => note.id, :old_id => params[:id].to_i, :formatted_body => HTML5Sanitizer::hs(note.formatted_body)}) + else + respond_to_error(note, :controller => "post", :action => "show", :id => note.post_id) + end + end +end diff --git a/app/controllers/pool_controller.rb b/app/controllers/pool_controller.rb new file mode 100644 index 00000000..19eedd71 --- /dev/null +++ b/app/controllers/pool_controller.rb @@ -0,0 +1,251 @@ +class PoolController < ApplicationController + layout "default" + before_filter :member_only, :only => [:destroy, :update, :add_post, :remove_post, :import, :zip] + before_filter :post_member_only, :only => [:create] + helper :post + + def index + options = { + :per_page => 20, + :page => params[:page] + } + + case params[:order] + when "name": options[:order] = "nat_sort(name) asc" + when "date": options[:order] = "created_at desc" + when "updated": options[:order] = "updated_at desc" + when "date": options[:order] = "id desc" + else + if params.has_key?(:query) + options[:order] = "nat_sort(name) asc" + else + options[:order] = "created_at desc" + end + end + + if params[:query] + options[:conditions] = ["lower(name) like ?", "%" + params[:query].to_escaped_for_sql_like + "%"] + end + + @pools = Pool.paginate options + @samples = {} + @pools.each { |p| + post = p.get_sample + if not post then next end + @samples[p] = post + } + + respond_to_list("pools") + end + + def show + if params[:samples] == "0" then params.delete(:samples) end + if params[:originals] == "0" then params.delete(:originals) end + + post_assoc = params[:originals] ? :pool_posts : :pool_parent_posts + @pool = Pool.find(params[:id], :include => [post_assoc => :post]) + + # We have the Pool.pool_parent_posts association for this, but that doesn't seem to want to work... + conds = ["pools_posts.pool_id = ?"] + cond_params = params[:id] + + if params[:originals] + conds << "pools_posts.active = 't'" + else + conds << "((pools_posts.active = true AND pools_posts.slave_id IS NULL) OR pools_posts.master_id IS NOT NULL)" + end + + @posts = Post.paginate :per_page => 24, :order => "nat_sort(pools_posts.sequence), pools_posts.post_id", :joins => "JOIN pools_posts ON posts.id = pools_posts.post_id", :conditions => [conds.join(" AND "), *cond_params], :select => "posts.*", :page => params[:page] + + set_title @pool.pretty_name + respond_to do |fmt| + fmt.html + fmt.xml do + builder = Builder::XmlMarkup.new(:indent => 2) + builder.instruct! + + xml = @pool.to_xml(:builder => builder, :skip_instruct => true) do + builder.posts do + @posts.each do |post| + post.to_xml(:builder => builder, :skip_instruct => true) + end + end + end + render :xml => xml + end + end + end + + def update + @pool = Pool.find(params[:id]) + + unless @pool.can_be_updated_by?(@current_user) + access_denied() + return + end + + if request.post? + @pool.update_attributes(params[:pool]) + respond_to_success("Pool updated", :action => "show", :id => params[:id]) + end + end + + def create + if request.post? + @pool = Pool.create(params[:pool].merge(:user_id => @current_user.id)) + + if @pool.errors.empty? + respond_to_success("Pool created", :action => "show", :id => @pool.id) + else + respond_to_error(@pool, :action => "index") + end + else + @pool = Pool.new(:user_id => @current_user.id) + end + end + + def destroy + @pool = Pool.find(params[:id]) + + if request.post? + if @pool.can_be_updated_by?(@current_user) + @pool.destroy + respond_to_success("Pool deleted", :action => "index") + else + access_denied() + end + end + end + + def add_post + if request.post? + @pool = Pool.find(params[:pool_id]) + session[:last_pool_id] = @pool.id + + if params[:pool] && !params[:pool][:sequence].blank? + sequence = params[:pool][:sequence] + else + sequence = nil + end + + begin + @pool.add_post(params[:post_id], :sequence => sequence, :user => @current_user) + respond_to_success("Post added", :controller => "post", :action => "show", :id => params[:post_id]) + rescue Pool::PostAlreadyExistsError + respond_to_error("Post already exists", {:controller => "post", :action => "show", :id => params[:post_id]}, :status => 423) + rescue Pool::AccessDeniedError + access_denied() + rescue Exception => x + respond_to_error(x.class, :controller => "post", :action => "show", :id => params[:post_id]) + end + else + if @current_user.is_anonymous? + @pools = Pool.find(:all, :order => "name", :conditions => "is_active = TRUE AND is_public = TRUE") + else + @pools = Pool.find(:all, :order => "name", :conditions => ["is_active = TRUE AND (is_public = TRUE OR user_id = ?)", @current_user.id]) + end + + @post = Post.find(params[:post_id]) + end + end + + def remove_post + if request.post? + @pool = Pool.find(params[:pool_id]) + + begin + @pool.remove_post(params[:post_id], :user => @current_user) + rescue Pool::AccessDeniedError + access_denied() + return + end + + response.headers["X-Post-Id"] = params[:post_id] + respond_to_success("Post removed", :controller => "post", :action => "show", :id => params[:post_id]) + else + @pool = Pool.find(params[:pool_id]) + @post = Post.find(params[:post_id]) + end + end + + def order + @pool = Pool.find(params[:id]) + + unless @pool.can_be_updated_by?(@current_user) + access_denied() + return + end + + if request.post? + PoolPost.transaction do + params[:pool_post_sequence].each do |i, seq| + PoolPost.update(i, :sequence => seq) + end + + @pool.reload + @pool.update_pool_links + end + + flash[:notice] = "Ordering updated" + redirect_to :action => "show", :id => params[:id] + else + @pool_posts = @pool.pool_posts + end + end + + def import + @pool = Pool.find(params[:id]) + + unless @pool.can_be_updated_by?(@current_user) + access_denied() + return + end + + if request.post? + if params[:posts].is_a?(Hash) + ordered_posts = params[:posts].sort { |a,b| a[1]<=>b[1] }.map { |a| a[0] } + + PoolPost.transaction do + ordered_posts.each do |post_id| + begin + @pool.add_post(post_id, :skip_update_pool_links => true) + rescue Pool::PostAlreadyExistsError + # ignore + end + end + @pool.update_pool_links + end + end + + redirect_to :action => "show", :id => @pool.id + else + respond_to do |fmt| + fmt.html + fmt.js do + @posts = Post.find_by_tags(params[:query], :order => "id desc", :limit => 500) + @posts = @posts.select {|x| x.can_be_seen_by?(@current_user)} + end + end + end + end + + def select + if @current_user.is_anonymous? + @pools = Pool.find(:all, :order => "name", :conditions => "is_active = TRUE AND is_public = TRUE") + else + @pools = Pool.find(:all, :order => "name", :conditions => ["is_active = TRUE AND (is_public = TRUE OR user_id = ?)", @current_user.id]) + end + + render :layout => false + end + + # Generate a ZIP control file for lighttpd, and redirect to the ZIP. + if CONFIG["pool_zips"] + def zip + post_assoc = params[:originals] ? :pool_posts : :pool_parent_posts + pool = Pool.find(params[:id], :include => [post_assoc => :post]) + control_path = pool.get_zip_control_file_path(params) + redirect_to pool.get_zip_url(control_path, params) + end + end +end diff --git a/app/controllers/post_controller.rb b/app/controllers/post_controller.rb new file mode 100644 index 00000000..9ea0cb38 --- /dev/null +++ b/app/controllers/post_controller.rb @@ -0,0 +1,721 @@ +require "download" + +class PostController < ApplicationController + layout 'default' + helper :avatar + + verify :method => :post, :only => [:update, :destroy, :create, :revert_tags, :vote, :flag], :redirect_to => {:action => :show, :id => lambda {|c| c.params[:id]}} + + before_filter :member_only, :only => [:create, :destroy, :delete, :flag, :revert_tags, :activate, :update_batch] + before_filter :post_member_only, :only => [:update, :upload, :flag] + before_filter :janitor_only, :only => [:moderate, :undelete] + after_filter :save_tags_to_cookie, :only => [:update, :create] + if CONFIG["load_average_threshold"] + before_filter :check_load_average, :only => [:index, :popular_by_day, :popular_by_week, :popular_by_month, :random, :atom, :piclens] + end + + if CONFIG["enable_caching"] + around_filter :cache_action, :only => [:index, :atom, :piclens] + end + + helper :wiki, :tag, :comment, :pool, :favorite, :advertisement + + def verify_action(options) + redirect_to_proc = false + + if options[:redirect_to] && options[:redirect_to][:id].is_a?(Proc) + redirect_to_proc = options[:redirect_to][:id] + options[:redirect_to][:id] = options[:redirect_to][:id].call(self) + end + + result = super(options) + + if redirect_to_proc + options[:redirect_to][:id] = redirect_to_proc + end + + return result + end + + def activate + ids = params[:post_ids].map { |id| id.to_i } + changed = Post.batch_activate(@current_user.is_mod_or_higher? ? nil: @current_user.id, ids) + respond_to_success("Posts activated", {:action => "moderate"}, :api => {:count => changed}) + end + + def upload_problem + end + + def upload + @deleted_posts = FlaggedPostDetail.new_deleted_posts(@current_user) +# redirect_to :action => "upload_problem" +# return + +# if params[:url] +# @post = Post.find(:first, :conditions => ["source = ?", params[:url]]) +# end + + if @post.nil? + @post = Post.new + end + end + + def create +# respond_to_error("Uploads temporarily disabled due to Amazon S3 issues", :action => "upload_problem") +# return + + if @current_user.is_member_or_lower? && Post.count(:conditions => ["user_id = ? AND created_at > ? ", @current_user.id, 1.day.ago]) >= CONFIG["member_post_limit"] + respond_to_error("Daily limit exceeded", {:action => "index"}, :status => 421) + return + end + + if @current_user.is_privileged_or_higher? + status = "active" + else + status = "pending" + end + + @post = Post.create(params[:post].merge(:updater_user_id => @current_user.id, :updater_ip_addr => request.remote_ip, :user_id => @current_user.id, :ip_addr => request.remote_ip, :status => status)) + + if @post.errors.empty? + if params[:md5] && @post.md5 != params[:md5].downcase + @post.destroy + respond_to_error("MD5 mismatch", {:action => "upload"}, :status => 420) + else + if CONFIG["dupe_check_on_upload"] && @post.image? && @post.parent_id.nil? + if params[:format] == "xml" || params[:format] == "json" + options = { :services => SimilarImages.get_services("local"), :type => :post, :source => @post } + + res = SimilarImages.similar_images(options) + if not res[:posts].empty? + @post.tags = @post.tags + " possible_duplicate" + @post.save! + end + end + + respond_to_success("Post uploaded", {:controller => "post", :action => "similar", :id => @post.id, :initial => 1}, :api => {:post_id => @post.id, :location => url_for(:controller => "post", :action => "similar", :id => @post.id, :initial => 1)}) + else + respond_to_success("Post uploaded", {:controller => "post", :action => "show", :id => @post.id, :tag_title => @post.tag_title}, :api => {:post_id => @post.id, :location => url_for(:controller => "post", :action => "show", :id => @post.id)}) + end + end + elsif @post.errors.invalid?(:md5) + p = Post.find_by_md5(@post.md5) + + update = { :tags => p.cached_tags + " " + params[:post][:tags], :updater_user_id => session[:user_id], :updater_ip_addr => request.remote_ip } + update[:source] = @post.source if p.source.blank? && !@post.source.blank? + p.update_attributes(update) + + respond_to_error("Post already exists", {:controller => "post", :action => "show", :id => p.id, :tag_title => @post.tag_title}, :api => {:location => url_for(:controller => "post", :action => "show", :id => p.id)}, :status => 423) + else + respond_to_error(@post, :action => "upload") + end + end + + def moderate + if request.post? + Post.transaction do + if params[:ids] + params[:ids].keys.each do |post_id| + if params[:commit] == "Approve" + post = Post.find(post_id) + post.approve!(@current_user.id) + elsif params[:commit] == "Delete" + Post.destroy_with_reason(post_id, params[:reason] || params[:reason2], @current_user) + end + end + end + end + + if params[:commit] == "Approve" + respond_to_success("Post approved", {:action => "moderate"}) + elsif params[:commit] == "Delete" + respond_to_success("Post deleted", {:action => "moderate"}) + end + else + if params[:query] + @pending_posts = Post.find_by_sql(Post.generate_sql(params[:query], :pending => true, :order => "id desc")) + @flagged_posts = Post.find_by_sql(Post.generate_sql(params[:query], :flagged => true, :order => "id desc")) + else + @pending_posts = Post.find(:all, :conditions => "status = 'pending'", :order => "id desc") + @flagged_posts= Post.find(:all, :conditions => "status = 'flagged'", :order => "id desc") + end + end + end + + def update + @post = Post.find(params[:id]) + user_id = @current_user.id + + if @post.update_attributes(params[:post].merge(:updater_user_id => user_id, :updater_ip_addr => request.remote_ip)) + # Reload the post to send the new status back; not all changes will be reflected in + # @post due to after_save changes. + @post.reload + respond_to_success("Post updated", {:action => "show", :id => @post.id, :tag_title => @post.tag_title}, :api => {:post => @post}) + else + respond_to_error(@post, :action => "show", :id => params[:id]) + end + end + + def update_batch + user_id = @current_user.id + + ids = {} + params["post"].each { |post| + post_id = post[:id] + post.delete(:id) + @post = Post.find(post_id) + ids[@post.id] = true + + # If an entry has only an ID, it was just included in the list to receive changes to + # a post without changing it (for example, to receive the parent's data after reparenting + # a post under it). + next if post.empty? + + old_parent_id = @post.parent_id + + if @post.update_attributes(post.merge(:updater_user_id => user_id, :updater_ip_addr => request.remote_ip)) + # Reload the post to send the new status back; not all changes will be reflected in + # @post due to after_save changes. + @post.reload + end + + if @post.parent_id != old_parent_id + ids[@post.parent_id] = true if @post.parent_id + ids[old_parent_id] = true if old_parent_id + end + } + + # Updates to one post may affect others, so only generate the return list after we've already + # updated everything. + ret = Post.find_by_sql(["SELECT * FROM posts WHERE id IN (?)", ids.map { |id, t| id }]) + + respond_to_success("Post updated", {:action => "show", :id => @post.id, :tag_title => @post.tag_title}, :api => {:posts => ret}) + end + + def delete + @post = Post.find(params[:id]) + + if @post && @post.parent_id + @post_parent = Post.find(@post.parent_id) + end + end + + def destroy + if params[:commit] == "Cancel" + redirect_to :action => "show", :id => params[:id] + return + end + + @post = Post.find(params[:id]) + + if @post.can_user_delete?(@current_user) + if @post.status == "deleted" + @post.delete_from_database + else + Post.destroy_with_reason(@post.id, params[:reason], @current_user) + end + + respond_to_success("Post deleted", :action => "index") + else + access_denied() + end + end + + def deleted_index + if !@current_user.is_anonymous? && params[:user_id] && params[:user_id].to_i == @current_user.id + @current_user.update_attribute(:last_deleted_post_seen_at, Time.now) + end + + if params[:user_id] + @posts = Post.paginate(:per_page => 25, :order => "flagged_post_details.created_at DESC", :joins => "JOIN flagged_post_details ON flagged_post_details.post_id = posts.id", :select => "flagged_post_details.reason, posts.cached_tags, posts.id, posts.user_id", :conditions => ["posts.status = 'deleted' AND posts.user_id = ? ", params[:user_id]], :page => params[:page]) + else + @posts = Post.paginate(:per_page => 25, :order => "flagged_post_details.created_at DESC", :joins => "JOIN flagged_post_details ON flagged_post_details.post_id = posts.id", :select => "flagged_post_details.reason, posts.cached_tags, posts.id, posts.user_id", :conditions => ["posts.status = 'deleted'"], :page => params[:page]) + end + end + + def acknowledge_new_deleted_posts + @current_user.update_attribute(:last_deleted_post_seen_at, Time.now) if !@current_user.is_anonymous? + respond_to_success("Success", {}) + end + + def index + tags = params[:tags].to_s + split_tags = QueryParser.parse(tags) + page = params[:page].to_i + + if @current_user.is_member_or_lower? && split_tags.size > 2 + respond_to_error("You can only search up to two tags at once with a basic account", :action => "index") + return + elsif split_tags.size > 6 + respond_to_error("You can only search up to six tags at once", :action => "index") + return + end + + q = Tag.parse_query(tags) + + limit = params[:limit].to_i if params.has_key?(:limit) + limit ||= q[:limit].to_i if q.has_key?(:limit) + limit ||= 16 + limit = 1000 if limit > 1000 + + count = 0 + + begin + count = Post.fast_count(tags) + rescue => x + respond_to_error("Error: #{x}", :action => "index") + return + end + + set_title "/" + tags.tr("_", " ") + + if count < 16 && split_tags.size == 1 + @tag_suggestions = Tag.find_suggestions(tags) + end + + @ambiguous_tags = Tag.select_ambiguous(split_tags) + + @posts = WillPaginate::Collection.new(page, limit, count) + offset = @posts.offset + posts_to_load = @posts.per_page * 2 + + # If we're not on the first page, load the previous page for prefetching. Prefetching + # the previous page when the user is scanning forward should be free, since it'll already + # be in cache, so this makes scanning the index from back to front as responsive as from + # front to back. + if page && page > 1 then + offset -= @posts.per_page + posts_to_load += @posts.per_page + end + + @showing_holds_only = q.has_key?(:show_holds_only) && q[:show_holds_only] + + from_api = (params[:format] == "json" || params[:format] == "xml") + results = Post.find_by_sql(Post.generate_sql(q, :original_query => tags, :from_api => from_api, :order => "p.id DESC", :offset => offset, :limit => @posts.per_page * 3)) + @preload = [] + if page && page > 1 then + @preload = results[0, limit] || [] + results = results[limit..-1] || [] + end + @posts.replace(results[0..limit-1]) + @preload += results[limit..-1] || [] + + respond_to do |fmt| + fmt.html do + if split_tags.any? + @tags = Tag.parse_query(tags) + else + @tags = Cache.get("$poptags", 1.hour) do + {:include => Tag.count_by_period(1.day.ago, Time.now, :limit => 25, :exclude_types => CONFIG["exclude_from_tag_sidebar"])} + end + end + end + fmt.xml do + render :layout => false + end + fmt.json {render :json => @posts.to_json} + end + end + + def atom + # We get a lot of bogus "/post/atom.feed" requests that spam our error logs. Make sure + # we only try to format atom.xml. + if not params[:format].nil? then + # If we don't change the format, it tries to render "404.feed". + params[:format] = "html" + raise ActiveRecord::RecordNotFound + end + + @posts = Post.find_by_sql(Post.generate_sql(params[:tags], :limit => 20, :order => "p.id DESC")) + headers["Content-Type"] = "application/atom+xml" + render :layout => false + end + + def piclens + @posts = WillPaginate::Collection.create(params[:page], 16, Post.fast_count(params[:tags])) do |pager| + pager.replace(Post.find_by_sql(Post.generate_sql(params[:tags], :order => "p.id DESC", :offset => pager.offset, :limit => pager.per_page))) + end + + headers["Content-Type"] = "application/rss+xml" + render :layout => false + end + + def show + begin + if params[:md5] + @post = Post.find_by_md5(params[:md5].downcase) || raise(ActiveRecord::RecordNotFound) + else + @post = Post.find(params[:id]) + end + + if !@current_user.is_anonymous? && @post + @vote = PostVotes.find_by_ids(@current_user.id, @post.id).score rescue 0 + end + + @pools = Pool.find(:all, :joins => "JOIN pools_posts ON pools_posts.pool_id = pools.id", :conditions => "pools_posts.post_id = #{@post.id} AND (active = true OR master_id IS NOT NULL)", :order => "pools.name", :select => "pools.name, pools.id") + @tags = {:include => @post.cached_tags.split(/ /)} + @include_tag_reverse_aliases = true + set_title @post.title_tags.tr("_", " ") + rescue ActiveRecord::RecordNotFound + render :action => "show_empty", :status => 404 + end + end + + def view + redirect_to :action=>"show", :id=>params[:id] + end + + def popular_recent + case params[:period] + when "1w" + @period_name = "last week" + period = 1.week + when "1m" + @period_name = "last month" + period = 1.month + when "1y" + @period_name = "last year" + period = 1.year + else + params[:period] = "1d" + @period_name = "last 24 hours" + period = 1.day + end + + @params = params + @end = Time.now + @start = @end - period + @previous = @start - period + + set_title "Exploring %s" % @period_name + + @posts = Post.find(:all, :conditions => ["status <> 'deleted' AND posts.index_timestamp >= ? AND posts.index_timestamp <= ? ", @start, @end], :order => "score DESC", :limit => 20) + + respond_to_list("posts") + end + + def popular_by_day + if params[:year] && params[:month] && params[:day] + @day = Time.gm(params[:year].to_i, params[:month], params[:day]) + else + @day = Time.new.getgm.at_beginning_of_day + end + + set_title "Exploring #{@day.year}/#{@day.month}/#{@day.day}" + + @posts = Post.find(:all, :conditions => ["posts.created_at >= ? AND posts.created_at <= ? ", @day, @day.tomorrow], :order => "score DESC", :limit => 20) + + respond_to_list("posts") + end + + def popular_by_week + if params[:year] && params[:month] && params[:day] + @start = Time.gm(params[:year].to_i, params[:month], params[:day]).beginning_of_week + else + @start = Time.new.getgm.beginning_of_week + end + + @end = @start.next_week + + set_title "Exploring #{@start.year}/#{@start.month}/#{@start.day} - #{@end.year}/#{@end.month}/#{@end.day}" + + @posts = Post.find(:all, :conditions => ["posts.created_at >= ? AND posts.created_at < ? ", @start, @end], :order => "score DESC", :limit => 20) + + respond_to_list("posts") + end + + def popular_by_month + if params[:year] && params[:month] + @start = Time.gm(params[:year].to_i, params[:month], 1) + else + @start = Time.new.getgm.beginning_of_month + end + + @end = @start.next_month + + set_title "Exploring #{@start.year}/#{@start.month}" + + @posts = Post.find(:all, :conditions => ["posts.created_at >= ? AND posts.created_at < ? ", @start, @end], :order => "score DESC", :limit => 20) + + respond_to_list("posts") + end + + def revert_tags + user_id = @current_user.id + @post = Post.find(params[:id]) + @post.update_attributes(:tags => PostTagHistory.find(params[:history_id].to_i).tags, :updater_user_id => user_id, :updater_ip_addr => request.remote_ip) + + respond_to_success("Tags reverted", :action => "show", :id => @post.id, :tag_title => @post.tag_title) + end + + def vote + p = Post.find(params[:id]) + score = params[:score].to_i + + if !@current_user.is_mod_or_higher? && score < 0 || score > 3 + respond_to_error("Invalid score", {:action => "show", :id => params[:id], :tag_title => p.tag_title}, :status => 424) + return + end + + options = {} + if p.vote!(score, @current_user, request.remote_ip, options) + voted_by = p.voted_by + voted_by.each_key { |vote| + users = voted_by[vote] + users.map! { |user| + { :name => user.pretty_name, :id => user.id } + } + } + respond_to_success("Vote saved", {:action => "show", :id => params[:id], :tag_title => p.tag_title}, :api => {:vote => score, :score => p.score, :post_id => p.id, :votes => voted_by }) + else + respond_to_error("Already voted", {:action => "show", :id => params[:id], :tag_title => p.tag_title}, :status => 423) + end + end + + def flag + post = Post.find(params[:id]) + if post.status != "active" + respond_to_error("Can only flag active posts", :action => "show", :id => params[:id]) + return + end + + post.flag!(params[:reason], @current_user.id) + respond_to_success("Post flagged", :action => "show", :id => params[:id]) + end + + def random + max_id = Post.maximum(:id) + + 10.times do + post = Post.find(:first, :conditions => ["id = ? AND status <> 'deleted'", rand(max_id) + 1]) + + if post != nil && post.can_be_seen_by?(@current_user) + redirect_to :action => "show", :id => post.id, :tag_title => post.tag_title + return + end + end + + flash[:notice] = "Couldn't find a post in 10 tries. Try again." + redirect_to :action => "index" + end + + def similar + @params = params + if params[:file].blank? then params.delete(:file) end + if params[:url].blank? then params.delete(:url) end + if params[:id].blank? then params.delete(:id) end + if params[:search_id].blank? then params.delete(:search_id) end + if params[:services].blank? then params.delete(:services) end + if params[:threshold].blank? then params.delete(:threshold) end + if params[:forcegray].blank? || params[:forcegray] == "0" then params.delete(:forcegray) end + if params[:initial] == "0" then params.delete(:initial) end + if not SimilarImages.valid_saved_search(params[:search_id]) then params.delete(:search_id) end + params[:width] = params[:width].to_i if params[:width] + params[:height] = params[:height].to_i if params[:height] + + @initial = params[:initial] + if @initial && !params[:services] + params[:services] = "local" + end + + @services = SimilarImages.get_services(params[:services]) + if params[:id] + begin + @compared_post = Post.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render :status => 404 + return; + end + end + + if @compared_post && @compared_post.is_deleted? + respond_to_error("Post deleted", :controller => "post", :action => "show", :id => params[:id], :tag_title => @compared_post.tag_title) + return + end + + # We can do these kinds of searches: + # + # File: Search from a specified file. The image is saved locally with an ID, and sent + # as a file to the search servers. + # + # URL: search from a remote URL. The URL is downloaded, and then treated as a :file + # search. This way, changing options doesn't repeatedly download the remote image, + # and it removes a layer of abstraction when an error happens during download + # compared to having the search server download it. + # + # Post ID: Search from a post ID. The preview image is sent as a URL. + # + # Search ID: Search using an image uploaded with a previous File search, using + # the search MD5 created. We're not allowed to repopulate filename fields in the + # user's browser, so we can't re-submit the form as a file search when changing search + # parameters. Instead, we hide the search ID in the form, and use it to recall the + # file from before. These files are expired after a while; we check for expired files + # when doing later searches, so we don't need a cron job. + def search(params) + options = params.merge({ + :services => @services, + }) + + # Check search_id first, so options links that include it will use it. If the + # user searches with the actual form, search_id will be cleared on submission. + if params[:search_id] then + file_path = SimilarImages.find_saved_search(params[:search_id]) + if file_path.nil? + # The file was probably purged. Delete :search_id before redirecting, so the + # error doesn't loop. + params.delete(:search_id) + return { :errors => { :error => "Search expired" } } + end + elsif params[:url] || params[:file] then + # Save the file locally. + begin + if params[:url] then + search = Timeout::timeout(30) do + Danbooru.http_get_streaming(params[:url]) do |res| + SimilarImages.save_search do |f| + res.read_body do |block| + f.write(block) + end + end + end + end + else # file + search = SimilarImages.save_search do |f| + wrote = 0 + buf = "" + while params[:file].read(1024*64, buf) do + wrote += buf.length + f.write(buf) + end + + if wrote == 0 then + return { :errors => { :error => "No file received" } } + end + end + end + rescue SocketError, URI::Error, SystemCallError, Danbooru::ResizeError => e + return { :errors => { :error => "#{e}" } } + rescue Timeout::Error => e + return { :errors => { :error => "Download timed out" } } + end + + file_path = search[:file_path] + + # Set :search_id in params for generated URLs that point back here. + params[:search_id] = search[:search_id] + + # The :width and :height params specify the size of the original image, for display + # in the results. The user can specify them; if not specified, fill it in. + params[:width] ||= search[:original_width] + params[:height] ||= search[:original_height] + elsif params[:id] then + options[:source] = @compared_post + options[:type] = :post + end + + if params[:search_id] then + options[:source] = File.open(file_path, 'rb') + options[:source_filename] = params[:search_id] + options[:source_thumb] = "/data/search/#{params[:search_id]}" + options[:type] = :file + end + options[:width] = params[:width] + options[:height] = params[:height] + + if options[:type] == :file + SimilarImages.cull_old_searches + end + + return SimilarImages.similar_images(options) + end + + unless params[:url].nil? and params[:id].nil? and params[:file].nil? and params[:search_id].nil? then + res = search(params) + + @errors = res[:errors] + @searched = true + @search_id = res[:search_id] + + # Never pass :file on through generated URLs. + params.delete(:file) + else + res = {} + @errors = {} + @searched = false + end + + @posts = res[:posts] + @similar = res + + respond_to do |fmt| + fmt.html do + if @initial=="1" && @posts.empty? + flash.keep + redirect_to :controller => "post", :action => "show", :id => params[:id], :tag_title => @compared_post.tag_title + return + end + if @errors[:error] + flash[:notice] = @errors[:error] + end + + if @posts then + @posts = res[:posts_external] + @posts + @posts = @posts.sort { |a, b| res[:similarity][b] <=> res[:similarity][a] } + + # Add the original post to the start of the list. + if res[:source] + @posts = [ res[:source] ] + @posts + else + @posts = [ res[:external_source] ] + @posts + end + end + end + fmt.xml do + if @errors[:error] + respond_to_error(@errors[:error], {:action => "index"}, :status => 503) + return + end + x = Builder::XmlMarkup.new(:indent => 2) + x.instruct! + render :xml => x.posts() { + unless res[:errors].empty? + res[:errors].map { |server, error| + { :server=>server, :message=>error[:message], :services=>error[:services].join(",") }.to_xml(:root => "error", :builder => x, :skip_instruct => true) + } + end + + if res[:source] + x.source() { + res[:source].to_xml(:builder => x, :skip_instruct => true) + } + else + x.source() { + res[:external_source].to_xml(:builder => x, :skip_instruct => true) + } + end + + @posts.each { |e| + x.similar(:similarity=>res[:similarity][e]) { + e.to_xml(:builder => x, :skip_instruct => true) + } + } + res[:posts_external].each { |e| + x.similar(:similarity=>res[:similarity][e]) { + e.to_xml(:builder => x, :skip_instruct => true) + } + } + } + end + end + end + + def undelete + post = Post.find(params[:id]) + post.undelete! + respond_to_success("Post was undeleted", :action => "show", :id => params[:id]) + end + + def exception + raise "error" + end +end diff --git a/app/controllers/post_tag_history_controller.rb b/app/controllers/post_tag_history_controller.rb new file mode 100644 index 00000000..18ef2867 --- /dev/null +++ b/app/controllers/post_tag_history_controller.rb @@ -0,0 +1,50 @@ +class PostTagHistoryController < ApplicationController + layout 'default' + before_filter :member_only + verify :method => :post, :only => [:undo] + + def index + @changes = PostTagHistory.paginate(PostTagHistory.generate_sql(params).merge(:order => "id DESC", :per_page => 20, :select => "post_tag_histories.*", :page => params[:page])) + @change_list = @changes.map do |c| + { :change => c }.merge(c.tag_changes(c.previous)) + end + end + + def revert + @change = PostTagHistory.find(params[:id]) + @post = Post.find(@change.post_id) + + if request.post? + if params[:commit] == "Yes" + @post.update_attributes(:updater_ip_addr => request.remote_ip, :updater_user_id => @current_user.id, :tags => @change.tags) + flash[:notice] = "Tags reverted" + end + + redirect_to :controller => "post", :action => "show", :id => @post.id + end + end + + def undo + ids = params[:id].split(/,/) + + if ids.length > 1 && !@current_user.is_privileged_or_higher? + respond_to_error("Only privileged users can undo more than one change at once", :status => 403) + return + end + + options = { + :update_options => { :updater_ip_addr => request.remote_ip, :updater_user_id => @current_user.id } + } + + ids.each do |id| + @change = PostTagHistory.find(id) + @change.undo(options) + end + + options[:posts].each do |id, post| + post.save! + end + + respond_to_success("Tag changes undone", :action => "index") + end +end diff --git a/app/controllers/report_controller.rb b/app/controllers/report_controller.rb new file mode 100644 index 00000000..396caedc --- /dev/null +++ b/app/controllers/report_controller.rb @@ -0,0 +1,75 @@ +class ReportController < ApplicationController + layout 'default' + + def tag_changes + @users = Report.usage_by_user("histories", 3.days.ago, Time.now, ["group_by_table = ?"], ["posts"]) + GoogleChart::PieChart.new("600x300", "Tag Changes", false) do |pc| + @users.each do |user| + # Hack to work around the limited Google API: there's no way to send a literal + # pipe, since that's the field separator, and it won't render any of the alternate + # pipe-like characters. + pc.data user["name"].gsub(/\|/, 'l'), user["change_count"].to_i + end + + @tag_changes_url = pc.to_url + end + end + + def note_changes + @users = Report.usage_by_user("note_versions", 3.days.ago, Time.now) + GoogleChart::PieChart.new("600x300", "Note Changes", false) do |pc| + @users.each do |user| + pc.data user["name"].gsub(/\|/, 'l'), user["change_count"].to_i + end + + @note_changes_url = pc.to_url + end + end + + def wiki_changes + @users = Report.usage_by_user("wiki_page_versions", 3.days.ago, Time.now) + GoogleChart::PieChart.new("600x300", "Wiki Changes", false) do |pc| + @users.each do |user| + pc.data user["name"].gsub(/\|/, 'l'), user["change_count"].to_i + end + + @wiki_changes_url = pc.to_url + end + end + + def votes + start = 3.days.ago + stop = Time.now + @users = Report.usage_by_user("post_votes", start, stop, ["score > 0"], [], "updated_at") + GoogleChart::PieChart.new("600x300", "Votes", false) do |pc| + @users.each do |user| + pc.data user["name"].gsub(/\|/, 'l'), user["change_count"].to_i + end + + @votes_url = pc.to_url + end + + @users.each do |user| + conds = ["updated_at BETWEEN ? AND ?"] + params = [] + params << start + params << stop + + if user["user"] then + conds << "user_id = ?" + params << user["user_id"] + else + conds << "user_id NOT IN (?)" + params << @users.select {|x| x["user_id"]}.map {|x| x["user_id"]} + end + + votes = ActiveRecord::Base.connection.select_all(ActiveRecord::Base.sanitize_sql(["SELECT COUNT(score) AS sum, score FROM post_votes WHERE #{conds.join(" AND ")} GROUP BY score", *params])) + user["votes"] = {} + votes.each { |vote| + score = vote["score"].to_i + user["votes"][score] = vote["sum"] + } + end + + end +end diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb new file mode 100644 index 00000000..d4646ec4 --- /dev/null +++ b/app/controllers/static_controller.rb @@ -0,0 +1,3 @@ +class StaticController < ApplicationController + layout "bare" +end diff --git a/app/controllers/tag_alias_controller.rb b/app/controllers/tag_alias_controller.rb new file mode 100644 index 00000000..2305e7a9 --- /dev/null +++ b/app/controllers/tag_alias_controller.rb @@ -0,0 +1,67 @@ +class TagAliasController < ApplicationController + layout "default" + before_filter :member_only, :only => [:create] + verify :method => :post, :only => [:create, :update] + + def create + ta = TagAlias.new(params[:tag_alias].merge(:is_pending => true)) + + if ta.save + flash[:notice] = "Tag alias created" + else + flash[:notice] = "Error: " + ta.errors.full_messages.join(", ") + end + + redirect_to :action => "index" + end + + def index + set_title "Tag Aliases" + + if params[:commit] == "Search Implications" + redirect_to :controller => "tag_implication", :action => "index", :query => params[:query] + return + end + + if params[:query] + name = "%" + params[:query].to_escaped_for_sql_like + "%" + @aliases = TagAlias.paginate :order => "is_pending DESC, name", :per_page => 20, :conditions => ["name LIKE ? ESCAPE E'\\\\' OR alias_id IN (SELECT id FROM tags WHERE name ILIKE ? ESCAPE E'\\\\')", name, name], :page => params[:page] + else + @aliases = TagAlias.paginate :order => "is_pending DESC, name", :per_page => 20, :page => params[:page] + end + + respond_to_list("aliases") + end + + def update + ids = params[:aliases].keys + + case params[:commit] + when "Delete" + if @current_user.is_mod_or_higher? || ids.all? {|x| ta = TagAlias.find(x) ; ta.is_pending? && ta.creator_id == @current_user.id} + ids.each {|x| TagAlias.find(x).destroy_and_notify(@current_user, params[:reason])} + + flash[:notice] = "Tag aliases deleted" + redirect_to :action => "index" + else + access_denied + end + + when "Approve" + if @current_user.is_mod_or_higher? + ids.each do |x| + if CONFIG["enable_asynchronous_tasks"] + JobTask.create(:task_type => "approve_tag_alias", :status => "pending", :data => {"id" => x, "updater_id" => @current_user.id, "updater_ip_addr" => request.remote_ip}) + else + TagAlias.find(x).approve(@current_user.id, request.remote_ip) + end + end + + flash[:notice] = "Tag alias approval jobs created" + redirect_to :controller => "job_task", :action => "index" + else + access_denied + end + end + end +end diff --git a/app/controllers/tag_controller.rb b/app/controllers/tag_controller.rb new file mode 100644 index 00000000..87c54397 --- /dev/null +++ b/app/controllers/tag_controller.rb @@ -0,0 +1,199 @@ +class TagController < ApplicationController + layout 'default' + auto_complete_for :tag, :name + before_filter :mod_only, :only => [:mass_edit, :edit_preview] + before_filter :member_only, :only => [:update, :edit] + + def cloud + set_title "Tags" + + @tags = Tag.find(:all, :conditions => "post_count > 0", :order => "post_count DESC", :limit => 100).sort {|a, b| a.name <=> b.name} + end + + def index + # TODO: convert to nagato + set_title "Tags" + + if params[:limit] == "0" + limit = nil + elsif params[:limit] == nil + limit = 50 + else + limit = params[:limit].to_i + end + + case params[:order] + when "name" + order = "name" + + when "count" + order = "post_count desc" + + when "date" + order = "id desc" + + else + order = "name" + end + + conds = ["true"] + cond_params = [] + + unless params[:name].blank? + conds << "name LIKE ? ESCAPE E'\\\\'" + + if params[:name].include?("*") + cond_params << params[:name].to_escaped_for_sql_like + else + cond_params << "%" + params[:name].to_escaped_for_sql_like + "%" + end + end + + unless params[:type].blank? + conds << "tag_type = ?" + cond_params << params[:type].to_i + end + + if params[:after_id] + conds << "id >= ?" + cond_params << params[:after_id] + end + + if params[:id] + conds << "id = ?" + cond_params << params[:id] + end + + respond_to do |fmt| + fmt.html do + @tags = Tag.paginate :order => order, :per_page => 50, :conditions => [conds.join(" AND "), *cond_params], :page => params[:page] + end + fmt.xml do + order = nil if params[:order] == nil + conds = conds.join(" AND ") + if conds == "true" && CONFIG["web_server"] == "nginx" && File.exists?("#{RAILS_ROOT}/public/tags.xml") + # Special case: instead of rebuilding a list of every tag every time, cache it locally and tell the web + # server to stream it directly. This only works on Nginx. + response.headers["X-Accel-Redirect"] = "#{RAILS_ROOT}/public/tags.xml" + render :nothing => true + else + render :xml => Tag.find(:all, :order => order, :limit => limit, :conditions => [conds, *cond_params]).to_xml(:root => "tags") + end + end + fmt.json do + @tags = Tag.find(:all, :order => order, :limit => limit, :conditions => [conds.join(" AND "), *cond_params]) + render :json => @tags.to_json + end + end + end + + def mass_edit + set_title "Mass Edit Tags" + + if request.post? + if params[:start].blank? + respond_to_error("Start tag missing", {:action => "mass_edit"}, :status => 424) + return + end + + if CONFIG["enable_asynchronous_tasks"] + task = JobTask.create(:task_type => "mass_tag_edit", :status => "pending", :data => {"start_tags" => params[:start], "result_tags" => params[:result], "updater_id" => session[:user_id], "updater_ip_addr" => request.remote_ip}) + respond_to_success("Mass tag edit job created", :controller => "job_task", :action => "index") + else + Tag.mass_edit(params[:start], params[:result], @current_user.id, request.remote_ip) + end + end + end + + def edit_preview + @posts = Post.find_by_sql(Post.generate_sql(params[:tags], :order => "p.id DESC", :limit => 500)) + render :layout => false + end + + def edit + if params[:id] + @tag = Tag.find(params[:id]) or Tag.new + else + @tag = Tag.find_by_name(params[:name]) or Tag.new + end + end + + def update + tag = Tag.find_by_name(params[:tag][:name]) + tag.update_attributes(params[:tag]) if tag + + respond_to_success("Tag updated", :action => "index") + end + + def related + if params[:type] + @tags = Tag.scan_tags(params[:tags]) + @tags = TagAlias.to_aliased(@tags) + @tags = @tags.inject({}) do |all, x| + all[x] = Tag.calculate_related_by_type(x, CONFIG["tag_types"][params[:type]]).map {|y| [y["name"], y["post_count"]]} + all + end + else + @tags = Tag.scan_tags(params[:tags]) + @patterns, @tags = @tags.partition {|x| x.include?("*")} + @tags = TagAlias.to_aliased(@tags) + @tags = @tags.inject({}) do |all, x| + all[x] = Tag.find_related(x).map {|y| [y[0], y[1]]} + all + end + @patterns.each do |x| + @tags[x] = Tag.find(:all, :conditions => ["name LIKE ? ESCAPE E'\\\\'", x.to_escaped_for_sql_like]).map {|y| [y.name, y.post_count]} + end + end + + respond_to do |fmt| + fmt.xml do + # We basically have to do this by hand. + builder = Builder::XmlMarkup.new(:indent => 2) + builder.instruct! + xml = builder.tag!("tags") do + @tags.each do |parent, related| + builder.tag!("tag", :name => parent) do + related.each do |tag, count| + builder.tag!("tag", :name => tag, :count => count) + end + end + end + end + + render :xml => xml + end + fmt.json {render :json => @tags.to_json} + end + end + + def popular_by_day + if params["year"] and params["month"] and params["day"] + @day = Time.gm(params["year"].to_i, params["month"], params["day"]) + else + @day = Time.new.getgm.at_beginning_of_day + end + + @tags = Tag.count_by_period(@day.beginning_of_day, @day.tomorrow.beginning_of_day) + end + + def popular_by_week + if params["year"] and params["month"] and params["day"] + @day = Time.gm(params["year"].to_i, params["month"], params["day"]).beginning_of_week + else + @day = Time.new.getgm.at_beginning_of_day.beginning_of_week + end + + @tags = Tag.count_by_period(@day, @day.next_week) + end + + def popular_by_month + if params["year"] and params["month"] + @day = Time.gm(params["year"].to_i, params["month"], params["day"]).beginning_of_month + else + @day = Time.new.getgm.at_beginning_of_day.beginning_of_month + end + + @tags = Tag.count_by_period(@day, @day.next_month) + end +end diff --git a/app/controllers/tag_implication_controller.rb b/app/controllers/tag_implication_controller.rb new file mode 100644 index 00000000..92463250 --- /dev/null +++ b/app/controllers/tag_implication_controller.rb @@ -0,0 +1,67 @@ +class TagImplicationController < ApplicationController + layout "default" + before_filter :member_only, :only => [:create] + verify :method => :post, :only => [:create, :update] + + def create + ti = TagImplication.new(params[:tag_implication].merge(:is_pending => true)) + + if ti.save + flash[:notice] = "Tag implication created" + else + flash[:notice] = "Error: " + ti.errors.full_messages.join(", ") + end + + redirect_to :action => "index" + end + + def update + ids = params[:implications].keys + + case params[:commit] + when "Delete" + if @current_user.is_mod_or_higher? || ids.all? {|x| ti = TagImplication.find(x) ; ti.is_pending? && ti.creator_id == @current_user.id} + ids.each {|x| TagImplication.find(x).destroy_and_notify(@current_user, params[:reason])} + + flash[:notice] = "Tag implications deleted" + redirect_to :action => "index" + else + access_denied + end + + when "Approve" + if @current_user.is_mod_or_higher? + ids.each do |x| + if CONFIG["enable_asynchronous_tasks"] + JobTask.create(:task_type => "approve_tag_implication", :status => "pending", :data => {"id" => x, "updater_id" => @current_user.id, "updater_ip_addr" => request.remote_ip}) + else + TagImplication.find(x).approve(@current_user.id, request.remote_ip) + end + end + + flash[:notice] = "Tag implication approval jobs created" + redirect_to :controller => "job_task", :action => "index" + else + access_denied + end + end + end + + def index + set_title "Tag Implications" + + if params[:commit] == "Search Aliases" + redirect_to :controller => "tag_alias", :action => "index", :query => params[:query] + return + end + + if params[:query] + name = "%" + params[:query].to_escaped_for_sql_like + "%" + @implications = TagImplication.paginate :order => "is_pending DESC, (SELECT name FROM tags WHERE id = tag_implications.predicate_id), (SELECT name FROM tags WHERE id = tag_implications.consequent_id)", :per_page => 20, :conditions => ["predicate_id IN (SELECT id FROM tags WHERE name ILIKE ? ESCAPE '\\\\') OR consequent_id IN (SELECT id FROM tags WHERE name ILIKE ? ESCAPE '\\\\')", name, name], :page => params[:page] + else + @implications = TagImplication.paginate :order => "is_pending DESC, (SELECT name FROM tags WHERE id = tag_implications.predicate_id), (SELECT name FROM tags WHERE id = tag_implications.consequent_id)", :per_page => 20, :page => params[:page] + end + + respond_to_list("implications") + end +end diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb new file mode 100644 index 00000000..244869fc --- /dev/null +++ b/app/controllers/user_controller.rb @@ -0,0 +1,352 @@ +require 'digest/sha2' + +class UserController < ApplicationController + layout "default" + verify :method => :post, :only => [:authenticate, :update, :create, :unban, :modify_blacklist] + before_filter :blocked_only, :only => [:authenticate, :update, :edit, :modify_blacklist] + before_filter :janitor_only, :only => [:invites] + before_filter :mod_only, :only => [:block, :unblock, :show_blocked_users] + before_filter :post_member_only, :only => [:set_avatar] + helper :post + helper :avatar + filter_parameter_logging :password + auto_complete_for :user, :name + + protected + def save_cookies(user) + cookies[:login] = {:value => user.name, :expires => 1.year.from_now} + cookies[:pass_hash] = {:value => user.password_hash, :expires => 1.year.from_now} + session[:user_id] = user.id + end + + public + def auto_complete_for_member_name + @users = User.find(:all, :order => "lower(name)", :conditions => ["level = ? AND name ILIKE ? ESCAPE E'\\\\'", CONFIG["user_levels"]["Member"], params[:member][:name] + "%"]) + render :layout => false, :text => "" + end + + def show + if params[:name] + @user = User.find_by_name(params[:name]) + else + @user = User.find(params[:id]) + end + + if @user.nil? + redirect_to "/404" + end + if @current_user.is_mod_or_higher? + @user_ips = UserLog.find_by_sql("SELECT ul.ip_addr, ul.created_at FROM user_logs ul WHERE ul.user_id = #{@user.id} ORDER BY ul.created_at DESC") + @user_ips.map! { |ul| ul.ip_addr } + @user_ips.uniq! + end + end + + def invites + if request.post? + if params[:member] + begin + @current_user.invite!(params[:member][:name], params[:member][:level]) + flash[:notice] = "User was invited" + + rescue ActiveRecord::RecordNotFound + flash[:notice] = "Account not found" + + rescue User::NoInvites + flash[:notice] = "You have no invites for use" + + rescue User::HasNegativeRecord + flash[:notice] = "This use has a negative record and must be invited by an admin" + end + end + + redirect_to :action => "invites" + else + @invited_users = User.find(:all, :conditions => ["invited_by = ?", @current_user.id], :order => "lower(name)") + end + end + + def home + set_title "My Account" + end + + def index + set_title "Users" + + @users = User.paginate(User.generate_sql(params).merge(:per_page => 20, :page => params[:page])) + respond_to_list("users") + end + + def authenticate + save_cookies(@current_user) + + if params[:url].blank? + path = {:action => "home"} + else + path = params[:url] + end + + respond_to_success("You are now logged in", path) + end + + def check + if request.post? + user = User.find_by_name(params[:username]) + ret = { :exists => false } + ret[:name] = params[:username] + + if not user + respond_to_success("User does not exist", {}, :api => {:response => "unknown-user"}.merge(ret)) + return + end + + # Return some basic information about the user even if the password isn't given, for + # UI cosmetics. + ret[:exists] = true + ret[:id] = user.id + ret[:name] = user.name + ret[:no_email] = user.email.blank? + + user = User.authenticate(params[:username], params[:password] || "") + if not user + respond_to_success("Wrong password", {}, :api => {:response => "wrong-password"}.merge(ret)) + return + end + + ret[:pass_hash] = user.password_hash + respond_to_success("Successful", {}, :api => {:response => "success"}.merge(ret)) + end + end + + def login + set_title "Login" + end + + def create + user = User.create(params[:user]) + + if user.errors.empty? + save_cookies(user) + + if CONFIG["enable_account_email_activation"] + begin + UserMailer::deliver_confirmation_email(user) + notice = "New account created. Confirmation email sent to #{user.email}" + rescue Net::SMTPSyntaxError, Net::SMTPFatalError + user.destroy + respond_to_success("Could not send confirmation email; account creation canceled", + {:action => "signup"}, :api => {:response => "error", :errors => ["Could not send confirmation email; account creation canceled"]}) + return + end + else + notice = "New account created" + end + + ret = { :exists => false } + ret[:name] = user.name + ret[:id] = user.id + ret[:pass_hash] = user.password_hash + + respond_to_success(notice, {:action => "home"}, :api => {:response => "success"}.merge(ret)) + else + error = user.errors.full_messages.join(", ") + respond_to_success("Error: " + error, {:action => "signup"}, :api => {:response => "error", :errors => user.errors.full_messages}) + end + end + + def signup + set_title "Signup" + @user = User.new + end + + def logout + set_title "Logout" + session[:user_id] = nil + cookies[:login] = nil + cookies[:pass_hash] = nil + + respond_to_success("You are now logged out", :action => "home") + end + + def update + if params[:commit] == "Cancel" + redirect_to :action => "home" + return + end + + if @current_user.update_attributes(params[:user]) + respond_to_success("Account settings saved", :action => "home") + else + respond_to_error(@current_user, :action => "edit") + end + end + + def modify_blacklist + added_tags = params[:add] || [] + removed_tags = params[:remove] || [] + + tags = @current_user.blacklisted_tags_array + added_tags.each { |tag| + tags << tag if not tags.include?(tag) + } + + tags -= removed_tags + + if @current_user.update_attribute(:blacklisted_tags, tags.join("\n")) + respond_to_success("Tag blacklist updated", {:action => "home"}, :api => {:result => @current_user.blacklisted_tags_array}) + else + respond_to_error(@current_user, :action => "edit") + end + end + + def remove_from_blacklist + end + + def edit + set_title "Edit Account" + @user = @current_user + end + + def reset_password + set_title "Reset Password" + + if request.post? + @user = User.find_by_name(params[:user][:name]) + + if @user.nil? + respond_to_error("That account does not exist", {:action => "reset_password"}, :api => {:result => "unknown-user"}) + return + end + + if @user.email.blank? + respond_to_error("You never supplied an email address, therefore you cannot have your password automatically reset", + {:action => "login"}, :api => {:result => "no-email"}) + return + end + + if @user.email != params[:user][:email] + respond_to_error("That is not the email address you supplied", + {:action => "login"}, :api => {:result => "wrong-email"}) + return + end + + begin + User.transaction do + # If the email is invalid, abort the password reset + new_password = @user.reset_password + UserMailer.deliver_new_password(@user, new_password) + respond_to_success("Password reset. Check your email in a few minutes.", + {:action => "login"}, :api => {:result => "success"}) + return + end + rescue Net::SMTPSyntaxError, Net::SMTPFatalError + respond_to_success("Your email address was invalid", + {:action => "login"}, :api => {:result => "invalid-email"}) + return + end + else + @user = User.new + end + end + + def block + @user = User.find(params[:id]) + + if request.post? + if @user.is_mod_or_higher? + flash[:notice] = "You can not ban other moderators or administrators" + redirect_to :action => "block" + return + end + + Ban.create(params[:ban].merge(:banned_by => @current_user.id, :user_id => params[:id])) + redirect_to :action => "show_blocked_users" + else + @ban = Ban.new(:user_id => @user.id, :duration => "1") + end + end + + def unblock + params[:user].keys.each do |user_id| + Ban.destroy_all(["user_id = ?", user_id]) + user = User.find(user_id) + user.level = CONFIG["user_levels"]["Member"] + user.save + end + + redirect_to :action => "show_blocked_users" + end + + def show_blocked_users + #@users = User.find(:all, :select => "users.*", :joins => "JOIN bans ON bans.user_id = users.id", :conditions => ["bans.banned_by = ?", @current_user.id]) + @users = User.find(:all, :select => "users.*", :joins => "JOIN bans ON bans.user_id = users.id") + @ip_bans = IpBans.find(:all) + end + + if CONFIG["enable_account_email_activation"] + def resend_confirmation + if request.post? + user = User.find_by_email(params[:email]) + + if user.nil? + flash[:notice] = "No account exists with that email" + redirect_to :action => "home" + return + end + + if user.is_blocked_or_higher? + flash[:notice] = "Your account is already activated" + redirect_to :action => "home" + return + end + + UserMailer::deliver_confirmation_email(user) + flash[:notice] = "Confirmation email sent" + redirect_to :action => "home" + end + end + + def activate_user + flash[:notice] = "Invalid confirmation code" + + users = User.find(:all, :conditions => ["level = ?", CONFIG["user_levels"]["Unactivated"]]) + users.each do |user| + if User.confirmation_hash(user.name) == params["hash"] + user.update_attribute(:level, CONFIG["starting_level"]) + flash[:notice] = "Account has been activated" + break + end + end + + redirect_to :action => "home" + end + end + + def set_avatar + @user = @current_user + if params[:user_id] then + @user = User.find(params[:user_id]) + respond_to_error("Not found", :action => "index", :status => 404) unless @user + end + + if !@user.is_anonymous? && !@current_user.has_permission?(@user, :id) + access_denied() + return + end + + if request.post? + if @user.set_avatar(params) then + redirect_to :action => "show", :id => @user.id + else + respond_to_error(@user, :action => "home") + end + end + + if !@user.is_anonymous? && params[:id] == @user.avatar_post_id then + @old = params + end + + @params = params + @post = Post.find(params[:id]) + end +end diff --git a/app/controllers/user_record_controller.rb b/app/controllers/user_record_controller.rb new file mode 100644 index 00000000..b84471a8 --- /dev/null +++ b/app/controllers/user_record_controller.rb @@ -0,0 +1,40 @@ +class UserRecordController < ApplicationController + layout "default" + before_filter :privileged_only, :only => [:create, :destroy] + + def index + if params[:user_id] + @user = User.find(params[:user_id]) + @user_records = UserRecord.paginate :per_page => 20, :order => "created_at desc", :conditions => ["user_id = ?", params[:user_id]], :page => params[:page] + else + @user_records = UserRecord.paginate :per_page => 20, :order => "created_at desc", :page => params[:page] + end + end + + def create + @user = User.find(params[:user_id]) + + if request.post? + if @user.id == @current_user.id + flash[:notice] = "You cannot create a record for yourself" + else + @user_record = UserRecord.create(params[:user_record].merge(:user_id => params[:user_id], :reported_by => @current_user.id)) + flash[:notice] = "Record updated" + end + redirect_to :action => "index", :user_id => @user.id + end + end + + def destroy + if request.post? + @user_record = UserRecord.find(params[:id]) + if @current_user.is_mod_or_higher? || @current_user.id == @user_record.reported_by + UserRecord.destroy(params[:id]) + + respond_to_success("Record updated", :action => "index", :user_id => params[:id]) + else + access_denied() + end + end + end +end diff --git a/app/controllers/wiki_controller.rb b/app/controllers/wiki_controller.rb new file mode 100644 index 00000000..bf10ef40 --- /dev/null +++ b/app/controllers/wiki_controller.rb @@ -0,0 +1,172 @@ +class WikiController < ApplicationController + layout 'default' + before_filter :post_member_only, :only => [:update, :create, :edit, :revert] + before_filter :mod_only, :only => [:lock, :unlock, :destroy, :rename] + verify :method => :post, :only => [:lock, :unlock, :destroy, :update, :create, :revert] + helper :post + + def destroy + page = WikiPage.find_page(params[:title]) + page.destroy + respond_to_success("Page deleted", :action => "show", :title => params[:title]) + end + + def lock + page = WikiPage.find_page(params[:title]) + page.lock! + respond_to_success("Page locked", :action => "show", :title => params[:title]) + end + + def unlock + page = WikiPage.find_page(params["title"]) + page.unlock! + respond_to_success("Page unlocked", :action => "show", :title => params[:title]) + end + + def index + set_title "Wiki" + + @params = params + if params[:order] == "date" + order = "updated_at DESC" + else + order = "lower(title)" + end + + limit = params[:limit] || 25 + query = params[:query] || "" + query = query.scan(/\S+/) + + search_params = { + :order => order, + :per_page => limit, + :page => params[:page] + } + + if !query.empty? + if query =~ /^title:/ + search_params[:conditions] = ["title ilike ?", "%" + query[6..-1].to_escaped_for_sql_like + "%"] + else + search_params[:conditions] = ["text_search_index @@ plainto_tsquery(?)", query.join(" & ")] + end + end + + @wiki_pages = WikiPage.paginate(search_params) + + respond_to_list("wiki_pages") + end + + def preview + render :inline => "<%= format_text(params[:body]) %>" + end + + def add + @wiki_page = WikiPage.new + @wiki_page.title = params[:title] || "Title" + end + + def create + page = WikiPage.create(params[:wiki_page].merge(:ip_addr => request.remote_ip, :user_id => session[:user_id])) + + if page.errors.empty? + respond_to_success("Page created", {:action => "show", :title => page.title}, :location => url_for(:action => "show", :title => page.title)) + else + respond_to_error(page, :action => "index") + end + end + + def edit + if params[:title] == nil + render :text => "no title specified" + else + @wiki_page = WikiPage.find_page(params[:title], params[:version]) + + if @wiki_page == nil + redirect_to :action => "add", :title => params[:title] + end + end + end + + def update + @page = WikiPage.find_page(params[:title] || params[:wiki_page][:title]) + + if @page.is_locked? + respond_to_error("Page is locked", {:action => "show", :title => @page.title}, :status => 422) + else + if @page.update_attributes(params[:wiki_page].merge(:ip_addr => request.remote_ip, :user_id => session[:user_id])) + respond_to_success("Page created", :action => "show", :title => @page.title) + else + respond_to_error(@page, {:action => "show", :title => @page.title}) + end + end + end + + def show + if params[:title] == nil + render :text => "no title specified" + return + end + + @title = params[:title] + @page = WikiPage.find_page(params[:title], params[:version]) + @posts = Post.find_by_tags(params[:title], :limit => 8, :order => "id desc").select {|x| x.can_be_seen_by?(@current_user)} + @artist = Artist.find_by_name(params[:title]) + @tag = Tag.find_by_name(params[:title]) + set_title params[:title].tr("_", " ") + end + + def revert + @page = WikiPage.find_page(params[:title]) + + if @page.is_locked? + respond_to_error("Page is locked", {:action => "show", :title => params[:title]}, :status => 422) + else + @page.revert_to(params[:version]) + @page.ip_addr = request.remote_ip + @page.user_id = @current_user.id + + if @page.save + respond_to_success("Page reverted", :action => "show", :title => params[:title]) + else + respond_to_error(@page) + end + end + end + + def recent_changes + set_title "Recent Changes" + + @wiki_pages = WikiPage.paginate :order => "updated_at DESC", :per_page => (params[:per_page] || 25), :page => params[:page] + respond_to_list("wiki_pages") + end + + def history + set_title "Wiki History" + + @wiki_pages = WikiPageVersion.find(:all, :conditions => ["title = ?", params[:title]], :order => "updated_at DESC") + + respond_to_list("wiki_pages") + end + + def diff + set_title "Wiki Diff" + + if params[:redirect] + redirect_to :action => "diff", :title => params[:title], :from => params[:from], :to => params[:to] + return + end + + if params[:title].blank? || params[:to].blank? || params[:from].blank? + flash[:notice] = "No title was specificed" + redirect_to :action => "index" + return + end + + @oldpage = WikiPage.find_page(params[:title], params[:from]) + @difference = @oldpage.diff(params[:to]) + end + + def rename + @wiki_page = WikiPage.find_page(params[:title]) + end +end diff --git a/app/daemons/job_task_processor.rb b/app/daemons/job_task_processor.rb new file mode 100755 index 00000000..39db1bc4 --- /dev/null +++ b/app/daemons/job_task_processor.rb @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require File.dirname(__FILE__) + '/../../config/environment' + +JobTask.execute_all diff --git a/app/daemons/job_task_processor_ctl.rb b/app/daemons/job_task_processor_ctl.rb new file mode 100755 index 00000000..410bb1b0 --- /dev/null +++ b/app/daemons/job_task_processor_ctl.rb @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require 'rubygems' +require 'daemons' + +Daemons.run(File.dirname(__FILE__) + "/job_task_processor.rb", :log_output => true, :dir => "../../log") diff --git a/app/helpers/admin_helper.rb b/app/helpers/admin_helper.rb new file mode 100644 index 00000000..d5c6d355 --- /dev/null +++ b/app/helpers/admin_helper.rb @@ -0,0 +1,2 @@ +module AdminHelper +end diff --git a/app/helpers/advertisement_helper.rb b/app/helpers/advertisement_helper.rb new file mode 100644 index 00000000..26337a23 --- /dev/null +++ b/app/helpers/advertisement_helper.rb @@ -0,0 +1,10 @@ +module AdvertisementHelper + def print_advertisement(ad_type) + if CONFIG["can_see_ads"].call(@current_user) + ad = Advertisement.find(:first, :conditions => ["ad_type = ? AND status = 'active'", ad_type], :order => "random()") + content_tag("div", link_to(image_tag(ad.image_url, :alt => "Advertisement", :width => ad.width, :height => ad.height), :controller => "advertisement", :action => "redirect_ad", :id => ad.id), :style => "margin-bottom: 1em;") + else + "" + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 00000000..3de77e56 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,275 @@ +module ApplicationHelper + # Scale percentage table widths to 100% to make variable column tables + # easier. + class TableScale + def add(*list) + @val = nil + @list ||= [] + list.each { |val| + @list << val.to_f + } + @idx ||= 0 + end + + def get + @idx += 1 + get_idx(@idx - 1) + end + + def get_idx(n) + if !@val then + # Scale the list to sum to 100%. + # + # Both FF3 and Opera treat values under 1% as unspecified. (Why?) If any value + # is less than 1%, clamp it to 1% and recompute the remainder. + @val = @list + indexes = [] + @val.each_index { |idx| indexes << idx } + while !indexes.empty? + sum = @val.length - indexes.length # 1% for each 1% value we excluded + indexes.each { |idx| sum += @val[idx] } + break if sum == 0 + + indexes.each { |idx| @val[idx] = [@val[idx] / (sum/100), 1].max } + new_indexes = [] + indexes.each { |idx| new_indexes << idx if @val[idx] > 1 } + break if indexes.length == new_indexes.length + indexes = new_indexes + end + end + "%.2f%%" % @val[n] + end + end + + def navbar_link_to(text, options, html_options = nil) + if options[:controller] == params[:controller] + klass = "current-page" + else + klass = nil + end + + content_tag("li", link_to(text, options, html_options), :class => klass) + end + + def format_text(text, options = {}) + DText.parse(text) + end + + def format_inline(inline, num, id, preview_html=nil) + url = inline.inline_images.first.preview_url + if not preview_html + preview_html = %{} + end + id_text = "inline-%s-%i" % [id, num] + block = %{ +
+
+ #{preview_html} +
+ +
+ } + inline_id = "inline-%s-%i" % [id, num] + script = 'InlineImage.register("%s", %s);' % [inline_id, inline.to_json] + return block, script, inline_id + end + + def format_inlines(text, id) + num = 0 + list = [] + text.gsub!(/image #(\d+)/i) { |t| + i = Inline.find($1) rescue nil + if i then + block, script = format_inline(i, num, id) + list << script + num += 1 + block + else + t + end + } + + if num > 0 then + text << '' + end + + text + end + + def id_to_color(id) + r = id % 255 + g = (id >> 8) % 255 + b = (id >> 16) % 255 + "rgb(#{r}, #{g}, #{b})" + end + + def tag_header(tags) + unless tags.blank? + '/' + Tag.scan_query(tags).map {|t| link_to(t.tr("_", " "), :controller => "post", :action => "index", :tags => t)}.join("+") + end + end + + def compact_time(time) + if time > Time.now.beginning_of_day + time.strftime("%H:%M") + elsif time > Time.now.beginning_of_year + time.strftime("%b %e") + else + time.strftime("%b %e, %Y") + end + end + + def time_ago_in_words(time) + from_time = time + to_time = Time.now + distance_in_minutes = (((to_time - from_time).abs)/60).round + distance_in_seconds = ((to_time - from_time).abs).round + + case distance_in_minutes + when 0..1 + '1 minute' + + when 2..44 + "#{distance_in_minutes} minutes" + + when 45..89 + '1 hour' + + when 90..1439 + "#{(distance_in_minutes.to_f / 60.0).round} hours" + + when 1440..2879 + '1 day' + + when 2880..43199 + "#{(distance_in_minutes / 1440).round} days" + + when 43200..86399 + '1 month' + + when 86400..525959 + "#{(distance_in_minutes / 43200).round} months" + + else + "%.1f years" % (distance_in_minutes / 525960.0) + end + end + + def content_for_prefix(name, &block) + existing_content_for = instance_variable_get("@content_for_#{name}").to_s + new_content_for = (block_given? ? capture(&block) : content) + existing_content_for + instance_variable_set("@content_for_#{name}", new_content_for) + end + + def navigation_links(post) + html = [] + + if post.is_a?(Post) + html << tag("link", :rel => "prev", :title => "Previous Post", :href => url_for(:controller => "post", :action => "show", :id => post.id - 1)) + html << tag("link", :rel => "next", :title => "Next Post", :href => url_for(:controller => "post", :action => "show", :id => post.id + 1)) + + elsif post.is_a?(Array) + posts = post + + unless posts.previous_page.nil? + html << tag("link", :href => url_for(params.merge(:page => 1)), :rel => "first", :title => "First Page") + html << tag("link", :href => url_for(params.merge(:page => posts.previous_page)), :rel => "prev", :title => "Previous Page") + end + + unless posts.next_page.nil? + html << tag("link", :href => url_for(params.merge(:page => posts.next_page)), :rel => "next", :title => "Next Page") + html << tag("link", :href => url_for(params.merge(:page => posts.page_count)), :rel => "last", :title => "Last Page") + end + end + + return html.join("\n") + end + + # Return true if the user can access the given level, or if creating an + # account would. This is only actually used for actions that require + # privileged or higher; it's assumed that starting_level is no lower + # than member. + def can_access?(level) + needed_level = User.get_user_level(level) + starting_level = CONFIG["starting_level"] + user_level = @current_user.level + return true if user_level.to_i >= needed_level + return true if starting_level >= needed_level + return false + end + + # Return true if the starting level is high enough to execute + # this action. This is used by User.js. + def need_signup?(level) + needed_level = User.get_user_level(level) + starting_level = CONFIG["starting_level"] + return starting_level >= needed_level + end + + require "action_view/helpers/form_tag_helper.rb" + include ActionView::Helpers::FormTagHelper + alias_method :orig_form_tag, :form_tag + def form_tag(url_for_options = {}, options = {}, *parameters_for_url, &block) + # Add the need-signup class if signing up would allow a logged-out user to + # execute this action. User.js uses this to determine whether it should ask + # the user to create an account. + if options[:level] + if need_signup?(options[:level]) + classes = (options[:class] || "").split(" ") + classes += ["need-signup"] + options[:class] = classes.join(" ") + end + options.delete(:level) + end + + orig_form_tag url_for_options, options, *parameters_for_url, &block + end + + require "action_view/helpers/javascript_helper.rb" + include ActionView::Helpers::JavaScriptHelper + alias_method :orig_link_to_function, :link_to_function + def link_to_function(name, *args, &block) + html_options = args.extract_options! + if html_options[:level] + if need_signup?(html_options[:level]) && args[0] + args[0] = "User.run_login(false, function() { #{args[0]} })" + end + html_options.delete(:level) + end + args = ([args] + [html_options]) + orig_link_to_function name, *args, &block + end + + alias_method :orig_button_to_function, :button_to_function + def button_to_function(name, *args, &block) + html_options = args.extract_options! + if html_options[:level] + if need_signup?(html_options[:level]) && args[0] + args[0] = "User.run_login(false, function() { #{args[0]} })" + end + html_options.delete(:level) + end + args = ([args] + [html_options]) + orig_button_to_function name, *args, &block + end + + require "action_view/helpers/url_helper.rb" + include ActionView::Helpers::UrlHelper + +private + alias_method :orig_convert_options_to_javascript!, :convert_options_to_javascript! + # This handles link_to (and others, not tested). + def convert_options_to_javascript!(html_options, url = '') + level = html_options["level"] + html_options.delete("level") + orig_convert_options_to_javascript!(html_options, url) + if level + if need_signup?(level) + html_options["onclick"] = "if(!User.run_login_onclick(event)) return false; #{html_options["onclick"] || "return true;"}" + end + end + end +end diff --git a/app/helpers/artist_helper.rb b/app/helpers/artist_helper.rb new file mode 100644 index 00000000..cfeb7325 --- /dev/null +++ b/app/helpers/artist_helper.rb @@ -0,0 +1,2 @@ +module ArtistHelper +end diff --git a/app/helpers/avatar_helper.rb b/app/helpers/avatar_helper.rb new file mode 100644 index 00000000..bcf13031 --- /dev/null +++ b/app/helpers/avatar_helper.rb @@ -0,0 +1,30 @@ +module AvatarHelper + # id is an identifier for the object referencing this avatar; it's passed down + # to the javascripts to implement blacklisting "click again to open". + def avatar(user, id, html_options = {}) + @shown_avatars ||= {} + @posts_to_send ||= [] + + #if not @shown_avatars[user] then + @shown_avatars[user] = true + @posts_to_send << user.avatar_post + img = image_tag(user.avatar_url + "?" + user.avatar_timestamp.tv_sec.to_s, + {:class => "avatar", :width => user.avatar_width, :height => user.avatar_height}.merge(html_options)) + link_to(img, + { :controller => "post", :action => "show", :id => user.avatar_post.id.to_s }, + :class => "ca" + user.avatar_post.id.to_s, + :onclick => %{return Post.check_avatar_blacklist(#{user.avatar_post.id.to_s}, #{id});}) + #end + end + + def avatar_init + return "" if not defined?(@posts_to_send) + ret = "" + @posts_to_send.uniq.each do |post| + ret << %{Post.register(#{ post.to_json })\n} + end + ret << %{Post.init_blacklisted()} + ret + end +end + diff --git a/app/helpers/cache_helper.rb b/app/helpers/cache_helper.rb new file mode 100644 index 00000000..51df4375 --- /dev/null +++ b/app/helpers/cache_helper.rb @@ -0,0 +1,40 @@ +module CacheHelper + def build_cache_key(base, tags, page, limit, options = {}) + page = page.to_i + page = 1 if page < 1 + tags = tags.to_s.downcase.scan(/\S+/).sort + + if options[:user] + user_level = options[:user].level + user_level = CONFIG["user_levels"]["Member"] if user_level < CONFIG["user_levels"]["Member"] + else + user_level = "?" + end + + if tags.empty? || tags.any? {|x| x =~ /(?:^-|[*:])/} + version = Cache.get("$cache_version").to_i + tags = tags.join(",") + else + version = "?" + tags = tags.map {|x| x + ":" + Cache.get("tag:#{x}").to_i.to_s}.join(",") + end + + ["#{base}/v=#{version}&t=#{tags}&p=#{page}&ul=#{user_level}&l=#{limit}", 0] + end + + def get_cache_key(controller_name, action_name, params, options = {}) + case "#{controller_name}/#{action_name}" + when "post/index" + build_cache_key("p/i", params[:tags], params[:page], params[:limit], options) + + when "post/atom" + build_cache_key("p/a", params[:tags], 1, "", options) + + when "post/piclens" + build_cache_key("p/p", params[:tags], params[:page], params[:limit], options) + + else + nil + end + end +end diff --git a/app/helpers/comment_helper.rb b/app/helpers/comment_helper.rb new file mode 100644 index 00000000..2466d7eb --- /dev/null +++ b/app/helpers/comment_helper.rb @@ -0,0 +1,2 @@ +module CommentHelper +end diff --git a/app/helpers/dmail_helper.rb b/app/helpers/dmail_helper.rb new file mode 100644 index 00000000..da676103 --- /dev/null +++ b/app/helpers/dmail_helper.rb @@ -0,0 +1,2 @@ +module DmailHelper +end diff --git a/app/helpers/favorite_helper.rb b/app/helpers/favorite_helper.rb new file mode 100644 index 00000000..528196a2 --- /dev/null +++ b/app/helpers/favorite_helper.rb @@ -0,0 +1,24 @@ +module FavoriteHelper + def favorite_list(post) + html = "" + + users = post.favorited_by + + if users.empty? + html << "no one" + else + html << users.slice(0, 6).map {|user| link_to(ERB::Util.h(user.pretty_name), :controller => "user", :action => "show", :id => user.id)}.join(", ") + + if users.size > 6 + html << content_tag("span", :id => "remaining-favs", :style => "display: none;") do + ", " + users.slice(6..-1).map {|user| link_to(ERB::Util.h(user.pretty_name), {:controller => "user", :action => "show", :id => user.id})}.join(", ") + end + html << content_tag("span", :id => "remaining-favs-link") do + " (" + link_to_function("#{users.size - 6} more", "$('remaining-favs').show(); $('remaining-favs-link').hide()") + ")" + end + end + end + + return html + end +end diff --git a/app/helpers/forum_helper.rb b/app/helpers/forum_helper.rb new file mode 100644 index 00000000..cfbd978b --- /dev/null +++ b/app/helpers/forum_helper.rb @@ -0,0 +1,2 @@ +module ForumHelper +end diff --git a/app/helpers/history_helper.rb b/app/helpers/history_helper.rb new file mode 100644 index 00000000..40b2268b --- /dev/null +++ b/app/helpers/history_helper.rb @@ -0,0 +1,372 @@ +require 'post_helper' +require 'diff' + +module HistoryHelper + include PostHelper + # :all: By default, some changes are not displayed. When displaying details + # for a single change, set :all=>true to display all changes. + # + # :show_all_tags: Show unchanged tags. + def get_default_field_options + @default_field_options ||= { + :suppress_fields => [], + } + end + + def get_attribute_options + return @att_options if @att_options + + @att_options = { + # :suppress_fields => If this attribute was changed, don't display changes to specified + # fields to the same object in the same change. + # + # :force_show_initial => For initial changes, created when the object itself is created, + # attributes that are set to an explicit :default are omitted from the display. This + # prevents things like "parent:none" being shown for every new post. Set :force_show_initial + # to override this behavior. + # + # :primary_order => Changes are sorted alphabetically by field name. :primary_order + # overrides this sorting with a top-level sort (default 1). + # + # :never_obsolete => Changes that are no longer current or have been reverted are + # given the class "obsolete". Changes in fields named by :never_obsolete are not + # tested. + # + # Some cases: + # + # - When viewing a single object (eg. "post:123"), the display is always changed to + # the appropriate type, so if we're viewing a single object, :specific_table will + # always be true. + # + # - Changes to pool descriptions can be large, and are reduced to "description changed" + # in the "All" view. The diff is displayed if viewing the Pool view or a specific object. + # + # - Adding a post to a pool usually causes the sequence number to change, too, but + # this isn't very interesting and clutters the display. :suppress_fields is used + # to hide these unless viewing the specific change. + :Post => { + :fields => { + :cached_tags => { :primary_order => 2 }, # show tag changes after other things + :source => { :primary_order => 3 }, + }, + :never_obsolete => {:cached_tags=>true} # tags handle obsolete themselves per-tag + }, + + :Pool => { + :primary_order => 0, + + :fields => { + :description => { :primary_order => 5 } # we don't handle commas correctly if this isn't last + }, + :never_obsolete => {:description=>true} # changes to description aren't obsolete just because the text has changed again + }, + + :PoolPost => { + :fields => { + :sequence => { :max_to_display => 5 }, + :active => { + :max_to_display => 10, + :suppress_fields => [:sequence], # changing active usually changes sequence; this isn't interesting + :primary_order => 2, # show pool post changes after other things + }, + :cached_tags => { }, + }, + }, + + :Tag => { + }, + } + + @att_options.each_key { |classname| + @att_options[classname] = { + :fields => {}, + :primary_order => 1, + :never_obsolete => {}, + :force_show_initial => {}, + }.merge(@att_options[classname]) + + c = @att_options[classname][:fields] + c.each_key { |field| + c[field] = get_default_field_options.merge(c[field]) + } + } + + return @att_options + end + + def format_changes(history, options={}) + html = "" + + changes = history.history_changes + + # Group the changes by class and field. + change_groups = {} + changes.each do |c| + change_groups[c.table_name] ||= {} + change_groups[c.table_name][c.field.to_sym] ||= [] + change_groups[c.table_name][c.field.to_sym] << c + end + + att_options = get_attribute_options + + # Number of changes hidden (not including suppressions): + hidden = 0 + parts = [] + change_groups.each do |table_name, fields| + # Apply supressions. + to_suppress = [] + fields.each do |field, group| + class_name = group[0].master_class.to_s.to_sym + table_options = att_options[class_name] ||= {} + field_options = table_options[:fields][field] || get_default_field_options + to_suppress += field_options[:suppress_fields] + end + + to_suppress.each { |suppress| fields.delete(suppress) } + + fields.each do |field, group| + class_name = group[0].master_class.to_s.to_sym + table_options = att_options[class_name] ||= {} + field_options = table_options[:fields][field] || get_default_field_options + + # Check for entry limits. + if not options[:specific_history] + max = field_options[:max_to_display] + if max && group.length > max + hidden += group.length - max + group = group[0,max] || [] + end + end + + # Format the rest. + group.each do |c| + if !c.previous && c.changes_to_default? && !table_options[:force_show_initial][field] + next + end + + part = format_change(history, c, options, table_options).merge(:primary_order => field_options[:primary_order] || table_options[:primary_order]) + parts << part + end + end + end + + parts.sort! { |a,b| + comp = 0 + [:primary_order, :field, :sort_key].each { |field| + comp = a[field] <=> b[field] + break if comp != 0 + } + comp + } + + parts.each_index { |idx| + next if idx == 0 + next if parts[idx][:field] == parts[idx-1][:field] + parts[idx-1][:html] << ", " + } + + html = "" + + if !options[:show_name] && history.group_by_table == "tags" + p history.history_changes.first.obj.pretty_name + tag = history.history_changes.first.obj + html << tag_link(tag.name) + html << ": " + end + + html << parts.map { |part| part[:html] }.join(" ") + + if hidden > 0 + html << " (#{link_to("%i more..." % hidden, :action => @params[:action], :search => "change:%i" % history.id)})" + end + + return html + end + + def format_change(history, change, options, table_options) + html = "" + + classes = [] + if !table_options[:never_obsolete][change.field.to_sym] && change.is_obsolete? then + classes << ["obsolete"] + end + + added = %{+} + removed = %{-} + + sort_key = change.remote_id + primary_order = 1 + case change.table_name + when"posts" + case change.field + when "rating" + html << %{rating:} + html << change.value + if change.previous then + html << %{←} + html << change.previous.value + end + html << %{} + when "parent_id" + html << "parent:" + if change.value + begin + new = Post.find(change.value.to_i) + html << link_to("%i" % new.id, :controller => "post", :action => "show", :id => new.id) + rescue ActiveRecord::RecordNotFound => e + html << "%i" % change.value.to_i + end + else + html << "none" + end + + if change.previous + html << %{←} + if change.previous.value + begin + old = Post.find(change.previous.value.to_i) + html << link_to("%i" % old.id, :controller => "post", :action => "show", :id => old.id) + rescue ActiveRecord::RecordNotFound => e + html << "%i" % change.previous.value.to_i + end + else + html << "none" + end + end + + when "source" + if change.previous + html << "source changed from %s to %s" % [source_link(change.previous.value, false), source_link(change.value, false)] + else + html << "source: %s" % [source_link(change.value, false)] + end + + when "is_rating_locked" + html << (change.value == 't' ? added : removed) + html << "rating-locked" + + when "is_note_locked" + html << (change.value == 't' ? added : removed) + html << "note-locked" + + when "is_shown_in_index" + html << (change.value == 't' ? added : removed) + html << "shown" + + when "cached_tags" + previous = change.previous + + changes = Post.tag_changes(change, previous, change.latest) + + list = [] + list += tag_list(changes[:added_tags], :obsolete => changes[:obsolete_added_tags], :prefix => "+", :class => "added") + list += tag_list(changes[:removed_tags], :obsolete => changes[:obsolete_removed_tags], :prefix=>"-", :class => "removed") + + if options[:show_all_tags] + list += tag_list(changes[:unchanged_tags], :prefix => "", :class => "unchanged") + end + html << list.join(" ") + end + when "pools" + primary_order = 0 + + case change.field + when "name" + if change.previous + html << "name changed from %s to %s" % [h(change.previous.value), h(change.value)] + else + html << "name: %s" % [h(change.value)] + end + + when "description" + if options[:specific_history] || options[:specific_table] + if change.previous + html << "description changed:
#{Danbooru.diff(change.previous.value, change.value)}
" + else + html << "description:
#{change.value}
" + end + else + html << "description changed" + end + when "is_public" + html << (change.value == 't' ? added : removed) + html << "public" + when "is_active" + html << (change.value == 't' ? added : removed) + html << "active" + end + when "pools_posts" + # Sort the output by the post id. + sort_key = change.obj.post.id + case change.field + when "active" + html << (change.value == 't' ? added : removed) + + html << link_to("post #%i" % change.obj.post_id, :controller => "post", :action => "show", :id => change.obj.post_id) + + when "sequence" + seq = "order:%i:%s" % [change.obj.post_id, change.value] + if change.previous then + seq << %{←#{change.previous.value}} + end + html << link_to("%s" % seq, :controller => "post", :action => "show", :id => change.obj.post_id) + end + when "tags" + case change.field + when "tag_type" + html << "type:" + tag_type = Tag.type_name_from_value(change.value.to_i) + html << %{#{tag_type}} + if change.previous then + tag_type = Tag.type_name_from_value(change.previous.value.to_i) + html << %{←#{tag_type}} + end + when "is_ambiguous" + html << (change.value == 't' ? added : removed) + html << "ambiguous" + end + end + + span = "" + span << %{#{html}} + + return { + :html => span, + :field => change.field, + :sort_key => sort_key, + } + end + + def tag_link(name, options = {}) + name ||= "UNKNOWN" + prefix = options[:prefix] || "" + obsolete = options[:obsolete] || [] + + tag_type = Tag.type_name(name) + + obsolete_tag = ([name] & obsolete).empty? ? "":" obsolete" + tag = "" + tag << %{} + tag << %{#{prefix}#{h(name)}} + tag << '' + tag + end + + def tag_list(tags, options = {}) + return [] if tags.blank? + + html = "" + html << %{} + + tags_html = [] + tags.each do |name| + tags_html << tag_link(name, options) + end + + return [] if tags_html.empty? + + html << tags_html.join(" ") + html << %{} + return [html] + end +end diff --git a/app/helpers/inline_helper.rb b/app/helpers/inline_helper.rb new file mode 100644 index 00000000..f390699b --- /dev/null +++ b/app/helpers/inline_helper.rb @@ -0,0 +1,15 @@ +module InlineHelper + def inline_image_tag(image, options = {}, tag_options = {}) + if options[:use_sample] and image.has_sample? + url = image.sample_url + tag_options[:width] = image.sample_width + tag_options[:height] = image.sample_height + else + url = image.file_url + tag_options[:width] = image.width + tag_options[:height] = image.height + end + + return image_tag(url, tag_options) + end +end diff --git a/app/helpers/invite_helper.rb b/app/helpers/invite_helper.rb new file mode 100644 index 00000000..389f1238 --- /dev/null +++ b/app/helpers/invite_helper.rb @@ -0,0 +1,2 @@ +module InviteHelper +end diff --git a/app/helpers/job_task_helper.rb b/app/helpers/job_task_helper.rb new file mode 100644 index 00000000..b941a3ff --- /dev/null +++ b/app/helpers/job_task_helper.rb @@ -0,0 +1,2 @@ +module JobTaskHelper +end diff --git a/app/helpers/note_helper.rb b/app/helpers/note_helper.rb new file mode 100644 index 00000000..d27337d4 --- /dev/null +++ b/app/helpers/note_helper.rb @@ -0,0 +1,2 @@ +module NoteHelper +end diff --git a/app/helpers/pool_helper.rb b/app/helpers/pool_helper.rb new file mode 100644 index 00000000..c3a5b105 --- /dev/null +++ b/app/helpers/pool_helper.rb @@ -0,0 +1,25 @@ +module PoolHelper + def pool_list(post) + html = "" + pools = Pool.find(:all, :joins => "JOIN pools_posts ON pools_posts.pool_id = pools.id", :conditions => "pools_posts.post_id = #{post.id}", :order => "pools.name", :select => "pools.name, pools.id") + + if pools.empty? + html << "none" + else + html << pools.map {|p| link_to(h(p.pretty_name), :controller => "pool", :action => "show", :id => p.id)}.join(", ") + end + + return html + end + + def link_to_pool_zip(text, pool, zip_params, options={}) + text = "%s%s (%s)" % [text, + options[:has_jpeg]? " PNGs":"", + number_to_human_size(pool.get_zip_size(zip_params)), + ] + options = { :action => "zip", :id => pool.id, :filename => pool.get_zip_filename(zip_params) } + options[:originals] = 1 if zip_params[:originals] + options[:jpeg] = 1 if zip_params[:jpeg] + link_to text, options, :level => :member + end +end diff --git a/app/helpers/post_helper.rb b/app/helpers/post_helper.rb new file mode 100644 index 00000000..25ca3156 --- /dev/null +++ b/app/helpers/post_helper.rb @@ -0,0 +1,144 @@ +module PostHelper + def source_link(source, abbreviate = true) + if source.empty? + "none" + elsif source[/^http/] + text = source + text = text[7, 20] + "..." if abbreviate + link_to text, source + else + source + end + end + + def auto_discovery_link_tag_with_id(type = :rss, url_options = {}, tag_options = {}) + tag( + "link", + "rel" => tag_options[:rel] || "alternate", + "type" => tag_options[:type] || "application/#{type}+xml", + "title" => tag_options[:title] || type.to_s.upcase, + "id" => tag_options[:id], + "href" => url_options.is_a?(Hash) ? url_for(url_options.merge(:only_path => false)) : url_options + ) + end + + def print_preview(post, options = {}) + unless CONFIG["can_see_post"].call(@current_user, post) + return "" + end + + image_class = "preview" + image_class += " flagged" if post.is_flagged? + image_class += " pending" if post.is_pending? + image_class += " has-children" if post.has_children? + image_class += " has-parent" if post.parent_id + image_id = options[:image_id] + image_id = %{id="#{h(image_id)}"} if image_id + image_title = h("Rating: #{post.pretty_rating} Score: #{post.score} Tags: #{h(post.cached_tags)} User:#{post.author}") + link_onclick = options[:onclick] + link_onclick = %{onclick="#{link_onclick}"} if link_onclick + link_onmouseover = %{ onmouseover="#{options[:onmouseover]}"} if options[:onmouseover] + link_onmouseout = %{ onmouseout="#{options[:onmouseout]}"} if options[:onmouseout] + width, height = post.preview_dimensions + + image = %{#{image_title}} + plid = %{#pl http://#{h CONFIG["server_host"]}/post/show/#{post.id}} + link = %{#{image}#{plid}} + span = %{#{link}} + + directlink = if options[:similarity] + icon = %{} + size = %{ (#{post.width}x#{post.height})} + + similarity_class = "similar similar_directlink" + similarity_class += " similar_original" if options[:similarity].to_s == "Original" + similarity_class += " similar-match" if options[:similarity].to_f >= 90 rescue false + similarity_text = options[:similarity].to_s == "Original"? + (if @initial then "Your post" else "Original" end): + %{#{options[:similarity].to_i}%} + + %{#{icon}#{similarity_text}#{size}} + else + if post.width.to_i > 1500 or post.height.to_i > 1500 + %{#{post.width} x #{post.height}} + else + %{#{post.width} x #{post.height}} + end + end + directlink = "" if options[:hide_directlink] + + li_class = "" + li_class += " javascript-hide" if options[:blacklisting] + li_class += " creator-id-#{post.user_id}" + item = %{
  • #{span}#{directlink}
  • } + return item + end + + def print_ext_similarity_preview(post, options = {}) + image_class = "preview external" + width, height = post.preview_dimensions + + image = %{#{(post.md5)}} + link = %{#{image}} + icon = %{#{post.service}} + span = %{#{link}} + + size = if post.width > 0 then (%{ (#{post.width}x#{post.height})}) else "" end + similarity_class = "similar" + similarity_class += " similar-match" if options[:similarity].to_f >= 90 rescue false + similarity_class += " similar_original" if options[:similarity].to_s == "Original" + similarity_text = options[:similarity].to_s == "Original"? "Image":%{#{options[:similarity].to_i}%} + similarity = %{#{icon}#{similarity_text}#{size}} + item = %{
  • #{span}#{similarity}
  • } + return item + end + + def vote_tooltip_widget(post) + return %{} + end + + def vote_widget(post, user, options = {}) + html = [] + + html << %{} + + if user.is_anonymous? + current_user_vote = -100 + else + current_user_vote = PostVotes.find_by_ids(user.id, post.id).score rescue 0 + end + + #(CONFIG["vote_sum_min"]..CONFIG["vote_sum_max"]).each do |vote| + if !user.is_anonymous? + html << link_to_function('↶', "Post.vote(#{post.id}, 0)", :class => "star", :onmouseover => "Post.vote_mouse_over('Remove vote', #{post.id}, 0)", :onmouseout => "Post.vote_mouse_out('', #{post.id}, 0)") + html << " " + + (1..3).each do |vote| + star = '' + + desc = CONFIG["vote_descriptions"][vote] + + html << link_to_function(star, "Post.vote(#{post.id}, #{vote})", :class => "star star-#{vote}", :id => "star-#{vote}-#{post.id}", :onmouseover => "Post.vote_mouse_over('#{desc}', #{post.id}, #{vote})", :onmouseout => "Post.vote_mouse_out('#{desc}', #{post.id}, #{vote})") + end + + html << " (" + link_to_function('vote up', "Post.vote(#{post.id}, Post.posts.get(#{post.id}).vote + 1)", :class => "star") + ")" + else + html << "(" + link_to_function('vote up', "Post.vote(#{post.id}, +1)", :class => "star") + ")" + end + + html << %{} + return html + end + + def get_tag_types(posts) + post_tags = [] + posts.each { |post| post_tags += post.cached_tags.split(/ /) } + tag_types = {} + post_tags.uniq.each { |tag| tag_types[tag] = Tag.type_name(tag) } + return tag_types + end + + def get_service_icon(service) + ExternalPost.get_service_icon(service) + end +end diff --git a/app/helpers/post_tag_history_helper.rb b/app/helpers/post_tag_history_helper.rb new file mode 100644 index 00000000..7191ebe8 --- /dev/null +++ b/app/helpers/post_tag_history_helper.rb @@ -0,0 +1,35 @@ +module PostTagHistoryHelper + def tag_list(tags, options = {}) + return "" if tags.blank? + prefix = options[:prefix] || "" + obsolete = options[:obsolete] || [] + + html = "" + + # tags contains versioned metatags; split these out. + metatags, tags = tags.partition {|x| x=~ /^(?:rating):/} + metatags.each do |name| + obsolete_tag = ([name] & obsolete).empty? ? "":" obsolete-tag-change" + html << %{} + + html << %{#{prefix}#{h(name)} } + html << '' + end + + tags = Tag.find(:all, :conditions => ["name in (?)", tags], :select => "name").inject([]) {|all, x| all << x.name; all}.to_a.sort {|a, b| a <=> b} + + tags.each do |name| + name ||= "UNKNOWN" + + tag_type = Tag.type_name(name) + + obsolete_tag = ([name] & obsolete).empty? ? "":" obsolete-tag-change" + html << %{} + + html << %{#{prefix}#{h(name)} } + html << '' + end + + return html + end +end diff --git a/app/helpers/report_helper.rb b/app/helpers/report_helper.rb new file mode 100644 index 00000000..0847d3bf --- /dev/null +++ b/app/helpers/report_helper.rb @@ -0,0 +1,2 @@ +module ReportHelper +end diff --git a/app/helpers/static_helper.rb b/app/helpers/static_helper.rb new file mode 100644 index 00000000..8cfc9af4 --- /dev/null +++ b/app/helpers/static_helper.rb @@ -0,0 +1,2 @@ +module StaticHelper +end diff --git a/app/helpers/tag_alias_helper.rb b/app/helpers/tag_alias_helper.rb new file mode 100644 index 00000000..2940c5af --- /dev/null +++ b/app/helpers/tag_alias_helper.rb @@ -0,0 +1,2 @@ +module TagAliasHelper +end diff --git a/app/helpers/tag_helper.rb b/app/helpers/tag_helper.rb new file mode 100644 index 00000000..c23e5fab --- /dev/null +++ b/app/helpers/tag_helper.rb @@ -0,0 +1,91 @@ +module TagHelper + def tag_link(tag) + tag_type = Tag.type_name(tag) + html = %{} + html << link_to(h(tag), :action => "index", :tags => tag) + html << %{} + end + + def tag_links(tags, options = {}) + return "" if tags.blank? + prefix = options[:prefix] || "" + + html = "" + + case tags[0] + when String + tags = Tag.find(:all, :conditions => ["name in (?)", tags], :select => "name, post_count, id").inject({}) {|all, x| all[x.name] = [x.post_count, x.id]; all}.sort {|a, b| a[0] <=> b[0]}.map { |a| [a[0], a[1][0], a[1][1]] } + + when Hash + tags = tags.map {|x| [x["name"], x["post_count"], nil]} + + when Tag + tags = tags.map {|x| [x.name, x.post_count, x.id]} + end + + tags.each do |name, count, id| + name ||= "UNKNOWN" + + tag_type = Tag.type_name(name) + + html << %{
  • } + + if CONFIG["enable_artists"] && tag_type == "artist" + html << %{? } + else + html << %{? } + end + + if @current_user.is_privileged_or_higher? + html << %{+ } + html << %{ } + end + + if options[:with_hover_highlight] then + mouseover=%{ onmouseover='Post.highlight_posts_with_tag("#{escape_javascript(name).gsub("'", "‘")}")'} + mouseout=%{ onmouseout='Post.highlight_posts_with_tag(null)'} + end + html << %{#{h(name.tr("_", " "))} } + html << %{#{count} } + html << '
  • ' + end + + if options[:with_aliases] then + # Map tags to aliases to the tag, and include the original tag so search engines can + # find it. + id_list = tags.map { |t| t[2] } + alternate_tags = TagAlias.find(:all, :select => :name, :conditions => ["alias_id IN (?)", id_list]).map { |t| t.name }.uniq + if not alternate_tags.empty? + html << %{#{alternate_tags.map { |t| t.tr("_", " ") }.join(" ")}} + end + end + + return html + end + + def cloud_view(tags, divisor = 6) + html = "" + + tags.sort {|a, b| a["name"] <=> b["name"]}.each do |tag| + size = Math.log(tag["post_count"].to_i) / divisor + size = 0.8 if size < 0.8 + html << %{#{h(tag["name"])} } + end + + return html + end + + def related_tags(tags) + if tags.blank? + return "" + end + + all = [] + pattern, related = tags.split(/\s+/).partition {|i| i.include?("*")} + pattern.each {|i| all += Tag.find(:all, :conditions => ["name LIKE ?", i.tr("*", "%")]).map {|j| j.name}} + if related.any? + Tag.find(:all, :conditions => ["name IN (?)", TagAlias.to_aliased(related)]).each {|i| all += i.related.map {|j| j[0]}} + end + all.join(" ") + end +end diff --git a/app/helpers/tag_implication_helper.rb b/app/helpers/tag_implication_helper.rb new file mode 100644 index 00000000..423c54a8 --- /dev/null +++ b/app/helpers/tag_implication_helper.rb @@ -0,0 +1,2 @@ +module TagImplicationHelper +end diff --git a/app/helpers/user_helper.rb b/app/helpers/user_helper.rb new file mode 100644 index 00000000..0147c3fe --- /dev/null +++ b/app/helpers/user_helper.rb @@ -0,0 +1,2 @@ +module UserHelper +end diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb new file mode 100644 index 00000000..d97194c2 --- /dev/null +++ b/app/helpers/wiki_helper.rb @@ -0,0 +1,9 @@ +module WikiHelper + def linked_from(to) + links = to.find_pages_that_link_to_this.map do |page| + link_to(h(page.pretty_title), :controller => "wiki", :action => "show", :title => page.title) + end.join(", ") + + links.empty? ? "None" : links + end +end diff --git a/app/models/advertisement.rb b/app/models/advertisement.rb new file mode 100644 index 00000000..48a18789 --- /dev/null +++ b/app/models/advertisement.rb @@ -0,0 +1,3 @@ +class Advertisement < ActiveRecord::Base + validates_inclusion_of :ad_type, :in => %w(horizontal vertical) +end diff --git a/app/models/artist.rb b/app/models/artist.rb new file mode 100644 index 00000000..ad4ffdfe --- /dev/null +++ b/app/models/artist.rb @@ -0,0 +1,244 @@ +class Artist < ActiveRecord::Base + module UrlMethods + module ClassMethods + def find_all_by_url(url) + url = ArtistUrl.normalize(url) + artists = [] + + while artists.empty? && url.size > 10 + u = url.to_escaped_for_sql_like.gsub(/\*/, '%') + '%' + artists += Artist.find(:all, :joins => "JOIN artist_urls ON artist_urls.artist_id = artists.id", :conditions => ["artists.alias_id IS NULL AND artist_urls.normalized_url LIKE ? ESCAPE E'\\\\'", u], :order => "artists.name") + + # Remove duplicates based on name + artists = artists.inject({}) {|all, artist| all[artist.name] = artist ; all}.values + url = File.dirname(url) + end + + return artists[0, 20] + end + end + + def self.included(m) + m.extend(ClassMethods) + m.after_save :commit_urls + m.has_many :artist_urls, :dependent => :delete_all + end + + def commit_urls + if @urls + artist_urls.clear + + @urls.scan(/\S+/).each do |url| + artist_urls.create(:url => url) + end + end + end + + def urls=(urls) + @urls = urls + end + + def urls + artist_urls.map {|x| x.url}.join("\n") + end + end + + module NoteMethods + def self.included(m) + m.after_save :commit_notes + end + + def wiki_page + WikiPage.find_page(name) + end + + def notes_locked? + wiki_page.is_locked? rescue false + end + + def notes + wiki_page.body rescue "" + end + + def notes=(text) + @notes = text + end + + def commit_notes + unless @notes.blank? + if wiki_page.nil? + WikiPage.create(:title => name, :body => @notes, :ip_addr => updater_ip_addr, :user_id => updater_id) + elsif wiki_page.is_locked? + errors.add(:notes, "are locked") + else + wiki_page.update_attributes(:body => @notes, :ip_addr => updater_ip_addr, :user_id => updater_id) + end + end + end + end + + module AliasMethods + def self.included(m) + m.after_save :commit_aliases + end + + def commit_aliases + transaction do + connection.execute("UPDATE artists SET alias_id = NULL WHERE alias_id = #{id}") + + if @alias_names + @alias_names.each do |name| + a = Artist.find_or_create_by_name(name) + a.update_attributes(:alias_id => id, :updater_id => updater_id) + end + end + end + end + + def alias_names=(names) + @alias_names = names.split(/\s*,\s*/) + end + + def alias_names + aliases.map(&:name).join(", ") + end + + def aliases + if new_record? + return [] + else + return Artist.find(:all, :conditions => "alias_id = #{id}", :order => "name") + end + end + + def alias_name + if alias_id + begin + return Artist.find(alias_id).name + rescue ActiveRecord::RecordNotFound + end + end + + return nil + end + + def alias_name=(name) + if name.blank? + self.alias_id = nil + else + artist = Artist.find_or_create_by_name(name) + self.alias_id = artist.id + end + end + end + + module GroupMethods + def self.included(m) + m.after_save :commit_members + end + + def commit_members + transaction do + connection.execute("UPDATE artists SET group_id = NULL WHERE group_id = #{id}") + + if @member_names + @member_names.each do |name| + a = Artist.find_or_create_by_name(name) + a.update_attributes(:group_id => id, :updater_id => updater_id) + end + end + end + end + + def group_name + if group_id + return Artist.find(group_id).name + else + nil + end + end + + def members + if new_record? + return [] + else + Artist.find(:all, :conditions => "group_id = #{id}", :order => "name") + end + end + + def member_names + members.map(&:name).join(", ") + end + + def member_names=(names) + @member_names = names.split(/\s*,\s*/) + end + + def group_name=(name) + if name.blank? + self.group_id = nil + else + artist = Artist.find_or_create_by_name(name) + self.group_id = artist.id + end + end + end + + module ApiMethods + def api_attributes + return { + :id => id, + :name => name, + :alias_id => alias_id, + :group_id => group_id, + :urls => artist_urls.map {|x| x.url} + } + end + + def to_xml(options = {}) + attribs = api_attributes + attribs[:urls] = attribs[:urls].join(" ") + attribs.to_xml(options.merge(:root => "artist")) + end + + def to_json(*args) + return api_attributes.to_json(*args) + end + end + + include UrlMethods + include NoteMethods + include AliasMethods + include GroupMethods + include ApiMethods + + before_validation :normalize + validates_uniqueness_of :name + belongs_to :updater, :class_name => "User", :foreign_key => "updater_id" + attr_accessor :updater_ip_addr + + def normalize + self.name = name.downcase.gsub(/^\s+/, "").gsub(/\s+$/, "").gsub(/ /, '_') + end + + def to_s + return name + end + + def self.generate_sql(name) + b = Nagato::Builder.new do |builder, cond| + case name + when /^[a-fA-F0-9]{32,32}$/ + cond.add "name IN (SELECT t.name FROM tags t JOIN posts_tags pt ON pt.tag_id = t.id JOIN posts p ON p.id = pt.post_id WHERE p.md5 = ?)", name + + when /^http/ + cond.add "id IN (?)", find_all_by_url(name).map {|x| x.id} + + else + cond.add "name LIKE ? ESCAPE E'\\\\'", name.to_escaped_for_sql_like + "%" + end + end + + return b.to_hash + end +end diff --git a/app/models/artist_url.rb b/app/models/artist_url.rb new file mode 100644 index 00000000..bc64fbb7 --- /dev/null +++ b/app/models/artist_url.rb @@ -0,0 +1,29 @@ +class ArtistUrl < ActiveRecord::Base + before_save :normalize + validates_presence_of :url + + def self.normalize(url) + if url.nil? + return nil + else + url = url.gsub(/^http:\/\/blog\d+\.fc2/, "http://blog.fc2") + url = url.gsub(/^http:\/\/blog-imgs-\d+\.fc2/, "http://blog.fc2") + url = url.gsub(/^http:\/\/img\d+\.pixiv\.net/, "http://img.pixiv.net") + return url + end + end + + def self.normalize_for_search(url) + if url =~ /\.\w+$/ && url =~ /\w\/\w/ + url = File.dirname(url) + end + + url = url.gsub(/^http:\/\/blog\d+\.fc2/, "http://blog*.fc2") + url = url.gsub(/^http:\/\/blog-imgs-\d+\.fc2/, "http://blog*.fc2") + url = url.gsub(/^http:\/\/img\d+\.pixiv\.net/, "http://img*.pixiv.net") + end + + def normalize + self.normalized_url = self.class.normalize(self.url) + end +end diff --git a/app/models/ban.rb b/app/models/ban.rb new file mode 100644 index 00000000..77407148 --- /dev/null +++ b/app/models/ban.rb @@ -0,0 +1,33 @@ +class Ban < ActiveRecord::Base + before_create :save_level + after_create :save_to_record + after_create :update_level + after_destroy :restore_level + + def restore_level + User.find(user_id).update_attribute(:level, old_level) + end + + def save_level + self.old_level = User.find(user_id).level + end + + def update_level + user = User.find(user_id) + user.level = CONFIG["user_levels"]["Blocked"] + user.save + end + + def save_to_record + UserRecord.create(:user_id => self.user_id, :reported_by => self.banned_by, :is_positive => false, :body => "Blocked: #{self.reason}") + end + + def duration=(dur) + self.expires_at = (dur.to_f * 60*60*24).seconds.from_now + @duration = dur + end + + def duration + @duration + end +end diff --git a/app/models/coefficient.rb b/app/models/coefficient.rb new file mode 100644 index 00000000..39bd700b --- /dev/null +++ b/app/models/coefficient.rb @@ -0,0 +1,3 @@ +class Coefficient < ActiveRecord::Base + belongs_to :post +end diff --git a/app/models/comment.rb b/app/models/comment.rb new file mode 100644 index 00000000..b354f7b4 --- /dev/null +++ b/app/models/comment.rb @@ -0,0 +1,59 @@ +class Comment < ActiveRecord::Base + validates_format_of :body, :with => /\S/, :message => 'has no content' + belongs_to :post + belongs_to :user + after_save :update_last_commented_at + after_destroy :update_last_commented_at + attr_accessor :do_not_bump_post + + def self.generate_sql(params) + return Nagato::Builder.new do |builder, cond| + cond.add_unless_blank "post_id = ?", params[:post_id] + end.to_hash + end + + def self.updated?(user) + conds = [] + conds += ["user_id <> %d" % [user.id]] unless user.is_anonymous? + + newest_comment = Comment.find(:first, :order => "id desc", :limit => 1, :select => "created_at", :conditions => conds) + return false if newest_comment == nil + return newest_comment.created_at > user.last_comment_read_at + end + + def update_last_commented_at + # return if self.do_not_bump_post + + comment_count = connection.select_value("SELECT COUNT(*) FROM comments WHERE post_id = #{post_id}").to_i + if comment_count <= CONFIG["comment_threshold"] + connection.execute("UPDATE posts SET last_commented_at = (SELECT created_at FROM comments WHERE post_id = #{post_id} ORDER BY created_at DESC LIMIT 1) WHERE posts.id = #{post_id}") + end + end + + def author + return User.find_name(self.user_id) + end + + def pretty_author + author.tr("_", " ") + end + + def api_attributes + return { + :id => id, + :created_at => created_at, + :post_id => post_id, + :creator => author, + :creator_id => user_id, + :body => body + } + end + + def to_xml(options = {}) + return api_attributes.to_xml(options.merge(:root => "comment")) + end + + def to_json(*args) + return api_attributes.to_json(*args) + end +end diff --git a/app/models/dmail.rb b/app/models/dmail.rb new file mode 100644 index 00000000..6bb8be05 --- /dev/null +++ b/app/models/dmail.rb @@ -0,0 +1,58 @@ +class Dmail < ActiveRecord::Base + validates_presence_of :to_id + validates_presence_of :from_id + validates_format_of :title, :with => /\S/ + validates_format_of :body, :with => /\S/ + + belongs_to :to, :class_name => "User", :foreign_key => "to_id" + belongs_to :from, :class_name => "User", :foreign_key => "from_id" + + after_create :update_recipient + after_create :send_dmail + + def send_dmail + if to.receive_dmails? && to.email.include?("@") + UserMailer.deliver_dmail(to, from, title, body) + end + end + + def mark_as_read!(current_user) + update_attribute(:has_seen, true) + + unless Dmail.exists?(["to_id = ? AND has_seen = false", current_user.id]) + current_user.update_attribute(:has_mail, false) + end + end + + def update_recipient + to.update_attribute(:has_mail, true) + end + + def to_name + User.find_name(to_id) + end + + def from_name + User.find_name(from_id) + end + + def to_name=(name) + user = User.find_by_name(name) + return if user.nil? + self.to_id = user.id + end + + def from_name=(name) + user = User.find_by_name(name) + return if user.nil? + self.from_id = user.id + end + + def title + if parent_id + return "Re: " + self[:title] + else + return self[:title] + end + end +end diff --git a/app/models/favorite.rb b/app/models/favorite.rb new file mode 100644 index 00000000..3cfa23d8 --- /dev/null +++ b/app/models/favorite.rb @@ -0,0 +1,2 @@ +class Favorite < ActiveRecord::Base +end diff --git a/app/models/favorite_tag.rb b/app/models/favorite_tag.rb new file mode 100644 index 00000000..6e35aaef --- /dev/null +++ b/app/models/favorite_tag.rb @@ -0,0 +1,55 @@ +class FavoriteTag < ActiveRecord::Base + belongs_to :user + before_create :initialize_post_ids + + def initialize_post_ids + if user.is_privileged_or_higher? + self.cached_post_ids = Post.find_by_tags(tag_query, :limit => 60, :select => "p.id").map(&:id).join(",") + end + end + + def interested?(post_id) + Post.find_by_tags(tag_query + " id:#{post_id}").any? + end + + def add_post!(post_id) + if cached_post_ids.blank? + update_attribute :cached_post_ids, post_id.to_s + else + update_attribute :cached_post_ids, "#{post_id},#{cached_post_ids}" + end + end + + def prune! + hoge = cached_post_ids.split(/,/) + + if hoge.size > CONFIG["favorite_tag_limit"] + update_attribute :cached_post_ids, hoge[0, CONFIG["favorite_tag_limit"]].join(",") + end + end + + def self.find_post_ids(user_id, limit = 60) + find(:all, :conditions => ["user_id = ?", user_id], :select => "id, cached_post_ids").map {|x| x.cached_post_ids.split(/,/)}.flatten + end + + def self.find_posts(user_id, limit = 60) + Post.find(:all, :conditions => ["id in (?)", find_post_ids(user_id, limit)], :order => "id DESC", :limit => limit) + end + + def self.process_all(last_processed_post_id) + posts = Post.find(:all, :conditions => ["id > ?", last_processed_post_id], :order => "id DESC", :select => "id") + fav_tags = FavoriteTag.find(:all) + + fav_tags.each do |fav_tag| + if fav_tag.user.is_privileged_or_higher? + posts.each do |post| + if fav_tag.interested?(post.id) + fav_tag.add_post!(post.id) + end + end + + fav_tag.prune! + end + end + end +end diff --git a/app/models/flagged_post_detail.rb b/app/models/flagged_post_detail.rb new file mode 100644 index 00000000..fef7bb90 --- /dev/null +++ b/app/models/flagged_post_detail.rb @@ -0,0 +1,19 @@ +class FlaggedPostDetail < ActiveRecord::Base + belongs_to :post + belongs_to :user + + def author + return User.find_name(self.user_id) + end + + def self.new_deleted_posts(user) + return 0 if user.is_anonymous? + + return Cache.get("deleted_posts:#{user.id}:#{user.last_deleted_post_seen_at.to_i}", 1.minute) do + select_value_sql( + "SELECT COUNT(*) FROM flagged_post_details fpd JOIN posts p ON (p.id = fpd.post_id) " + + "WHERE p.status = 'deleted' AND p.user_id = ? AND fpd.user_id <> ? AND fpd.created_at > ?", + user.id, user.id, user.last_deleted_post_seen_at).to_i + end + end +end diff --git a/app/models/forum_post.rb b/app/models/forum_post.rb new file mode 100644 index 00000000..3bd29030 --- /dev/null +++ b/app/models/forum_post.rb @@ -0,0 +1,160 @@ +class ForumPost < ActiveRecord::Base + belongs_to :creator, :class_name => "User", :foreign_key => :creator_id + after_create :initialize_last_updated_by + before_validation :validate_title + validates_length_of :body, :minimum => 1, :message => "You need to enter a body" + + module LockMethods + module ClassMethods + def lock!(id) + # Run raw SQL to skip the lock check + execute_sql("UPDATE forum_posts SET is_locked = TRUE WHERE id = ?", id) + end + + def unlock!(id) + # Run raw SQL to skip the lock check + execute_sql("UPDATE forum_posts SET is_locked = FALSE WHERE id = ?", id) + end + end + + def self.included(m) + m.extend(ClassMethods) + m.before_validation :validate_lock + end + + def validate_lock + if root.is_locked? + errors.add_to_base("Thread is locked") + return false + end + + return true + end + end + + module StickyMethods + module ClassMethods + def stick!(id) + # Run raw SQL to skip the lock check + execute_sql("UPDATE forum_posts SET is_sticky = TRUE WHERE id = ?", id) + end + + def unstick!(id) + # Run raw SQL to skip the lock check + execute_sql("UPDATE forum_posts SET is_sticky = FALSE WHERE id = ?", id) + end + end + + def self.included(m) + m.extend(ClassMethods) + end + end + + module ParentMethods + def self.included(m) + m.after_create :update_parent_on_create + m.before_destroy :update_parent_on_destroy + m.has_many :children, :class_name => "ForumPost", :foreign_key => :parent_id, :order => "id" + m.belongs_to :parent, :class_name => "ForumPost", :foreign_key => :parent_id + end + + def update_parent_on_destroy + unless is_parent? + p = parent + p.update_attributes(:response_count => p.response_count - 1) + end + end + + def update_parent_on_create + unless is_parent? + p = parent + p.update_attributes(:updated_at => updated_at, :response_count => p.response_count + 1, :last_updated_by => creator_id) + end + end + + def is_parent? + return parent_id.nil? + end + + def root + if is_parent? + return self + else + return ForumPost.find(parent_id) + end + end + + def root_id + if is_parent? + return id + else + return parent_id + end + end + end + + module ApiMethods + def api_attributes + return { + :body => body, + :creator => author, + :creator_id => creator_id, + :id => id, + :parent_id => parent_id, + :title => title + } + end + + def to_json(*params) + api_attributes.to_json(*params) + end + + def to_xml(options = {}) + api_attributes.to_xml(options.merge(:root => "forum_post")) + end + end + + include LockMethods + include StickyMethods + include ParentMethods + include ApiMethods + + def self.updated?(user) + conds = [] + conds += ["creator_id <> %d" % [user.id]] unless user.is_anonymous? + + newest_topic = ForumPost.find(:first, :order => "id desc", :limit => 1, :select => "created_at", :conditions => conds) + return false if newest_topic == nil + return newest_topic.created_at > user.last_forum_topic_read_at + end + + def validate_title + if is_parent? + if title.blank? + errors.add :title, "missing" + return false + end + + if title !~ /\S/ + errors.add :title, "missing" + return false + end + end + + return true + end + + def initialize_last_updated_by + if is_parent? + update_attribute(:last_updated_by, creator_id) + end + end + + def last_updater + User.find_name(last_updated_by) + end + + def author + User.find_name(creator_id) + end +end diff --git a/app/models/history.rb b/app/models/history.rb new file mode 100644 index 00000000..1fce4ffa --- /dev/null +++ b/app/models/history.rb @@ -0,0 +1,159 @@ +class History < ActiveRecord::Base + belongs_to :user + has_many :history_changes, :order => "id" + + def group_by_table_class + Object.const_get(group_by_table.classify) + end + + def get_group_by_controller + group_by_table_class.get_versioning_group_by[:controller] + end + + def get_group_by_action + group_by_table_class.get_versioning_group_by[:action] + end + + def group_by_obj + group_by_table_class.find(group_by_id) + end + + def user + User.find(user_id) + end + + def author + User.find_name(user_id) + end + + # Undo all changes in the array changes. + def self.undo(changes, user, redo_change=false, errors={}) + # Save parent objects after child objects, so changes to the children are + # committed when we save the parents. + objects = {} + + changes.each { |change| + # If we have no previous change, this was the first change to this property + # and we have no default, so this change can't be undone. + previous_change = change.previous + if !previous_change && !change.options[:allow_reverting_to_default] + next + end + + if not user.can_change?(change.obj, change.field.to_sym) then + errors[change] = :denied + next + end + + # Add this node and its parent objects to objects. + node = cache_object_recurse(objects, change.table_name, change.remote_id, change.obj) + node[:changes] ||= [] + node[:changes] << change + } + + return unless objects[:objects] + + # objects contains one or more trees of objects. Flatten this to an ordered + # list, so we can always save child nodes before parent nodes. + done = {} + stack = [] + objects[:objects].each { |table_name, rhs| + rhs.each { |id, node| + # Start adding from the node at the top of the tree. + while node[:parent] do + node = node[:parent] + end + self.stack_object_recurse(node, stack, done) + } + } + + stack.reverse.each { |node| + object = node[:o] + changes = node[:changes] + if changes + changes.each { |change| + if redo_change + redo_func = ("%s_redo" % change.field).to_sym + if object.respond_to?(redo_func) then + object.send(redo_func, change) + else + object.attributes = { change.field.to_sym => change.value } + end + else + undo_func = ("%s_undo" % change.field).to_sym + if object.respond_to?(undo_func) then + object.send(undo_func, change) + else + if change.previous + previous = change.previous.value + else + previous = change.options[:default] # when :allow_reverting_to_default + end + object.attributes = { change.field.to_sym => previous } + end + end + } + end + + object.run_callbacks(:after_undo) + object.save! + } + end + + def self.generate_sql(options = {}) + Nagato::Builder.new do |builder, cond| + cond.add_unless_blank "histories.remote_id = ?", options[:remote_id] + cond.add_unless_blank "histories.user_id = ?", options[:user_id] + + if options[:user_name] + builder.join "users ON users.id = histories.user_id" + cond.add "users.name = ?", options[:user_name] + end + end.to_hash + end + +private + # Find and return the node for table_name/id in objects. If the node doesn't + # exist, create it and point it at object. + def self.cache_object(objects, table_name, id, object) + objects[:objects] ||= {} + objects[:objects][table_name] ||= {} + objects[:objects][table_name][id] ||= { + :o => object + } + return objects[:objects][table_name][id] + end + + # Find and return the node for table_name/id in objects. Recursively create + # nodes for parent objects. + def self.cache_object_recurse(objects, table_name, id, object) + node = self.cache_object(objects, table_name, id, object) + + # If this class has a master class, register the master object for update callbacks too. + master = object.versioned_master_object + if master + master_node = cache_object_recurse(objects, master.class.to_s, master.id, master) + + master_node[:children] ||= [] + master_node[:children] << node + node[:parent] = master_node + end + + return node + end + + # Recursively add all nodes to stack, parents first. + def self.stack_object_recurse(node, stack, done = {}) + return if done[node] + done[node] = true + + stack << node + + if node[:children] then + node[:children].each { |child| + self.stack_object_recurse(child, stack, done) + } + end + end +end + diff --git a/app/models/history_change.rb b/app/models/history_change.rb new file mode 100644 index 00000000..668a321c --- /dev/null +++ b/app/models/history_change.rb @@ -0,0 +1,78 @@ +class HistoryChange < ActiveRecord::Base + belongs_to :history + belongs_to :previous, :class_name => "HistoryChange", :foreign_key => :previous_id + after_create :set_previous + + def options + master_class.get_versioned_attribute_options(field) or {} + end + + def master_class + # Hack because Rails is stupid and can't reliably derive class names + # from table names: + if table_name == "pools_posts" + class_name = "PoolPost" + else + class_name = table_name.classify + end + Object.const_get(class_name) + end + + # Return true if this changes the value to the default value. + def changes_to_default? + return false if not has_default? + + # Cast our value to the actual type; if this is a boolean value, this + # casts "f" to false. + column = master_class.columns_hash[field] + typecasted_value = column.type_cast(value) + + return typecasted_value == get_default + end + + def is_obsolete? + latest_change = latest + return self.value != latest_change.value + end + + def has_default? + options.has_key?(:default) + end + + def get_default + default = options[:default] + end + + # Return the default value for the field recorded by this change. + def default_history + return nil if not has_default? + + History.new :table_name => self.table_name, + :remote_id => self.remote_id, + :field => self.field, + :value => get_default + end + + # Return the object this change modifies. + def obj + @obj ||= master_class.find(self.remote_id) + @obj + end + + def latest + HistoryChange.find(:first, :order => "id DESC", + :conditions => ["table_name = ? AND remote_id = ? AND field = ?", table_name, remote_id, field]) + end + + def next + HistoryChange.find(:first, :order => "h.id ASC", + :conditions => ["table_name = ? AND remote_id = ? AND id > ? AND field = ?", table_name, remote_id, id, field]) + end + + def set_previous + self.previous = HistoryChange.find(:first, :order => "id DESC", + :conditions => ["table_name = ? AND remote_id = ? AND id < ? AND field = ?", table_name, remote_id, id, field]) + self.save! + end +end + diff --git a/app/models/inline.rb b/app/models/inline.rb new file mode 100644 index 00000000..8c802612 --- /dev/null +++ b/app/models/inline.rb @@ -0,0 +1,83 @@ +class Inline < ActiveRecord::Base + belongs_to :user + has_many :inline_images, :dependent => :destroy, :order => "sequence" + + # Sequence numbers must start at 1 and increase monotonically, to keep the UI simple. + # If we've been given sequences with gaps or duplicates, sanitize them. + def renumber_sequences + first = 1 + for image in inline_images do + image.sequence = first + image.save! + first += 1 + end + end + + def pretty_name + "x" + end + + def crop(params) + if params[:top].to_f < 0 or params[:top].to_f > 1 or + params[:bottom].to_f < 0 or params[:bottom].to_f > 1 or + params[:left].to_f < 0 or params[:left].to_f > 1 or + params[:right].to_f < 0 or params[:right].to_f > 1 or + params[:top] >= params[:bottom] or + params[:left] >= params[:right] + then + errors.add(:parameter, "error") + return false + end + + def reduce_and_crop(image_width, image_height, params) + cropped_image_width = image_width * (params[:right].to_f - params[:left].to_f) + cropped_image_height = image_height * (params[:bottom].to_f - params[:top].to_f) + + size = {} + size[:width] = cropped_image_width + size[:height] = cropped_image_height + size[:crop_top] = image_height * params[:top].to_f + size[:crop_bottom] = image_height * params[:bottom].to_f + size[:crop_left] = image_width * params[:left].to_f + size[:crop_right] = image_width * params[:right].to_f + size + end + + images = self.inline_images + for image in images do + # Create a new image with the same properties, crop this image into the new one, + # and delete the old one. + new_image = InlineImage.new(:description => image.description, :sequence => image.sequence, :inline_id => self.id, :file_ext => "jpg") + size = reduce_and_crop(image.width, image.height, params) + + begin + # Create one crop for the image, and InlineImage will create the sample and preview from that. + Danbooru.resize(image.file_ext, image.file_path, new_image.tempfile_image_path, size, 95) + FileUtils.chmod(0775, new_image.tempfile_image_path) + rescue Exception => x + FileUtils.rm_f(new_image.tempfile_image_path) + + errors.add "crop", "couldn't be generated (#{x})" + return false + end + + new_image.got_file + new_image.save! + image.destroy + end + end + + def api_attributes + return { + :id => id, + :description => description, + :user_id => user_id, + :images => inline_images + } + end + + def to_json(*params) + api_attributes.to_json(*params) + end +end + diff --git a/app/models/inline_image.rb b/app/models/inline_image.rb new file mode 100644 index 00000000..c3da931c --- /dev/null +++ b/app/models/inline_image.rb @@ -0,0 +1,327 @@ +require "fileutils" + +# InlineImages can be uploaded, copied directly from posts, or cropped from other InlineImages. +# To create an image by cropping a post, the post must be copied to an InlineImage of its own, +# and cropped from there; the only UI for cropping is InlineImage->InlineImage. +# +# InlineImages can be posted directly in the forum and wiki (and possibly comments). +# +# An inline image can have three versions, like a post. For consistency, they use the +# same names: image, sample, preview. As with posts, sample and previews are always JPEG, +# and the dimensions of preview is derived from image rather than stored. +# +# Image files are effectively garbage collected: InlineImages can share files, and the file +# is deleted when the last one using it is deleted. This allows any user to copy another user's +# InlineImage, to crop it or to include it in an Inline. +# +# Example use cases: +# +# - Plain inlining, eg. for tutorials. Thumbs and larger images can be shown inline, allowing +# a click to expand. +# - Showing edits. Each user can upload his edit as an InlineImage and post it directly +# into the forum. +# - Comparing edits. A user can upload his own edit, pair it with another version (using +# Inline), crop to a region of interest, and post that inline. The images can then be +# compared in-place. This can be used to clearly show editing problems and differences. + +class InlineImage < ActiveRecord::Base + belongs_to :inline + before_validation_on_create :download_source + before_validation_on_create :determine_content_type + before_validation_on_create :set_image_dimensions + before_validation_on_create :generate_sample + before_validation_on_create :generate_preview + before_validation_on_create :move_file + before_validation_on_create :set_default_sequence + after_destroy :delete_file + before_create :validate_uniqueness + + def tempfile_image_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}.upload" + end + + def tempfile_sample_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}-sample.upload" + end + + def tempfile_preview_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}-preview.upload" + end + + attr_accessor :source + attr_accessor :received_file + attr_accessor :file_needs_move + def post_id=(id) + post = Post.find_by_id(id) + file = post.file_path + + FileUtils.ln_s(file.local_path, tempfile_image_path) + + self.received_file = true + self.md5 = post.md5 + end + + # Call once a file is available in tempfile_image_path. + def got_file + generate_hash(tempfile_image_path) + FileUtils.chmod(0775, self.tempfile_image_path) + self.file_needs_move = true + self.received_file = true + end + + def file=(f) + return if f.nil? || f.size == 0 + + if f.local_path + FileUtils.cp(f.local_path, tempfile_image_path) + else + File.open(tempfile_image_path, 'wb') {|nf| nf.write(f.read)} + end + + got_file + end + + def download_source + return if source !~ /^http:\/\// || !file_ext.blank? + return if received_file + + begin + Danbooru.http_get_streaming(source) do |response| + File.open(tempfile_image_path, "wb") do |out| + response.read_body do |block| + out.write(block) + end + end + end + got_file + + return true + rescue SocketError, URI::Error, Timeout::Error, SystemCallError => x + delete_tempfile + errors.add "source", "couldn't be opened: #{x}" + return false + end + end + + def determine_content_type + return true if self.file_ext + + if not File.exists?(tempfile_image_path) + errors.add_to_base("No file received") + return false + end + + imgsize = ImageSize.new(File.open(tempfile_image_path, "rb")) + + unless imgsize.get_width.nil? + self.file_ext = imgsize.get_type.gsub(/JPEG/, "JPG").downcase + end + + unless %w(jpg png gif).include?(file_ext.downcase) + errors.add(:file, "is an invalid content type: " + (file_ext.downcase or "unknown")) + return false + end + + return true + end + + def set_image_dimensions + return true if self.width and self.height + imgsize = ImageSize.new(File.open(tempfile_image_path, "rb")) + self.width = imgsize.get_width + self.height = imgsize.get_height + + return true + end + + def preview_dimensions + return Danbooru.reduce_to({:width => width, :height => height}, {:width => 150, :height => 150}) + end + + def thumb_size + size = Danbooru.reduce_to({:width => width, :height => height}, {:width => 400, :height => 400}) + end + + def generate_sample + return true if File.exists?(sample_path) + + # We can generate the sample image during upload or offline. Use tempfile_image_path + # if it exists, otherwise use file_path. + path = tempfile_image_path + path = file_path unless File.exists?(path) + unless File.exists?(path) + errors.add(:file, "not found") + return false + end + + # If we're not reducing the resolution for the sample image, only reencode if the + # source image is above the reencode threshold. Anything smaller won't be reduced + # enough by the reencode to bother, so don't reencode it and save disk space. + sample_size = Danbooru.reduce_to({:width => width, :height => height}, {:width => CONFIG["inline_sample_width"], :height => CONFIG["inline_sample_height"]}) + if sample_size[:width] == width && sample_size[:height] == height && File.size?(path) < CONFIG["sample_always_generate_size"] + return true + end + + # If we already have a sample image, and the parameters havn't changed, + # don't regenerate it. + if sample_size[:width] == sample_width && sample_size[:height] == sample_height + return true + end + + begin + Danbooru.resize(file_ext, path, tempfile_sample_path, sample_size, 95) + rescue Exception => x + errors.add "sample", "couldn't be created: #{x}" + return false + end + + self.sample_width = sample_size[:width] + self.sample_height = sample_size[:height] + return true + end + + def generate_preview + return true if File.exists?(preview_path) + + unless File.exists?(tempfile_image_path) + errors.add(:file, "not found") + return false + end + + # Generate the preview from the new sample if we have one to save CPU, otherwise from the image. + if File.exists?(tempfile_sample_path) + path, ext = tempfile_sample_path, "jpg" + else + path, ext = tempfile_image_path, file_ext + end + + begin + Danbooru.resize(ext, path, tempfile_preview_path, preview_dimensions, 95) + rescue Exception => x + errors.add "preview", "couldn't be generated (#{x})" + return false + end + return true + end + + def move_file + return true if not file_needs_move + FileUtils.mv(tempfile_image_path, file_path) + + if File.exists?(tempfile_preview_path) + FileUtils.mv(tempfile_preview_path, preview_path) + end + if File.exists?(tempfile_sample_path) + FileUtils.mv(tempfile_sample_path, sample_path) + end + self.file_needs_move = false + return true + end + + def set_default_sequence + return if not self.sequence.nil? + siblings = self.inline.inline_images + max_sequence = siblings.map { |image| image.sequence }.max + max_sequence ||= 0 + self.sequence = max_sequence + 1 + end + + def generate_hash(path) + md5_obj = Digest::MD5.new + File.open(path, 'rb') { |fp| + buf = "" + while fp.read(1024*64, buf) do md5_obj << buf end + } + + self.md5 = md5_obj.hexdigest + end + + def has_sample? + return (not self.sample_height.nil?) + end + + def file_name + "#{md5}.#{file_ext}" + end + + def file_name_jpg + "#{md5}.jpg" + end + + def file_path + "#{RAILS_ROOT}/public/data/inline/image/#{file_name}" + end + + def preview_path + "#{RAILS_ROOT}/public/data/inline/preview/#{file_name_jpg}" + end + + def sample_path + "#{RAILS_ROOT}/public/data/inline/sample/#{file_name_jpg}" + end + + def file_url + CONFIG["url_base"] + "/data/inline/image/#{file_name}" + end + + def sample_url + if self.has_sample? + return CONFIG["url_base"] + "/data/inline/sample/#{file_name_jpg}" + else + return file_url + end + end + + def preview_url + CONFIG["url_base"] + "/data/inline/preview/#{file_name_jpg}" + end + + def delete_file + # If several inlines use the same image, they'll share the same file via the MD5. Only + # delete the file if this is the last one using it. + exists = InlineImage.find(:first, :conditions => ["id <> ? AND md5 = ?", self.id, self.md5]) + return if not exists.nil? + + FileUtils.rm_f(file_path) + FileUtils.rm_f(preview_path) + FileUtils.rm_f(sample_path) + end + + # We should be able to use validates_uniqueness_of for this, but Rails is completely + # brain-damaged: it only lets you specify an error message that starts with the name + # of the column, capitalized, so if we say "foo", the message is "Md5 foo". This is + # useless. + def validate_uniqueness + siblings = self.inline.inline_images + for s in siblings do + next if s.id == self + if s.md5 == self.md5 + errors.add_to_base("##{s.sequence} already exists.") + return false + end + end + return true + end + + def api_attributes + return { + :id => id, + :sequence => sequence, + :md5 => md5, + :width => width, + :height => height, + :sample_width => sample_width, + :sample_height => sample_height, + :preview_width => preview_dimensions[:width], + :preview_height => preview_dimensions[:height], + :description => description, + :file_url => file_url, + :sample_url => sample_url, + :preview_url => preview_url, + } + end + + def to_json(*params) + api_attributes.to_json(*params) + end +end diff --git a/app/models/ip_bans.rb b/app/models/ip_bans.rb new file mode 100644 index 00000000..c6dfd626 --- /dev/null +++ b/app/models/ip_bans.rb @@ -0,0 +1,18 @@ +class IpBans < ActiveRecord::Base + belongs_to :user, :foreign_key => :banned_by + + def duration=(dur) + if not dur or dur == "" then + self.expires_at = nil + @duration = nil + else + self.expires_at = (dur.to_f * 60*60*24).seconds.from_now + @duration = dur + end + end + + def duration + @duration + end +end + diff --git a/app/models/job_task.rb b/app/models/job_task.rb new file mode 100644 index 00000000..5293986c --- /dev/null +++ b/app/models/job_task.rb @@ -0,0 +1,190 @@ +class JobTask < ActiveRecord::Base + TASK_TYPES = %w(mass_tag_edit approve_tag_alias approve_tag_implication calculate_favorite_tags upload_posts_to_mirrors periodic_maintenance) + STATUSES = %w(pending processing finished error) + + validates_inclusion_of :task_type, :in => TASK_TYPES + validates_inclusion_of :status, :in => STATUSES + + def data + JSON.parse(data_as_json) + end + + def data=(hoge) + self.data_as_json = hoge.to_json + end + + def execute! + if repeat_count > 0 + count = repeat_count - 1 + else + count = repeat_count + end + + begin + execute_sql("SET statement_timeout = 0") + update_attributes(:status => "processing") + __send__("execute_#{task_type}") + + if count == 0 + update_attributes(:status => "finished") + else + update_attributes(:status => "pending", :repeat_count => count) + end + rescue SystemExit => x + update_attributes(:status => "pending") + raise x + rescue Exception => x + update_attributes(:status => "error", :status_message => "#{x.class}: #{x}") + end + end + + def execute_mass_tag_edit + start_tags = data["start_tags"] + result_tags = data["result_tags"] + updater_id = data["updater_id"] + updater_ip_addr = data["updater_ip_addr"] + Tag.mass_edit(start_tags, result_tags, updater_id, updater_ip_addr) + end + + def execute_approve_tag_alias + ta = TagAlias.find(data["id"]) + updater_id = data["updater_id"] + updater_ip_addr = data["updater_ip_addr"] + ta.approve(updater_id, updater_ip_addr) + end + + def execute_approve_tag_implication + ti = TagImplication.find(data["id"]) + updater_id = data["updater_id"] + updater_ip_addr = data["updater_ip_addr"] + ti.approve(updater_id, updater_ip_addr) + end + + def execute_calculate_favorite_tags + return if Cache.get("delay-favtags-calc") + + last_processed_post_id = data["last_processed_post_id"].to_i + + if last_processed_post_id == 0 + last_processed_post_id = Post.maximum("id").to_i + end + + Cache.put("delay-favtags-calc", "1", 10.minutes) + FavoriteTag.process_all(last_processed_post_id) + update_attributes(:data => {"last_processed_post_id" => Post.maximum("id")}) + end + + def update_data(*args) + hash = data.merge(args[0]) + update_attributes(:data => hash) + end + + def execute_periodic_maintenance + return if data["next_run"] && data["next_run"] > Time.now.to_i + + update_data("step" => "recalculating post count") + Post.recalculate_row_count + update_data("step" => "recalculating tag post counts") + Tag.recalculate_post_count + update_data("step" => "purging old tags") + Tag.purge_tags + + update_data("next_run" => Time.now.to_i + 60*60*6, "step" => nil) + end + + def execute_upload_posts_to_mirrors + # This is a little counterintuitive: if we're backlogged, mirror newer posts first, + # since they're the ones that receive the most attention. Mirror held posts after + # unheld posts. + # + # Apply a limit, so if we're backlogged heavily, we'll only upload a few posts and + # then give other jobs a chance to run. + data = {} + (1..10).each do + post = Post.find(:first, :conditions => ["NOT is_warehoused AND status <> 'deleted'"], :order => "is_held ASC, index_timestamp DESC") + break if not post + + data["left"] = Post.count(:conditions => ["NOT is_warehoused AND status <> 'deleted'"]) + data["post_id"] = post.id + update_attributes(:data => data) + + begin + post.upload_to_mirrors + ensure + data["post_id"] = nil + update_attributes(:data => data) + end + + data["left"] = Post.count(:conditions => ["NOT is_warehoused AND status <> 'deleted'"]) + update_attributes(:data => data) + end + end + + def pretty_data + case task_type + when "mass_tag_edit" + start = data["start_tags"] + result = data["result_tags"] + user = User.find_name(data["updater_id"]) + + "start:#{start} result:#{result} user:#{user}" + + when "approve_tag_alias" + ta = TagAlias.find(data["id"]) + "start:#{ta.name} result:#{ta.alias_name}" + + when "approve_tag_implication" + ti = TagImplication.find(data["id"]) + "start:#{ti.predicate.name} result:#{ti.consequent.name}" + + when "calculate_favorite_tags" + "post_id:#{data['last_processed_post_id']}" + + when "upload_posts_to_mirrors" + ret = "" + if data["post_id"] + ret << "uploading post_id #{data["post_id"]}" + elsif data["left"] + ret << "sleeping" + else + ret << "idle" + end + ret << (" (%i left) " % data["left"]) if data["left"] + ret + + when "periodic_maintenance" + if status == "processing" then + data["step"] + elsif status != "error" then + next_run = (data["next_run"] or 0) - Time.now.to_i + next_run_in_minutes = next_run.to_i / 60 + if next_run_in_minutes > 0 + eta = "next run in #{(next_run_in_minutes.to_f / 60.0).round} hours" + else + eta = "next run imminent" + end + "sleeping (#{eta})" + end + end + end + + def self.execute_once + find(:all, :conditions => ["status = ?", "pending"], :order => "id desc").each do |task| + task.execute! + sleep 1 + end + end + + def self.execute_all + # If we were interrupted without finishing a task, it may be left in processing; reset + # thos tasks to pending. + find(:all, :conditions => ["status = ?", "processing"]).each do |task| + task.update_attributes(:status => "pending") + end + + while true + execute_once + sleep 10 + end + end +end diff --git a/app/models/note.rb b/app/models/note.rb new file mode 100644 index 00000000..6811e469 --- /dev/null +++ b/app/models/note.rb @@ -0,0 +1,82 @@ +class Note < ActiveRecord::Base + include ActiveRecord::Acts::Versioned + + belongs_to :post + before_save :blank_body + acts_as_versioned :order => "updated_at DESC" + after_save :update_post + + module LockMethods + def self.included(m) + m.validate :post_must_not_be_note_locked + end + + def post_must_not_be_note_locked + if is_locked? + errors.add_to_base "Post is note locked" + return false + end + end + + def is_locked? + if select_value_sql("SELECT 1 FROM posts WHERE id = ? AND is_note_locked = ?", post_id, true) + return true + else + return false + end + end + end + + module ApiMethods + def api_attributes + return { + :id => id, + :created_at => created_at, + :updated_at => updated_at, + :creator_id => user_id, + :x => x, + :y => y, + :width => width, + :height => height, + :is_active => is_active, + :post_id => post_id, + :body => body, + :version => version + } + end + + def to_xml(options = {}) + api_attributes.to_xml(options.merge(:root => "note")) + end + + def to_json(*args) + return api_attributes.to_json(*args) + end + end + + include LockMethods + include ApiMethods + + def blank_body + self.body = "(empty)" if body.blank? + end + + # TODO: move this to a helper + def formatted_body + body.gsub(/(.+?)<\/tn>/m, '

    \1

    ').gsub(/\n/, '
    ') + end + + def update_post + active_notes = select_value_sql("SELECT 1 FROM notes WHERE is_active = ? AND post_id = ? LIMIT 1", true, post_id) + + if active_notes + execute_sql("UPDATE posts SET last_noted_at = ? WHERE id = ?", updated_at, post_id) + else + execute_sql("UPDATE posts SET last_noted_at = ? WHERE id = ?", nil, post_id) + end + end + + def author + User.find_name(user_id) + end +end diff --git a/app/models/note_version.rb b/app/models/note_version.rb new file mode 100644 index 00000000..ac3af6de --- /dev/null +++ b/app/models/note_version.rb @@ -0,0 +1,13 @@ +class NoteVersion < ActiveRecord::Base + def to_xml(options = {}) + {:created_at => created_at, :updated_at => updated_at, :creator_id => user_id, :x => x, :y => y, :width => width, :height => height, :is_active => is_active, :post_id => post_id, :body => body, :version => version}.to_xml(options.merge(:root => "note_version")) + end + + def to_json(*args) + {:created_at => created_at, :updated_at => updated_at, :creator_id => user_id, :x => x, :y => y, :width => width, :height => height, :is_active => is_active, :post_id => post_id, :body => body, :version => version}.to_json(*args) + end + + def author + User.find_name(user_id) + end +end diff --git a/app/models/pool.rb b/app/models/pool.rb new file mode 100644 index 00000000..01d76c6e --- /dev/null +++ b/app/models/pool.rb @@ -0,0 +1,391 @@ +require 'mirror' +require "erb" +include ERB::Util + +class Pool < ActiveRecord::Base + belongs_to :user + + class PostAlreadyExistsError < Exception + end + + class AccessDeniedError < Exception + end + + module PostMethods + def self.included(m) + # Prefer child posts (the posts that were actually added to the pool). This is what's displayed + # when editing the pool. + m.has_many :pool_posts, :class_name => "PoolPost", :order => "nat_sort(sequence), post_id", :conditions => "pools_posts.active = true" + + # Prefer parent posts (the parents of posts that were added to the pool). This is what's displayed by + # default in post/show. + m.has_many :pool_parent_posts, :class_name => "PoolPost", :order => "nat_sort(sequence), post_id", + :conditions => "(pools_posts.active = true AND pools_posts.slave_id IS NULL) OR pools_posts.master_id IS NOT NULL" + m.has_many :all_pool_posts, :class_name => "PoolPost", :order => "nat_sort(sequence), post_id" + m.versioned :name + m.versioned :description, :default => "" + m.versioned :is_public, :default => true + m.versioned :is_active, :default => true + m.after_undo :update_pool_links + end + + def can_be_updated_by?(user) + is_public? || user.has_permission?(self) + end + + def add_post(post_id, options = {}) + transaction do + if options[:user] && !can_be_updated_by?(options[:user]) + raise AccessDeniedError + end + + seq = options[:sequence] || next_sequence + + pool_post = all_pool_posts.find(:first, :conditions => ["post_id = ?", post_id]) + if pool_post + raise PostAlreadyExistsError if pool_post.active + pool_post.active = true + pool_post.sequence = seq + pool_post.save! + else + PoolPost.create(:pool_id => id, :post_id => post_id, :sequence => seq) + end + + increment!(:post_count) + + unless options[:skip_update_pool_links] + self.reload + update_pool_links + end + end + end + + def remove_post(post_id, options = {}) + transaction do + if options[:user] && !can_be_updated_by?(options[:user]) + raise AccessDeniedError + end + + pool_post = pool_posts.find(:first, :conditions => ["post_id = ?", post_id]) + if pool_post then + pool_post.active = false + pool_post.save! + self.reload + decrement!(:post_count) + update_pool_links + end + end + end + + def get_sample + # By preference, pick the first post (by sequence) in the pool that isn't hidden from + # the index. + PoolPost.find(:all, :order => "posts.is_shown_in_index DESC, nat_sort(pools_posts.sequence), pools_posts.post_id", + :joins => "JOIN posts ON posts.id = pools_posts.post_id", + :conditions => ["pool_id = ? AND posts.status = 'active' AND pools_posts.active", self.id]).each { |pool_post| + return pool_post.post if pool_post.post.can_be_seen_by?(Thread.current["danbooru-user"]) + } + return rescue nil + end + + def can_change_is_public?(user) + user.has_permission?(self) + end + + def has_originals? + pool_posts.each { |pp| return true if pp.slave_id } + return false + end + + def can_change?(user, attribute) + return false if not user.is_member_or_higher? + return is_public? || user.has_permission?(self) + end + + def update_pool_links + transaction do + pp = pool_parent_posts(true) # force reload + pp.each_index do |i| + pp[i].next_post_id = nil + pp[i].prev_post_id = nil + pp[i].next_post_id = pp[i + 1].post_id unless i == pp.size - 1 + pp[i].prev_post_id = pp[i - 1].post_id unless i == 0 + pp[i].save + end + end + end + + def next_sequence + seq = 0 + pool_posts.find(:all, :select => "sequence", :order => "sequence DESC").each { |pp| + seq = [seq, pp.sequence.to_i].max + } + + return seq + 1 + end + end + + module ApiMethods + def api_attributes + return { + :id => id, + :name => name, + :created_at => created_at, + :updated_at => updated_at, + :user_id => user_id, + :is_public => is_public, + :post_count => post_count, + } + end + + def to_json(*params) + api_attributes.to_json(*params) + end + + def to_xml(options = {}) + options[:indent] ||= 2 + xml = options[:builder] ||= Builder::XmlMarkup.new(:indent => options[:indent]) + xml.pool(api_attributes) do + xml.description(description) + yield options[:builder] if block_given? + end + end + end + + module NameMethods + module ClassMethods + def find_by_name(name) + if name =~ /^\d+$/ + find_by_id(name) + else + find(:first, :conditions => ["lower(name) = lower(?)", name]) + end + end + end + + def self.included(m) + m.extend(ClassMethods) + m.validates_uniqueness_of :name + m.before_validation :normalize_name + end + + def normalize_name + self.name = name.gsub(/\s/, "_") + end + + def pretty_name + name.tr("_", " ") + end + end + + module ZipMethods + def get_zip_filename(options={}) + filename = pretty_name.gsub(/\?/, "") + filename += " (JPG)" if options[:jpeg] + filename += " (orig)" if options[:originals] + "#{filename}.zip" + end + + # Return true if any posts in this pool have a generated JPEG version. + def has_jpeg_zip?(options={}) + posts = options[:originals] ? pool_posts : pool_parent_posts + posts.each do |pool_post| + post = pool_post.post + return true if post.has_jpeg? + end + return false + end + + def get_zip_url(control_path, options={}) + url = Mirrors.select_image_server(self.zip_is_warehoused, self.zip_created_at.to_i, :zipfile => true) + url += "/data/zips/#{File.basename(control_path)}" + + # Adds the pretty filename to the end. This is ignored by lighttpd. + url += "/#{url_encode(get_zip_filename(options))}" + return url + end + + # Estimate the size of the ZIP. + def get_zip_size(options={}) + sum = 0 + posts = options[:originals] ? pool_posts : pool_parent_posts + posts.each do |pool_post| + post = pool_post.post + next if post.status == 'deleted' + sum += options[:jpeg] && post.has_jpeg? ? post.jpeg_size : post.file_size + end + + return sum + end + + def get_zip_control_file_path_for_time(time, options={}) + jpeg = options[:jpeg] || false + originals = options[:originals] || false + + # If this pool has a JPEG version, name the normal version "png". Otherwise, name it + # "normal". This only affects the URL used to access the file, so the frontend can + # match it for QOS purposes; it doesn't affect the downloaded pool's filename. + if jpeg + type = "jpeg" + elsif has_jpeg_zip?(options) then + type = "png" + else + type = "normal" + end + + "#{RAILS_ROOT}/public/data/zips/%s-pool-%08i-%i%s" % [type, self.id, time.to_i, originals ? "-orig":""] + end + + def all_posts_in_zip_are_warehoused?(options={}) + posts = options[:originals] ? pool_posts : pool_parent_posts + posts.each do |pool_post| + post = pool_post.post + next if post.status == 'deleted' + return false if not post.is_warehoused? + end + return true + end + + # Generate a mod_zipfile control file for this pool. + def get_zip_control_file(options={}) + return "" if pool_posts.empty? + + jpeg = options[:jpeg] || false + originals = options[:originals] || false + + buf = "" + + # Pad sequence numbers in filenames to the longest sequence number. Ignore any text + # after the sequence for padding; for example, if we have 1, 5, 10a and 12, then pad + # to 2 digits. + + # Always pad to at least 3 digits. + max_sequence_digits = 3 + pool_posts.each do |pool_post| + filtered_sequence = pool_post.sequence.gsub(/^([0-9]+(-[0-9]+)?)?.*/, '\1') # 45a -> 45 + filtered_sequence.split(/-/).each { |p| + max_sequence_digits = [p.length, max_sequence_digits].max + } + end + + filename_count = {} + posts = originals ? pool_posts : pool_parent_posts + posts.each do |pool_post| + post = pool_post.post + next if post.status == 'deleted' + + # Strip RAILS_ROOT/public off the file path, so the paths are relative to document-root. + if jpeg && post.has_jpeg? + path = post.jpeg_path + file_ext = "jpg" + else + path = post.file_path + file_ext = post.file_ext + end + path = path[(RAILS_ROOT + "/public").length .. path.length] + + # For padding filenames, break numbers apart on hyphens and pad each part. For + # example, if max_sequence_digits is 3, and we have "88-89", pad it to "088-089". + filename = pool_post.sequence.gsub(/^([0-9]+(-[0-9]+)*)(.*)$/) { |m| + if $1 != "" + suffix = $3 + numbers = $1.split(/-/).map { |p| + "%0*i" % [max_sequence_digits, p.to_i] + }.join("-") + "%s%s" % [numbers, suffix] + else + "%s" % [$3] + end + } + + #filename = "%0*i" % [max_sequence_digits, pool_post.sequence] + + # Avoid duplicate filenames. + filename_count[filename] ||= 0 + filename_count[filename] = filename_count[filename] + 1 + if filename_count[filename] > 1 + filename << " (%i)" % [filename_count[filename]] + end + filename << ".%s" % [file_ext] + + buf << "#{filename}\n" + buf << "#{path}\n" + if jpeg && post.has_jpeg? + buf << "#{post.jpeg_size}\n" + buf << "#{post.jpeg_crc32}\n" + else + buf << "#{post.file_size}\n" + buf << "#{post.crc32}\n" + end + end + + return buf + end + + def get_zip_control_file_path(options = {}) + control_file = self.get_zip_control_file(options) + + # The latest pool ZIP we generated is stored in pool.zip_created_at. If that ZIP + # control file still exists, compare it against the control file we just generated, + # and reuse it if it hasn't changed. + control_path_time = Time.now + control_path = self.get_zip_control_file_path_for_time(control_path_time, options) + reuse_old_control_file = false + if self.zip_created_at then + old_path = self.get_zip_control_file_path_for_time(self.zip_created_at, options) + begin + old_control_file = File.open(old_path).read + + if control_file == old_control_file + reuse_old_control_file = true + control_path = old_path + control_path_time = self.zip_created_at + end + rescue SystemCallError => e + end + end + + if not reuse_old_control_file then + control_path_temp = control_path + ".temp" + File.open(control_path_temp, 'w+') do |fp| + fp.write(control_file) + end + + FileUtils.mv(control_path_temp, control_path) + + # Only after we've attempted to mirror the control file, update self.zip_created_at. + self.update_attributes(:zip_created_at => control_path_time, :zip_is_warehoused => false) + end + + if !self.zip_is_warehoused && all_posts_in_zip_are_warehoused?(options) + delay = ServerKey.find(:first, :conditions => ["name = 'delay-mirrors-down'"]) + if delay.nil? + delay = ServerKey.create(:name => "delay-mirrors-down", :value => 0) + end + if delay.value.to_i < Time.now.to_i + # Send the control file to all mirrors, if we have any. + begin + # This is being done interactively, so use a low timeout. + Mirrors.copy_file_to_mirrors(control_path, :timeout => 5) + self.update_attributes(:zip_is_warehoused => true) + rescue Mirrors::MirrorError => e + # If mirroring is failing, disable it for a while. It might be timing out, and this + # will make the UI unresponsive. + delay.update_attributes!(:value => Time.now.to_i + 60*60) + ActiveRecord::Base.logger.error("Error warehousing ZIP control file: #{e}") + end + end + end + + return control_path + end + end + + include PostMethods + include ApiMethods + include NameMethods + if CONFIG["pool_zips"] + include ZipMethods + end +end + diff --git a/app/models/pool_post.rb b/app/models/pool_post.rb new file mode 100644 index 00000000..208d5478 --- /dev/null +++ b/app/models/pool_post.rb @@ -0,0 +1,172 @@ +class PoolPost < ActiveRecord::Base + set_table_name "pools_posts" + belongs_to :post + belongs_to :pool + versioned_parent :pool + versioning_display :class => :pool + versioned :active, :default => 'f', :allow_reverting_to_default => true + versioned :sequence + before_save :update_pool + + def can_change_is_public?(user) + return user.has_permission?(pool) # only the owner can change is_public + end + + def can_change?(user, attribute) + return false if not user.is_member_or_higher? + return pool.is_public? || user.has_permission?(pool) + end + + def pretty_sequence + if sequence =~ /^[0-9]+.*/ + return "##{sequence}" + else + return "\"#{sequence}\"" + end + end + + def update_pool + # Implicit posts never affect the post count, because we always show either the + # parent or the child posts in the index, but not both. + return if master_id + + if active_changed? then + if active then + pool.increment!(:post_count) + else + pool.decrement!(:post_count) + end + + pool.save! + end + end + + # A master pool_post is a post which was added explicitly to the pool whose post has + # a parent. A slave pool_post is a post which was added implicitly to the pool, because + # it has a child which was added to the pool. (Master/slave terminology is used because + # calling these parent and child becomes confusing with its close relationship to + # post parents.) + # + # The active flag is always false for an implicit slave post. Setting the active flag + # to true on a slave post means you're adding it explicitly, which will cause it to no + # longer be a slave post. This behavior cooperates well with history: simply setting + # and unsetting active are converse operations, regardless of whether a post is a slave + # or not. For example, if you have a parent and a child that are both explicitly in the + # pool, and you remove the parent (causing it to be added as a slave), this will register + # as a removal in history; undoing that history action will cause the active flag to be + # set to true again, which will undo as expected. + belongs_to :master, :class_name => "PoolPost", :foreign_key => "master_id" + belongs_to :slave, :class_name => "PoolPost", :foreign_key => "slave_id" + +protected + # Find a pool_post that can be a master post of pp: active, explicitly in this pool (not another + # slave), doesn't already have a master post, and has self.post as its post parent. + def self.find_master_pool_post(pp) + sql = <<-SQL + SELECT pp.* FROM posts p JOIN pools_posts pp ON (p.id = pp.post_id) + WHERE p.parent_id = #{pp.post_id} + AND pp.active + AND pp.pool_id = #{pp.pool_id} + AND pp.master_id IS NULL + AND pp.slave_id IS NULL + ORDER BY pp.id ASC + LIMIT 1 + SQL + new_master = PoolPost.find_by_sql([sql]) + + return nil if new_master.empty? + return new_master[0] + end + + # If our master post is no longer valid, by being deactivated or the post having + # its parent changed, unlink us from it. + def detach_stale_master + # If we already have no master, we have nothing to do. + return if not self.master + + # If our master has been deactivated, or we've been explicitly activated, or if our + # master is no longer our child, it's no longer a valid parent. + return if self.master.active && !self.active && self.master.post.parent_id == self.post_id + + self.master.slave_id = nil + self.master.save! + + self.master_id = nil + self.master = nil + self.save! + end + + def find_master_and_propagate + # If we have a master post, verify that it's still valid; if not, detach us from it. + detach_stale_master + + need_save = false + + # Don't set a slave if we already have a master or a slave, or if we're already active. + if !self.slave_id && !self.master_id && !self.active + new_master = PoolPost.find_master_pool_post(self) + if new_master + self.master_id = new_master.id + new_master.slave_id = self.id + new_master.save! + need_save = true + end + end + + # If we have a master, propagate changes from it to us. + if self.master + self.sequence = master.sequence + need_save = true if self.sequence_changed? + end + + self.save! if need_save + end + +public + # The specified post has had its parent changed. + def self.post_parent_changed(post) + PoolPost.find(:all, :conditions => ["post_id = ?", post.id]).each { |pp| + pp.need_slave_update = true + pp.copy_changes_to_slave + } + end + + # Since copy_changes_to_slave may call self.save, it needs to be run from + # post_save and not after_save. We need to know whether attributes have changed + # (so we don't run this unnecessarily), so that check needs to be done in after_save, + # while dirty flags are still set. + after_save :check_if_need_slave_update + post_save :copy_changes_to_slave + + attr_accessor :need_slave_update + def check_if_need_slave_update + self.need_slave_update = true if sequence_changed? || active_changed? + return true + end + + # After a PoolPost or its post changes, update master PoolPosts. + def copy_changes_to_slave + return true if !self.need_slave_update + self.need_slave_update = false + + # If our sequence changed, we need to copy that to our slave (if any), and if our + # active flag was turned off we need to detach from our slave. + post_to_update = self.slave + + if !post_to_update && self.active && self.post.parent_id + # We have no slave, but we have a parent post and we're active, so we might need to + # assign it. Make sure that a PoolPost exists for the parent. + post_to_update = PoolPost.find(:first, :conditions => {:pool_id => self.pool_id, :post_id => post.parent_id}) + if not post_to_update + post_to_update = PoolPost.create(:pool_id => self.pool_id, :post_id => post.parent_id, :active => false) + end + end + + post_to_update.find_master_and_propagate if post_to_update + + self.find_master_and_propagate + + return true + end +end + diff --git a/app/models/post.rb b/app/models/post.rb new file mode 100644 index 00000000..3b02b8ca --- /dev/null +++ b/app/models/post.rb @@ -0,0 +1,168 @@ +Dir["#{RAILS_ROOT}/app/models/post/**/*.rb"].each {|x| require_dependency x} + +class Post < ActiveRecord::Base + STATUSES = %w(active pending flagged deleted) + + define_callbacks :after_delete + define_callbacks :after_undelete + has_many :notes, :order => "id desc" + has_one :flag_detail, :class_name => "FlaggedPostDetail" + belongs_to :user + before_validation_on_create :set_random! + before_create :set_index_timestamp! + belongs_to :approver, :class_name => "User" + attr_accessor :updater_ip_addr, :updater_user_id + attr_accessor :metatag_flagged + has_many :avatars, :class_name => "User", :foreign_key => "avatar_post_id" + after_delete :clear_avatars + after_save :commit_flag + + include PostSqlMethods + include PostCommentMethods + include PostImageStoreMethods + include PostVoteMethods + include PostTagMethods + include PostCountMethods + include PostCacheMethods if CONFIG["enable_caching"] + include PostParentMethods if CONFIG["enable_parent_posts"] + include PostFileMethods + include PostChangeSequenceMethods + include PostRatingMethods + include PostStatusMethods + include PostApiMethods + include PostMirrorMethods + + def self.destroy_with_reason(id, reason, current_user) + post = Post.find(id) + post.flag!(reason, current_user.id) + if post.flag_detail + post.flag_detail.update_attributes(:is_resolved => true) + end + + post.delete + end + + def delete + self.update_attributes(:status => "deleted") + self.run_callbacks(:after_delete) + end + + def undelete + return if self.status == "active" + self.update_attributes(:status => "active") + self.run_callbacks(:after_undelete) + end + + def can_user_delete?(user) + if not user.has_permission?(self) + return false + end + + if not user.is_mod_or_higher? and Time.now - self.created_at > 1.day + return false + end + + return true + end + + def clear_avatars + User.clear_avatars(self.id) + end + + def set_random! + self.random = rand; + end + + def set_index_timestamp! + self.index_timestamp = self.created_at + end + + def flag!(reason, creator_id) + transaction do + update_attributes(:status => "flagged") + + if flag_detail + flag_detail.update_attributes(:reason => reason, :user_id => creator_id, :created_at => Time.now) + else + FlaggedPostDetail.create!(:post_id => id, :reason => reason, :user_id => creator_id, :is_resolved => false) + end + end + end + + # If the flag_post metatag was used and the current user has access, flag the post. + def commit_flag + return if self.metatag_flagged.nil? + return if not Thread.current["danbooru-user"].is_mod_or_higher? + return if self.status != "active" + + self.flag!(self.metatag_flagged, Thread.current["danbooru-user"].id) + end + + def approve!(approver_id) + if flag_detail + flag_detail.update_attributes(:is_resolved => true) + end + + update_attributes(:status => "active", :approver_id => approver_id) + end + + def voted_by + # Cache results + if @voted_by.nil? + @voted_by = {} + (1..3).each { |v| + @voted_by[v] = User.find(:all, :joins => "JOIN post_votes v ON v.user_id = users.id", :select => "users.name, users.id", :conditions => ["v.post_id = ? and v.score = ?", self.id, v], :order => "v.updated_at DESC") || [] + } + end + + return @voted_by + end + + def favorited_by + return voted_by[3] + end + + def author + return User.find_name(user_id) + end + + def delete_from_database + delete_file + execute_sql("DELETE FROM posts WHERE id = ?", id) + end + + def active_notes + notes.select {|x| x.is_active?} + end + + STATUSES.each do |x| + define_method("is_#{x}?") do + return status == x + end + end + + def can_be_seen_by?(user, options={}) + if not options[:show_deleted] and self.status == 'deleted' + return false + end + CONFIG["can_see_post"].call(user, self) + end + + def self.new_deleted?(user) + conds = [] + conds += ["creator_id <> %d" % [user.id]] unless user.is_anonymous? + + newest_topic = ForumPost.find(:first, :order => "id desc", :limit => 1, :select => "created_at", :conditions => conds) + return false if newest_topic == nil + return newest_topic.created_at > user.last_forum_topic_read_at + end + + def normalized_source + if source =~ /pixiv\.net\/img\// + img_id = source[/(\d+)\.\w+$/, 1] + "http://www.pixiv.net/member_illust.php?mode=medium&illust_id=#{img_id}" + else + source + end + end +end diff --git a/app/models/post/api_methods.rb b/app/models/post/api_methods.rb new file mode 100644 index 00000000..a1006f22 --- /dev/null +++ b/app/models/post/api_methods.rb @@ -0,0 +1,43 @@ +module PostApiMethods + def api_attributes + return { + :id => id, + :tags => cached_tags, + :created_at => created_at, + :creator_id => user_id, + :author => author, + :change => change_seq, + :source => source, + :score => score, + :md5 => md5, + :file_size => file_size, + :file_url => file_url, + :is_shown_in_index => is_shown_in_index, + :preview_url => preview_url, + :preview_width => preview_dimensions[0], + :preview_height => preview_dimensions[1], + :sample_url => sample_url, + :sample_width => sample_width || width, + :sample_height => sample_height || height, + :sample_file_size => sample_size, + :jpeg_url => jpeg_url, + :jpeg_width => jpeg_width || width, + :jpeg_height => jpeg_height || height, + :jpeg_file_size => jpeg_size, + :rating => rating, + :has_children => has_children, + :parent_id => parent_id, + :status => status, + :width => width, + :height => height + } + end + + def to_json(*args) + return api_attributes.to_json(*args) + end + + def to_xml(options = {}) + return api_attributes.to_xml(options.merge(:root => "post")) + end +end diff --git a/app/models/post/cache_methods.rb b/app/models/post/cache_methods.rb new file mode 100644 index 00000000..f85a0031 --- /dev/null +++ b/app/models/post/cache_methods.rb @@ -0,0 +1,12 @@ +module PostCacheMethods + def self.included(m) + m.after_save :expire_cache + m.after_destroy :expire_cache + end + + def expire_cache + # Have to call this twice in order to expire tags that may have been removed + Cache.expire(:tags => old_cached_tags) if old_cached_tags + Cache.expire(:tags => cached_tags) + end +end diff --git a/app/models/post/change_sequence_methods.rb b/app/models/post/change_sequence_methods.rb new file mode 100644 index 00000000..60d10a8c --- /dev/null +++ b/app/models/post/change_sequence_methods.rb @@ -0,0 +1,18 @@ +module PostChangeSequenceMethods + attr_accessor :increment_change_seq + + def self.included(m) + m.before_create :touch_change_seq! + m.after_save :update_change_seq + end + + def touch_change_seq! + self.increment_change_seq = true + end + + def update_change_seq + return if increment_change_seq.nil? + execute_sql("UPDATE posts SET change_seq = nextval('post_change_seq') WHERE id = ?", id) + self.change_seq = select_value_sql("SELECT change_seq FROM posts WHERE id = ?", id) + end +end diff --git a/app/models/post/comment_methods.rb b/app/models/post/comment_methods.rb new file mode 100644 index 00000000..d58101cc --- /dev/null +++ b/app/models/post/comment_methods.rb @@ -0,0 +1,9 @@ +module PostCommentMethods + def self.included(m) + m.has_many :comments, :order => "id" + end + + def recent_comments + Comment.find(:all, :conditions => ["post_id = ?", id], :order => "id desc", :limit => 6).reverse + end +end diff --git a/app/models/post/count_methods.rb b/app/models/post/count_methods.rb new file mode 100644 index 00000000..bd910427 --- /dev/null +++ b/app/models/post/count_methods.rb @@ -0,0 +1,49 @@ +module PostCountMethods + module ClassMethods + def fast_count(tags = nil) + cache_version = Cache.get("$cache_version").to_i + key = "post-count/v=#{cache_version}/#{tags}" + + # memcached protocol is dumb so we need to escape spaces + key = key.gsub(/-/, "--").gsub(/ /, "-_") + + count = Cache.get(key) { + Post.count_by_sql(Post.generate_sql(tags, :count => true)) + }.to_i + + return count + + # This is just too brittle, and hard to make work with other features that may + # hide posts from the index. +# if tags.blank? +# return select_value_sql("SELECT row_count FROM table_data WHERE name = 'posts'").to_i +# else +# c = select_value_sql("SELECT post_count FROM tags WHERE name = ?", tags).to_i +# if c == 0 +# return Post.count_by_sql(Post.generate_sql(tags, :count => true)) +# else +# return c +# end +# end + end + + def recalculate_row_count + execute_sql("UPDATE table_data SET row_count = (SELECT COUNT(*) FROM posts WHERE parent_id IS NULL AND status <> 'deleted') WHERE name = 'posts'") + end + end + + def self.included(m) + m.extend(ClassMethods) + m.after_create :increment_count + m.after_delete :decrement_count + m.after_undelete :increment_count + end + + def increment_count + execute_sql("UPDATE table_data SET row_count = row_count + 1 WHERE name = 'posts'") + end + + def decrement_count + execute_sql("UPDATE table_data SET row_count = row_count - 1 WHERE name = 'posts'") + end +end diff --git a/app/models/post/file_methods.rb b/app/models/post/file_methods.rb new file mode 100644 index 00000000..c389ccee --- /dev/null +++ b/app/models/post/file_methods.rb @@ -0,0 +1,470 @@ +require "download" +require "zlib" + +# These are methods dealing with getting the image and generating the thumbnail. +# It works in conjunction with the image_store methods. Since these methods have +# to be called in a specific order, they've been bundled into one module. +module PostFileMethods + def self.included(m) + m.before_validation_on_create :download_source + m.before_validation_on_create :ensure_tempfile_exists + m.before_validation_on_create :determine_content_type + m.before_validation_on_create :validate_content_type + m.before_validation_on_create :generate_hash + m.before_validation_on_create :set_image_dimensions + m.before_validation_on_create :generate_sample + m.before_validation_on_create :generate_jpeg + m.before_validation_on_create :generate_preview + m.before_validation_on_create :move_file + end + + def ensure_tempfile_exists + unless File.exists?(tempfile_path) + errors.add :file, "not found, try uploading again" + return false + end + end + + def validate_content_type + unless %w(jpg png gif swf).include?(file_ext.downcase) + errors.add(:file, "is an invalid content type: " + file_ext.downcase) + return false + end + end + + def pretty_file_name(options={}) + # Include the post number and tags. Don't include too many tags for posts that have too + # many of them. + options[:type] ||= :image + + # If the filename is too long, it might fail to save or lose the extension when saving. + # Cut it down as needed. Most tags on moe with lots of tags have lots of characters, + # and those tags are the least important (compared to tags like artists, circles, "fixme", + # etc). + # + # Prioritize tags: + # - remove artist and circle tags last; these are the most important + # - general tags can either be important ("fixme") or useless ("red hair") + # - remove character tags first; + + tags = Tag.compact_tags(self.cached_tags, 150) + if options[:type] == :sample then + tags = "sample" + end + + # Filter characters. + tags = tags.gsub(/[\/]/, "_") + + name = "#{self.id} #{tags}" + if CONFIG["download_filename_prefix"] != "" + name = CONFIG["download_filename_prefix"] + " " + name + end + + name + end + + def file_name + md5 + "." + file_ext + end + + def delete_tempfile + FileUtils.rm_f(tempfile_path) + FileUtils.rm_f(tempfile_preview_path) + FileUtils.rm_f(tempfile_sample_path) + FileUtils.rm_f(tempfile_jpeg_path) + end + + def tempfile_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}.upload" + end + + def tempfile_preview_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}-preview.jpg" + end + + # Generate MD5 and CRC32 hashes for the file. Do this before generating samples, so if this + # is a duplicate we'll notice before we spend time resizing the image. + def regenerate_hash + path = tempfile_path + if not File.exists?(path) + path = file_path + end + + if not File.exists?(path) + errors.add(:file, "not found") + return false + end + + # Compute both hashes in one pass. + md5_obj = Digest::MD5.new + crc32_accum = 0 + File.open(path, 'rb') { |fp| + buf = "" + while fp.read(1024*64, buf) do + md5_obj << buf + crc32_accum = Zlib.crc32(buf, crc32_accum) + end + } + + self.md5 = md5_obj.hexdigest + self.crc32 = crc32_accum + end + + def generate_hash + if not regenerate_hash + return false + end + + if Post.exists?(["md5 = ?", md5]) + delete_tempfile + errors.add "md5", "already exists" + return false + else + return true + end + end + + # Generate the specified image type. If options[:force_regen] is set, generate the file even + # if it already exists. + def regenerate_images(type, options = {}) + return true unless image? + + if type == :sample then + return false if not generate_sample(options[:force_regen]) + temp_path = tempfile_sample_path + dest_path = sample_path + elsif type == :jpeg then + return false if not generate_jpeg(options[:force_regen]) + temp_path = tempfile_jpeg_path + dest_path = jpeg_path + elsif type == :preview then + return false if not generate_preview + temp_path = tempfile_preview_path + dest_path = preview_path + else + raise Exception, "unknown type: %s" % type + end + + # Only move in the changed files on success. When we return false, the caller won't + # save us to the database; we need to only move the new files in if we're going to be + # saved. This is normally handled by move_file. + if File.exists?(temp_path) + FileUtils.mkdir_p(File.dirname(dest_path), :mode => 0775) + FileUtils.mv(temp_path, dest_path) + FileUtils.chmod(0775, dest_path) + end + + return true + end + + def generate_preview + return true unless image? && width && height + + size = Danbooru.reduce_to({:width=>width, :height=>height}, {:width=>150, :height=>150}) + + # Generate the preview from the new sample if we have one to save CPU, otherwise from the image. + if File.exists?(tempfile_sample_path) + path, ext = tempfile_sample_path, "jpg" + elsif File.exists?(sample_path) + path, ext = sample_path, "jpg" + elsif File.exists?(tempfile_path) + path, ext = tempfile_path, file_ext + elsif File.exists?(file_path) + path, ext = file_path, file_ext + else + errors.add(:file, "not found") + return false + end + + begin + Danbooru.resize(ext, path, tempfile_preview_path, size, 95) + rescue Exception => x + errors.add "preview", "couldn't be generated (#{x})" + return false + end + + return true + end + + # Automatically download from the source if it's a URL. + attr_accessor :received_file + def download_source + return if source !~ /^http:\/\// || !file_ext.blank? + return if received_file + + begin + Danbooru.http_get_streaming(source) do |response| + File.open(tempfile_path, "wb") do |out| + response.read_body do |block| + out.write(block) + end + end + end + + if self.source.to_s =~ /^http/ + #self.source = "Image board" + self.source = "" + end + + return true + rescue SocketError, URI::Error, Timeout::Error, SystemCallError => x + delete_tempfile + errors.add "source", "couldn't be opened: #{x}" + return false + end + end + + def determine_content_type + if not File.exists?(tempfile_path) + errors.add_to_base("No file received") + return false + end + + imgsize = ImageSize.new(File.open(tempfile_path, "rb")) + + unless imgsize.get_width.nil? + self.file_ext = imgsize.get_type.gsub(/JPEG/, "JPG").downcase + end + end + + # Assigns a CGI file to the post. This writes the file to disk and generates a unique file name. + def file=(f) + return if f.nil? || f.size == 0 + + if f.local_path + # Large files are stored in the temp directory, so instead of + # reading/rewriting through Ruby, just rely on system calls to + # copy the file to danbooru's directory. + FileUtils.cp(f.local_path, tempfile_path) + else + File.open(tempfile_path, 'wb') {|nf| nf.write(f.read)} + end + + self.received_file = true + end + + def set_image_dimensions + if image? or flash? + imgsize = ImageSize.new(File.open(tempfile_path, "rb")) + self.width = imgsize.get_width + self.height = imgsize.get_height + end + self.file_size = File.size(tempfile_path) rescue 0 + end + + # Returns true if the post is an image format that GD can handle. + def image? + %w(jpg jpeg gif png).include?(file_ext.downcase) + end + + # Returns true if the post is a Flash movie. + def flash? + file_ext == "swf" + end + + def find_ext(file_path) + ext = File.extname(file_path) + if ext.blank? + return "txt" + else + ext = ext[1..-1].downcase + ext = "jpg" if ext == "jpeg" + return ext + end + end + + def content_type_to_file_ext(content_type) + case content_type.chomp + when "image/jpeg" + return "jpg" + + when "image/gif" + return "gif" + + when "image/png" + return "png" + + when "application/x-shockwave-flash" + return "swf" + + else + nil + end + end + + def preview_dimensions + if image? + dim = Danbooru.reduce_to({:width => width, :height => height}, {:width => 150, :height => 150}) + return [dim[:width], dim[:height]] + else + return [150, 150] + end + end + + def tempfile_sample_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}-sample.jpg" + end + + def generate_sample(force_regen = false) + return true unless image? + return true unless CONFIG["image_samples"] + return true unless (width && height) + return true if (file_ext.downcase == "gif") + + size = Danbooru.reduce_to({:width => width, :height => height}, {:width => CONFIG["sample_width"], :height => CONFIG["sample_height"]}, CONFIG["sample_ratio"]) + + # We can generate the sample image during upload or offline. Use tempfile_path + # if it exists, otherwise use file_path. + path = tempfile_path + path = file_path unless File.exists?(path) + unless File.exists?(path) + errors.add(:file, "not found") + return false + end + + # If we're not reducing the resolution for the sample image, only reencode if the + # source image is above the reencode threshold. Anything smaller won't be reduced + # enough by the reencode to bother, so don't reencode it and save disk space. + if size[:width] == width && size[:height] == height && File.size?(path) < CONFIG["sample_always_generate_size"] + self.sample_width = nil + self.sample_height = nil + return true + end + + # If we already have a sample image, and the parameters havn't changed, + # don't regenerate it. + if !force_regen && (size[:width] == sample_width && size[:height] == sample_height) + return true + end + + size = Danbooru.reduce_to({:width => width, :height => height}, {:width => CONFIG["sample_width"], :height => CONFIG["sample_height"]}) + begin + Danbooru.resize(file_ext, path, tempfile_sample_path, size, CONFIG["sample_quality"]) + rescue Exception => x + errors.add "sample", "couldn't be created: #{x}" + return false + end + + self.sample_width = size[:width] + self.sample_height = size[:height] + self.sample_size = File.size(tempfile_sample_path) + + crc32_accum = 0 + File.open(tempfile_sample_path, 'rb') { |fp| + buf = "" + while fp.read(1024*64, buf) do + crc32_accum = Zlib.crc32(buf, crc32_accum) + end + } + self.sample_crc32 = crc32_accum + + return true + end + + # Returns true if the post has a sample image. + def has_sample? + sample_width.is_a?(Integer) + end + + # Returns true if the post has a sample image, and we're going to use it. + def use_sample?(user = nil) + if user && !user.show_samples? + false + else + CONFIG["image_samples"] && has_sample? + end + end + + def sample_url(user = nil) + if status != "deleted" && use_sample?(user) + store_sample_url + else + file_url + end + end + + def get_sample_width(user = nil) + if use_sample?(user) + sample_width + else + width + end + end + + def get_sample_height(user = nil) + if use_sample?(user) + sample_height + else + height + end + end + + def tempfile_jpeg_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}-jpeg.jpg" + end + + # If the JPEG version needs to be generated (or regenerated), output it to tempfile_jpeg_path. On + # error, return false; on success or no-op, return true. + def generate_jpeg(force_regen = false) + return true unless image? + return true unless CONFIG["jpeg_enable"] + return true unless (width && height) + # Only generate JPEGs for PNGs. Don't do it for files that are already JPEGs; we'll just add + # artifacts and/or make the file bigger. Don't do it for GIFs; they're usually animated. + return true if (file_ext.downcase != "png") + + # We can generate the image during upload or offline. Use tempfile_path + # if it exists, otherwise use file_path. + path = tempfile_path + path = file_path unless File.exists?(path) + unless File.exists?(path) + errors.add(:file, "not found") + return false + end + + # If we already have the image, don't regenerate it. + if !force_regen && jpeg_width.is_a?(Integer) + return true + end + + size = Danbooru.reduce_to({:width => width, :height => height}, {:width => CONFIG["jpeg_width"], :height => CONFIG["jpeg_height"]}, CONFIG["jpeg_ratio"]) + begin + Danbooru.resize(file_ext, path, tempfile_jpeg_path, size, CONFIG["jpeg_quality"]) + rescue Exception => x + errors.add "jpeg", "couldn't be created: #{x}" + return false + end + + self.jpeg_width = size[:width] + self.jpeg_height = size[:height] + self.jpeg_size = File.size(tempfile_jpeg_path) + + crc32_accum = 0 + File.open(tempfile_jpeg_path, 'rb') { |fp| + buf = "" + while fp.read(1024*64, buf) do + crc32_accum = Zlib.crc32(buf, crc32_accum) + end + } + self.jpeg_crc32 = crc32_accum + + return true + end + + def has_jpeg? + jpeg_width.is_a?(Integer) + end + + # Returns true if the post has a JPEG version, and we're going to use it. + def use_jpeg?(user = nil) + CONFIG["jpeg_enable"] && has_jpeg? + end + + def jpeg_url(user = nil) + if status != "deleted" && use_jpeg?(user) + store_jpeg_url + else + file_url + end + end +end diff --git a/app/models/post/g b/app/models/post/g new file mode 100644 index 00000000..20a7a166 --- /dev/null +++ b/app/models/post/g @@ -0,0 +1,312 @@ +require "download" + +# These are methods dealing with getting the image and generating the thumbnail. +# It works in conjunction with the image_store methods. Since these methods have +# to be called in a specific order, they've been bundled into one module. +module PostFileMethods + def self.included(m) + m.before_validation_on_create :download_source + m.before_validation_on_create :determine_content_type + m.before_validation_on_create :validate_content_type + m.before_validation_on_create :generate_hash + m.before_validation_on_create :set_image_dimensions + m.before_validation_on_create :generate_sample + m.before_validation_on_create :generate_preview + m.before_validation_on_create :move_file + end + + def validate_content_type + if file_ext.empty? + errors.add_to_base("No file received") + return false + end + + unless %w(jpg png gif swf).include?(file_ext.downcase) + errors.add(:file, "is an invalid content type: " + file_ext.downcase) + return false + end + end + + def file_name + md5 + "." + file_ext + end + + def delete_tempfile + FileUtils.rm_f(tempfile_path) + FileUtils.rm_f(tempfile_preview_path) + FileUtils.rm_f(tempfile_sample_path) + end + + def tempfile_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}.upload" + end + + def tempfile_preview_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}-preview.jpg" + end + + def file_size + File.size(file_path) rescue 0 + end + + # Generate an MD5 hash for the file. + def generate_hash + unless File.exists?(tempfile_path) + errors.add(:file, "not found") + return false + end + + self.md5 = File.open(tempfile_path, 'rb') {|fp| Digest::MD5.hexdigest(fp.read)} + + if Post.exists?(["md5 = ?", md5]) + delete_tempfile + errors.add "md5", "already exists" + return false + else + return true + end + end + + def generate_preview + return true unless image? && width && height + + unless File.exists?(tempfile_path) + errors.add(:file, "not found") + return false + end + + size = Danbooru.reduce_to({:width=>width, :height=>height}, {:width=>150, :height=>150}) + + # Generate the preview from the new sample if we have one to save CPU, otherwise from the image. + if File.exists?(tempfile_sample_path) + path, ext = tempfile_sample_path, "jpg" + else + path, ext = tempfile_path, file_ext + end + + begin + Danbooru.resize(ext, path, tempfile_preview_path, size, 95) + rescue Exception => x + errors.add "preview", "couldn't be generated (#{x})" + return false + end + + return true + end + + # Automatically download from the source if it's a URL. + def download_source + return if source !~ /^http:\/\// || !file_ext.blank? + + begin + Download.download(:url => source) do |res| + File.open(tempfile_path, 'wb') do |out| + res.read_body do |block| + out.write(block) + end + end + end + + if self.source.to_s =~ /^http/ + self.source = "Image board" + end + + return true + rescue SocketError, URI::Error, SystemCallError => x + delete_tempfile + errors.add "source", "couldn't be opened: #{x}" + return false + end + end + + def determine_content_type + if not File.exists?(tempfile_path) + errors.add_to_base("No file received") + return false + end + + imgsize = ImageSize.new(File.open(tempfile_path, 'rb')) + if !imgsize.get_width.nil? + self.file_ext = imgsize.get_type.gsub(/JPEG/, "JPG").downcase + end + end + + # Assigns a CGI file to the post. This writes the file to disk and generates a unique file name. + def file=(f) + return if f.nil? || f.size == 0 +a = File.new("/tmp/templog", "a+") + + if f.local_path + # Large files are stored in the temp directory, so instead of + # reading/rewriting through Ruby, just rely on system calls to + # copy the file to danbooru's directory. + +a.write("%s to %s\n" % f.local_path, tempfile_path) + FileUtils.cp(f.local_path, tempfile_path) + else + +a.write("... to %s\n" % tempfile_path) + + File.open(tempfile_path, 'wb') {|nf| nf.write(f.read)} + end + +a.close + + end + + def set_image_dimensions + if image? or flash? + imgsize = ImageSize.new(File.open(tempfile_path, "rb")) + self.width = imgsize.get_width + self.height = imgsize.get_height + end + end + + # Returns true if the post is an image format that GD can handle. + def image? + %w(jpg jpeg gif png).include?(file_ext.downcase) + end + + # Returns true if the post is a Flash movie. + def flash? + file_ext == "swf" + end + + def find_ext(file_path) + ext = File.extname(file_path) + if ext.blank? + return "txt" + else + ext = ext[1..-1].downcase + ext = "jpg" if ext == "jpeg" + return ext + end + end + + def content_type_to_file_ext(content_type) + case content_type.chomp + when "image/jpeg" + return "jpg" + + when "image/gif" + return "gif" + + when "image/png" + return "png" + + when "application/x-shockwave-flash" + return "swf" + + else + nil + end + end + + def preview_dimensions + if image? && !is_deleted? + dim = Danbooru.reduce_to({:width => width, :height => height}, {:width => 150, :height => 150}) + return [dim[:width], dim[:height]] + else + return [150, 150] + end + end + + def tempfile_sample_path + "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}-sample.jpg" + end + + def regenerate_sample + return false unless image? + + if generate_sample && File.exists?(tempfile_sample_path) + FileUtils.mkdir_p(File.dirname(sample_path), :mode => 0775) + FileUtils.mv(tempfile_sample_path, sample_path) + FileUtils.chmod(0775, sample_path) + return true + else + return false + end + end + + def generate_sample + return true unless image? + return true unless CONFIG["image_samples"] + return true unless (width && height) + return true if (file_ext.downcase == "gif") + + size = Danbooru.reduce_to({:width => width, :height => height}, {:width => CONFIG["sample_width"], :height => CONFIG["sample_height"]}, CONFIG["sample_ratio"]) + + # We can generate the sample image during upload or offline. Use tempfile_path + # if it exists, otherwise use file_path. + path = tempfile_path + path = file_path unless File.exists?(path) + unless File.exists?(path) + errors.add(:file, "not found") + return false + end + + # If we're not reducing the resolution for the sample image, only reencode if the + # source image is above the reencode threshold. Anything smaller won't be reduced + # enough by the reencode to bother, so don't reencode it and save disk space. + if size[:width] == width && size[:height] == height && + File.size?(path) < CONFIG["sample_always_generate_size"] + return true + end + + # If we already have a sample image, and the parameters havn't changed, + # don't regenerate it. + if size[:width] == sample_width && size[:height] == sample_height + return true + end + + size = Danbooru.reduce_to({:width => width, :height => height}, {:width => CONFIG["sample_width"], :height => CONFIG["sample_height"]}) + begin + Danbooru.resize(file_ext, path, tempfile_sample_path, size, 95) + rescue Exception => x + errors.add "sample", "couldn't be created: #{x}" + return false + end + + self.sample_width = size[:width] + self.sample_height = size[:height] + return true + end + + # Returns true if the post has a sample image. + def has_sample? + sample_width.is_a?(Integer) + end + + # Returns true if the post has a sample image, and we're going to use it. + def use_sample?(user = nil) + if user && !user.show_samples? + false + else + CONFIG["image_samples"] && has_sample? + end + end + + def sample_url(user = nil) + if status != "deleted" && use_sample?(user) + store_sample_url + else + file_url + end + end + + def get_sample_width(user = nil) + if use_sample?(user) + sample_width + else + width + end + end + + def get_sample_height(user = nil) + if use_sample?(user) + sample_height + else + height + end + end +end diff --git a/app/models/post/image_store/amazon_s3.rb b/app/models/post/image_store/amazon_s3.rb new file mode 100644 index 00000000..f05fbb8a --- /dev/null +++ b/app/models/post/image_store/amazon_s3.rb @@ -0,0 +1,56 @@ +module PostImageStoreMethods + module AmazonS3 + def move_file + begin + base64_md5 = Base64.encode64(self.md5.unpack("a2" * (self.md5.size / 2)).map {|x| x.hex.chr}.join) + + AWS::S3::Base.establish_connection!(:access_key_id => CONFIG["amazon_s3_access_key_id"], :secret_access_key => CONFIG["amazon_s3_secret_access_key"]) + AWS::S3::S3Object.store(file_name, open(self.tempfile_path, "rb"), CONFIG["amazon_s3_bucket_name"], :access => :public_read, "Content-MD5" => base64_md5, "Cache-Control" => "max-age=315360000") + + if image? + AWS::S3::S3Object.store("preview/#{md5}.jpg", open(self.tempfile_preview_path, "rb"), CONFIG["amazon_s3_bucket_name"], :access => :public_read, "Cache-Control" => "max-age=315360000") + end + + if File.exists?(tempfile_sample_path) + AWS::S3::S3Object.store("sample/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg", open(self.tempfile_sample_path, "rb"), CONFIG["amazon_s3_bucket_name"], :access => :public_read, "Cache-Control" => "max-age=315360000") + end + + if File.exists?(tempfile_jpeg_path) + AWS::S3::S3Object.store("jpeg/#{md5}.jpg", open(self.tempfile_jpeg_path, "rb"), CONFIG["amazon_s3_bucket_name"], :access => :public_read, "Cache-Control" => "max-age=315360000") + end + + return true + ensure + self.delete_tempfile() + end + end + + def file_url + "http://s3.amazonaws.com/" + CONFIG["amazon_s3_bucket_name"] + "/#{file_name}" + end + + def preview_url + if self.image? + "http://s3.amazonaws.com/" + CONFIG["amazon_s3_bucket_name"] + "/preview/#{md5}.jpg" + else + "http://s3.amazonaws.com/" + CONFIG["amazon_s3_bucket_name"] + "/preview/download.png" + end + end + + def store_sample_url + "http://s3.amazonaws.com/" + CONFIG["amazon_s3_bucket_name"] + "/sample/deleted.png" + end + + def store_jpeg_url + "http://s3.amazonaws.com/" + CONFIG["amazon_s3_bucket_name"] + "/jpeg/deleted.png" + end + + def delete_file + AWS::S3::Base.establish_connection!(:access_key_id => CONFIG["amazon_s3_access_key_id"], :secret_access_key => CONFIG["amazon_s3_secret_access_key"]) + AWS::S3::S3Object.delete(file_name, CONFIG["amazon_s3_bucket_name"]) + AWS::S3::S3Object.delete("preview/#{md5}.jpg", CONFIG["amazon_s3_bucket_name"]) + AWS::S3::S3Object.delete("sample/#{md5}.jpg", CONFIG["amazon_s3_bucket_name"]) + AWS::S3::S3Object.delete("jpeg/#{md5}.jpg", CONFIG["amazon_s3_bucket_name"]) + end + end +end diff --git a/app/models/post/image_store/local_flat.rb b/app/models/post/image_store/local_flat.rb new file mode 100644 index 00000000..c8251c43 --- /dev/null +++ b/app/models/post/image_store/local_flat.rb @@ -0,0 +1,88 @@ +module PostImageStoreMethods + module LocalFlat + def file_path + "#{RAILS_ROOT}/public/data/#{file_name}" + end + + def file_url + if CONFIG["use_pretty_image_urls"] then + CONFIG["url_base"] + "/image/#{md5}/#{url_encode(pretty_file_name)}.#{file_ext}" + else + CONFIG["url_base"] + "/data/#{file_name}" + end + end + + def preview_path + if status == "deleted" + "#{RAILS_ROOT}/public/deleted-preview.png" + elsif image? + "#{RAILS_ROOT}/public/data/preview/#{md5}.jpg" + else + "#{RAILS_ROOT}/public/download-preview.png" + end + end + + def sample_path + "#{RAILS_ROOT}/public/data/sample/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg" + end + + def preview_url + if image? + CONFIG["url_base"] + "/data/preview/#{md5}.jpg" + else + CONFIG["url_base"] + "/download-preview.png" + end + end + + def jpeg_path + "#{RAILS_ROOT}/public/data/jpeg/#{file_hierarchy}/#{md5}.jpg" + end + + def store_jpeg_url + if CONFIG["use_pretty_image_urls"] then + CONFIG["url_base"] + "/jpeg/#{md5}/#{url_encode(pretty_file_name({:type => :jpeg}))}.jpg" + else + CONFIG["url_base"] + "/data/jpeg/#{md5}.jpg" + end + end + + def store_sample_url + if CONFIG["use_pretty_image_urls"] then + path = "/sample/#{md5}/#{url_encode(pretty_file_name({:type => :sample}))}.jpg" + else + path = "/data/sample/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg" + end + + CONFIG["url_base"] + path + end + + def delete_file + FileUtils.rm_f(file_path) + FileUtils.rm_f(preview_path) if image? + FileUtils.rm_f(sample_path) if image? + FileUtils.rm_f(jpeg_path) if image? + end + + def move_file + FileUtils.mv(tempfile_path, file_path) + FileUtils.chmod(0664, file_path) + + if image? + FileUtils.mv(tempfile_preview_path, preview_path) + FileUtils.chmod(0664, preview_path) + end + + if File.exists?(tempfile_sample_path) + FileUtils.mv(tempfile_sample_path, sample_path) + FileUtils.chmod(0664, sample_path) + end + + if File.exists?(tempfile_jpeg_path) + FileUtils.mv(tempfile_jpeg_path, jpeg_path) + FileUtils.chmod(0664, jpeg_path) + end + + delete_tempfile + end + end +end diff --git a/app/models/post/image_store/local_flat_with_amazon_s3_backup.rb b/app/models/post/image_store/local_flat_with_amazon_s3_backup.rb new file mode 100644 index 00000000..4affb61a --- /dev/null +++ b/app/models/post/image_store/local_flat_with_amazon_s3_backup.rb @@ -0,0 +1,105 @@ +module PostImageStoreMethods + module LocalFlatWithAmazonS3Backup + def move_file + FileUtils.mv(tempfile_path, file_path) + FileUtils.chmod(0664, file_path) + + if image? + FileUtils.mv(tempfile_preview_path, preview_path) + FileUtils.chmod(0664, preview_path) + end + + if File.exists?(tempfile_sample_path) + FileUtils.mv(tempfile_sample_path, sample_path) + FileUtils.chmod(0664, sample_path) + end + + if File.exists?(tempfile_jpeg_path) + FileUtils.mv(tempfile_jpeg_path, jpeg_path) + FileUtils.chmod(0664, jpeg_path) + end + + self.delete_tempfile() + + base64_md5 = Base64.encode64(self.md5.unpack("a2" * (self.md5.size / 2)).map {|x| x.hex.chr}.join) + + AWS::S3::Base.establish_connection!(:access_key_id => CONFIG["amazon_s3_access_key_id"], :secret_access_key => CONFIG["amazon_s3_secret_access_key"]) + AWS::S3::S3Object.store(file_name, open(self.file_path, "rb"), CONFIG["amazon_s3_bucket_name"], :access => :public_read, "Content-MD5" => base64_md5) + + if image? + AWS::S3::S3Object.store("preview/#{md5}.jpg", open(self.preview_path, "rb"), CONFIG["amazon_s3_bucket_name"], :access => :public_read) + end + + if File.exists?(tempfile_sample_path) + AWS::S3::S3Object.store("sample/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg", open(self.sample_path, "rb"), CONFIG["amazon_s3_bucket_name"], :access => :public_read) + end + + if File.exists?(tempfile_jpeg_path) + AWS::S3::S3Object.store("jpeg/#{md5}.jpg", open(self.jpeg_path, "rb"), CONFIG["amazon_s3_bucket_name"], :access => :public_read) + end + + return true + end + + def file_path + "#{RAILS_ROOT}/public/data/#{file_name}" + end + + def file_url + #"http://s3.amazonaws.com/" + CONFIG["amazon_s3_bucket_name"] + "/#{file_name}" + CONFIG["url_base"] + "/data/#{file_name}" + end + + def preview_path + if status == "deleted" + "#{RAILS_ROOT}/public/deleted-preview.png" + elsif image? + "#{RAILS_ROOT}/public/data/preview/#{md5}.jpg" + else + "#{RAILS_ROOT}/public/download-preview.png" + end + end + + def sample_path + "#{RAILS_ROOT}/public/data/sample/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg" + end + + def preview_url +# if self.image? +# "http://s3.amazonaws.com/" + CONFIG["amazon_s3_bucket_name"] + "/preview/#{md5}.jpg" +# else +# "http://s3.amazonaws.com/" + CONFIG["amazon_s3_bucket_name"] + "/preview/download.png" +# end + + if self.image? + CONFIG["url_base"] + "/data/preview/#{md5}.jpg" + else + CONFIG["url_base"] + "/download-preview.png" + end + end + + def jpeg_path + "#{RAILS_ROOT}/public/data/jpeg/#{md5}.jpg" + end + + def store_jpeg_url + CONFIG["url_base"] + "/data/jpeg/#{md5}.jpg" + end + + def store_sample_url + CONFIG["url_base"] + "/data/sample/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg" + end + + def delete_file + AWS::S3::Base.establish_connection!(:access_key_id => CONFIG["amazon_s3_access_key_id"], :secret_access_key => CONFIG["amazon_s3_secret_access_key"]) + AWS::S3::S3Object.delete(file_name, CONFIG["amazon_s3_bucket_name"]) + AWS::S3::S3Object.delete("preview/#{md5}.jpg", CONFIG["amazon_s3_bucket_name"]) + AWS::S3::S3Object.delete("sample/#{md5}.jpg", CONFIG["amazon_s3_bucket_name"]) + AWS::S3::S3Object.delete("jpeg/#{md5}.jpg", CONFIG["amazon_s3_bucket_name"]) + FileUtils.rm_f(file_path) + FileUtils.rm_f(preview_path) if image? + FileUtils.rm_f(sample_path) if image? + FileUtils.rm_f(jpeg_path) if image? + end + end +end diff --git a/app/models/post/image_store/local_hierarchy.rb b/app/models/post/image_store/local_hierarchy.rb new file mode 100644 index 00000000..43d18b1d --- /dev/null +++ b/app/models/post/image_store/local_hierarchy.rb @@ -0,0 +1,94 @@ +module PostImageStoreMethods + module LocalHierarchy + def file_hierarchy + "%s/%s" % [md5[0,2], md5[2,2]] + end + + def file_path + "#{RAILS_ROOT}/public/data/image/#{file_hierarchy}/#{file_name}" + end + + def file_url + if CONFIG["use_pretty_image_urls"] then + CONFIG["url_base"] + "/image/#{md5}/#{url_encode(pretty_file_name)}.#{file_ext}" + else + CONFIG["url_base"] + "/data/image/#{file_hierarchy}/#{file_name}" + end + end + + def preview_path + if status == "deleted" + "#{RAILS_ROOT}/public/deleted-preview.png" + elsif image? + "#{RAILS_ROOT}/public/data/preview/#{file_hierarchy}/#{md5}.jpg" + else + "#{RAILS_ROOT}/public/download-preview.png" + end + end + + def sample_path + "#{RAILS_ROOT}/public/data/sample/#{file_hierarchy}/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg" + end + + def preview_url + if image? + CONFIG["url_base"] + "/data/preview/#{file_hierarchy}/#{md5}.jpg" + else + CONFIG["url_base"] + "/download-preview.png" + end + end + + def jpeg_path + "#{RAILS_ROOT}/public/data/jpeg/#{file_hierarchy}/#{md5}.jpg" + end + + def store_jpeg_url + if CONFIG["use_pretty_image_urls"] then + CONFIG["url_base"] + "/jpeg/#{md5}/#{url_encode(pretty_file_name({:type => :jpeg}))}.jpg" + else + CONFIG["url_base"] + "/data/jpeg/#{file_hierarchy}/#{md5}.jpg" + end + end + + def store_sample_url + if CONFIG["use_pretty_image_urls"] then + CONFIG["url_base"] + "/sample/#{md5}/#{url_encode(pretty_file_name({:type => :sample}))}.jpg" + else + CONFIG["url_base"] + "/data/sample/#{file_hierarchy}/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg" + end + end + + def delete_file + FileUtils.rm_f(file_path) + FileUtils.rm_f(preview_path) if image? + FileUtils.rm_f(sample_path) if image? + FileUtils.rm_f(jpeg_path) if image? + end + + def move_file + FileUtils.mkdir_p(File.dirname(file_path), :mode => 0775) + FileUtils.mv(tempfile_path, file_path) + FileUtils.chmod(0664, file_path) + + if image? + FileUtils.mkdir_p(File.dirname(preview_path), :mode => 0775) + FileUtils.mv(tempfile_preview_path, preview_path) + FileUtils.chmod(0664, preview_path) + end + + if File.exists?(tempfile_sample_path) + FileUtils.mkdir_p(File.dirname(sample_path), :mode => 0775) + FileUtils.mv(tempfile_sample_path, sample_path) + FileUtils.chmod(0664, sample_path) + end + + if File.exists?(tempfile_jpeg_path) + FileUtils.mkdir_p(File.dirname(jpeg_path), :mode => 0775) + FileUtils.mv(tempfile_jpeg_path, jpeg_path) + FileUtils.chmod(0664, jpeg_path) + end + + delete_tempfile + end + end +end diff --git a/app/models/post/image_store/remote_hierarchy.rb b/app/models/post/image_store/remote_hierarchy.rb new file mode 100644 index 00000000..0965ce85 --- /dev/null +++ b/app/models/post/image_store/remote_hierarchy.rb @@ -0,0 +1,117 @@ +require "mirror" +require "erb" +include ERB::Util + +module PostImageStoreMethods + module RemoteHierarchy + def file_hierarchy + "%s/%s" % [md5[0,2], md5[2,2]] + end + + def select_random_image_server(*options) + Mirrors.select_image_server(self.is_warehoused?, self.created_at.to_i, *options) + end + + def file_path + "#{RAILS_ROOT}/public/data/image/#{file_hierarchy}/#{file_name}" + end + + def file_url + if CONFIG["use_pretty_image_urls"] then + select_random_image_server + "/image/#{md5}/#{url_encode(pretty_file_name)}.#{file_ext}" + else + select_random_image_server + "/data/image/#{file_hierarchy}/#{file_name}" + end + end + + def preview_path + if image? + "#{RAILS_ROOT}/public/data/preview/#{file_hierarchy}/#{md5}.jpg" + else + "#{RAILS_ROOT}/public/download-preview.png" + end + end + + def sample_path + "#{RAILS_ROOT}/public/data/sample/#{file_hierarchy}/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg" + end + + def preview_url + if self.is_warehoused? + if status == "deleted" + CONFIG["url_base"] + "/deleted-preview.png" + + elsif image? + select_random_image_server(:preview => true) + "/data/preview/#{file_hierarchy}/#{md5}.jpg" + else + CONFIG["url_base"] + "/download-preview.png" + end + else + if status == "deleted" + CONFIG["url_base"] + "/deleted-preview.png" + elsif image? + Mirrors.select_main_image_server + "/data/preview/#{file_hierarchy}/#{md5}.jpg" + else + CONFIG["url_base"] + "/download-preview.png" + end + end + end + + def jpeg_path + "#{RAILS_ROOT}/public/data/jpeg/#{file_hierarchy}/#{md5}.jpg" + end + + def store_jpeg_url + if CONFIG["use_pretty_image_urls"] then + path = CONFIG["url_base"] + "/jpeg/#{md5}/#{url_encode(pretty_file_name({:type => :jpeg}))}.jpg" + else + path = CONFIG["url_base"] + "/data/jpeg/#{file_hierarchy}/#{md5}.jpg" + end + + return select_random_image_server + path + end + + def store_sample_url + if CONFIG["use_pretty_image_urls"] then + path = "/sample/#{md5}/#{url_encode(pretty_file_name({:type => :sample}))}.jpg" + else + path = "/data/sample/#{file_hierarchy}/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg" + end + + return select_random_image_server + path + end + + def delete_file + FileUtils.rm_f(file_path) + FileUtils.rm_f(preview_path) if image? + FileUtils.rm_f(sample_path) if image? + FileUtils.rm_f(jpeg_path) if image? + end + + def move_file + FileUtils.mkdir_p(File.dirname(file_path), :mode => 0775) + FileUtils.mv(tempfile_path, file_path) + FileUtils.chmod(0664, file_path) + + if image? + FileUtils.mkdir_p(File.dirname(preview_path), :mode => 0775) + FileUtils.mv(tempfile_preview_path, preview_path) + FileUtils.chmod(0664, preview_path) + end + + if File.exists?(tempfile_sample_path) + FileUtils.mkdir_p(File.dirname(sample_path), :mode => 0775) + FileUtils.mv(tempfile_sample_path, sample_path) + FileUtils.chmod(0664, sample_path) + end + + if File.exists?(tempfile_jpeg_path) + FileUtils.mkdir_p(File.dirname(jpeg_path), :mode => 0775) + FileUtils.mv(tempfile_jpeg_path, jpeg_path) + FileUtils.chmod(0664, jpeg_path) + end + + delete_tempfile + end + end +end diff --git a/app/models/post/image_store_methods.rb b/app/models/post/image_store_methods.rb new file mode 100644 index 00000000..37d7fd4c --- /dev/null +++ b/app/models/post/image_store_methods.rb @@ -0,0 +1,20 @@ +module PostImageStoreMethods + def self.included(m) + case CONFIG["image_store"] + when :local_flat + m.__send__(:include, PostImageStoreMethods::LocalFlat) + + when :local_flat_with_amazon_s3_backup + m.__send__(:include, PostImageStoreMethods::LocalFlatWithAmazonS3Backup) + + when :local_hierarchy + m.__send__(:include, PostImageStoreMethods::LocalHierarchy) + + when :remote_hierarchy + m.__send__(:include, PostImageStoreMethods::RemoteHierarchy) + + when :amazon_s3 + m.__send__(:include, PostImageStoreMethods::AmazonS3) + end + end +end diff --git a/app/models/post/mirror_methods.rb b/app/models/post/mirror_methods.rb new file mode 100644 index 00000000..9b6b01b8 --- /dev/null +++ b/app/models/post/mirror_methods.rb @@ -0,0 +1,49 @@ +require "mirror" + +class MirrorError < Exception ; end + +module PostMirrorMethods + def upload_to_mirrors + return if is_warehoused + return if self.status == "deleted" + + files_to_copy = [self.file_path] + files_to_copy << self.preview_path if self.image? + files_to_copy << self.sample_path if self.has_sample? + files_to_copy << self.jpeg_path if self.has_jpeg? + files_to_copy = files_to_copy.uniq + + # CONFIG[:data_dir] is equivalent to our local_base. + local_base = "#{RAILS_ROOT}/public/data/" + + CONFIG["mirrors"].each { |mirror| + remote_user_host = "#{mirror[:user]}@#{mirror[:host]}" + remote_dirs = [] + files_to_copy.each { |file| + remote_filename = file[local_base.length, file.length] + remote_dir = File.dirname(remote_filename) + remote_dirs << mirror[:data_dir] + "/" + File.dirname(remote_filename) + } + + # Create all directories in one go. + system("/usr/bin/ssh", "-o", "Compression=no", "-o", "BatchMode=yes", + remote_user_host, "mkdir -p #{remote_dirs.uniq.join(" ")}") + } + + begin + files_to_copy.each { |file| + Mirrors.copy_file_to_mirrors(file) + } + rescue MirrorError => e + # The post might be deleted while it's uploading. Check the post status after + # an error. + self.reload + raise if self.status != "deleted" + end + + # This might take a while. Rather than hold a transaction, just reload the post + # after uploading. + self.reload + self.update_attributes(:is_warehoused => true) + end +end diff --git a/app/models/post/parent_methods.rb b/app/models/post/parent_methods.rb new file mode 100644 index 00000000..2e819d22 --- /dev/null +++ b/app/models/post/parent_methods.rb @@ -0,0 +1,70 @@ +module PostParentMethods + module ClassMethods + def update_has_children(post_id) + has_children = Post.exists?(["parent_id = ? AND status <> 'deleted'", post_id]) + execute_sql("UPDATE posts SET has_children = ? WHERE id = ?", has_children, post_id) + end + + def recalculate_has_children + transaction do + execute_sql("UPDATE posts SET has_children = false WHERE has_children = true") + execute_sql("UPDATE posts SET has_children = true WHERE id IN (SELECT parent_id FROM posts WHERE parent_id IS NOT NULL AND status <> 'deleted')") + end + end + + def set_parent(post_id, parent_id, old_parent_id = nil) + if old_parent_id.nil? + old_parent_id = select_value_sql("SELECT parent_id FROM posts WHERE id = ?", post_id) + end + + if parent_id.to_i == post_id.to_i || parent_id.to_i == 0 + parent_id = nil + end + + execute_sql("UPDATE posts SET parent_id = ? WHERE id = ?", parent_id, post_id) + + update_has_children(old_parent_id) + update_has_children(parent_id) + end + end + + def self.included(m) + m.extend(ClassMethods) + m.after_save :update_parent + m.after_save :update_pool_children + m.validate :validate_parent + m.after_delete :give_favorites_to_parent + m.versioned :parent_id, :default => nil + end + + def validate_parent + errors.add("parent_id") unless parent_id.nil? or Post.exists?(parent_id) + end + + def update_parent + return if !parent_id_changed? && !status_changed? + self.class.set_parent(id, parent_id, parent_id_was) + end + + def update_pool_children + # If the parent didn't change, we don't need to update any pool posts. (Don't use + # parent_id_changed?; we want to know if the id changed, not if it was just overwritten + # with the same value.) + return if self.parent_id == self.parent_id_was + + # Give PoolPost a chance to update parenting when post parents change. + PoolPost.post_parent_changed(self) + end + + def give_favorites_to_parent + return if parent_id.nil? + parent = Post.find(parent_id) + + transaction do + for vote in PostVotes.find(:all, :conditions => ["post_id = ?", self.id], :include => :user) + parent.vote!(vote.score, vote.user, nil) + self.vote!(0, vote.user, nil) + end + end + end +end diff --git a/app/models/post/rating_methods.rb b/app/models/post/rating_methods.rb new file mode 100644 index 00000000..c32adaa8 --- /dev/null +++ b/app/models/post/rating_methods.rb @@ -0,0 +1,55 @@ +module PostRatingMethods + attr_accessor :old_rating + + def self.included(m) + m.versioned :rating + m.versioned :is_rating_locked, :default => false + m.versioned :is_note_locked, :default => false + end + + def rating=(r) + if r == nil && !new_record? + return + end + + if is_rating_locked? + return + end + + r = r.to_s.downcase[0, 1] + + if %w(q e s).include?(r) + new_rating = r + else + new_rating = 'q' + end + + return if rating == new_rating + self.old_rating = rating + write_attribute(:rating, new_rating) + touch_change_seq! + end + + def pretty_rating + case rating + when "q" + return "Questionable" + + when "e" + return "Explicit" + + when "s" + return "Safe" + end + end + + def can_change_is_note_locked?(user) + return user.has_permission?(pool) + end + def can_change_rating_locked?(user) + return user.has_permission?(pool) + end + def can_change_rating?(user) + return user.is_member_or_higher? && (!is_rating_locked? || user.has_permission?(self)) + end +end diff --git a/app/models/post/sql_methods.rb b/app/models/post/sql_methods.rb new file mode 100644 index 00000000..5633ef7d --- /dev/null +++ b/app/models/post/sql_methods.rb @@ -0,0 +1,336 @@ +module PostSqlMethods + module ClassMethods + def generate_sql_range_helper(arr, field, c, p) + case arr[0] + when :eq + c << "#{field} = ?" + p << arr[1] + + when :gt + c << "#{field} > ?" + p << arr[1] + + when :gte + c << "#{field} >= ?" + p << arr[1] + + when :lt + c << "#{field} < ?" + p << arr[1] + + when :lte + c << "#{field} <= ?" + p << arr[1] + + when :between + c << "#{field} BETWEEN ? AND ?" + p << arr[1] + p << arr[2] + + else + # do nothing + end + end + + def generate_sql(q, options = {}) + if q.is_a?(Hash) + original_query = options[:original_query] + else + original_query = q + q = Tag.parse_query(q) + end + + conds = ["true"] + joins = ["posts p"] + join_params = [] + cond_params = [] + + if q.has_key?(:error) + conds << "FALSE" + end + + generate_sql_range_helper(q[:post_id], "p.id", conds, cond_params) + generate_sql_range_helper(q[:mpixels], "p.width*p.height/1000000.0", conds, cond_params) + generate_sql_range_helper(q[:width], "p.width", conds, cond_params) + generate_sql_range_helper(q[:height], "p.height", conds, cond_params) + generate_sql_range_helper(q[:score], "p.score", conds, cond_params) + generate_sql_range_helper(q[:date], "p.created_at::date", conds, cond_params) + generate_sql_range_helper(q[:change], "p.change_seq", conds, cond_params) + + if q[:md5].is_a?(String) + conds << "p.md5 IN (?)" + cond_params << q[:md5].split(/,/) + end + + if q[:deleted_only] == true + conds << "p.status = 'deleted'" + else + conds << "p.status <> 'deleted'" + end + + if q.has_key?(:parent_id) && q[:parent_id].is_a?(Integer) + conds << "(p.parent_id = ? or p.id = ?)" + cond_params << q[:parent_id] + cond_params << q[:parent_id] + elsif q.has_key?(:parent_id) && q[:parent_id] == false + conds << "p.parent_id is null" + end + + if q[:source].is_a?(String) + conds << "p.source LIKE ? ESCAPE E'\\\\'" + cond_params << q[:source] + end + + if q[:favtag].is_a?(String) + user = User.find_by_name(q[:favtag]) + + if user + post_ids = FavoriteTag.find_post_ids(user.id) + conds << "p.id IN (?)" + cond_params << post_ids + end + end + + if q[:fav].is_a?(String) + joins << "JOIN favorites f ON f.post_id = p.id JOIN users fu ON f.user_id = fu.id" + conds << "lower(fu.name) = lower(?)" + cond_params << q[:fav] + end + + if q.has_key?(:vote_negated) + joins << "LEFT JOIN post_votes v ON p.id = v.post_id AND v.user_id = ?" + join_params << q[:vote_negated] + conds << "v.score IS NULL" + end + + if q.has_key?(:vote) + joins << "JOIN post_votes v ON p.id = v.post_id" + conds << "v.user_id = ?" + cond_params << q[:vote][1] + + generate_sql_range_helper(q[:vote][0], "v.score", conds, cond_params) + end + + if q[:user].is_a?(String) + joins << "JOIN users u ON p.user_id = u.id" + conds << "lower(u.name) = lower(?)" + cond_params << q[:user] + end + + if q.has_key?(:exclude_pools) + q[:exclude_pools].each_index do |i| + if q[:exclude_pools][i].is_a?(Integer) + joins << "LEFT JOIN pools_posts ep#{i} ON (ep#{i}.post_id = p.id AND ep#{i}.pool_id = ?)" + join_params << q[:exclude_pools][i] + conds << "ep#{i} IS NULL" + end + + if q[:exclude_pools][i].is_a?(String) + joins << "LEFT JOIN pools_posts ep#{i} ON ep#{i}.post_id = p.id LEFT JOIN pools epp#{i} ON (ep#{i}.pool_id = epp#{i}.id AND epp#{i}.name ILIKE ? ESCAPE E'\\\\')" + join_params << ("%" + q[:exclude_pools][i].to_escaped_for_sql_like + "%") + conds << "ep#{i} IS NULL" + end + end + end + + if q.has_key?(:pool) + if q.has_key?(:pool_posts) && q[:pool_posts] == "all" + conds << "(pools_posts.active OR pools_posts.master_id IS NOT NULL)" + elsif q.has_key?(:pool_posts) && q[:pool_posts] == "orig" + conds << "pools_posts.active = true" + else + conds << "((pools_posts.active = true AND pools_posts.slave_id IS NULL) OR pools_posts.master_id IS NOT NULL)" + end + + if not q.has_key?(:order) + pool_ordering = " ORDER BY pools_posts.pool_id ASC, nat_sort(pools_posts.sequence), pools_posts.post_id" + end + + if q[:pool].is_a?(Integer) + joins << "JOIN pools_posts ON pools_posts.post_id = p.id JOIN pools ON pools_posts.pool_id = pools.id" + conds << "pools.id = ?" + cond_params << q[:pool] + end + + if q[:pool].is_a?(String) + joins << "JOIN pools_posts ON pools_posts.post_id = p.id JOIN pools ON pools_posts.pool_id = pools.id" + conds << "pools.name ILIKE ? ESCAPE E'\\\\'" + cond_params << ("%" + q[:pool].to_escaped_for_sql_like + "%") + end + end + + if q.has_key?(:include) + joins << "JOIN posts_tags ipt ON ipt.post_id = p.id" + conds << "ipt.tag_id IN (SELECT id FROM tags WHERE name IN (?))" + cond_params << (q[:include] + q[:related]) + elsif q[:related].any? + raise "You cannot search for more than #{CONFIG['tag_query_limit']} tags at a time" if q[:related].size > CONFIG["tag_query_limit"] + + q[:related].each_with_index do |rtag, i| + joins << "JOIN posts_tags rpt#{i} ON rpt#{i}.post_id = p.id AND rpt#{i}.tag_id = (SELECT id FROM tags WHERE name = ?)" + join_params << rtag + end + end + + if q[:exclude].any? + raise "You cannot search for more than #{CONFIG['tag_query_limit']} tags at a time" if q[:exclude].size > CONFIG["tag_query_limit"] + q[:exclude].each_with_index do |etag, i| + joins << "LEFT JOIN posts_tags ept#{i} ON p.id = ept#{i}.post_id AND ept#{i}.tag_id = (SELECT id FROM tags WHERE name = ?)" + conds << "ept#{i}.tag_id IS NULL" + join_params << etag + end + end + + if q[:rating].is_a?(String) + case q[:rating][0, 1].downcase + when "s" + conds << "p.rating = 's'" + + when "q" + conds << "p.rating = 'q'" + + when "e" + conds << "p.rating = 'e'" + end + end + + if q[:rating_negated].is_a?(String) + case q[:rating_negated][0, 1].downcase + when "s" + conds << "p.rating <> 's'" + + when "q" + conds << "p.rating <> 'q'" + + when "e" + conds << "p.rating <> 'e'" + end + end + + if q[:unlocked_rating] == true + conds << "p.is_rating_locked = FALSE" + end + + if options[:pending] + conds << "p.status = 'pending'" + end + + if options[:flagged] + conds << "p.status = 'flagged'" + end + + if q.has_key?(:show_holds_only) + if q[:show_holds_only] + conds << "p.is_held" + end + else + # Hide held posts by default only when not using the API. + if not options[:from_api] then + conds << "NOT p.is_held" + end + end + + if q.has_key?(:shown_in_index) + if q[:shown_in_index] + conds << "p.is_shown_in_index" + else + conds << "NOT p.is_shown_in_index" + end + elsif original_query.blank? and not options[:from_api] + # Hide not shown posts by default only when not using the API. + conds << "p.is_shown_in_index" + end + + sql = "SELECT " + + if options[:count] + sql << "COUNT(*)" + elsif options[:select] + sql << options[:select] + else + sql << "p.*" + end + + sql << " FROM " + joins.join(" ") + sql << " WHERE " + conds.join(" AND ") + + if q[:order] && !options[:count] + case q[:order] + when "id" + sql << " ORDER BY p.id" + + when "id_desc" + sql << " ORDER BY p.id DESC" + + when "score" + sql << " ORDER BY p.score DESC" + + when "score_asc" + sql << " ORDER BY p.score" + + when "mpixels" + # Use "w*h/1000000", even though "w*h" would give the same result, so this can use + # the posts_mpixels index. + sql << " ORDER BY width*height/1000000.0 DESC" + + when "mpixels_asc" + sql << " ORDER BY width*height/1000000.0" + + when "portrait" + sql << " ORDER BY 1.0*width/GREATEST(1, height)" + + when "landscape" + sql << " ORDER BY 1.0*width/GREATEST(1, height) DESC" + + when "change", "change_asc" + sql << " ORDER BY change_seq" + + when "change_desc" + sql << " ORDER BY change_seq DESC" + + when "vote" + if q.has_key?(:vote) + sql << " ORDER BY v.updated_at DESC" + end + + when "fav" + if q[:fav].is_a?(String) + sql << " ORDER BY f.id DESC" + end + + when "random" + sql << " ORDER BY random" + + else + if pool_ordering + sql << pool_ordering + else + if options[:from_api] then + # When using the API, default to sorting by ID. + sql << " ORDER BY p.id DESC" + else + sql << " ORDER BY p.index_timestamp DESC" + end + end + end + elsif options[:order] + sql << " ORDER BY " + options[:order] + end + + if options[:limit] + sql << " LIMIT " + options[:limit].to_s + end + + if options[:offset] + sql << " OFFSET " + options[:offset].to_s + end + + params = join_params + cond_params + return Post.sanitize_sql([sql, *params]) + end + end + + def self.included(m) + m.extend(ClassMethods) + end +end diff --git a/app/models/post/status_methods.rb b/app/models/post/status_methods.rb new file mode 100644 index 00000000..320dcde3 --- /dev/null +++ b/app/models/post/status_methods.rb @@ -0,0 +1,115 @@ +module PostStatusMethods + def status=(s) + return if s == status + write_attribute(:status, s) + touch_change_seq! + end + + def reset_index_timestamp + self.index_timestamp = self.created_at + end + + # Bump the post to the front of the index. + def touch_index_timestamp + self.index_timestamp = Time.now + end + + module ClassMethods + # If user_id is nil, allow activating any user's posts. + def batch_activate(user_id, post_ids) + conds = [] + cond_params = [] + + conds << "is_held = true" + conds << "id IN (?)" + cond_params << post_ids + + if user_id + conds << "user_id = ?" + cond_params << user_id + end + + # Tricky: we want posts to show up in the index in the same order they were posted. + # If we just bump the posts, the index_timestamps will all be the same, and they'll + # show up in an undefined order. We don't want to do this in the ORDER BY when + # searching, because that's too expensive. Instead, tweak the timestamps slightly: + # for each post updated, set the index_timestamps 1ms newer than the previous. + # + # Returns the number of posts actually activated. + count = nil + transaction do + # result_id gives us an index for each result row; multiplying this by 1ms + # gives us an increasing counter. This should be a lot easier than this. + sql = <<-EOS + CREATE TEMP SEQUENCE result_id; + + UPDATE posts + SET index_timestamp = now() + (interval '1 ms' * idx) + FROM + (SELECT nextval('result_id') AS idx, * FROM ( + SELECT id, index_timestamp FROM posts + WHERE #{conds.join(" AND ")} + ORDER BY created_at DESC + ) AS n) AS nn + WHERE posts.id IN (nn.id); + + DROP SEQUENCE result_id; + EOS + execute_sql(sql, *cond_params) + + count = select_value_sql("SELECT COUNT(*) FROM posts WHERE #{conds.join(" AND ")}", *cond_params).to_i + + sql = "UPDATE posts SET is_held = false WHERE #{conds.join(" AND ")}" + execute_sql(sql, *cond_params) + end + + Cache.expire if count > 0 + + return count + end + end + + def update_status_on_destroy + # Can't use update_attributes here since this method is wrapped inside of a destroy call + execute_sql("UPDATE posts SET status = ? WHERE id = ?", "deleted", id) + Post.update_has_children(parent_id) if parent_id + flag_detail.update_attributes(:is_resolved => true) if flag_detail + return false + end + + def self.included(m) + m.extend(ClassMethods) + m.before_create :reset_index_timestamp + m.versioned :is_shown_in_index, :default => true + end + + def is_held=(hold) + # Hack because the data comes in as a string: + hold = false if hold == "false" + + user = Thread.current["danbooru-user"] + + # Only the original poster can hold or unhold a post. + return if user && !user.has_permission?(self) + + if hold + # A post can only be held within one minute of posting (except by a moderator); + # this is intended to be used on initial posting, before it shows up in the index. + return if self.created_at && self.created_at < 1.minute.ago + end + + was_held = self.is_held + + write_attribute(:is_held, hold) + + # When a post is unheld, bump it. + if was_held && !hold + touch_index_timestamp + end + end + + def undelete! + execute_sql("UPDATE posts SET status = ? WHERE id = ?", "active", id) + Post.update_has_children(parent_id) if parent_id + end +end diff --git a/app/models/post/t b/app/models/post/t new file mode 100644 index 00000000..f4825cda --- /dev/null +++ b/app/models/post/t @@ -0,0 +1,62 @@ +Index: image_store/remote_hierarchy.rb +=================================================================== +--- image_store/remote_hierarchy.rb (revision 755) ++++ image_store/remote_hierarchy.rb (working copy) +@@ -8,9 +8,14 @@ + end + + def select_random_image_server ++ if not self.is_warehoused? ++ # return CONFIG["url_base"] ++ return CONFIG["image_servers"][0] ++ end ++ + # age = Time.now - self.created_at + i = 0 +-# if age > (60*60*3) then ++# if age > (60*60*24) then + # i = 2 # Ascaroth + # elsif age > (60*60*24)*2 then + # i = 1 # saki +@@ -28,17 +33,9 @@ + + def file_url + if CONFIG["use_pretty_image_urls"] then +- if self.is_warehoused? +- select_random_image_server() + "/image/#{md5}/#{url_encode(pretty_file_name)}.#{file_ext}" +- else +- CONFIG["url_base"] + "/image/#{md5}/#{url_encode(pretty_file_name)}.#{file_ext}" +- end ++ select_random_image_server + "/image/#{md5}/#{url_encode(pretty_file_name)}.#{file_ext}" + else +- if self.is_warehoused? +- select_random_image_server() + "/data/#{file_hierarchy}/#{file_name}" +- else +- CONFIG["url_base"] + "/data/#{file_hierarchy}/#{file_name}" +- end ++ select_random_image_server + "/data/#{file_hierarchy}/#{file_name}" + end + end + +@@ -68,7 +65,7 @@ + if status == "deleted" + CONFIG["url_base"] + "/deleted-preview.png" + elsif image? +- CONFIG["url_base"] + "/data/preview/#{file_hierarchy}/#{md5}.jpg" ++ CONFIG["image_servers"][0] + "/data/preview/#{file_hierarchy}/#{md5}.jpg" + else + CONFIG["url_base"] + "/download-preview.png" + end +@@ -82,11 +79,7 @@ + path = "/data/sample/#{file_hierarchy}/" + CONFIG["sample_filename_prefix"] + "#{md5}.jpg" + end + +- if self.is_warehoused? +- return select_random_image_server() + path +- else +- return CONFIG["url_base"] + path +- end ++ return select_random_image_server + path + end + + def delete_file diff --git a/app/models/post/tag_methods.rb b/app/models/post/tag_methods.rb new file mode 100644 index 00000000..980509ad --- /dev/null +++ b/app/models/post/tag_methods.rb @@ -0,0 +1,246 @@ +module PostTagMethods + attr_accessor :tags, :new_tags, :old_tags, :old_cached_tags + + module ClassMethods + def find_by_tags(tags, options = {}) + return find_by_sql(Post.generate_sql(tags, options)) + end + + def recalculate_cached_tags(id = nil) + conds = [] + cond_params = [] + + sql = %{ + UPDATE posts p SET cached_tags = ( + SELECT array_to_string(coalesce(array( + SELECT t.name + FROM tags t, posts_tags pt + WHERE t.id = pt.tag_id AND pt.post_id = p.id + ORDER BY t.name + ), '{}'::text[]), ' ') + ) + } + + if id + conds << "WHERE p.id = ?" + cond_params << id + end + + sql = [sql, conds].join(" ") + execute_sql sql, *cond_params + end + + # new, previous and latest are History objects for cached_tags. Split + # the tag changes apart. + def tag_changes(new, previous, latest) + new_tags = new.value.scan(/\S+/) + old_tags = (previous.value rescue "").scan(/\S+/) + latest_tags = latest.value.scan(/\S+/) + + { + :added_tags => new_tags - old_tags, + :removed_tags => old_tags - new_tags, + :unchanged_tags => new_tags & old_tags, + :obsolete_added_tags => (new_tags - old_tags) - latest_tags, + :obsolete_removed_tags => (old_tags - new_tags) & latest_tags, + } + end + end + + def self.included(m) + m.extend ClassMethods + m.before_save :commit_metatags + m.after_save :commit_tags + m.after_save :save_post_history + m.has_many :tag_history, :class_name => "PostTagHistory", :table_name => "post_tag_histories", :order => "id desc" + m.versioned :source, :default => "" + m.versioned :cached_tags + end + + def cached_tags_undo(change, redo_changes=false) + current_tags = self.cached_tags.scan(/\S+/) + prev = change.previous + + change, prev = prev, change if redo_changes + changes = Post.tag_changes(change, prev, change.latest) + new_tags = (current_tags - changes[:added_tags]) | changes[:removed_tags] + self.attributes = {:tags => new_tags.join(" ")} + end + + def cached_tags_redo(change) + cached_tags_undo(change, true) + end + + # === Parameters + # * :tag:: the tag to search for + def has_tag?(tag) + return cached_tags =~ /(^|\s)#{tag}($|\s)/ + end + + # Returns the tags in a URL suitable string + def tag_title + return title_tags.gsub(/\W+/, "-")[0, 50] + end + + # Return the tags we display in URLs, page titles, etc. + def title_tags + ret = "" + ret << "hentai " if self.rating == "e" + ret << cached_tags + ret + end + + def tags + cached_tags + end + + # Sets the tags for the post. Does not actually save anything to the database when called. + # + # === Parameters + # * :tags:: a whitespace delimited list of tags + def tags=(tags) + self.new_tags = Tag.scan_tags(tags) + + current_tags = cached_tags.scan(/\S+/) + self.touch_change_seq! if new_tags != current_tags + end + + # Returns all versioned tags and metatags. + def cached_tags_versioned + ["rating:" + self.rating, cached_tags].map.join(" ") + end + + # Commit metatags; this is done before save, so any changes are stored normally. + def commit_metatags + return if new_tags.nil? + + transaction do + metatags, self.new_tags = new_tags.partition {|x| x=~ /^(hold|unhold|show|hide|\+flag)$/} + metatags.each do |metatag| + case metatag + when /^hold$/ + self.is_held = true + + when /^unhold$/ + self.is_held = false + + when /^show$/ + self.is_shown_in_index = true + + when /^hide$/ + self.is_shown_in_index = false + + when /^\+flag$/ + # Permissions for this are checked on commit. + self.metatag_flagged = "moderator flagged" + end + end + end + end + + # Commit any tag changes to the database. This is done after save, so any changes + # must be made directly to the database. + def commit_tags + return if new_tags.nil? + + if old_tags + # If someone else committed changes to this post before we did, + # then try to merge the tag changes together. + current_tags = cached_tags.scan(/\S+/) + self.old_tags = Tag.scan_tags(old_tags) + self.new_tags = (current_tags + new_tags) - old_tags + (current_tags & new_tags) + end + + metatags, self.new_tags = new_tags.partition {|x| x=~ /^(?:-pool|pool|rating|parent):/} + + transaction do + metatags.each do |metatag| + case metatag + when /^pool:(.+)/ + begin + name, seq = $1.split(":") + + pool = Pool.find_by_name(name) + options = {:user => User.find(updater_user_id)} + if defined?(seq) then + options[:sequence] = seq + end + + if pool.nil? and name !~ /^\d+$/ + pool = Pool.create(:name => name, :is_public => false, :user_id => updater_user_id) + end + + next if Thread.current["danbooru-user"] && !pool.can_change?(Thread.current["danbooru-user"], nil) + pool.add_post(id, options) if pool + rescue Pool::PostAlreadyExistsError + rescue Pool::AccessDeniedError + end + + + when /^-pool:(.+)/ + name = $1 + pool = Pool.find_by_name(name) + next if Thread.current["danbooru-user"] && !pool.can_change?(Thread.current["danbooru-user"], nil) + + pool.remove_post(id) if pool + + when /^rating:([qse])/ + self.rating = $1 # so we don't have to reload for history_tag_string below + execute_sql("UPDATE posts SET rating = ? WHERE id = ?", $1, id) + + + when /^parent:(\d*)/ + self.parent_id = $1 + + if CONFIG["enable_parent_posts"] && (Post.exists?(parent_id) or parent_id == 0) + Post.set_parent(id, parent_id) + end + end + end + + self.new_tags << "tagme" if new_tags.empty? + self.new_tags = TagAlias.to_aliased(new_tags) + self.new_tags = TagImplication.with_implied(new_tags).uniq + + # TODO: be more selective in deleting from the join table + execute_sql("DELETE FROM posts_tags WHERE post_id = ?", id) + self.new_tags = new_tags.map {|x| Tag.find_or_create_by_name(x)}.uniq + + # Tricky: Postgresql's locking won't serialize this DELETE/INSERT, so it's + # possible for two simultaneous updates to both delete all tags, then insert + # them, duplicating them all. + # + # Work around this by selecting the existing tags within the INSERT and removing + # any that already exist. Normally, the inner SELECT will return no rows; if + # another process inserts rows before our INSERT, it'll return the rows that it + # inserted and we'll avoid duplicating them. + tag_set = new_tags.map {|x| ("(#{id}, #{x.id})")}.join(", ") + #execute_sql("INSERT INTO posts_tags (post_id, tag_id) VALUES " + tag_set) + sql = <<-EOS + INSERT INTO posts_tags (post_id, tag_id) + SELECT t.post_id, t.tag_id + FROM (VALUES #{tag_set}) AS t(post_id, tag_id) + WHERE t.tag_id NOT IN (SELECT tag_id FROM posts_tags pt WHERE pt.post_id = #{self.id}) + EOS + + execute_sql(sql) + + Post.recalculate_cached_tags(self.id) + + # Store the old cached_tags, so we can expire them. + self.old_cached_tags = self.cached_tags + self.cached_tags = select_value_sql("SELECT cached_tags FROM posts WHERE id = #{id}") + + self.new_tags = nil + end + end + + def save_post_history + new_cached_tags = cached_tags_versioned + if tag_history.empty? or tag_history.first.tags != new_cached_tags + PostTagHistory.create(:post_id => id, :tags => new_cached_tags, + :user_id => Thread.current["danbooru-user_id"], + :ip_addr => Thread.current["danbooru-ip_addr"] || "127.0.0.1") + end + end +end diff --git a/app/models/post/vote_methods.rb b/app/models/post/vote_methods.rb new file mode 100644 index 00000000..4d5b7d72 --- /dev/null +++ b/app/models/post/vote_methods.rb @@ -0,0 +1,71 @@ +module PostVoteMethods + module ClassMethods + def recalculate_score(id=nil) + conds = [] + cond_params = [] + + sql = "UPDATE posts AS p SET score = " + + "(SELECT COALESCE(SUM(GREATEST(?, LEAST(?, score))), 0) FROM post_votes v WHERE v.post_id = p.id) " + + "+ p.anonymous_votes" + cond_params << CONFIG["vote_sum_min"] + cond_params << CONFIG["vote_sum_max"] + + if id + conds << "WHERE p.id = ?" + cond_params << id + end + + sql = [sql, conds].join(" ") + execute_sql sql, *cond_params + end + end + + def self.included(m) + m.extend(ClassMethods) + end + + def recalculate_score!() + save! + Post.recalculate_score(self.id) + connection.clear_query_cache + reload + end + + def vote!(score, user, ip_addr, options={}) + score = CONFIG["vote_record_min"] if score < CONFIG["vote_record_min"] + score = CONFIG["vote_record_max"] if score > CONFIG["vote_record_max"] + + if user.is_anonymous? + score = 0 if score < 0 + score = 1 if score > 1 + + if last_voter_ip == ip_addr + return false + end + + self.anonymous_votes += score + self.last_voter_ip = ip_addr + self.last_vote = score + else + vote = PostVotes.find_by_ids(user.id, self.id) + + if ip_addr and last_voter_ip == ip_addr and not vote + # The user voted anonymously, then logged in and tried to vote again. A user + # may be browsing anonymously, decide to make an account, then once he has access + # to full voting, decide to set his permanent vote. Just undo the anonymous vote. + self.anonymous_votes -= self.last_vote + self.last_vote = 0 + end + + if not vote + vote = PostVotes.find_or_create_by_id(user.id, self.id) + end + + vote.update_attributes(:score => score, :updated_at => Time.now) + end + + recalculate_score! + + return true + end +end diff --git a/app/models/post_tag_history.rb b/app/models/post_tag_history.rb new file mode 100644 index 00000000..85b7ab06 --- /dev/null +++ b/app/models/post_tag_history.rb @@ -0,0 +1,102 @@ +class PostTagHistory < ActiveRecord::Base + belongs_to :user + belongs_to :post + + def self.undo_user_changes(user_id) + posts = Post.find(:all, :joins => "join post_tag_histories pth on pth.post_id = posts.id", :select => "distinct posts.id", :conditions => ["pth.user_id = ?", user_id]) + puts posts.size + p posts.map {|x| x.id} +# destroy_all(["user_id = ?", user_id]) +# posts.each do |post| +# post.tags = post.tag_history.first.tags +# post.updater_user_id = post.tag_history.first.user_id +# post.updater_ip_addr = post.tag_history.first.ip_addr +# post.save! +# end + end + + def self.generate_sql(options = {}) + Nagato::Builder.new do |builder, cond| + cond.add_unless_blank "post_tag_histories.post_id = ?", options[:post_id] + cond.add_unless_blank "post_tag_histories.user_id = ?", options[:user_id] + + if options[:user_name] + builder.join "users ON users.id = post_tag_histories.user_id" + cond.add "users.name = ?", options[:user_name] + end + end.to_hash + end + + def self.undo_changes_by_user(user_id) + transaction do + posts = Post.find(:all, :joins => "join post_tag_histories pth on pth.post_id = posts.id", :select => "distinct posts.*", :conditions => ["pth.user_id = ?", user_id]) + + PostTagHistory.destroy_all(["user_id = ?", user_id]) + posts.each do |post| + first = post.tag_history.first + if first + post.tags = first.tags + post.updater_ip_addr = first.ip_addr + post.updater_user_id = first.user_id + post.save! + end + end + end + end + + # The contents of options[:posts] must be saved by the caller. This allows + # undoing many tag changes across many posts; all †changes to a particular + # post will be condensed into one change. + def undo(options={}) + # TODO: refactor. modifying parameters is a bad habit. + options[:posts] ||= {} + options[:posts][post_id] ||= options[:post] = Post.find(post_id) + post = options[:posts][post_id] + + current_tags = post.cached_tags.scan(/\S+/) + + prev = previous + return if not prev + + changes = tag_changes(prev) + + new_tags = (current_tags - changes[:added_tags]) | changes[:removed_tags] + options[:update_options] ||= {} + post.attributes = {:tags => new_tags.join(" ")}.merge(options[:update_options]) + end + + def author + User.find_name(user_id) + end + + def tag_changes(prev) + new_tags = tags.scan(/\S+/) + old_tags = (prev.tags rescue "").scan(/\S+/) + latest = Post.find(post_id).cached_tags_versioned + latest_tags = latest.scan(/\S+/) + + { + :added_tags => new_tags - old_tags, + :removed_tags => old_tags - new_tags, + :unchanged_tags => new_tags & old_tags, + :obsolete_added_tags => (new_tags - old_tags) - latest_tags, + :obsolete_removed_tags => (old_tags - new_tags) & latest_tags, + } + end + + def next + return PostTagHistory.find(:first, :order => "id ASC", :conditions => ["post_id = ? AND id > ?", post_id, id]) + end + + def previous + PostTagHistory.find(:first, :order => "id DESC", :conditions => ["post_id = ? AND id < ?", post_id, id]) + end + + def to_xml(options = {}) + {:id => id, :post_id => post_id, :tags => tags}.to_xml(options.merge(:root => "tag_history")) + end + + def to_json(*args) + {:id => id, :post_id => post_id, :tags => tags}.to_json(*args) + end +end diff --git a/app/models/post_votes.rb b/app/models/post_votes.rb new file mode 100644 index 00000000..b7233812 --- /dev/null +++ b/app/models/post_votes.rb @@ -0,0 +1,18 @@ +class PostVotes < ActiveRecord::Base + belongs_to :post, :class_name => "Post", :foreign_key => :post_id + belongs_to :user, :class_name => "User", :foreign_key => :user_id + + def self.find_by_ids(user_id, post_id) + self.find(:first, :conditions => ["user_id = ? AND post_id = ?", user_id, post_id]) + end + + def self.find_or_create_by_id(user_id, post_id) + entry = self.find_by_ids(user_id, post_id) + + if entry + return entry + else + return create(:user_id => user_id, :post_id => post_id) + end + end +end diff --git a/app/models/report_mailer.rb b/app/models/report_mailer.rb new file mode 100644 index 00000000..b751d9f5 --- /dev/null +++ b/app/models/report_mailer.rb @@ -0,0 +1,10 @@ +class ReportMailer < ActionMailer::Base + default_url_options["host"] = CONFIG["server_host"] + + def moderator_report(email) + recipients email + from CONFIG["email_from"] + subject "#{CONFIG['app_name']} - Moderator Report" + content_type "text/html" + end +end diff --git a/app/models/server_key.rb b/app/models/server_key.rb new file mode 100644 index 00000000..57b7956d --- /dev/null +++ b/app/models/server_key.rb @@ -0,0 +1,9 @@ +class ServerKey < ActiveRecord::Base + def self.[](key) + begin + ActiveRecord::Base.connection.select_value("SELECT value FROM server_keys WHERE name = '#{key}'") + rescue Exception + nil + end + end +end diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 00000000..2d8e8121 --- /dev/null +++ b/app/models/tag.rb @@ -0,0 +1,111 @@ +Dir["#{RAILS_ROOT}/app/models/tag/**/*.rb"].each {|x| require_dependency x} + +class Tag < ActiveRecord::Base + include TagTypeMethods + include TagCacheMethods if CONFIG["enable_caching"] + include TagRelatedTagMethods + include TagParseMethods + include TagApiMethods + + def self.count_by_period(start, stop, options = {}) + options[:limit] ||= 50 + options[:exclude_types] ||= [] + sql = <<-SQL + SELECT + COUNT(pt.tag_id) AS post_count, + (SELECT name FROM tags WHERE id = pt.tag_id) AS name + FROM posts p, posts_tags pt, tags t + WHERE p.created_at BETWEEN ? AND ? AND + p.id = pt.post_id AND + pt.tag_id = t.id AND + t.tag_type IN (?) + GROUP BY pt.tag_id + ORDER BY post_count DESC + LIMIT ? + SQL + + tag_types_to_show = Tag.tag_type_indexes - options[:exclude_types] + counts = select_all_sql(sql, start, stop, tag_types_to_show, options[:limit]) + end + + def self.tag_type_indexes + CONFIG["tag_types"].keys.select { |x| x =~ /^[A-Z]/ }.inject([]) { |all, x| + all << CONFIG["tag_types"][x] + }.sort + end + + def pretty_name + name + end + + def self.find_or_create_by_name(name) + name = name.downcase.tr(" ", "_").gsub(/^[-~]+/, "") + + ambiguous = false + tag_type = nil + + if name =~ /^ambiguous:(.+)/ + ambiguous = true + name = $1 + end + + if name =~ /^(.+?):(.+)$/ && CONFIG["tag_types"][$1] + tag_type = CONFIG["tag_types"][$1] + name = $2 + end + + tag = find_by_name(name) + + if tag + if tag_type + tag.update_attributes(:tag_type => tag_type) + end + + if ambiguous + tag.update_attributes(:is_ambiguous => ambiguous) + end + + return tag + else + create(:name => name, :tag_type => tag_type || CONFIG["tag_types"]["General"], :cached_related_expires_on => Time.now, :is_ambiguous => ambiguous) + end + end + + def self.select_ambiguous(tags) + return [] if tags.blank? + return select_values_sql("SELECT name FROM tags WHERE name IN (?) AND is_ambiguous = TRUE ORDER BY name", tags) + end + + def self.purge_tags + sql = + "DELETE FROM tags " + + "WHERE post_count = 0 AND " + + "id NOT IN (SELECT alias_id FROM tag_aliases UNION SELECT predicate_id FROM tag_implications UNION SELECT consequent_id FROM tag_implications)" + execute_sql sql + end + + def self.recalculate_post_count + sql = "UPDATE tags SET post_count = (SELECT COUNT(*) FROM posts_tags pt, posts p WHERE pt.tag_id = tags.id AND pt.post_id = p.id AND p.status <> 'deleted')" + execute_sql sql + end + + def self.mass_edit(start_tags, result_tags, updater_id, updater_ip_addr) + Post.find_by_tags(start_tags).each do |p| + start = TagAlias.to_aliased(Tag.scan_tags(start_tags)) + result = TagAlias.to_aliased(Tag.scan_tags(result_tags)) + tags = (p.cached_tags.scan(/\S+/) - start + result).join(" ") + p.update_attributes(:updater_user_id => updater_id, :updater_ip_addr => updater_ip_addr, :tags => tags) + end + end + + def self.find_suggestions(query) + if query.include?("_") && query.index("_") == query.rindex("_") + # Contains only one underscore + search_for = query.split(/_/).reverse.join("_").to_escaped_for_sql_like + else + search_for = "%" + query.to_escaped_for_sql_like + "%" + end + + Tag.find(:all, :conditions => ["name LIKE ? ESCAPE E'\\\\' AND name <> ?", search_for, query], :order => "post_count DESC", :limit => 6, :select => "name").map(&:name).sort + end +end diff --git a/app/models/tag/api_methods.rb b/app/models/tag/api_methods.rb new file mode 100644 index 00000000..f1b16d16 --- /dev/null +++ b/app/models/tag/api_methods.rb @@ -0,0 +1,19 @@ +module TagApiMethods + def api_attributes + return { + :id => id, + :name => name, + :count => post_count, + :type => tag_type, + :ambiguous => is_ambiguous + } + end + + def to_xml(options = {}) + api_attributes.to_xml(options.merge(:root => "tag")) + end + + def to_json(*args) + api_attributes.to_json(*args) + end +end diff --git a/app/models/tag/cache_methods.rb b/app/models/tag/cache_methods.rb new file mode 100644 index 00000000..dd11f76f --- /dev/null +++ b/app/models/tag/cache_methods.rb @@ -0,0 +1,10 @@ +module TagCacheMethods + def self.included(m) + m.after_save :update_cache + end + + def update_cache + Cache.put("tag_type:#{name}", self.class.type_name_from_value(tag_type)) + end +end + diff --git a/app/models/tag/parse_methods.rb b/app/models/tag/parse_methods.rb new file mode 100644 index 00000000..bbdcc4c6 --- /dev/null +++ b/app/models/tag/parse_methods.rb @@ -0,0 +1,158 @@ +module TagParseMethods + module ClassMethods + def scan_query(query) + query.to_s.downcase.scan(/\S+/).uniq + end + + def scan_tags(tags) + tags.to_s.gsub(/[*%,]/, "").downcase.scan(/\S+/).uniq + end + + def parse_cast(x, type) + if type == :integer + x.to_i + elsif type == :float + x.to_f + elsif type == :date + begin + x.to_date + rescue Exception + nil + end + end + end + + def parse_helper(range, type = :integer) + # "1", "0.5", "5.", ".5": + # (-?(\d+(\.\d*)?|\d*\.\d+)) + case range + when /^(.+?)\.\.(.+)/ + return [:between, parse_cast($1, type), parse_cast($2, type)] + + when /^<=(.+)/, /^\.\.(.+)/ + return [:lte, parse_cast($1, type)] + + when /^<(.+)/ + return [:lt, parse_cast($1, type)] + + when /^>=(.+)/, /^(.+)\.\.$/ + return [:gte, parse_cast($1, type)] + + when /^>(.+)/ + return [:gt, parse_cast($1, type)] + + else + return [:eq, parse_cast(range, type)] + + end + end + + # Parses a query into three sets of tags: reject, union, and intersect. + # + # === Parameters + # * +query+: String, array, or nil. The query to parse. + # * +options+: A hash of options. + def parse_query(query, options = {}) + q = Hash.new {|h, k| h[k] = []} + + scan_query(query).each do |token| + if token =~ /^(unlocked|deleted|user|favtag|vote|-vote|fav|md5|-rating|rating|width|height|mpixels|score|source|id|date|pool|-pool|pool_posts|parent|order|change|holds|shown|limit):(.+)$/ + if $1 == "user" + q[:user] = $2 + elsif $1 == "vote" + vote, user = $2.split(":") + user_id = User.find_by_name_nocase(user).id rescue nil + q[:vote] = [parse_helper(vote), user_id] + elsif $1 == "-vote" + q[:vote_negated] = User.find_by_name_nocase($2).id rescue nil + q[:error] = "no user named %s" % user if q[:vote_negated].nil? + elsif $1 == "fav" + q[:fav] = $2 + elsif $1 == "favtag" + q[:favtag] = $2 + elsif $1 == "md5" + q[:md5] = $2 + elsif $1 == "-rating" + q[:rating_negated] = $2 + elsif $1 == "rating" + q[:rating] = $2 + elsif $1 == "id" + q[:post_id] = parse_helper($2) + elsif $1 == "width" + q[:width] = parse_helper($2) + elsif $1 == "height" + q[:height] = parse_helper($2) + elsif $1 == "mpixels" + q[:mpixels] = parse_helper($2, :float) + elsif $1 == "score" + q[:score] = parse_helper($2) + elsif $1 == "source" + q[:source] = $2.to_escaped_for_sql_like + "%" + elsif $1 == "date" + q[:date] = parse_helper($2, :date) + elsif $1 == "pool" + q[:pool] = $2 + if q[:pool] =~ /^(\d+)$/ + q[:pool] = q[:pool].to_i + end + elsif $1 == "-pool" + pool = $2 + if pool =~ /^(\d+)$/ + pool = pool.to_i + end + q[:exclude_pools] ||= [] + q[:exclude_pools] << pool + elsif $1 == "pool_posts" + q[:pool_posts] = $2 + elsif $1 == "parent" + if $2 == "none" + q[:parent_id] = false + else + q[:parent_id] = $2.to_i + end + elsif $1 == "order" + q[:order] = $2 + elsif $1 == "unlocked" + if $2 == "rating" + q[:unlocked_rating] = true + end + elsif $1 == "deleted" && $2 == "true" + q[:deleted_only] = true + elsif $1 == "change" + q[:change] = parse_helper($2) + elsif $1 == "shown" + q[:shown_in_index] = ($2 == "true") + elsif $1 == "holds" + if $2 == "only" + q[:show_holds_only] = true + elsif $2 == "true" + q[:show_holds_only] = false # all posts, held or not + end + elsif $1 == "limit" + q[:limit] = $2 + end + elsif token[0] == ?- + q[:exclude] << token[1..-1] + elsif token[0] == ?~ + q[:include] << token[1..-1] + elsif token.include?("*") + q[:include] += find(:all, :conditions => ["name LIKE ? ESCAPE E'\\\\'", token.to_escaped_for_sql_like], :select => "name, post_count", :limit => 25, :order => "post_count DESC").map {|i| i.name} + else + q[:related] << token + end + end + + unless options[:skip_aliasing] + q[:exclude] = TagAlias.to_aliased(q[:exclude]) if q.has_key?(:exclude) + q[:include] = TagAlias.to_aliased(q[:include]) if q.has_key?(:include) + q[:related] = TagAlias.to_aliased(q[:related]) if q.has_key?(:related) + end + + return q + end + end + + def self.included(m) + m.extend(ClassMethods) + end +end diff --git a/app/models/tag/related_tag_methods.rb b/app/models/tag/related_tag_methods.rb new file mode 100644 index 00000000..93607437 --- /dev/null +++ b/app/models/tag/related_tag_methods.rb @@ -0,0 +1,93 @@ +module TagRelatedTagMethods + module ClassMethods + def calculate_related_by_type(tag, type, limit = 25) + if CONFIG["enable_caching"] && tag.size < 230 + results = Cache.get("reltagsbytype/#{type}/#{tag}") + + if results + return JSON.parse(results) + end + end + + sql = <<-EOS + SELECT (SELECT name FROM tags WHERE id = pt0.tag_id) AS name, + COUNT(pt0.tag_id) AS post_count + FROM posts_tags pt0, posts_tags pt1 + WHERE pt0.post_id = pt1.post_id + AND (SELECT TRUE FROM POSTS p0 WHERE p0.id = pt0.post_id AND p0.status <> 'deleted') + AND pt1.tag_id = (SELECT id FROM tags WHERE name = ?) + AND pt0.tag_id IN (SELECT id FROM tags WHERE tag_type = ?) + GROUP BY pt0.tag_id + ORDER BY post_count DESC + LIMIT ? + EOS + + results = select_all_sql(sql, tag, type, limit) + + if CONFIG["enable_caching"] && tag.size < 230 + post_count = (Tag.find_by_name(tag).post_count rescue 0) / 3 + post_count = 12 if post_count < 12 + post_count = 200 if post_count > 200 + + Cache.put("reltagsbytype/#{type}/#{tag}", results.map {|x| {"name" => x["name"], "post_count" => x["post_count"]}}.to_json, post_count.hours) + end + + return results + end + + def calculate_related(tags) + tags = Array(tags) + return [] if tags.empty? + + from = ["posts_tags pt0"] + cond = ["pt0.post_id = pt1.post_id"] + sql = "" + + # Ignore deleted posts in pt0, so the count excludes them. + cond << "(SELECT TRUE FROM POSTS p0 WHERE p0.id = pt0.post_id AND p0.status <> 'deleted')" + + (1..tags.size).each {|i| from << "posts_tags pt#{i}"} + (2..tags.size).each {|i| cond << "pt1.post_id = pt#{i}.post_id"} + (1..tags.size).each {|i| cond << "pt#{i}.tag_id = (SELECT id FROM tags WHERE name = ?)"} + + sql << "SELECT (SELECT name FROM tags WHERE id = pt0.tag_id) AS tag, COUNT(pt0.*) AS tag_count" + sql << " FROM " << from.join(", ") + sql << " WHERE " << cond.join(" AND ") + sql << " GROUP BY pt0.tag_id" + sql << " ORDER BY tag_count DESC LIMIT 25" + + return select_all_sql(sql, *tags).map {|x| [x["tag"], x["tag_count"]]} + end + + def find_related(tags) + if tags.is_a?(Array) && tags.size > 1 + return calculate_related(tags) + elsif tags.to_s != "" + t = find_by_name(tags.to_s) + if t + return t.related + end + end + + return [] + end + end + + def self.included(m) + m.extend(ClassMethods) + end + + def related + if Time.now > cached_related_expires_on + length = post_count / 3 + length = 12 if length < 12 + length = 8760 if length > 8760 + + execute_sql("UPDATE tags SET cached_related = ?, cached_related_expires_on = ? WHERE id = ?", self.class.calculate_related(name).flatten.join(","), length.hours.from_now, id) + reload + end + + return cached_related.split(/,/).in_groups_of(2) + end +end + diff --git a/app/models/tag/type_methods.rb b/app/models/tag/type_methods.rb new file mode 100644 index 00000000..5a0c8ab4 --- /dev/null +++ b/app/models/tag/type_methods.rb @@ -0,0 +1,92 @@ +module TagTypeMethods + module ClassMethods + attr_accessor :type_map + + # Find the type name for a type value. + # + # === Parameters + # * :type_value:: The tag type value to search for + def type_name_from_value(type_value) + type_map[type_value] + end + + def type_name_helper(tag_name) # :nodoc: + tag = Tag.find(:first, :conditions => ["name = ?", tag_name], :select => "tag_type") + + if tag == nil + "general" + else + type_map[tag.tag_type] + end + end + + # Find the tag type name of a tag. + # + # === Parameters + # * :tag_name:: The tag name to search for + def type_name(tag_name) + tag_name = tag_name.gsub(/\s/, "_") + + if CONFIG["enable_caching"] + return Cache.get("tag_type:#{tag_name}", 1.day) do + type_name_helper(tag_name) + end + else + type_name_helper(tag_name) + end + end + + # Given an array of tags, remove tags to reduce the joined length to <= max_len. + def compact_tags(tags, max_len) + return tags if tags.length < max_len + + split_tags = tags.split(/ /) + + # Put long tags first, so we don't remove every tag because of one very long one. + split_tags.sort! do |a,b| b.length <=> a.length end + + # Tag types that we're allowed to remove: + length = tags.length + split_tags.each_index do |i| + length -= split_tags[i].length + 1 + split_tags[i] = nil + break if length <= max_len + end + + split_tags.compact! + split_tags.sort! + return split_tags.join(" ") + end + + def tag_list_order(tag_type) + case tag_type + when "artist": 0 + when "circle": 1 + when "copyright": 2 + when "character": 3 + when "general": 5 + when "faults": 6 + else 4 + end + end + end + + def self.included(m) + m.extend(ClassMethods) + m.versioned :tag_type + m.versioned :is_ambiguous, :default => false + + m.versioning_display :action => "edit" + + # This maps ids to names + m.type_map = CONFIG["tag_types"].keys.select {|x| x =~ /^[A-Z]/}.inject({}) {|all, x| all[CONFIG["tag_types"][x]] = x.downcase; all} + end + + def type_name + self.class.type_name_from_value(tag_type) + end + + def pretty_type_name + type_name.capitalize + end +end diff --git a/app/models/tag_alias.rb b/app/models/tag_alias.rb new file mode 100644 index 00000000..4c9d5d9e --- /dev/null +++ b/app/models/tag_alias.rb @@ -0,0 +1,88 @@ +class TagAlias < ActiveRecord::Base + before_create :normalize + before_create :validate_uniqueness + + # Maps tags to their preferred names. Returns an array of strings. + # + # === Parameters + # * :tags>:: list of tags to transform. + def self.to_aliased(tags) + Array(tags).inject([]) do |aliased_tags, tag_name| + aliased_tags << to_aliased_helper(tag_name) + end + end + + def self.to_aliased_helper(tag_name) + # TODO: add memcached support + tag = find(:first, :select => "tags.name AS name", :joins => "JOIN tags ON tags.id = tag_aliases.alias_id", :conditions => ["tag_aliases.name = ? AND tag_aliases.is_pending = FALSE", tag_name]) + tag ? tag.name : tag_name + end + + # Destroys the alias and sends a message to the alias's creator. + def destroy_and_notify(current_user, reason) + if creator_id && creator_id != current_user.id + msg = "A tag alias you submitted (#{name} → #{alias_name}) was deleted for the following reason: #{reason}." + Dmail.create(:from_id => current_user.id, :to_id => creator_id, :title => "One of your tag aliases was deleted", :body => msg) + end + + destroy + end + + # Strips out any illegal characters and makes sure the name is lowercase. + def normalize + self.name = name.downcase.gsub(/ /, "_").gsub(/^[-~]+/, "") + end + + # Makes sure the alias does not conflict with any other aliases. + def validate_uniqueness + if self.class.exists?(["name = ?", name]) + errors.add_to_base("#{name} is already aliased to something") + return false + end + + if self.class.exists?(["alias_id = (select id from tags where name = ?)", name]) + errors.add_to_base("#{name} is already aliased to something") + return false + end + + if self.class.exists?(["name = ?", alias_name]) + errors.add_to_base("#{alias_name} is already aliased to something") + return false + end + end + + def alias=(name) + tag = Tag.find_or_create_by_name(name) + self.alias_id = tag.id + end + + def alias_name + Tag.find(alias_id).name + end + + def approve(user_id, ip_addr) + execute_sql("UPDATE tag_aliases SET is_pending = FALSE WHERE id = ?", id) + + Post.find(:all, :conditions => ["id IN (SELECT pt.post_id FROM posts_tags pt WHERE pt.tag_id = (SELECT id FROM tags WHERE name = ?))", name]).each do |post| + post.reload + post.update_attributes(:tags => post.cached_tags, :updater_user_id => user_id, :updater_ip_addr => ip_addr) + end + end + + def api_attributes + return { + :id => id, + :name => name, + :alias_id => alias_id, + :pending => is_pending + } + end + + def to_xml(options = {}) + api_attributes.to_xml(options.merge(:root => "tag_alias")) + end + + def to_json(*args) + return api_attributes.to_json(*args) + end +end diff --git a/app/models/tag_implication.rb b/app/models/tag_implication.rb new file mode 100644 index 00000000..842e210d --- /dev/null +++ b/app/models/tag_implication.rb @@ -0,0 +1,87 @@ +class TagImplication < ActiveRecord::Base + before_create :validate_uniqueness + + def validate_uniqueness + if self.class.find(:first, :conditions => ["(predicate_id = ? AND consequent_id = ?) OR (predicate_id = ? AND consequent_id = ?)", predicate_id, consequent_id, consequent_id, predicate_id]) + self.errors.add_to_base("Tag implication already exists") + return false + end + end + + # Destroys the alias and sends a message to the alias's creator. + def destroy_and_notify(current_user, reason) + if creator_id && creator_id != current_user.id + msg = "A tag implication you submitted (#{predicate.name} → #{consequent.name}) was deleted for the following reason: #{reason}." + + Dmail.create(:from_id => current_user.id, :to_id => creator_id, :title => "One of your tag implications was deleted", :body => msg) + end + + destroy + end + + def predicate + return Tag.find(self.predicate_id) + end + + def consequent + return Tag.find(self.consequent_id) + end + + def predicate=(name) + t = Tag.find_or_create_by_name(name) + self.predicate_id = t.id + end + + def consequent=(name) + t = Tag.find_or_create_by_name(name) + self.consequent_id = t.id + end + + def approve(user_id, ip_addr) + connection.execute("UPDATE tag_implications SET is_pending = FALSE WHERE id = #{self.id}") + + p = Tag.find(self.predicate_id) + implied_tags = self.class.with_implied(p.name).join(" ") + Post.find(:all, :conditions => Tag.sanitize_sql(["id IN (SELECT pt.post_id FROM posts_tags pt WHERE pt.tag_id = ?)", p.id])).each do |post| + post.reload + post.update_attributes(:tags => post.cached_tags + " " + implied_tags, :updater_user_id => user_id, :updater_ip_addr => ip_addr) + end + end + + def self.with_implied(tags) + return [] if tags.blank? + all = [] + + tags.each do |tag| + all << tag + results = [tag] + + 10.times do + results = connection.select_values(sanitize_sql([<<-SQL, results])) + SELECT t1.name + FROM tags t1, tags t2, tag_implications ti + WHERE ti.predicate_id = t2.id + AND ti.consequent_id = t1.id + AND t2.name IN (?) + AND ti.is_pending = FALSE + SQL + + if results.any? + all += results + else + break + end + end + end + + return all + end + + def to_xml(options = {}) + {:id => id, :consequent_id => consequent_id, :predicate_id => predicate_id, :pending => is_pending}.to_xml(options.merge(:root => "tag_implication")) + end + + def to_json(*args) + {:id => id, :consequent_id => consequent_id, :predicate_id => predicate_id, :pending => is_pending}.to_json(*args) + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 00000000..1cfea790 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,668 @@ +require 'digest/sha1' + +class User < ActiveRecord::Base + class AlreadyFavoritedError < Exception; end + + module UserBlacklistMethods + # TODO: I don't see the advantage of normalizing these. Since commas are illegal + # characters in tags, they can be used to separate lines (with whitespace separating + # tags). Denormalizing this into a field in users would save a SQL query. + def self.included(m) + m.after_save :commit_blacklists + m.after_create :set_default_blacklisted_tags + m.has_many :user_blacklisted_tags, :dependent => :delete_all, :order => :id + end + + def blacklisted_tags=(blacklists) + @blacklisted_tags = blacklists + end + + def blacklisted_tags + blacklisted_tags_array.join("\n") + "\n" + end + + def blacklisted_tags_array + user_blacklisted_tags.map {|x| x.tags} + end + + def commit_blacklists + if @blacklisted_tags + user_blacklisted_tags.clear + + @blacklisted_tags.scan(/[^\r\n]+/).each do |tags| + user_blacklisted_tags.create(:tags => tags) + end + end + end + + def set_default_blacklisted_tags + CONFIG["default_blacklists"].each do |b| + UserBlacklistedTag.create(:user_id => self.id, :tags => b) + end + end + end + + module UserAuthenticationMethods + module ClassMethods + def authenticate(name, pass) + authenticate_hash(name, sha1(pass)) + end + + def authenticate_hash(name, pass) + find(:first, :conditions => ["lower(name) = lower(?) AND password_hash = ?", name, pass]) + end + + if CONFIG["enable_account_email_activation"] + def confirmation_hash(name) + Digest::SHA256.hexdigest("~-#{name}-~#{salt}") + end + end + + def sha1(pass) + Digest::SHA1.hexdigest("#{salt}--#{pass}--") + end + end + + def self.included(m) + m.extend(ClassMethods) + end + end + + module UserPasswordMethods + attr_accessor :password + + def self.included(m) + m.before_save :encrypt_password + m.validates_length_of :password, :minimum => 5, :if => lambda {|rec| rec.password} + m.validates_confirmation_of :password + end + + def encrypt_password + self.password_hash = User.sha1(password) if password + end + + def reset_password + consonants = "bcdfghjklmnpqrstvqxyz" + vowels = "aeiou" + pass = "" + + 4.times do + pass << consonants[rand(21), 1] + pass << vowels[rand(5), 1] + end + + pass << rand(100).to_s + execute_sql("UPDATE users SET password_hash = ? WHERE id = ?", User.sha1(pass), self.id) + return pass + end + end + + module UserCountMethods + module ClassMethods + def fast_count + return select_value_sql("SELECT row_count FROM table_data WHERE name = 'users'").to_i + end + end + + def self.included(m) + m.extend(ClassMethods) + m.after_create :increment_count + m.after_destroy :decrement_count + end + + def increment_count + connection.execute("update table_data set row_count = row_count + 1 where name = 'users'") + end + + def decrement_count + connection.execute("update table_data set row_count = row_count - 1 where name = 'users'") + end + end + + module UserNameMethods + module ClassMethods + def find_name_helper(user_id) + if user_id.nil? + return CONFIG["default_guest_name"] + end + + user = find(:first, :conditions => ["id = ?", user_id], :select => "name") + + if user + return user.name + else + return CONFIG["default_guest_name"] + end + end + + def find_name(user_id) + if CONFIG["enable_caching"] + return Cache.get("user_name:#{user_id}") do + find_name_helper(user_id) + end + else + find_name_helper(user_id) + end + end + + def find_by_name(name) + find(:first, :conditions => ["lower(name) = lower(?)", name]) + end + end + + def self.included(m) + m.extend(ClassMethods) + m.validates_length_of :name, :within => 2..20, :on => :create + m.validates_format_of :name, :with => /\A[^\s;,]+\Z/, :on => :create, :message => "cannot have whitespace, commas, or semicolons" +# validates_format_of :name, :with => /^(Anonymous|[Aa]dministrator)/, :on => :create, :message => "this is a disallowed username" + m.validates_uniqueness_of :name, :case_sensitive => false, :on => :create + m.after_save :update_cached_name if CONFIG["enable_caching"] + end + + def pretty_name + name.tr("_", " ") + end + + def update_cached_name + Cache.put("user_name:#{id}", name) + end + end + + module UserApiMethods + def to_xml(options = {}) + options[:indent] ||= 2 + xml = options[:builder] ||= Builder::XmlMarkup.new(:indent => options[:indent]) + xml.post(:name => name, :id => id) do + blacklisted_tags_array.each do |t| + xml.blacklisted_tag(:tag => t) + end + + yield options[:builder] if block_given? + end + end + + def to_json(*args) + {:name => name, :blacklisted_tags => blacklisted_tags_array, :id => id}.to_json(*args) + end + end + + def self.find_by_name_nocase(name) + return User.find(:first, :conditions => ["lower(name) = lower(?)", name]) + end + + module UserTagMethods + def uploaded_tags(options = {}) + type = options[:type] + + if CONFIG["enable_caching"] + uploaded_tags = Cache.get("uploaded_tags/#{id}/#{type}") + return uploaded_tags unless uploaded_tags == nil + end + + if RAILS_ENV == "test" + # disable filtering in test mode to simplify tests + popular_tags = "" + else + popular_tags = select_values_sql("SELECT id FROM tags WHERE tag_type = #{CONFIG['tag_types']['General']} ORDER BY post_count DESC LIMIT 8").join(", ") + popular_tags = "AND pt.tag_id NOT IN (#{popular_tags})" unless popular_tags.blank? + end + + if type + sql = <<-EOS + SELECT (SELECT name FROM tags WHERE id = pt.tag_id) AS tag, COUNT(*) AS count + FROM posts_tags pt, tags t, posts p + WHERE p.user_id = #{id} + AND p.id = pt.post_id + AND pt.tag_id = t.id + #{popular_tags} + AND t.tag_type = #{type.to_i} + GROUP BY pt.tag_id + ORDER BY count DESC + LIMIT 6 + EOS + else + sql = <<-EOS + SELECT (SELECT name FROM tags WHERE id = pt.tag_id) AS tag, COUNT(*) AS count + FROM posts_tags pt, posts p + WHERE p.user_id = #{id} + AND p.id = pt.post_id + #{popular_tags} + GROUP BY pt.tag_id + ORDER BY count DESC + LIMIT 6 + EOS + end + + uploaded_tags = select_all_sql(sql) + + if CONFIG["enable_caching"] + Cache.put("uploaded_tags/#{id}/#{type}", uploaded_tags, 1.day) + end + + return uploaded_tags + end + + def voted_tags(options = {}) + type = options[:type] + + if CONFIG["enable_caching"] + favorite_tags = Cache.get("favorite_tags/#{id}/#{type}") + return favorite_tags unless favorite_tags == nil + end + + if RAILS_ENV == "test" + # disable filtering in test mode to simplify tests + popular_tags = "" + else + popular_tags = select_values_sql("SELECT id FROM tags WHERE tag_type = #{CONFIG['tag_types']['General']} ORDER BY post_count DESC LIMIT 8").join(", ") + popular_tags = "AND pt.tag_id NOT IN (#{popular_tags})" unless popular_tags.blank? + end + + if type + sql = <<-EOS + SELECT (SELECT name FROM tags WHERE id = pt.tag_id) AS tag, SUM(v.score) AS sum + FROM posts_tags pt, tags t, post_votes v + WHERE v.user_id = #{id} + AND v.post_id = pt.post_id + AND pt.tag_id = t.id + #{popular_tags} + AND t.tag_type = #{type.to_i} + GROUP BY pt.tag_id + ORDER BY sum DESC + LIMIT 6 + EOS + else + sql = <<-EOS + SELECT (SELECT name FROM tags WHERE id = pt.tag_id) AS tag, SUM(v.score) AS sum + FROM posts_tags pt, post_votes v + WHERE v.user_id = #{id} + AND v.post_id = pt.post_id + #{popular_tags} + GROUP BY pt.tag_id + ORDER BY sum DESC + LIMIT 6 + EOS + end + + favorite_tags = select_all_sql(sql) + + if CONFIG["enable_caching"] + Cache.put("favorite_tags/#{id}/#{type}", favorite_tags, 1.day) + end + + return favorite_tags + end + end + + module UserPostMethods + def recent_uploaded_posts + Post.find_by_sql("SELECT p.* FROM posts p WHERE p.user_id = #{id} AND p.status <> 'deleted' ORDER BY p.id DESC LIMIT 6") + end + + def recent_favorite_posts + Post.find_by_sql("SELECT p.* FROM posts p, post_votes v WHERE p.id = v.post_id AND v.user_id = #{id} AND v.score = 3 AND p.status <> 'deleted' ORDER BY v.id DESC LIMIT 6") + end + + def favorite_post_count(options = {}) + PostVotes.count_by_sql("SELECT COUNT(*) FROM post_votes v WHERE v.user_id = #{id} AND v.score = 3") + end + + def post_count + @post_count ||= Post.count(:conditions => ["user_id = ? AND status = 'active'", id]) + end + + def held_post_count + version = Cache.get("$cache_version").to_i + key = "held-post-count/v=#{version}/u=#{self.id}" + + return Cache.get(key) { + Post.count(:conditions => ["user_id = ? AND is_held AND status <> 'deleted'", self.id]) + }.to_i + end + end + + module UserLevelMethods + def self.included(m) + m.extend(ClassMethods) + m.attr_protected :level + m.before_create :set_role + end + + def pretty_level + return CONFIG["user_levels"].invert[self.level] + end + + def set_role + if User.fast_count == 0 + self.level = CONFIG["user_levels"]["Admin"] + elsif CONFIG["enable_account_email_activation"] + self.level = CONFIG["user_levels"]["Unactivated"] + else + self.level = CONFIG["starting_level"] + end + + self.last_logged_in_at = Time.now + end + + def has_permission?(record, foreign_key = :user_id) + if is_mod_or_higher? + true + elsif record.respond_to?(foreign_key) + record.__send__(foreign_key) == id + else + false + end + end + + # Return true if this user can change the specified attribute. + # + # If record is an ActiveRecord object, returns true if the change is allowed to complete. + # + # If record is an ActiveRecord class (eg. Pool rather than an actual pool), returns + # false if the user would never be allowed to make this change for any instance of the + # object, and so the option should not be presented. + # + # For example, can_change(Pool, :description) returns true (unless the user level + # is too low to change any pools), but can_change(Pool.find(1), :description) returns + # false if that specific pool is locked. + # + # attribute usually corresponds with an actual attribute in the class, but any value + # can be used. + def can_change?(record, attribute) + method = "can_change_#{attribute.to_s}?" + if is_mod_or_higher? + true + elsif record.respond_to?(method) + record.__send__(method, self) + elsif record.respond_to?(:can_change?) + record.can_change?(self, attribute) + else + true + end + end + + # Defines various convenience methods for finding out the user's level + CONFIG["user_levels"].each do |name, value| + normalized_name = name.downcase.gsub(/ /, "_") + define_method("is_#{normalized_name}?") do + self.level == value + end + + define_method("is_#{normalized_name}_or_higher?") do + self.level >= value + end + + define_method("is_#{normalized_name}_or_lower?") do + self.level <= value + end + end + + + module ClassMethods + def get_user_level(level) + if not @user_level then + @user_level = {} + CONFIG["user_levels"].each do |name, value| + normalized_name = name.downcase.gsub(/ /, "_").to_sym + @user_level[normalized_name] = value + end + end + @user_level[level] + end + end + end + + module UserInviteMethods + class NoInvites < Exception ; end + class HasNegativeRecord < Exception ; end + + def invite!(name, level) + if invite_count <= 0 + raise NoInvites + end + + if level.to_i >= CONFIG["user_levels"]["Contributor"] + level = CONFIG["user_levels"]["Contributor"] + end + + invitee = User.find_by_name(name) + + if invitee.nil? + raise ActiveRecord::RecordNotFound + end + + if UserRecord.exists?(["user_id = ? AND is_positive = false AND reported_by IN (SELECT id FROM users WHERE level >= ?)", invitee.id, CONFIG["user_levels"]["Mod"]]) && !is_admin? + raise HasNegativeRecord + end + + transaction do + invitee.level = level + invitee.invited_by = id + invitee.save + decrement! :invite_count + end + end + + def self.included(m) + m.attr_protected :invite_count + end + end + + module UserAvatarMethods + module ClassMethods + # post_id is being destroyed. Clear avatar_post_ids for this post, so we won't use + # avatars from this post. We don't need to actually delete the image. + def clear_avatars(post_id) + execute_sql("UPDATE users SET avatar_post_id = NULL WHERE avatar_post_id = ?", post_id) + end + end + + def self.included(m) + m.extend(ClassMethods) + m.belongs_to :avatar_post, :class_name => "Post" + end + + def avatar_url + CONFIG["url_base"] + "/data/avatars/#{self.id}.jpg" + end + + def has_avatar? + return (not self.avatar_post_id.nil?) + end + + def avatar_path + "#{RAILS_ROOT}/public/data/avatars/#{self.id}.jpg" + end + + def set_avatar(params) + post = Post.find(params[:post_id]) + if not post.can_be_seen_by?(self) + errors.add(:access, "denied") + return false + end + + if params[:top].to_f < 0 or params[:top].to_f > 1 or + params[:bottom].to_f < 0 or params[:bottom].to_f > 1 or + params[:left].to_f < 0 or params[:left].to_f > 1 or + params[:right].to_f < 0 or params[:right].to_f > 1 or + params[:top] >= params[:bottom] or + params[:left] >= params[:right] + then + errors.add(:parameter, "error") + return false + end + + tempfile_path = "#{RAILS_ROOT}/public/data/#{$PROCESS_ID}.avatar.jpg" + + def reduce_and_crop(image_width, image_height, params) + cropped_image_width = image_width * (params[:right].to_f - params[:left].to_f) + cropped_image_height = image_height * (params[:bottom].to_f - params[:top].to_f) + + size = Danbooru.reduce_to({:width=>cropped_image_width, :height=>cropped_image_height}, {:width=>CONFIG["avatar_max_width"], :height=>CONFIG["avatar_max_height"]}, 1, true) + size[:crop_top] = image_height * params[:top].to_f + size[:crop_bottom] = image_height * params[:bottom].to_f + size[:crop_left] = image_width * params[:left].to_f + size[:crop_right] = image_width * params[:right].to_f + size + end + + use_sample = post.has_sample? + if use_sample + image_path = post.sample_path + image_ext = "jpg" + size = reduce_and_crop(post.sample_width, post.sample_height, params) + + # If we're cropping from a very small region in the sample, use the full + # image instead, to get a higher quality image. + if size[:crop_bottom] - size[:crop_top] < CONFIG["avatar_max_height"] or + size[:crop_right] - size[:crop_left] < CONFIG["avatar_max_width"] then + use_sample = false + end + end + + if not use_sample + image_path = post.file_path + image_ext = post.file_ext + size = reduce_and_crop(post.width, post.height, params) + end + + begin + Danbooru.resize(image_ext, image_path, tempfile_path, size, 95) + rescue Exception => x + FileUtils.rm_f(tempfile_path) + + errors.add "avatar", "couldn't be generated (#{x})" + return false + end + + FileUtils.mv(tempfile_path, avatar_path) + FileUtils.chmod(0775, avatar_path) + + self.update_attributes( + :avatar_post_id => params[:post_id], + :avatar_top => params[:top], + :avatar_bottom => params[:bottom], + :avatar_left => params[:left], + :avatar_right => params[:right], + :avatar_width => size[:width], + :avatar_height => size[:height], + :avatar_timestamp => Time.now) + end + end + + + module UserFavoriteTagMethods + def self.included(m) + m.has_many :favorite_tags, :dependent => :delete_all + end + + def favorite_tags_text=(text) + favorite_tags.clear + + text.scan(/\S+/).slice(0, 20).each do |new_fav_tag| + favorite_tags.create(:tag_query => new_fav_tag) + end + end + + def favorite_tags_text + favorite_tags.map(&:tag_query).sort.join(" ") + end + + def favorite_tag_posts(limit) + FavoriteTag.find_posts(id, limit) + end + end + + validates_presence_of :email, :on => :create if CONFIG["enable_account_email_activation"] + validates_uniqueness_of :email, :case_sensitive => false, :on => :create, :if => lambda {|rec| not rec.email.empty?} + before_create :set_show_samples if CONFIG["show_samples"] + has_one :ban + + include UserBlacklistMethods + include UserAuthenticationMethods + include UserPasswordMethods + include UserCountMethods + include UserNameMethods + include UserApiMethods + include UserTagMethods + include UserPostMethods + include UserLevelMethods + include UserInviteMethods + include UserAvatarMethods + include UserFavoriteTagMethods + + @salt = CONFIG["user_password_salt"] + + class << self + attr_accessor :salt + end + + # For compatibility with AnonymousUser class + def is_anonymous? + false + end + + def invited_by_name + self.class.find_name(invited_by) + end + + def similar_users + # This uses a naive cosine distance formula that is very expensive to calculate. + # TODO: look into alternatives, like SVD. + sql = <<-EOS + SELECT + f0.user_id as user_id, + COUNT(*) / (SELECT sqrt((SELECT COUNT(*) FROM post_votes WHERE user_id = f0.user_id) * (SELECT COUNT(*) FROM post_votes WHERE user_id = #{id}))) AS similarity + FROM + vote v0, + vote v1, + users u + WHERE + v0.post_id = v1.post_id + AND v1.user_id = #{id} + AND v0.user_id <> #{id} + AND u.id = v0.user_id + GROUP BY v0.user_id + ORDER BY similarity DESC + LIMIT 6 + EOS + + return select_all_sql(sql) + end + + def set_show_samples + self.show_samples = true + end + + def self.generate_sql(params) + return Nagato::Builder.new do |builder, cond| + if params[:name] + cond.add "name ILIKE ? ESCAPE E'\\\\'", "%" + params[:name].to_escaped_for_sql_like + "%" + end + + if params[:level] && params[:level] != "any" + cond.add "level = ?", params[:level] + end + + cond.add_unless_blank "id = ?", params[:id] + + case params[:order] + when "name" + builder.order "lower(name)" + + when "posts" + builder.order "(SELECT count(*) FROM posts WHERE user_id = users.id) DESC" + + when "favorites" + builder.order "(SELECT count(*) FROM favorites WHERE user_id = users.id) DESC" + + when "notes" + builder.order "(SELECT count(*) FROM note_versions WHERE user_id = users.id) DESC" + + else + builder.order "id DESC" + end + end.to_hash + end +end + diff --git a/app/models/user_blacklisted_tag.rb b/app/models/user_blacklisted_tag.rb new file mode 100644 index 00000000..8e7bfc1f --- /dev/null +++ b/app/models/user_blacklisted_tag.rb @@ -0,0 +1,2 @@ +class UserBlacklistedTag < ActiveRecord::Base +end diff --git a/app/models/user_log.rb b/app/models/user_log.rb new file mode 100644 index 00000000..3f7aabc8 --- /dev/null +++ b/app/models/user_log.rb @@ -0,0 +1,17 @@ +class UserLog < ActiveRecord::Base + def self.access(user, request) + return if user.is_anonymous? + + # Only do this periodically, so we don't do extra work for every request. + old_ip = Cache.get("userip:#{user.id}") if CONFIG["enable_caching"] + + return if !old_ip.nil? && old_ip == request.remote_ip + + execute_sql("SELECT * FROM user_logs_touch(?, ?)", user.id, request.remote_ip) + + # Clean up old records. + execute_sql("DELETE FROM user_logs WHERE created_at < now() - interval '3 days'") + + Cache.put("userip:#{user.id}", request.remote_ip, 8.seconds) if CONFIG["enable_caching"] + end +end diff --git a/app/models/user_mailer.rb b/app/models/user_mailer.rb new file mode 100644 index 00000000..6e9e95a2 --- /dev/null +++ b/app/models/user_mailer.rb @@ -0,0 +1,45 @@ +begin + require 'idn' +rescue LoadError +end + +class UserMailer < ActionMailer::Base + include ActionController::UrlWriter + helper :application + default_url_options["host"] = CONFIG["server_host"] + + def confirmation_email(user) + recipients UserMailer.normalize_address(user.email) + from CONFIG["email_from"] + subject "#{CONFIG["app_name"]} - Confirm email address" + body :user => user + content_type "text/html" + end + + def new_password(user, password) + recipients UserMailer.normalize_address(user.email) + subject "#{CONFIG["app_name"]} - Password Reset" + from CONFIG["email_from"] + body :user => user, :password => password + content_type "text/html" + end + + def dmail(recipient, sender, msg_title, msg_body) + recipients UserMailer.normalize_address(recipient.email) + subject "#{CONFIG["app_name"]} - Message received from #{sender.name}" + from CONFIG["email_from"] + body :recipient => recipient, :sender => sender, :title => msg_title, :body => msg_body + content_type "text/html" + end + + def self.normalize_address(address) + if defined?(IDN) + address =~ /\A([^@]+)@(.+)\Z/ + mailbox = $1 + domain = IDN::Idna.toASCII($2) + "#{mailbox}@#{domain}" + else + address + end + end +end diff --git a/app/models/user_record.rb b/app/models/user_record.rb new file mode 100644 index 00000000..bf48e39e --- /dev/null +++ b/app/models/user_record.rb @@ -0,0 +1,17 @@ +class UserRecord < ActiveRecord::Base + belongs_to :user + belongs_to :reporter, :foreign_key => "reported_by", :class_name => "User" + validates_presence_of :user_id + validates_presence_of :reported_by + after_save :generate_dmail + + def user=(name) + self.user_id = User.find_by_name(name).id rescue nil + end + + def generate_dmail + body = "#{reporter.name} created a #{is_positive? ? 'positive' : 'negative'} record for your account. View your record." + + Dmail.create(:from_id => reported_by, :to_id => user_id, :title => "Your user record has been updated", :body => body) + end +end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb new file mode 100644 index 00000000..5f30a6fd --- /dev/null +++ b/app/models/wiki_page.rb @@ -0,0 +1,103 @@ +require 'diff' + +class WikiPage < ActiveRecord::Base + acts_as_versioned :table_name => "wiki_page_versions", :foreign_key => "wiki_page_id", :order => "updated_at DESC" + before_save :normalize_title + belongs_to :user + validates_uniqueness_of :title, :case_sensitive => false + validates_presence_of :body + + class << self + def generate_sql(options) + joins = [] + conds = [] + params = [] + + if options[:title] + conds << "wiki_pages.title = ?" + params << options[:title] + end + + if options[:user_id] + conds << "wiki_pages.user_id = ?" + params << options[:user_id] + end + + joins = joins.join(" ") + conds = [conds.join(" AND "), *params] + + return joins, conds + end + end + + def normalize_title + self.title = title.tr(" ", "_").downcase + end + + def last_version? + self.version == next_version.to_i - 1 + end + + def first_version? + self.version == 1 + end + + def author + return User.find_name(user_id) + end + + def pretty_title + title.tr("_", " ") + end + + def diff(version) + otherpage = WikiPage.find_page(title, version) + Danbooru.diff(self.body, otherpage.body) + end + + def self.find_page(title, version = nil) + return nil if title.blank? + + page = find_by_title(title) + page.revert_to(version) if version && page + + return page + end + + def self.find_by_title(title) + find(:first, :conditions => ["lower(title) = lower(?)", title.tr(" ", "_")]) + end + + def lock! + self.is_locked = true + + transaction do + execute_sql("UPDATE wiki_pages SET is_locked = TRUE WHERE id = ?", id) + execute_sql("UPDATE wiki_page_versions SET is_locked = TRUE WHERE wiki_page_id = ?", id) + end + end + + def unlock! + self.is_locked = false + + transaction do + execute_sql("UPDATE wiki_pages SET is_locked = FALSE WHERE id = ?", id) + execute_sql("UPDATE wiki_page_versions SET is_locked = FALSE WHERE wiki_page_id = ?", id) + end + end + + def rename!(new_title) + transaction do + execute_sql("UPDATE wiki_pages SET title = ? WHERE id = ?", new_title, self.id) + execute_sql("UPDATE wiki_page_versions SET title = ? WHERE wiki_page_id = ?", new_title, self.id) + end + end + + def to_xml(options = {}) + {:id => id, :created_at => created_at, :updated_at => updated_at, :title => title, :body => body, :updater_id => user_id, :locked => is_locked, :version => version}.to_xml(options.merge(:root => "wiki_page")) + end + + def to_json(*args) + {:id => id, :created_at => created_at, :updated_at => updated_at, :title => title, :body => body, :updater_id => user_id, :locked => is_locked, :version => version}.to_json(*args) + end +end diff --git a/app/models/wiki_page_version.rb b/app/models/wiki_page_version.rb new file mode 100644 index 00000000..5b8193eb --- /dev/null +++ b/app/models/wiki_page_version.rb @@ -0,0 +1,17 @@ +class WikiPageVersion < ActiveRecord::Base + def author + return User.find_name(self.user_id) + end + + def pretty_title + self.title.tr("_", " ") + end + + def to_xml(options = {}) + {:id => id, :created_at => created_at, :updated_at => updated_at, :title => title, :body => body, :updater_id => user_id, :locked => is_locked, :version => version, :post_id => post_id}.to_xml(options.merge(:root => "wiki_page_version")) + end + + def to_json(*args) + {:id => id, :created_at => created_at, :updated_at => updated_at, :title => title, :body => body, :updater_id => user_id, :locked => is_locked, :version => version, :post_id => post_id}.to_json(*args) + end +end diff --git a/app/views/admin/edit_user.html.erb b/app/views/admin/edit_user.html.erb new file mode 100644 index 00000000..0a43178b --- /dev/null +++ b/app/views/admin/edit_user.html.erb @@ -0,0 +1,21 @@ +
    + <% form_tag(:controller => "admin", :action => "edit_user") do %> + + + + + + + + + + + + +
    <%= text_field_with_auto_complete "user", "name", {}, :min_chars => 3, :url => {:controller => "user", :action => "auto_complete_for_user_name"} %>
    + + + <%= select "user", "level", CONFIG["user_levels"].to_a.select {|x| x[1] > CONFIG["user_levels"]["Blocked"]} %> +
    <%= submit_tag "Save" %>
    + <% end %> +
    diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb new file mode 100644 index 00000000..8a9d9102 --- /dev/null +++ b/app/views/admin/index.html.erb @@ -0,0 +1,9 @@ +
    + +
    diff --git a/app/views/admin/reset_password.html.erb b/app/views/admin/reset_password.html.erb new file mode 100644 index 00000000..66c20101 --- /dev/null +++ b/app/views/admin/reset_password.html.erb @@ -0,0 +1,21 @@ +
    +

    Reset Password

    + + <% form_tag(:action => "reset_password") do %> + + + + + + + + + + + + +
    + <%= submit_tag "Reset" %> +
    <%= text_field_with_auto_complete "user", "name", {}, :min_chars => 3, :url => {:controller => "user", :action => "auto_complete_for_user_name"} %>
    + <% end %> +
    diff --git a/app/views/advertisement/show_stats.html.erb b/app/views/advertisement/show_stats.html.erb new file mode 100644 index 00000000..de9ddfce --- /dev/null +++ b/app/views/advertisement/show_stats.html.erb @@ -0,0 +1,29 @@ +

    Advertisement Statistics

    + +<% form_tag(:action => "reset_stats") do %> + + + + + + + + + + + + + + + <% @ads.each do |ad| %> + + + + + + <% end %> + +
    Hits
    + <%= submit_tag "Reset" %> +
    <%= link_to(ad.id, ad.image_url) %><%= ad.hit_count %>
    +<% end %> diff --git a/app/views/artist/_footer.html.erb b/app/views/artist/_footer.html.erb new file mode 100644 index 00000000..f1672e0d --- /dev/null +++ b/app/views/artist/_footer.html.erb @@ -0,0 +1,6 @@ +<% content_for('subnavbar') do %> +
  • <%= link_to "List", :action => "index" %>
  • +
  • <%= link_to "Add artist", :action => "create" %>
  • + <%= @content_for_footer %> +
  • <%= link_to "Help", :controller => "help", :action => "artists" %>
  • +<% end %> diff --git a/app/views/artist/create.html.erb b/app/views/artist/create.html.erb new file mode 100644 index 00000000..f62bce81 --- /dev/null +++ b/app/views/artist/create.html.erb @@ -0,0 +1,39 @@ +
    +

    Separate multiple aliases and group members with commas.

    + + <% form_tag({:action => "create"}, :level => :member) do %> + + + + + + <% if params[:alias_id] %> + + + + + <% end %> + + + + + + + + + + + + + + + + + + +
    <%= text_field "artist", "name", :size => 80 %>
    <%= text_field "artist", "alias_name", :size => 80 %>
    <%= text_field "artist", "alias_names", :size => 80, :value => params[:jp_name] %>
    <%= text_field "artist", "member_names", :size => 80 %>
    <%= text_area "artist", "urls", :size => "80x6", :class => "no-block" %>
    <%= text_area "artist", "notes", :size => "80x6", :class => "no-block" %>
    <%= submit_tag "Save" %> <%= button_to_function "Cancel", "history.back()" %>
    + <% end %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/artist/destroy.html.erb b/app/views/artist/destroy.html.erb new file mode 100644 index 00000000..87ec34a5 --- /dev/null +++ b/app/views/artist/destroy.html.erb @@ -0,0 +1,7 @@ +

    Delete Artist

    +

    Are you sure you want to delete <%= h @artist.name %>?

    + +<% form_tag({:action => "destroy"}, :level => :privileged) do %> + <%= submit_tag "Yes" %> + <%= submit_tag "No" %> +<% end %> diff --git a/app/views/artist/destroy.js.rjs b/app/views/artist/destroy.js.rjs new file mode 100644 index 00000000..e5707a85 --- /dev/null +++ b/app/views/artist/destroy.js.rjs @@ -0,0 +1,2 @@ +page.hide("artist-#{@artist.id}") +page.call(:notice, "Artist deleted") diff --git a/app/views/artist/index.html.erb b/app/views/artist/index.html.erb new file mode 100644 index 00000000..dbfe8acf --- /dev/null +++ b/app/views/artist/index.html.erb @@ -0,0 +1,50 @@ +
    +
    + <% form_tag({:action => "index"}, :method => :get) do %> + <%= text_field_tag "name", params[:name], :size => 40 %> <%= submit_tag "Search" %> + <% end %> +
    + + <% if @artists.any? %> + + + + + + + + + + <% @artists.each do |artist| %> + <% content_tag(:tr, :class => cycle('even', 'odd'), :id => "artist-#{artist.id}") do %> + + + <% if artist.updater_id %> + + <% else %> + + <% end %> + <% end %> + <% end %> + +
    NameUpdated By
    + <%= link_to_unless artist.alias_id, "P", {:controller => "post", :action => "index", :tags => artist.name}, :title => "Find posts for artist" %> + <%= link_to "E", {:action => "update", :id => artist.id}, :title => "Edit artist" %> + <%= link_to "D", {:action => "destroy", :id => artist.id} %> + + <%= link_to h(artist.name), {:action => "show", :id => artist.id} %> + <% if artist.alias_id %> + → <%= link_to h(artist.alias_name), {:action => "show", :id => artist.alias_id}, :title => "This artist is an alias" %> + <% end %> + <% if artist.group_id %> + [<%= link_to h(artist.group_name), {:action => "show", :id => artist.group_id}, :title => "This artist is a group" %>] + <% end %> + <%= h(User.find_name(artist.updater_id)) %>
    + <% end %> + +
    + <%= will_paginate(@artists) %> +
    + + <%= render :partial => "footer" %> +
    diff --git a/app/views/artist/show.html.erb b/app/views/artist/show.html.erb new file mode 100644 index 00000000..467c4708 --- /dev/null +++ b/app/views/artist/show.html.erb @@ -0,0 +1,68 @@ +
    +

    Artist: <%= h @artist.name.tr("_", " ") %>

    + <% unless @artist.notes.blank? %> +
    + <%= format_text(@artist.notes) %> +
    + <% end %> +
    + + + <% @artist.artist_urls.each do |artist_url| %> + + + + + <% end %> + <% if @artist.alias_id %> + + + + + <% end %> + <% if @artist.aliases.any? %> + + + + + <% end %> + <% if @artist.group_id %> + + + + + <% end %> + <% if @artist.members.any? %> + + + + + <% end %> + +
    URL + <%= link_to h(artist_url.url), h(artist_url.url) %> + <% if @current_user.is_mod_or_higher? %> + (<%= link_to "mass edit", :controller => "tag", :action => "mass_edit", :source => "-#{@artist.name} source:" + ArtistUrl.normalize_for_search(artist_url.url), :name => @artist.name %>) + <% end %> +
    Alias for<%= link_to h(@artist.alias_name), :action => "show", :id => @artist.alias_id %>
    Aliases<%= @artist.aliases.map {|x| link_to(h(x.name), :action => "show", :id => x.id)}.join(", ") %>
    Member of<%= link_to h(@artist.group_name), :action => "show", :id => @artist.group_id %>
    Members<%= @artist.members.map {|x| link_to(h(x.name), :action => "show", :id => x.id)}.join(", ") %>
    +
    + +
    +
      + <% @posts.each do |p| %> + <%= print_preview(p, :user => @current_user) %> + <% end %> +
    +
    + + <% content_for("footer") do %> +
  • <%= link_to "Edit", :action => "update", :id => @artist.id %>
  • +
  • <%= link_to "Delete", :action => "destroy", :id => @artist.id %>
  • + <% unless @artist.alias_id %> +
  • <%= link_to "View posts", :controller => "post", :action => "index", :tags => @artist.name %>
  • +
  • <%= link_to "Create alias", :action => "add", :alias_id => @artist.id %>
  • + <% end %> + <% end %> + + <%= render :partial => "footer" %> +
    diff --git a/app/views/artist/update.html.erb b/app/views/artist/update.html.erb new file mode 100644 index 00000000..3419fcf9 --- /dev/null +++ b/app/views/artist/update.html.erb @@ -0,0 +1,42 @@ +
    +

    Separate multiple aliases and group members with commas.

    + + + + <% form_tag({:action => "update"}, :level => :member) do %> + <%= hidden_field_tag "id", @artist.id %> + + + + + + + + + + + + + + + + + + + + + + + + + +
    <%= text_field "artist", "name", :size => 80 %>
    <%= text_field "artist", "alias_names", :size => 80 %>
    <%= text_field "artist", "member_names", :size => 80 %>
    <%= text_area "artist", "urls", :size => "80x6", :class => "no-block" %>
    <%= text_area "artist", "notes", :size => "80x6", :class => "no-block", :disabled => @artist.notes_locked? %>
    + <%= submit_tag "Save" %> + <%= button_to_function "Cancel", "history.back()" %> + <%= submit_to_remote "preview", "Preview Notes", :url => {:action => "preview"}, :method => :get, :update => "preview", :success => "$('preview').show()" %> +
    + <% end %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/banned/index.html.erb b/app/views/banned/index.html.erb new file mode 100644 index 00000000..5bcfa9fc --- /dev/null +++ b/app/views/banned/index.html.erb @@ -0,0 +1,10 @@ +
    +

    <%= link_to CONFIG['app_name'], '/' %>

    +
    You have been banned: <%= @ban.reason %>
    + + <% if @ban.expires_at then %> +

    This will expire in <%= time_ago_in_words(@ban.expires_at) %>.

    + <% else %> +

    This ban is permanent.

    + <% end %> +
    diff --git a/app/views/comment/_comment.html.erb b/app/views/comment/_comment.html.erb new file mode 100644 index 00000000..0fcf889f --- /dev/null +++ b/app/views/comment/_comment.html.erb @@ -0,0 +1,30 @@ +
    +
    + <% if comment.user_id %> +
    <%= h comment.pretty_author %>
    + <% else %> +
    <%= h comment.pretty_author %>
    + <% end %> + Posted <%= time_ago_in_words(comment.created_at) %> ago + <% if comment.user and comment.user.has_avatar? then %> +
    <%= avatar(comment.user, comment.id) %>
    + <% end %> +
    +
    +
    + <%= format_inlines(format_text(comment.body, :mode => :comment), comment.id) %> +
    + +
    +
    + diff --git a/app/views/comment/_comments.html.erb b/app/views/comment/_comments.html.erb new file mode 100644 index 00000000..ab75d558 --- /dev/null +++ b/app/views/comment/_comments.html.erb @@ -0,0 +1,27 @@ +
    + <% comments.each do |c| %> + <%= render :partial => "comment/comment", :locals => {:comment => c} %> + <% end %> +
    + +
    + <% if hide %> + <%= content_tag "h6", link_to_function("Reply »", "Element.hide('respond-link-#{post_id}'); Element.show('reply-#{post_id}')"), :id => "respond-link-#{post_id}" %> + <% end %> + + <% content_tag("div", :id => "reply-#{post_id}", :style => (hide ? "display: none;" : nil)) do %> + <% form_tag({:controller => "comment", :action => "create"}, {:level => :member}) do %> + <%= hidden_field_tag "comment[post_id]", post_id, :id => "comment_post_id_#{post_id}" %> + <%= text_area "comment", "body", :rows => "7", :id => "reply-text-#{post_id}", :style=>"width: 98%; margin-bottom: 2px;" %> + <%= submit_tag "Post" %> + + <% end %> + + <% end %> +
    + + + diff --git a/app/views/comment/_footer.html.erb b/app/views/comment/_footer.html.erb new file mode 100644 index 00000000..b5353bfb --- /dev/null +++ b/app/views/comment/_footer.html.erb @@ -0,0 +1,5 @@ +<% content_for("subnavbar") do %> +
  • <%= link_to "List", :action => "index" %>
  • +
  • <%= link_to "Moderate", :action => "moderate" %>
  • +
  • <%= link_to "Help", :controller => "help", :action => "comments" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/comment/edit.html.erb b/app/views/comment/edit.html.erb new file mode 100644 index 00000000..fc8d8a11 --- /dev/null +++ b/app/views/comment/edit.html.erb @@ -0,0 +1,9 @@ +
    +

    Edit Comment

    + + <% form_tag(:action => "update") do %> + <%= hidden_field_tag "id", params[:id] %> + <%= text_area("comment", "body", :rows => 10, :cols => 60) %>
    + <%= submit_tag "Save changes" %> + <% end %> +
    diff --git a/app/views/comment/index.html.erb b/app/views/comment/index.html.erb new file mode 100644 index 00000000..6c29cf33 --- /dev/null +++ b/app/views/comment/index.html.erb @@ -0,0 +1,61 @@ +
    + + + <% if @posts.empty? %> +

    No comments.

    + <% end %> + + <% @posts.each do |post| %> +
    +
    + <%= link_to(image_tag(post.preview_url, :title => post.cached_tags, :class => "preview javascript-hide", :id => "p%i" % post.id), :controller => "post", :action => "show", :id => post.id) %>  +
    +
    +
    +
    + Date <%= compact_time(post.created_at) %> + User <%= link_to h(post.author), :controller => "user", :action => "show", :id => post.user_id %> + Rating <%= post.pretty_rating %> + Score + <%= post.score %> + <%= vote_widget(post, @current_user) %> + <%= vote_tooltip_widget(post) %> + + + <% if post.comments.size > 6 %> + Hidden <%= link_to post.comments.size - 6, :controller => "post", :action => "show", :id => post.id %> + <% end %> +
    +
    + Tags + <% post.cached_tags.split(/ /).each do |name| %> + + <%= h name.tr("_", " ") %> + + <% end %> +
    +
    + <%= render :partial => "comments", :locals => {:comments => post.recent_comments, :post_id => post.id, :hide => true} %> +
    +
    + <% end %> + +
    + <%= will_paginate(@posts) %> +
    + + + + <%= render :partial => "footer" %> +
    diff --git a/app/views/comment/moderate.html.erb b/app/views/comment/moderate.html.erb new file mode 100644 index 00000000..1dda1682 --- /dev/null +++ b/app/views/comment/moderate.html.erb @@ -0,0 +1,45 @@ + + +
    + + + + + + + + + + + + + + + <% @comments.each do |c| %> + + + + + + <% end %> + +
    AuthorBody
    + <%= button_to_function "Select all", "$$('.c').each(function (i) {i.checked = true; highlight_row(i)}); return false" %> + <%= button_to_function "Invert selection", "$$('.c').each(function (i) {i.checked = !i.checked; highlight_row(i)}); return false" %> + <%= submit_tag "Approve" %> + <%= submit_tag "Delete" %> +
    <%= link_to h(c.author), :controller => "post", :action => "show", :id => c.post_id %><%= h(c.body) %>
    +
    diff --git a/app/views/comment/search.html.erb b/app/views/comment/search.html.erb new file mode 100644 index 00000000..7ce7aff9 --- /dev/null +++ b/app/views/comment/search.html.erb @@ -0,0 +1,38 @@ +
    + <% form_tag({:action => "search"}, :method => :get) do %> + <%= text_field_tag "query", params[:query] %> + <%= submit_tag "Search"%> + <% end %> + + + + + + + + + + + + <% @comments.each do |comment| %> + <% content_tag :tr, :class => cycle('even', 'odd') do %> + + + + + + <% end %> + <% end %> + +
    PostMessageAuthorTime
    <%= link_to("##{comment.post_id}", :controller => "post", :action => "show", :id => comment.post_id) %><%= link_to(h(comment.body[0, 70]) + "...", :controller => "post", :action => "show", :id => comment.post_id, :anchor => "c#{comment.id}") %> + <% if comment.user_id %> + <%= h comment.pretty_author %> + <% else %> + <%= h comment.pretty_author %> + <% end %> + <%= time_ago_in_words(comment.created_at) %> ago
    + +
    + <%= will_paginate(@comments) %> +
    +
    diff --git a/app/views/comment/show.html.erb b/app/views/comment/show.html.erb new file mode 100644 index 00000000..2ff5a796 --- /dev/null +++ b/app/views/comment/show.html.erb @@ -0,0 +1,5 @@ +<%= render :partial => "comment/comments", :locals => {:comments => [@comment], :post_id => @comment.post_id} %> + +
    +

    <%= link_to "Return to post", :controller => "post", :action => "show", :id => @comment.post_id %>

    +
    diff --git a/app/views/dmail/_compose.html.erb b/app/views/dmail/_compose.html.erb new file mode 100644 index 00000000..e3edc7d6 --- /dev/null +++ b/app/views/dmail/_compose.html.erb @@ -0,0 +1,26 @@ +<% form_tag(:action => "create") do %> + <%= hidden_field_tag "dmail[parent_id]", @dmail.parent_id || @dmail.id, :id => "dmail_parent_id" %> + + + + + + + + + + + + + + + + + + + + + + +
    <%= submit_tag "Send" %>
    <%= text_field_with_auto_complete "dmail", "to_name", {:value => params[:to]}, :min_chars => 3, :skip_style => true %>
    <%= text_field "dmail", "title" %>
    <%= text_area "dmail", "body", :size => "50x25", :class => "default" %>
    +<% end %> diff --git a/app/views/dmail/_footer.html.erb b/app/views/dmail/_footer.html.erb new file mode 100644 index 00000000..f8d1424d --- /dev/null +++ b/app/views/dmail/_footer.html.erb @@ -0,0 +1,6 @@ +<% content_for("subnavbar") do %> + <%= @content_for_footer %> +
  • <%= link_to "Inbox", :action => "inbox" %>
  • +
  • <%= link_to "Compose", :action => "compose" %>
  • +
  • <%= link_to "Mark all read", :action => "mark_all_read" %>
  • +<% end %> diff --git a/app/views/dmail/_message.html.erb b/app/views/dmail/_message.html.erb new file mode 100644 index 00000000..d41ae40f --- /dev/null +++ b/app/views/dmail/_message.html.erb @@ -0,0 +1,15 @@ +
    + <% if message.to_id == @current_user.id %> +
    <%= h(message.title) %>
    + <% else %> +
    <%= h(message.title) %>
    + <% end %> + +
    + Sent by <%= link_to h(message.from.name), :controller => "user", :action => "show", :id => message.from_id %> <%= time_ago_in_words(message.created_at) %> ago +
    + +
    + <%= format_text(message.body) %> +
    +
    diff --git a/app/views/dmail/compose.html.erb b/app/views/dmail/compose.html.erb new file mode 100644 index 00000000..7599281d --- /dev/null +++ b/app/views/dmail/compose.html.erb @@ -0,0 +1,5 @@ +

    Compose Message

    + +<%= render :partial => "compose", :locals => {:from_id => @current_user.id} %> + +<%= render :partial => "footer" %> diff --git a/app/views/dmail/inbox.html.erb b/app/views/dmail/inbox.html.erb new file mode 100644 index 00000000..85dc0a4c --- /dev/null +++ b/app/views/dmail/inbox.html.erb @@ -0,0 +1,44 @@ +

    My Inbox

    + +<% if @dmails.empty? %> +

    You have no messages.

    +<% else %> +
    + + + + + + + + + + + <% @dmails.each do |dmail| %> + + + + + + + <% end %> + +
    FromToTitleWhen
    <%= h dmail.from.name %><%= h dmail.to.name %> + <% if dmail.from_id == @current_user.id %> + <%= link_to(h(dmail.title), {:action => "show", :id => dmail.id}, :class => "sent") %> + <% else %> + <% if dmail.has_seen? %> + <%= link_to(h(dmail.title), {:action => "show", :id => dmail.id}, :class => "received") %> + <% else %> + <%= link_to(h(dmail.title), {:action => "show", :id => dmail.id}, :class => "received") %> + <% end %> + <% end %> + <%= time_ago_in_words(dmail.created_at) %> ago
    +
    +<% end %> + +
    + <%= will_paginate(@dmails) %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/dmail/mark_all_read.html.erb b/app/views/dmail/mark_all_read.html.erb new file mode 100644 index 00000000..9fd36b4a --- /dev/null +++ b/app/views/dmail/mark_all_read.html.erb @@ -0,0 +1,7 @@ +

    Mark All Read

    +

    Are you sure you want to mark all your messages as read?

    + +<% form_tag(:action => "mark_all_read") do %> + <%= submit_tag "Yes" %> + <%= submit_tag "No" %> +<% end %> diff --git a/app/views/dmail/show.html.erb b/app/views/dmail/show.html.erb new file mode 100644 index 00000000..7d813782 --- /dev/null +++ b/app/views/dmail/show.html.erb @@ -0,0 +1,19 @@ +
    + + + <%= render :partial => "message", :locals => {:message => @dmail} %> + + + + <% content_for(:footer) do %> +
  • <%= link_to_function "Show conversation", "Dmail.expand(#{@dmail.parent_id || @dmail.id}, #{@dmail.id})" %>
  • + <% if @dmail.to_id == @current_user.id %> +
  • <%= link_to_function "Respond", "Dmail.respond('#{escape_javascript(@dmail.from.name)}')" %>
  • + <% end %> + <% end %> + + <%= render :partial => "footer" %> +
    diff --git a/app/views/dmail/show_previous_messages.html.erb b/app/views/dmail/show_previous_messages.html.erb new file mode 100644 index 00000000..a74f1311 --- /dev/null +++ b/app/views/dmail/show_previous_messages.html.erb @@ -0,0 +1 @@ +<%= render :partial => "message", :collection => @dmails %> \ No newline at end of file diff --git a/app/views/forum/_post.html.erb b/app/views/forum/_post.html.erb new file mode 100644 index 00000000..fc24c91e --- /dev/null +++ b/app/views/forum/_post.html.erb @@ -0,0 +1,40 @@ +
    +
    +
    <%= link_to h(post.author), :controller => "user", :action => "show", :id => post.creator_id %>
    + <%= link_to time_ago_in_words(post.created_at) + " ago", :action => "show", :id => post.id %> + <% if post.creator.has_avatar? then %> +
    <%= avatar(post.creator, post.id) %>
    + <% end %> +
    +
    + <% if post.is_parent? %> +
    <%= h post.title %>
    + <% else %> +
    <%= h post.title %>
    + <% end %> +
    + <%= format_inlines(format_text(post.body), post.id) %> +
    + +
    +
    diff --git a/app/views/forum/add.html.erb b/app/views/forum/add.html.erb new file mode 100644 index 00000000..0b93b073 --- /dev/null +++ b/app/views/forum/add.html.erb @@ -0,0 +1,8 @@ +<% form_tag(:action => "create") do %> + <%= hidden_field_tag "forum_post[parent_id]", params["parent_id"], :id => "forum_post_parent_id" %> + + + + +
    <%= text_field "forum_post", "title", :size => 60 %>
    <%= text_area "forum_post", "body", :rows => 10, :cols => 60 %>
    <%= submit_tag "Post" %>
    +<% end %> diff --git a/app/views/forum/edit.html.erb b/app/views/forum/edit.html.erb new file mode 100644 index 00000000..509f32b2 --- /dev/null +++ b/app/views/forum/edit.html.erb @@ -0,0 +1,14 @@ + + +<% form_tag(:action => "update") do %> + <%= hidden_field_tag "id", params[:id] %> + + + + +
    <%= text_field "forum_post", "title", :size => 60 %>
    <%= text_area "forum_post", "body", :rows => 10, :cols => 60 %>
    + <%= submit_tag "Post" %> + <%= submit_to_remote "preview", "Preview", :url => {:action => "preview"}, :method => :get, :update => "preview", :success => "$('preview').show()" %> +
    +<% end %> diff --git a/app/views/forum/index.html.erb b/app/views/forum/index.html.erb new file mode 100644 index 00000000..4d4af9c4 --- /dev/null +++ b/app/views/forum/index.html.erb @@ -0,0 +1,79 @@ +
    + + + + + + + + + + + + + + <% @forum_posts.each do |fp| %> + <% content_tag :tr, :class => cycle('even', 'odd') do %> + + + + + + <% end %> + <% end %> + +
    TitleCreated byUpdated byUpdatedResponses
    + <% if @current_user.is_a?(User) && fp.updated_at > @current_user.last_forum_topic_read_at %> + <% if fp.is_sticky? %>Sticky: <% end %><%= link_to h(fp.title), :action => "show", :id => fp.id %> + <% else %> + <% if fp.is_sticky? %>Sticky: <% end %><%= link_to h(fp.title), :action => "show", :id => fp.id %> + <% end %> + + <% if fp.response_count > 30 %> + <%= link_to "last", {:action => "show", :id => fp.id, :page => (fp.response_count / 30.0).ceil}, :class => "last-page" %> + <% end %> + + <% if fp.is_locked? %> + (locked) + <% end %> + <%= h fp.author %><%= h fp.last_updater %><%= time_ago_in_words(fp.updated_at) %> ago<%= fp.response_count %>
    + +
    + <%= will_paginate(@forum_posts) %> +
    + + <% content_for("subnavbar") do %> +
  • <%= link_to "New topic", :action => "new" %>
  • + <% unless @current_user.is_anonymous? %> +
  • <%= link_to_function "Mark all read", "Forum.mark_all_read()" %>
  • + <% end %> +
  • <%= link_to "Help", :controller => "help", :action => "forum" %>
  • + <% end %> + + + + +
    diff --git a/app/views/forum/new.html.erb b/app/views/forum/new.html.erb new file mode 100644 index 00000000..c78561f7 --- /dev/null +++ b/app/views/forum/new.html.erb @@ -0,0 +1,31 @@ +

    New Topic

    + +
    + + +
    + <% form_tag({:action => "create"}) do %> + <%= hidden_field "forum_post", "parent_id", :value => params["parent_id"] %> + + + + + + + + + + + + +
    <%= text_field "forum_post", "title", :size => 60 %>
    <%= text_area "forum_post", "body", :rows => 10, :cols => 60 %>
    <%= submit_tag "Post" %><%= submit_to_remote "preview", "Preview", :url => {:action => "preview"}, :update => "preview", :method => :get, :success => "$('preview').show()" %>
    + <% end %> +
    + +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "List", :action => "index" %>
  • +
  • <%= link_to "Help", :controller => "help", :action => "forum" %>
  • +<% end %> diff --git a/app/views/forum/search.html.erb b/app/views/forum/search.html.erb new file mode 100644 index 00000000..5b63d235 --- /dev/null +++ b/app/views/forum/search.html.erb @@ -0,0 +1,33 @@ +
    +
    + <% form_tag({:action => "search"}, :method => :get) do %> + <%= text_field_tag "query", params[:query], :size => 40 %> + <%= submit_tag "Search"%> + <% end %> +
    + + + + + + + + + + + + <% @forum_posts.each do |fp| %> + <% content_tag :tr, :class => cycle('even', 'odd') do %> + + + + + <% end %> + <% end %> + +
    TopicMessageAuthorLast Updated
    <%= link_to(h(fp.root.title), :action => "show", :id => fp.root_id) %><%= link_to(h(fp.body[0, 70]) + "...", :action => "show", :id => fp.id) %><%= h fp.author %><%= time_ago_in_words(fp.updated_at) %> ago by <%= fp.last_updater %>
    + +
    + <%= will_paginate(@forum_posts) %> +
    +
    diff --git a/app/views/forum/show.html.erb b/app/views/forum/show.html.erb new file mode 100644 index 00000000..33c9998f --- /dev/null +++ b/app/views/forum/show.html.erb @@ -0,0 +1,54 @@ +<% if @forum_post.is_locked? %> +
    +

    This topic is locked.

    +
    +<% end %> + +
    + <% unless params[:page].to_i > 1 %> + <%= render :partial => "post", :locals => {:post => @forum_post} %> + <% end %> + + <% @children.each do |c| %> + <%= render :partial => "post", :locals => {:post => c} %> + <% end %> +
    + +<% unless @forum_post.is_locked? %> +
    + + + + +
    +<% end %> + +
    + <%= will_paginate(@children) %> +
    + + + +<% content_for("subnavbar") do %> + <% unless @forum_post.is_locked? %> +
  • <%= link_to_function "Reply", "Element.toggle('reply')" %>
  • + <% end %> +
  • <%= link_to "List", :action => "index" %>
  • +
  • <%= link_to "New topic", :action => "new" %>
  • + <% unless @forum_post.is_parent? %> +
  • <%= link_to "Parent", :action => "show", :id => @forum_post.parent_id %>
  • + <% end %> +
  • <%= link_to "Help", :controller => "help", :action => "forum" %>
  • +<% end %> diff --git a/app/views/help/about.html.erb b/app/views/help/about.html.erb new file mode 100644 index 00000000..4ac9001a --- /dev/null +++ b/app/views/help/about.html.erb @@ -0,0 +1,23 @@ +
    +

    Help: About

    + +
    +

    Danbooru is a web application that allows you to upload, share, and tag images. Much of it is inspired by both Moeboard and Flickr. It was specifically designed to be of maximum utility to seasoned imageboard hunters. Some of these features include:

    +
      +
    • Posts never expire
    • +
    • Tag and comment on posts
    • +
    • Search for tags via intersection, union, negation, or pattern
    • +
    • Integrated wiki
    • +
    • Annotate images with notes
    • +
    • Input a URL and Danbooru automatically downloads the file
    • +
    • Duplicate post detection (via MD5 hashes)
    • +
    • REST-based API
    • +
    • Atom and RSS feeds for posts
    • +
    • Bookmarklet and Firefox extension
    • +
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/api.html.erb b/app/views/help/api.html.erb new file mode 100644 index 00000000..9ab93882 --- /dev/null +++ b/app/views/help/api.html.erb @@ -0,0 +1,595 @@ +
    +

    Help: API v1.13.0

    + +
    +

    Danbooru offers a simple API to make scripting easy. All you need is a way to GET and POST to URLs. The ability to parse XML or JSON responses is nice, but not critical. The simplicity of the API means you can write scripts using JavaScript, Perl, Python, Ruby, even shell languages like bash or tcsh.

    +

    Change Log | Posts | Tags | Artists | Comments | Wiki | Notes | Users | Forum | Pools

    +
    + +
    +
    +

    Basics

    +

    HTTP defines two request methods: GET and POST. You'll be using these two methods to interact with the Danbooru API. Most API calls that change the state of the database (like creating, updating, or deleting something) require an HTTP POST call. API calls that only retrieve data can typically be done with an HTTP GET call.

    +

    In the Danbooru API, a URL is analogous to a function name. You pass in the function parameters as a query string. Here's an extremely simple example: /post/index.xml?limit=1.

    +

    The post part indicates the controller we're working with. In this case it's posts. index describes the action. Here we're retrieving a list of posts. Finally, the xml part describes what format we want the response in. You can specify .xml for XML responses, .json for JSON responses, and nothing at all for HTML responses.

    +
    + +
    +

    Responses

    +

    All API calls that change state will return a single element response (for XML calls). They are formatted like this:

    +
    + <?xml version="1.0" encoding="UTF-8"?>
    + <response success="false" reason="duplicate"/> +
    +

    For JSON responses, they'll look like this:

    +
    + {success: false, reason: "duplicate"} +
    +

    While you can usually determine success or failure based on the response object, you can also figure out what happened based on the HTTP status code. In addition to the standard ones, Danbooru uses some custom status codes in the 4xx and 5xx range.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Status CodeMeaning
    200 OKRequest was successful
    403 ForbiddenAccess denied
    404 Not FoundNot found
    420 Invalid RecordRecord could not be saved
    421 User ThrottledUser is throttled, try again later
    422 LockedThe resource is locked and cannot be modified
    423 Already ExistsResource already exists
    424 Invalid ParametersThe given parameters were invalid
    500 Internal Server ErrorSome unknown error occurred on the server
    503 Service UnavailableServer cannot currently handle the request, try again later
    +
    + +
    +

    JSON Responses

    +

    While you will probably want to work with XML in the majority of cases, if you're writing something in Javascript then the JSON responses may be preferable. They are much faster to parse and there's less code to write to get your data structure:

    +
    + var data = eval("(" + responseText + ")")
    + alert(data.response) +
    +
    + +
    +

    Logging In

    +

    Some actions may require you to log in. For any action you can always specify two parameters to identify yourself:

    +
      +
    • login Your login name.
    • +
    • password_hash Your SHA1 hashed password. Simply hashing your plain password will NOT work since Danbooru salts its passwords. The actual string that is hashed is "<%= CONFIG["password_salt"] %>--your-password--".
    • +
    +

    Please be aware of the security risks involved in sending your password through an unencrypted channel. Although your password will be hashed, it is still theoretically possible for someone to steal your account by creating a fake cookie based on your hashed password.

    +
    +
    + + + +
    + +

    Posts

    + +
    +

    List

    +

    The base URL is /post/index.xml.

    +
      +
    • limit How many posts you want to retrieve. There is a hard limit of 100 posts per request.
    • +
    • page The page number.
    • +
    • tags The tags to search for. Any tag combination that works on the web site will work here. This includes all the meta-tags.
    • +
    +
    + +
    +

    Create

    +

    The base URL is /post/create.xml. There are only two mandatory fields: you need to supply the tags, and you need to supply the file, either through a multipart form or through a source URL.

    +
      +
    • post[tags] A space delimited list of tags.
    • +
    • post[file] The file data encoded as a multipart form.
    • +
    • post[rating] The rating for the post. Can be: safe, questionable, or explicit.
    • +
    • post[source] If this is a URL, Danbooru will download the file.
    • +
    • post[is_rating_locked] Set to true to prevent others from changing the rating.
    • +
    • post[is_note_locked] Set to true to prevent others from adding notes.
    • +
    • post[parent_id] The ID of the parent post.
    • +
    • md5 Supply an MD5 if you want Danbooru to verify the file after uploading. If the MD5 doesn't match, the post is destroyed.
    • +
    +

    If the call fails, the following response reasons are possible:

    +
      +
    • MD5 mismatch This means you supplied an MD5 parameter and what Danbooru got doesn't match. Try uploading the file again.
    • +
    • duplicate This post already exists in Danbooru (based on the MD5 hash). An additional attribute called location will be set, pointing to the (relative) URL of the original post.
    • +
    • other Any other error will have its error message printed.
    • +
    +

    If the post upload succeeded, you'll get an attribute called location in the response pointing to the relative URL of your newly uploaded post.

    +
    +
    +

    Update

    +

    The base URL is /post/update.xml. Only the id parameter is required. Leave the other parameters blank if you don't want to change them.

    +
      +
    • id The id number of the post to update.
    • +
    • post[tags] A space delimited list of tags.
    • +
    • post[file] The file data encoded as a multipart form.
    • +
    • post[rating] The rating for the post. Can be: safe, questionable, or explicit.
    • +
    • post[source] If this is a URL, Danbooru will download the file.
    • +
    • post[is_rating_locked] Set to true to prevent others from changing the rating.
    • +
    • post[is_note_locked] Set to true to prevent others from adding notes.
    • +
    • post[parent_id] The ID of the parent post.
    • +
    +
    +
    +

    Destroy

    +

    You must be logged in to use this action. You must also be the user who uploaded the post (or you must be a moderator).

    +
      +
    • id The id number of the post to delete.
    • +
    +
    +
    +

    Tag History

    +

    This action retrieves the history of tag changes for a post (or all posts). The base URL is /post/tag_history.xml.

    +
      +
    • post_id Specify if you only want the tag histories for a single post.
    • +
    • limit How many histories you want to retrieve.
    • +
    • page The page number.
    • +
    +
    +
    +

    Revert Tags

    +

    This action reverts a post to a previous set of tags. The base URL is /post/revert_tags.xml.

    +
      +
    • id The post id number to update.
    • +
    • history_id The id number of the tag history.
    • +
    +
    +
    +

    Favorites

    +

    This action finds all the users who have favorited a post. The base URL is /post/favorites.xml.

    +
      +
    • id The post id number to query.
    • +
    +
    +
    +

    Vote

    +

    This action lets you vote for a post. You can only vote once per post per IP address. The base URL is /post/vote.xml.

    +
      +
    • id The post id number to update.
    • +
    • score Set to 1 to vote up and -1 to vote down. All other values will be ignored.
    • +
    +

    If the call did not succeed, the following reasons are possible:

    +
      +
    • already voted You have already voted for this post.
    • +
    • invalid score You have supplied an invalid score.
    • +
    +
    +
    + + + +
    + +

    Tags

    + +
    +

    List

    +

    The base URL is /tag/index.xml.

    +
      +
    • limit How many tags to retrieve. Setting this to 0 will return every tag.
    • +
    • page The page number.
    • +
    • order Can be date, count, or name.
    • +
    • id The id number of the tag.
    • +
    • after_id Return all tags that have an id number greater than this.
    • +
    • name The exact name of the tag.
    • +
    • name_pattern Search for any tag that has this parameter in its name.
    • +
    +
    +
    +

    Update

    +

    The base URL is /tag/update.xml.

    +
      +
    • name The name of the tag to update.
    • +
    • tag[tag_type] The tag type. General: 0, artist: 1, copyright: 3, character: 4.
    • +
    • tag[is_ambiguous] Whether or not this tag is ambiguous. Use 1 for true and 0 for false.
    • +
    +
    +
    +

    Related

    +

    The base URL is /tag/related.xml.

    +
      +
    • tags The tag names to query.
    • +
    • type Restrict results to this tag type (can be general, artist, copyright, or character).
    • +
    +
    +
    + + + +
    + +

    Artists

    + +
    +

    List

    +

    The base URL is /artist/index.xml.

    +
      +
    • name The name (or a fragment of the name) of the artist.
    • +
    • order Can be date or name.
    • +
    • page The page number.
    • +
    +
    +
    +

    Create

    +

    The base URL is /artist/create.xml.

    +
      +
    • artist[name] The artist's name.
    • +
    • artist[urls] A list of URLs associated with the artist, whitespace delimited.
    • +
    • artist[alias] The artist that this artist is an alias for. Simply enter the alias artist's name.
    • +
    • artist[group] The group or cicle that this artist is a member of. Simply enter the group's name.
    • +
    +
    +
    +

    Update

    +

    The base URL is /artist/update.xml. Only the id parameter is required. The other parameters are optional.

    +
      +
    • id The id of thr artist to update.
    • +
    • artist[name] The artist's name.
    • +
    • artist[urls] A list of URLs associated with the artist, whitespace delimited.
    • +
    • artist[alias] The artist that this artist is an alias for. Simply enter the alias artist's name.
    • +
    • artist[group] The group or cicle that this artist is a member of. Simply enter the group's name.
    • +
    +
    +
    +

    Destroy

    +

    The base URL is /artist/destroy.xml. You must be logged in to delete artists.

    +
      +
    • id The id of the artist to destroy.
    • +
    +
    +
    + + + +
    + +

    Comments

    + +
    +

    Show

    +

    The base URL is /comment/show.xml. This retrieves a single comment.

    +
      +
    • id The id number of the comment to retrieve.
    • +
    +
    + +
    +

    Create

    +

    The base URL is /comment/create.xml.

    +
      +
    • comment[anonymous] Set to 1 if you want to post this comment anonymously.
    • +
    • comment[post_id] The post id number to which you are responding.
    • +
    • comment[body] The body of the comment.
    • +
    +
    + +
    +

    Destroy

    +

    The base url is /comment/destroy.xml. You must be logged in to use this action. You must also be the owner of the comment, or you must be a moderator.

    +
      +
    • id The id number of the comment to delete.
    • +
    +
    +
    + + + +
    + +

    Wiki

    +

    All titles must be exact (but case and whitespace don't matter).

    + +
    +

    List

    +

    The base URL is /wiki/index.xml. This retrieves a list of every wiki page.

    +
      +
    • order How you want the pages ordered. Can be: title, date.
    • +
    • limit The number of pages to retrieve.
    • +
    • page The page number.
    • +
    • query A word or phrase to search for.
    • +
    +
    + +
    +

    Create

    +

    The base URL is /wiki/create.xml.

    +
      +
    • wiki_page[title] The title of the wiki page.
    • +
    • wiki_page[body] The body of the wiki page.
    • +
    +
    + +
    +

    Update

    +

    The base URL is /wiki/update.xml. Potential error reasons: "Page is locked"

    +
      +
    • title The title of the wiki page to update.
    • +
    • wiki_page[title] The new title of the wiki page.
    • +
    • wiki_page[body] The new body of the wiki page.
    • +
    +
    + +
    +

    Show

    +

    The base URL is /wiki/show.xml. Potential error reasons: "artist type"

    +
      +
    • title The title of the wiki page to retrieve.
    • +
    • version The version of the page to retrieve.
    • +
    +
    + +
    +

    Destroy

    +

    The base URL is /wiki/destroy.xml. You must be logged in as a moderator to use this action.

    +
      +
    • title The title of the page to delete.
    • +
    +
    + +
    +

    Lock

    +

    The base URL is /wiki/lock.xml. You must be logged in as a moderator to use this action.

    +
      +
    • title The title of the page to lock.
    • +
    +
    + +
    +

    Unlock

    +

    The base URL is /wiki/unlock.xml. You must be logged in as a moderator to use this action.

    +
      +
    • title The title of the page to unlock.
    • +
    +
    + +
    +

    Revert

    +

    The base URL is /wiki/revert.xml. Potential error reasons: "Page is locked"

    +
      +
    • title The title of the wiki page to update.
    • +
    • version The version to revert to.
    • +
    +
    + +
    +

    History

    +

    The base URL is /wiki/history.xml.

    +
      +
    • title The title of the wiki page to retrieve versions for.
    • +
    +
    +
    + + + +
    + +

    Notes

    + +
    +

    List

    +

    The base URL is /note/index.xml.

    +
      +
    • post_id The post id number to retrieve notes for.
    • +
    +
    + +
    +

    Search

    +

    The base URL is /note/search.xml.

    +
      +
    • query A word or phrase to search for.
    • +
    +
    + +
    +

    History

    +

    The base URL is /note/history.xml. You can either specify id, post_id, or nothing. Specifying nothing will give you a list of every note verison.

    +
      +
    • limit How many versions to retrieve.
    • +
    • page The offset.
    • +
    • post_id The post id number to retrieve note versions for.
    • +
    • id The note id number to retrieve versions for.
    • +
    +
    + +
    +

    Revert

    +

    The base URL is /note/revert.xml. Potential error reasons: "Post is locked"

    +
      +
    • id The note id to update.
    • +
    • version The version to revert to.
    • +
    +
    + +
    +

    Create/Update

    +

    The base URL is /note/update.xml. Notes differ from the other controllers in that the interface for creation and updates is the same. If you supply an id parameter, then Danbooru will assume you're updating an existing note. Otherwise, it will create a new note. Potential error reasons: "Post is locked"

    +
      +
    • id If you are updating a note, this is the note id number to update.
    • +
    • note[post_id] The post id number this note belongs to.
    • +
    • note[x] The x coordinate of the note.
    • +
    • note[y] The y coordinate of the note.
    • +
    • note[width] The width of the note.
    • +
    • note[height] The height of the note.
    • +
    • note[is_active] Whether or not the note is visible. Set to 1 for active, 0 for inactive.
    • +
    • note[body] The note message.
    • +
    +
    +
    + + + +
    + +

    Users

    + +
    +

    Search

    +

    The base URL is /user/index.xml. If you don't specify any parameters you'll get a listing of all users.

    +
      +
    • id The id number of the user.
    • +
    • name The name of the user.
    • +
    +
    +
    + + + +
    + +

    Forum

    + +
    +

    List

    +

    The base URL is /forum/index.xml. If you don't specify any parameters you'll get a list of all the parent topics.

    +
      +
    • parent_id The parent ID number. You'll return all the responses to that forum post.
    • +
    +
    +
    + + + +
    + +

    Pools

    + +
    +

    List Pools

    +

    The base URL is /pool/index.xml. If you don't specify any parameters you'll get a list of all pools.

    +
      +
    • query The title.
    • +
    • page The page.
    • +
    +
    + +
    +

    List Posts

    +

    The base URL is /pool/show.xml. If you don't specify any parameters you'll get a list of all pools.

    +
      +
    • id The pool id number.
    • +
    • page The page.
    • +
    +
    + +
    +

    Update

    +

    The base URL is /pool/update.xml.

    +
      +
    • id The pool id number.
    • +
    • pool[name] The name.
    • +
    • pool[is_public] 1 or 0, whether or not the pool is public.
    • +
    • pool[description] A description of the pool.
    • +
    +
    + +
    +

    Create

    +

    The base URL is /pool/create.xml.

    +
      +
    • pool[name] The name.
    • +
    • pool[is_public] 1 or 0, whether or not the pool is public.
    • +
    • pool[description] A description of the pool.
    • +
    +
    + +
    +

    Destroy

    +

    The base URL is /pool/destroy.xml.

    +
      +
    • id The pool id number.
    • +
    +
    + +
    +

    Add Post

    +

    The base URL is /pool/add_post.xml. Potential error reasons: "Post already exists", "access denied"

    +
      +
    • pool_id The pool to add the post to.
    • +
    • post_id The post to add.
    • +
    +
    + +
    +

    Remove Post

    +

    The base URL is /pool/remove_post.xml. Potential error reasons: "access denied"

    +
      +
    • pool_id The pool to remove the post from.
    • +
    • post_id The post to remove.
    • +
    +
    +
    + + + +
    + +

    Change Log

    +
    +

    1.15.0

    +
      +
    • Added documentation for pools
    • +
    + +

    1.13.0

    +
      +
    • Changed interface for artists to use new URL system
    • +
    • JSON requests now end in a .json suffix
    • +
    • Renamed some error reason messages
    • +
    • Removed comment/index from API
    • +
    • Removed url and md5 parameters from artist search (can just pass the URL or MD5 hash to the name parameter)
    • +
    +
    + +
    +

    1.8.1

    +
      +
    • Removed post[is_flagged] attribute
    • +
    +
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/artists.html.erb b/app/views/help/artists.html.erb new file mode 100644 index 00000000..cdd936b7 --- /dev/null +++ b/app/views/help/artists.html.erb @@ -0,0 +1,65 @@ +
    +

    Help: Artists

    + +
    +

    What are artists?

    +

    Artists in Danbooru represent the people who created a piece of art. Originally tags were used to describe artists (and they still are), but in many ways tags are insufficient. You can't tie a URL to a tag for example. You can fake a hierarchy using tag implications, but in most this cases this is excessive and leads to an explosion of redundant tags. For these reasons, artists were elevated to first class status in Danbooru.

    +
    + +
    +

    How do artists differ from tags?

    +

    For starters, artists can have URLs associated with them. These come in handy when you're uploading a post from the artist's site and want to auto-identify it; Danbooru will query the artist database for the URL and automatically figure out who it is. It isn't foolproof but as the database gets more artists, the more reliable it becomes.

    +

    You can also organize artists more. Doujin circles can be represented as artists and can have group members. Artists can also have aliases and notes for extraneous details.

    +
    + +
    +

    How do I search for artists?

    +

    Start at the <%= link_to "index", :controller => "artist", :action => "index" %>. In addition to browsing through the entire artist list, you can also search for artists.

    +

    By default, if you just enter a name in the search box Danbooru will return any artist that has your query in their name. This is probably the behavior you want in most cases.

    +

    Suppose you know the artist's homepage, but can't figure out their name. Simply search for the URL (beginning with http) and Danbooru will return any associated artists.

    +

    If you have an image, you can query the MD5 hash and Danbooru will try to deduce the artist that way. Simply enter a 32 character hex encoded hash and Danbooru will figure out what you mean.

    +
    + +
    +

    How do I create an artist?

    +

    First off, <%= link_to "go here", :controller => "artist", :action => "add" %>.

    +

    You'll see five fields. Name is self-explanatory. Jap Name/Aliases is for any aliases the artist has. For example, you would place the artist's name in kanji or kana in this field. If you have more than one alias to enter, you can separate them with commas. Notes are for any extra tidbits of information you want to mention (this field is actually saved to the artist's matching wiki page on Danbooru).

    +

    The URLs field is a list of URLs associated with the artist, like their home page, their blog, and any servers that store the artist's images. You can separate multiple artists with newlines or spaces.

    +
    + +
    +

    How do I update an artist?

    +

    The interface for updating an artist is nearly identical to the interface for creating artists, except for one additional field: members. Members is for artists who are a member of this circle. If there are more than one, you can separate them with commas.

    +
    + +
    +

    What are aliases?

    +

    Artists often have more than one name. In particular, they can have a Japanese name and a romanized name. Ideally, users should be able to search for either and get the same artist.

    +

    Danbooru allows you to alias artists to one reference artist, typically one that you can search posts on.

    +
    + +
    +

    Are artists in any way tied to posts or tags?

    +

    No. If you create an artist, a corresponding tag is not automatically created. If you create an artist-typed tag, a corresponding artist is not automatically created. If you create an artist but no corresponding tag, searching for posts by that artist won't return any results.

    +

    You can think of the artist database as separate from the tags/posts database.

    +

    This is an intentional design decision. By keeping the two separated, users have far more freedom when it comes to creating aliases, groups, and edits.

    +
    + +
    +

    When I search for a URL, I get a bunch of unrelated results. What's going on?

    +

    Short answer: this is just a side-effect of the way Danbooru searches URLs. Multiple results typically mean Danbooru couldn't find the artist.

    +

    Long answer: when you're searching for a URL, typically it's a URL to an image on the artist's site. If this is a new image, querying this will obviously return no results.

    +

    So what Danbooru does is progressively chop off directories from the URL. http://site.com/a/b/c.jpg becomes http://site.com/a/b becomes http://site.com/a becomes http://site.com. It keeps doing this until a match is found. Danbooru does this more than once because there are cases where the URL is nested by date, like in http://site.com/2007/06/05/image.jpg. Usually this algorithm works very well, provided the artist has an entry in the database.

    +

    If he doesn't, then the algorithm is probably going to cut the URL down to just the domain, i.e. http://geocities.co.jp. When this happens, you'll sometimes get every artist hosted on that domain.

    +

    Why not just dump all the results if you get more than one? Well, there are a few cases when multiple artists validly map to the same domain. Usually the domain is just being used to host files or something.

    +
    + +
    +

    Is there an API?

    +

    Yes. The artist controller uses the same interface as the rest of Danbooru. See the <%= link_to "API documentation", :controller => "help", :action => "api" %> for details.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/bookmarklet.html.erb b/app/views/help/bookmarklet.html.erb new file mode 100644 index 00000000..77c5e781 --- /dev/null +++ b/app/views/help/bookmarklet.html.erb @@ -0,0 +1,21 @@ +
    +

    Help: Bookmarklet

    + +
    +

    Bookmark the following link: Post to <%= CONFIG['app_name'] %>.

    + +
    +

    How to Use

    +
      +
    • Click on the bookmarklet.
    • +
    • All images that can be uploaded to <%= CONFIG['app_name'] %> will get a thick dashed blue border.
    • +
    • Click on an image to upload it to <%= CONFIG['app_name'] %>.
    • +
    • You'll be redirected to the upload page where you can fill out the tags, the title, and set the rating.
    • +
    +
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/cheatsheet.html.erb b/app/views/help/cheatsheet.html.erb new file mode 100644 index 00000000..61abaaaf --- /dev/null +++ b/app/views/help/cheatsheet.html.erb @@ -0,0 +1,230 @@ +
    +

    Help: Cheat Sheet

    + +
    +

    Searching

    +
    +
    tag1 tag2
    +
    Search for posts that have tag1 and tag2.
    + +
    ~tag1 ~tag2
    +
    Search for posts that have tag1 or tag2.
    + +
    -tag1
    +
    Search for posts that don't have tag1.
    + +
    tag1*
    +
    Search for posts with tags that start with tag1.
    + +
    user:bob
    +
    Search for posts uploaded by the user Bob.
    + +
    vote:3:bob
    +
    Search for posts favorited by the user Bob.
    + +
    md5:foo
    +
    Search for posts with the MD5 hash foo.
    + +
    rating:questionable
    +
    Search for posts that are rated questionable.
    + +
    -rating:questionable
    +
    Search for posts that are not rated questionable.
    + +
    source:http://site.com
    +
    Search for posts with a source that starts with http://site.com.
    + +
    id:100
    +
    Search for posts with an ID number of 100.
    + +
    id:100..
    +
    Search for posts with an ID number of 100 or greater.
    + +
    id:>=100
    +
    Same as above.
    + +
    id:>100
    +
    Search for posts with an ID number greater than 100.
    + +
    id:..100
    +
    Search for posts with an ID number of 100 or less.
    + +
    id:<=100
    +
    Same as above.
    + +
    id:<100
    +
    Search for posts with an ID number less than 100.
    + +
    id:100..200
    +
    Search for posts with an ID number between 100 and 200.
    + +
    width:100
    +
    Search for posts with a width of 100 pixels (uses same syntax as id search).
    + +
    height:100
    +
    Search for posts with a height of 100 pixels (uses same syntax as id search)
    + +
    score:100
    +
    Search for posts with a score of 100 (uses same syntax as id search).
    + +
    mpixels:2.5..
    +
    Search for posts with 2.5 million pixels or greater (uses same syntax as id search).
    + +
    date:2007-01-01
    +
    Search for posts uploaded on a certain date (uses same syntax as id search).
    + +
    order:id
    +
    Order search results in ascending order based on post ID.
    + +
    order:id_desc
    +
    Order search results in descending order based on post ID.
    + +
    order:score
    +
    Order search results in descending order based on post score.
    + +
    order:score_asc
    +
    Order search results in ascending order based on post score.
    + +
    order:mpixels
    +
    Order search results in descending order based on resolution.
    + +
    order:mpixels_asc
    +
    Order search results in descending order based on resolution.
    + +
    order:landscape
    +
    Order landscape images first.
    + +
    order:portrait
    +
    Order portrait images first.
    + +
    order:vote
    +
    Order by when the post was voted (only valid when doing a vote search)
    + + <% if CONFIG["enable_parent_posts"] %> +
    parent:1234
    +
    Search for posts that have 1234 as a parent (and include post 1234).
    + +
    parent:none
    +
    Search for posts that have no parent.
    + <% end %> + +
    gun* dress
    +
    Pattern searches do not work well with other tags.
    + +
    ~gun dress
    +
    Or searches do not work well with other tags.
    + +
    rating:questionable rating:safe
    +
    In general, combining the same metatags (the ones that have + colons in them) will not work.
    + +
    rating:questionable score:100.. id:..1000
    +
    You can combine different metatags, however.
    +
    +
    + +
    +

    Tagging

    +
    +
    tag1 tag2
    +
    Tags a post with tag1 and tag2.
    + +
    maria-sama_ga_miteru
    +
    Replace spaces in tags with underscores.
    + +
    tanaka_rie soryu_asuka_langley
    +
    Use LastName FirstName order for characters with Japanese last names, or characters with full Chinese or Korean names. Middle names always follow the first name.
    + +
    john_smith akira_ferrari tony_leung
    +
    Use FirstName LastName order for characters with non-Asian names, or characters with Japanese first names but non-Asian last names, or characters with non-Asian first names but Chinese last names.
    + +
    general:food
    +
    Prefix a tag with general to remove any type. The prefix will be dropped when the tag is saved.
    + +
    artist:wakatsuki_sana
    +
    Prefix a tag with artist: to type it as an artist. The prefix will be dropped when the tag is saved.
    + +
    character:gasai_yuno
    +
    Prefix a tag with character: (or char:) to type it as a character.
    + +
    copyright:mirai_nikki
    +
    Prefix a tag with copyright: (or copy:) to type is as a copyright. Copyright tags include things like anime, manga, games, novels, or original doujinshi works.
    + +
    ambiguous:sakura
    +
    Prefix a tag with ambiguous: (or amb:) to make it ambiguous. Ambiguous tags are indicated as such to users, and they are pointed to the wiki for disambiguation.
    + +
    rating:questionable
    +
    Rates a post as questionable. This tag is discarded after the rating is changed.
    + + <% if CONFIG["enable_parent_posts"] %> +
    parent:1234
    +
    Sets the post's parent id to 1234. This tag is discarded after the parent id is changed. If the parent id is the same as the post id, then the parent id will be cleared.
    + <% end %> + +
    pool:maria-sama_ga_miteru_manga
    +
    Adds the post to the "Maria-sama ga Miteru Manga" pool. This tag is discarded after the post is added to the pool. Make sure to replace spaces with underscores. If the pool with the given name doesn't exist, it will be automatically created.
    + +
    pool:10
    +
    Adds the post to pool #10.
    + +
    -pool:10
    +
    Removes the post from pool #10.
    +
    +
    + +
    +

    Comments & Forum

    +
    +
    post #1000
    +
    Creates a link to post #1000.
    + +
    comment #1000
    +
    Creates a link to comment #1000.
    + +
    forum #1000
    +
    Creates a link to forum post #1000.
    + +
    pool #1000
    +
    Creates a link to pool #1000.
    + +
    [spoiler]spoiler text[/spoiler]
    +
    Marks "spoiler text" as a spoiler.
    + +
    [[link to this page]]
    +
    Creates an internal link to the wiki page with title "link to this page".
    + +
    [[my wiki page|click this]]
    +
    Creates an internal link to the wiki page with title "my wiki page", using "click this" for the link text.
    +
    +
    + +
    +

    Wiki

    +

    The wiki uses Textile for formatting.

    +
    +
    [[link to this page]]
    +
    Creates an internal link to the wiki page with title "link to this page".
    + +
    [[my wiki page|click this]]
    +
    Creates an internal link to the wiki page with title "my wiki page", using "click this" for the link text.
    + +
    h2. Major Header
    +
    Major headers should use h2.
    + +
    h4. Minor Header
    +
    Minor headers should use h4.
    +
    +
    + +
    +

    Notes

    +
    +
    <tn>translation note</tn>
    +
    Styles "translation note" as a translation note.
    +
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/comments.html.erb b/app/views/help/comments.html.erb new file mode 100644 index 00000000..c02c348f --- /dev/null +++ b/app/views/help/comments.html.erb @@ -0,0 +1,11 @@ +
    +

    Help: Comments

    + +
    +

    All comments are formatted using <%= link_to "DText", :action => "dtext" %>.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/dtext.html.erb b/app/views/help/dtext.html.erb new file mode 100644 index 00000000..07aaeb0f --- /dev/null +++ b/app/views/help/dtext.html.erb @@ -0,0 +1,80 @@ +
    +

    Help: DText

    +
    +

    DText is the name for Danbooru's custom text formatting language. It's a mishmash of several markdown languages including Textile, MediaWiki, and BBCode.

    +
    + +
    +

    Inline

    +
    +
    http://danbooru.donmai.us
    +
    URLs are automatically linked.
    + +
    [b]strong text[/b]
    +
    Makes text bold.
    + +
    [i]emphasized text[/i]
    +
    Makes text italicized.
    + +
    [[wiki page]]
    +
    Links to the wiki.
    + +
    {{touhou monochrome}}
    +
    Links to a post search.
    + +
    post #1234
    +
    Links to post #1234.
    + +
    forum #1234
    +
    Links to forum #1234.
    + +
    comment #1234
    +
    Links to comment #1234.
    + +
    pool #1234
    +
    Links to pool #1234.
    + +
    [spoiler]Some spoiler text[/spoiler]
    +
    Marks a section of text as spoilers.
    +
    +
    + +
    +

    Block

    +
    +      A paragraph.
    +      
    +      Another paragraph
    +      that continues on multiple lines.
    +      
    +      
    +      h1. An Important Header
    +      
    +      h2. A Less Important Header
    +      
    +      h6. The Smallest Header
    +      
    +      
    +      [quote]
    +      bob said:
    +      
    +      When you are quoting someone.
    +      [/quote]
    +    
    +
    + +
    +

    Lists

    +
    +      * Item 1
    +      * Item 2
    +      ** Item 2.a
    +      ** Item 2.b
    +      * Item 3
    +    
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/extension.html.erb b/app/views/help/extension.html.erb new file mode 100644 index 00000000..402aae8c --- /dev/null +++ b/app/views/help/extension.html.erb @@ -0,0 +1,13 @@ +
    +

    Help: Firefox Extension

    + +
    +

    There is a Firefox extension available to upload files from sites that have some sort of referrer or cookie access restriction. It is an alternative to the bookmarklet. The extension provides autocomplete for tags when adding a post or using the site.

    +

    Note that you need Firefox 2.0.x for the version 0.2.7 and above, which is also now compatible with the lolifox, but 0.2.6 is still available for Firefox 1.5.x users. On upgrading from Firefox 1.5 to 2.0 you should be automatically prompted to update to the latest compatible version.

    +

    As of version 0.2.8 the autocomplete function extends to the input fields on Danbooru itself.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/faq.html.erb b/app/views/help/faq.html.erb new file mode 100644 index 00000000..5aff79a9 --- /dev/null +++ b/app/views/help/faq.html.erb @@ -0,0 +1,15 @@ +
    +

    Help: Frequently Asked Questions

    + +
    +

    How can I get a contributor account?

    +

    A moderator or janitor has to invite you.

    + +

    How do I delete a tag?

    +

    If you are asking how to delete a tag that has no posts associated with it, you don't have to. A nightly batch is run that cleans up any unused tag.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> diff --git a/app/views/help/favorites.html.erb b/app/views/help/favorites.html.erb new file mode 100644 index 00000000..7e7bf86a --- /dev/null +++ b/app/views/help/favorites.html.erb @@ -0,0 +1,13 @@ +
    +

    Help: Favorites

    + +
    +

    You can save individual posts to a personal list of favorites. You need an account in order to use this feature, and you must have Javascript enabled.

    +

    To add a post to your favorites, simply click on the Add to Favorites link. Alternatively, you can use the Add to Favorites mode from the main listing.

    +

    You can view your favorites by clicking on My Favorites from the main listing, or going to My Account, then My Favorites.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> diff --git a/app/views/help/forum.html.erb b/app/views/help/forum.html.erb new file mode 100644 index 00000000..424efa41 --- /dev/null +++ b/app/views/help/forum.html.erb @@ -0,0 +1,11 @@ +
    +

    Help: Forum

    + +
    +

    All forum posts are formatted using <%= link_to "DText", :action => "dtext" %>.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> diff --git a/app/views/help/image_sampling.html.erb b/app/views/help/image_sampling.html.erb new file mode 100644 index 00000000..1858e86c --- /dev/null +++ b/app/views/help/image_sampling.html.erb @@ -0,0 +1,15 @@ +
    +

    Help: Image Sampling

    + +
    +

    While high resolution images are nice for archival purposes, beyond a certain resolution they become impractical to view and time consuming to download.

    +

    Danbooru will automatically resize any image larger than <%= CONFIG["sample_width"] %>x<%= CONFIG["sample_height"] %> to a more manageable size, in addition to the thumbnail. It will also store the original, unresized image.

    + <% unless CONFIG["force_image_samples"] %> +

    You can toggle this behavior by changing the Show Image Samples setting in your <%= link_to "user settings", :controller => "user", :action => "edit" %>.

    + <% end %> +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> diff --git a/app/views/help/index.html.erb b/app/views/help/index.html.erb new file mode 100644 index 00000000..5420d97c --- /dev/null +++ b/app/views/help/index.html.erb @@ -0,0 +1,43 @@ +
    +

    Help

    + +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> diff --git a/app/views/help/irc.html.erb b/app/views/help/irc.html.erb new file mode 100644 index 00000000..87c83b6c --- /dev/null +++ b/app/views/help/irc.html.erb @@ -0,0 +1,11 @@ +
    +

    Help: IRC

    + +
    +

    IRC is the best way to contact the creator. The official Danbooru IRC channel is at irc.synirc.net/miezaru-illuminati. Ask for rq. However Moe's IRC channel is located at irc.rizon.net/moe-imouto

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/mass_tag_edit.html.erb b/app/views/help/mass_tag_edit.html.erb new file mode 100644 index 00000000..531e14af --- /dev/null +++ b/app/views/help/mass_tag_edit.html.erb @@ -0,0 +1,44 @@ +
    +

    Help: Mass Tag Edit

    +

    Note: this function is only available to moderators.

    +

    Mass tag edit allows you to make sweeping changes to posts. It allows you to add tags, remove tags, or change tags to potentially thousands of posts at once. It is an extremely powerful feature that should be used with great caution.

    +

    There are two text fields and two buttons. The first text field is where you enter your tag query. The tag parser is identical to the one used for the main listing so any tag query that works there will work here. This includes all the meta-tags like source, id, user, and date. The second text field is where you enter the tags you want to tag the matching posts with.

    +

    Click on the Preview button to see what posts will be affected. This is based solely on the first text field. When you click on Save, this is what happens: Danbooru finds all the posts that match the query you entered in the first text field. Then, for each post, it removes any tag from the first text field, and adds all the tags from the second text field.

    +

    Here is a table explaining some of the things that you can do:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Tag QueryAdd TagsEffect
    applebananaChange every instance of the apple tag to banana.
    appleDelete every instance of the apple tag.
    apple orangeappleFind every post that has both the apple tag and the orange tag and delete the orange tag.
    source:orchardappleFind every post with orchard as the source and add the apple tag.
    id:10..20 -appleappleFind posts with id numbers between 10 and 20 that don't have the apple tag, and tag them with apple.
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/notes.html.erb b/app/views/help/notes.html.erb new file mode 100644 index 00000000..a809c5ff --- /dev/null +++ b/app/views/help/notes.html.erb @@ -0,0 +1,22 @@ +
    +

    Help: Notes

    + +
    +

    You can annotate images with notes. This is primarily used to translate text. Please do not use a note when a comment would suffice.

    +

    Because this feature makes heavy usage of DHTML and Ajax, it probably won't work on many browsers. Currently it's been tested with Firefox 2, IE6, and IE7.

    +

    If you have an issue with an existing note or have a comment about it, instead of replacing the note, post a comment. Comments are more visible to other users, and chances are someone will respond to your inquiry.

    +

    You can create a new note via the Add Translation link in the sidebar. The note will appear in the middle of the image. You can drag this note inside the image. You can resize the note by dragging the little black box on the bottom-right corner of the note.

    +

    When you mouse over the note box, the note body will appear. You can click on the body and another box will appear where you can edit the text. This box will also contain four links:

    +
      +
    • Save This saves the note to the database.
    • +
    • Cancel This reverts the note to the last saved copy. The note position, dimensions, and text will all be restored.
    • +
    • History This will redirect you to the history of the note. Whenever you save a note the old data isn't destroyed. You can always revert to an older version. You can even undelete a note.
    • +
    • Remove This doesn't actually remove the note from the database; it only hides it from view. You can undelete a note by reverting to a previous version.
    • +
    +

    All HTML code will be sanitized. You can place small translation notes by surrounding a block of text with <tn>...</tn> tags.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/pools.html.erb b/app/views/help/pools.html.erb new file mode 100644 index 00000000..0f660654 --- /dev/null +++ b/app/views/help/pools.html.erb @@ -0,0 +1,15 @@ +
    +

    Help: Pools

    + +
    +

    Pools are groups of posts with a common theme. They are similar to <%= link_to "favorites", :action => "favorites" %> with three important differences: public pools allow anyone to add or remove from them, you can create multiple pools, and posts in a pool can be ordered. This makes pools ideal for subjective tags, or for posts that are part of a series (as is the case in manga).

    +

    The interface for adding and removing pools resembles the interface for favorites. You can click on Add to Pool from the post's page. You'll be redirected to a page where you can select the pool.

    +

    If you're importing several posts into a pool, this process can become tedious. You can instead click on the Import link at the bottom of the pool's page. This allows you to execute a post search using any <%= link_to "tag combination", :action => "cheatsheet" %> you would normally use. Remove any posts that are irrelevant to the pool, then finish the import process.

    +

    Pools can be private or public. A private pool means you are the only person who can add or remove from it. In contrast, public pools can be updated by anyone, even anonymous users.

    +

    To remove a post from a pool, go to the pool's page and select the Delete Mode checkbox. Then click on the posts you want to delete. This works similarly to how posts are deleted from favorites.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/post_relationships.html.erb b/app/views/help/post_relationships.html.erb new file mode 100644 index 00000000..f86f6881 --- /dev/null +++ b/app/views/help/post_relationships.html.erb @@ -0,0 +1,11 @@ +
    +

    Help: Post Relationships

    + +

    Every post can have a parent. Any post that has a parent will not show up in the <%= link_to "main listing", :controller => "post", :action => "index" %>. However, the post will appear again if a user does any kind of tag search. This makes it useful for things like duplicates.

    +

    Please do not use parent/children for pages of a manga or doujinshi. It's better to use a <%= link_to "pool", :action => "pools" %> for these.

    +

    To use this field, simply enter the id number of the parent post when you upload or edit a post. To search for all the children of a parent post, you can do a tag search for post:nnnn, where nnnn is the id number of the parent post.

    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/posts.html.erb b/app/views/help/posts.html.erb new file mode 100644 index 00000000..826ea30b --- /dev/null +++ b/app/views/help/posts.html.erb @@ -0,0 +1,102 @@ +
    +

    Help: Posts

    +

    A post represents a single file that's been uploaded. Each post can have several tags, comments, and notes. If you have an account, you can also add a post to your favorites.

    + +
    +

    Search

    +

    Searching for posts is straightforward. Simply enter the tags you want to search for, separated by spaces. For example, searching for original panties will return every post that has both the original tag AND the panties tag.

    +

    That's not all. Danbooru offers several meta-tags that let you further refine your search, allowing you to query on things like width, height, score, uploader, date, and more. Consult the cheat sheet for a complete list of what you can do.

    +
    + +
    +

    Tag List

    +

    In both the listing page and the show page you'll notice a list of tag links with characters next to them. Here's an explanation of what the links are:

    +
    +
    ?
    +
    This links to the wiki page for the tag. If the tag is an artist type, then you'll be redirected to the artist page.
    + +
    +
    +
    This adds the tag to the current search.
    + +
    +
    This adds the negated tag to the current search.
    + +
    950
    +
    The number next to the tag represents how many posts there are. This isn't always the total number of posts for that tag. If you're searching for a combination of tags, this will be the number of posts that have the tag AND the current tag query. If you're not searching for anything, this will be the number of posts found within the last twenty-four hours.
    + +
    Color
    +
    Some tag links may be colored green, purple, or red. Green means the tag represents a character. Purple means the tag represents a copyright (things like anime, manag, games, or novels). Red means the tag represents an artist.
    +
    +

    When you're not searching for a tag, by default the tag list will show the most popular tags within the last three days. When you are searching for tags, the tag list will show related tags, sorted by relevancy.

    +
    + +
    + +

    Mode Menu

    +

    In the main listing page, you'll notice a menu labeled "Mode" in the sidebar. This menu lets you make several changes without ever leaving the listing page. Simply select an option and whenever you click on a thumbnail, the action will be performed in the background.

    + +
    +
    View Posts
    +
    This is the default mode. Whenever you click on a thumbnail, you'll go to that post.
    + +
    Edit Posts
    +
    Whenever you click on a thumbnail, you'll get a JavaScript prompt. Here you can easily change the post's tags, and the site will update the post for you in the background.
    + +
    Add to Favorites
    +
    Whenever you click on a thumbnail, that post will be added to your list of favorites.
    + +
    Vote Up
    +
    Whenever you click on a thumbnail, that post will be voted up.
    + +
    Vote Down
    +
    Whenever you click on a thumbnail, that post will be voted down.
    + +
    Rate Safe
    +
    Whenever you click on a thumbnail, that post will be rated safe.
    + +
    Rate Questionable
    +
    Whenever you click on a thumbnail, that post will be rated questionable.
    + +
    Rate Explicit
    +
    Whenever you click on a thumbnail, that post will be rated explicit.
    + +
    Flag Post
    +
    Whenever you click on a thumbnail, that post will be flagged for deletion.
    + +
    Lock Rating
    +
    Whenever you click on a thumbnail, that post will be rating locked (no one will be able to change the rating).
    + +
    Lock Notes
    +
    Whenever you click on a thumbnail, that post will be note locked (no one will be able to edit notes for that post).
    + +
    Edit Tag Script
    +
    Go here for details on tag scripts.
    + +
    Apply Tag Script
    +
    Whenever you click on a thumbnail, the current tag script will be applied to the post.
    +
    +
    + +
    +

    Borders

    +

    In the listing page, you will notice that some thumbnails have a border. The meaning of this border depends on the color.

    + +
    +
    Red
    +
    The post was flagged for deletion.
    + +
    Blue
    +
    The post is pending moderator approval.
    + +
    Green
    +
    The post has child posts.
    + + Yellow +
    The post has a parent.
    +
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/ratings.html.erb b/app/views/help/ratings.html.erb new file mode 100644 index 00000000..301a49d9 --- /dev/null +++ b/app/views/help/ratings.html.erb @@ -0,0 +1,32 @@ +
    +

    Help: Ratings

    + +
    +

    All posts on Danbooru are one of three ratings: Safe, Questionable, and Explicit. Questionable is the default if you don't specify one. Please note that this system is not foolproof: from time to time explicit images will be tagged safe, and vice versa. Therefore you should not depend on ratings unless you can tolerate the occasional exception.

    + +
    +

    Explicit

    +

    Any image where the vagina or penis are exposed and easily visible. This includes depictions of sex, masturbation, or any sort of penetration.

    +
    + +
    +

    Safe

    +

    Safe posts are images that you would not feel guilty looking at openly in public. Pictures of nudes, exposed nipples or pubic hair, cameltoe, or any sort of sexually suggestive pose are NOT safe and belong in questionable. Swimsuits and lingerie are borderline cases; some are safe, some are questionable.

    +
    + +
    +

    Questionable

    +

    Basically anything that isn't safe or explicit. This is the great middle area, and since it includes unrated posts, you shouldn't really expect anything one way or the other when browsing questionable posts.

    +
    + +
    +

    Search

    +

    You can filter search results by querying for rating:s, rating:q, or rating:e for safe, questionable, and explicit posts, respectively. You can also combine them with other tags and they work as expected.

    +

    If you want to remove a rating from your search results, use -rating:s, -rating:q, and -rating:e.

    +
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/source_code.html.erb b/app/views/help/source_code.html.erb new file mode 100644 index 00000000..f8eaa641 --- /dev/null +++ b/app/views/help/source_code.html.erb @@ -0,0 +1,12 @@ +
    +

    Help: Source Code

    + +
    +

    You can get the Danbooru source code using Subversion. Run svn co svn://donmai.us/danbooru/trunk for the latest copy.

    +

    All Danbooru code is released under a FreeBSD license.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/start.html.erb b/app/views/help/start.html.erb new file mode 100644 index 00000000..d7bb8587 --- /dev/null +++ b/app/views/help/start.html.erb @@ -0,0 +1,9 @@ +
    +

    Help: Getting Started

    +

    If you are already familiar with Danbooru, you may want to consult the cheat sheet for a quick overview of the site.

    +

    The core of Danbooru is represented by posts and tags. Posts are the content, and tags are how you find the posts.

    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/tag_aliases.html.erb b/app/views/help/tag_aliases.html.erb new file mode 100644 index 00000000..5b06ca6b --- /dev/null +++ b/app/views/help/tag_aliases.html.erb @@ -0,0 +1,11 @@ +
    +

    Help: Tag Aliases

    +

    Sometimes, two tags can mean the same thing. For example, pantsu and panties have identical meanings. It makes sense that if you search for one, you should also get the results for the other.

    +

    Danbooru tries to fix this issue by using tag aliases. You can alias one or more tags to one reference tag. For example, if we aliased pantsu to panties, then whenever someone searched for pantsu or tagged a post with pantsu, it would be internally replaced with panties. Tags are normalized before they are saved to the database. This means that the pantsu tag only exists in the aliases table.

    +

    When a tag is aliased to another tag, that means that the two tags are equivalent. You would not generally alias rectangle to square, for example, because while all squares are rectangles, not all rectangles are squares. To model this sort of relationship, you would need to use <%= link_to "implications", :action => "tag_implications" %>.

    +

    While anyone can <%= link_to "suggest", :controller => "tag_alias", :action => "index" %> an alias, only an administrator can approve it.

    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/tag_implications.html.erb b/app/views/help/tag_implications.html.erb new file mode 100644 index 00000000..599ea9b9 --- /dev/null +++ b/app/views/help/tag_implications.html.erb @@ -0,0 +1,15 @@ +
    +

    Help: Tag Implications

    +

    Suppose you tag a post with miniskirt. Miniskirts are simply a type of skirt, so ideally, you would like people who search for skirt to see your miniskirt post. You could tag your post with both skirt and miniskirt, but this starts to get tedious after awhile.

    +

    Tag implications can be used to describe is-a relationships. A miniskirt is a type of skirt. When a miniskirt → skirt implication is created, then whenever someone tags a post with miniskirt, Danbooru will also tag it with skirt. The tag is normalized before it is saved to the database.

    +

    Tag implications have a predicate and a consequent. The predicate is what is matched against. In the previous example, it would be miniskirt. The consequent is the tag that is added. In the example, it would be skirt.

    +

    You can have multiple implications for the same predicate. Danbooru will just add all the matching consequent tags. For example, if we created a miniskirt → female_clothes implication, then anytime someone tagged a post with miniskirt it would be expanded to miniskirt skirt female_clothes.

    +

    Implications can also be chained together. Instead of miniskirt → female_clothes we could create a skirt → female_clothes implication. The end result would be the same.

    +

    This implication process occurs AFTER the alias process.

    +

    It's easy to go overboard with implications. It's important not to create implications for frivolous things; for example, we could theoretically implicate everything to an object tag, but this is pointless and only adds bloat to the database. For cases where the predicate and the consequent are synonymous, aliases are a much better idea as they have lower overhead.

    +

    While you can suggest new implications, only an administrator can approve them.

    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/tag_scripts.html.erb b/app/views/help/tag_scripts.html.erb new file mode 100644 index 00000000..4128ab2e --- /dev/null +++ b/app/views/help/tag_scripts.html.erb @@ -0,0 +1,55 @@ +
    +

    Help: Tag Scripts

    + +
    +

    Tag scripts allow you to batch together several tag changes. With a single script you can add tags, remove tags, conditionally add tags, conditionally remove tags, or any combination of the above. Simply create one, select it, and click on a post thumbnail to apply the tag script in the background. The best way to illustrate how they work is through examples.

    +

    You can combine commands, but you cannot nest them. For example, [if cat, dog] [if dog, cat] works, but [if cat, [reset]] does not.

    + +
    +

    Add

    +
      +
    • cat dog would add the cat and dog tag.
    • +
    +
    + +
    +

    Remove

    +
      +
    • -cat -dog would remove the cat and dog tag.
    • +
    • cat -dog would add the cat tag and remove the dog tag.
    • +
    +
    + +
    +

    Conditional

    +
      +
    • [if cat, dog] would add the dog tag if and only if the post had the cat tag.
    • +
    • [if -cat, dog] would add the dog tag if and only if the post did not have the cat tag.
    • +
    • [if cat, -dog] would remove the dog tag if and only if the post had the cat tag.
    • +
    • [if -cat, -dog] would remove the dog tag if and only if the post did not have the cat tag.
    • +
    • [if cat -animal, animal] would add the animal tag if and only if the post had the cat tag but did not have the animal tag.
    • +
    +
    + +
    +

    Reset

    +
      +
    • [reset] would remove every tag from the post.
    • +
    • [reset] cat would remove every tag from the post, then add the cat tag.
    • +
    • cat [reset] would add the cat tag, then remove every tag from the post (this is a pointless script).
    • +
    +
    + +
    +

    Rating Changes

    +
      +
    • rating:e would change the post's rating to explicit.
    • +
    • [if sex, rating:e] would change the post's rating to explicit if and only if it had the sex tag.
    • +
    +
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/tags.html.erb b/app/views/help/tags.html.erb new file mode 100644 index 00000000..72b78ef5 --- /dev/null +++ b/app/views/help/tags.html.erb @@ -0,0 +1,70 @@ +
    +

    Help: Tags

    + +
    +

    Tags are basically keywords you can use to describe posts, allowing you to easily search and explore posts based on their content. Consult the cheat sheet for a full list of what you can search on.

    + +
    +

    Guidelines

    +

    When you're tagging a post, use the following guidelines:

    + +
    +
    Replace spaces with underscores
    +

    For example, maria-sama ga miteru becomes maria-sama_ga_miteru. This small concession makes other features much easier to implement.

    +
    + +
    +
    Forbidden characters
    +

    The following characters are stripped from tags: commas and semicolons.

    +
    + +
    +
    Name order
    +

    This is somewhat complicated. In general, use whatever order the the anime uses. Failing this, use the ordering the character's nationality suggests. This typically means LastName FirstName order for Asian names, and FirstName LastName order for Western names.

    +

    But there are exceptions. Some characters use FirstName LastName order despite having Asian-sounding names. Subaru Nakajima is a good example of this (in all official promotional artwork FirstName LastName order is used). There is nothing we can do but shake our heads.

    +

    Some characters have a mixture of Asian and Western names. Refer to the source material for these cases. Failing that, the general rule is, use whatever ordering the character's last name suggests. Asuka Langley Soryuu has a Japanese last name, so it would become soryuu_asuka_langley. Akira Ferrari has an Italian last name, so it becomes akira_ferrari. But again, there are exceptions to this like setsuna_f_seiei. You can go ahead and curse the site for not standardizing on FirstName LastName ordering earlier on. It's too late to change the system now.

    +
    + +
    +
    Use full names
    +

    Using full names reduces the chances of collisions. The definitive resource for character names is Anime News Network (note that all their character names use FirstName LastName order).

    +
    + +
    +
    Ask
    +

    If you're not sure whether a tag is right or wrong, then post a comment asking for some opinions. There are plenty of obsessive Danbooru fans who will gladly weigh in.

    +
    +
    + +
    +

    Types

    +

    Tags can be typed. Currently there are only three types: artist, character, and copyright.

    + +
    +
    Artist
    +

    Artist tags identify the tag as the artist. This doesn't mean the artist of the original copyrighted artwork (for example, you wouldn't use the barasui tag on a picture of Miu drawn by hanaharu_naruko).

    +

    When tagging something, you can tell Danbooru that a tag is an artist tag by prefixing it with artist:. For example, tagging something artist:mark tree will tag a post with mark and tree. If the mark tag doesn't already exist, it'll be created with the tag type set to artist.

    +
    + +
    +
    Character
    +

    Character tags identify the tag as a character. They work exactly like artist tags, only you prefix with "character" (or "char").

    +
    + +
    +
    Copyright
    +

    The copyright type indicates the tag represents an anime, a game, a novel, or some sort of copyrighted setting. Otherwise they work identically to character and artist tags, only you prefix with "copyright" instead (or "copy").

    +
    + +
    +
    Ambiguous
    +

    Tag ambiguity is handled much in the same way that Wikipedia handles ambiguity. Users who search for an ambiguous tag are directed to a disambiguation page on the wiki, where they can clarify what they want to search for.

    +

    The flag for marking a tag as ambiguous is separate from the type. This means that an artist tag could be marked as ambiguous, for example. To mark a tag as ambiguous, prefix it with "ambiguous".

    +
    +
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/trac.html.erb b/app/views/help/trac.html.erb new file mode 100644 index 00000000..f9588831 --- /dev/null +++ b/app/views/help/trac.html.erb @@ -0,0 +1,9 @@ +
    +

    Help: Trac

    + +

    The best way to submit new bugs and feature requests is to create a ticket in Trac. Simply click the New Ticket button on the Trac site and enter a short title and description. For bug reports, try and enter some steps that reproduce the bug.

    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/users.html.erb b/app/views/help/users.html.erb new file mode 100644 index 00000000..0e7b8442 --- /dev/null +++ b/app/views/help/users.html.erb @@ -0,0 +1,12 @@ +
    +

    Help: Accounts

    + +
    +

    There are three types of accounts: basic, privileged, and blocked.

    +

    See the <%= link_to "signup", :controller => "user", :action => "signup" %> page for more details.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/voting.html.erb b/app/views/help/voting.html.erb new file mode 100644 index 00000000..5081b88d --- /dev/null +++ b/app/views/help/voting.html.erb @@ -0,0 +1,12 @@ +
    +

    Help: Voting

    + +
    +

    You can vote on posts. When you click on the vote up or vote down link, your browser queries Danbooru in the background and records your vote. You can change your vote only if you are logged in.

    +

    In order to vote, you must have Javascript enabled. You DO NOT need an account to vote on posts.

    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/help/wiki.html.erb b/app/views/help/wiki.html.erb new file mode 100644 index 00000000..392c2139 --- /dev/null +++ b/app/views/help/wiki.html.erb @@ -0,0 +1,24 @@ +
    +

    Help: Wiki

    +

    Danbooru uses <%= link_to "DText", :action => "dtext" %> for all formatting.

    +

    To create an internal wiki link, wrap the title in two sets of square brackets. [[Like this]].

    + +
    +

    Search

    +

    By default when you search for a keyword Danbooru will search both the title and the body. If you want to only search the title, prefix your query with title. For example: "title:tag group".

    +
    + +
    +

    Style Guideline

    +

    The Danbooru wiki has no specific style guide, but here's some general advice for creating wiki pages for tags:

    +
      +
    • Use h4 for all headers, h6 for subheaders.
    • +
    • Bundle any relevant links at the end under a See also section.
    • +
    • For artists, include the artist's home page under the See also section
    • +
    +
    +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Help", :action => "index" %>
  • +<% end %> diff --git a/app/views/history/index.html.erb b/app/views/history/index.html.erb new file mode 100644 index 00000000..a67b69d2 --- /dev/null +++ b/app/views/history/index.html.erb @@ -0,0 +1,130 @@ +
    +
    +
      + <% if @type == "all" || @type == "posts" %> +
    • » <%= link_to @options[:show_all_tags]? "Showing all tags":"Showing only changed tags", + :controller => "post", :action => "similar", :params => params.merge({:show_all_tags=>@options[:show_all_tags]? 0:1}) %>
    • + <% end %> + + <%# If we're searching for a specific object, omit the id/name column and + show it once at the top. %> + <% if @options[:specific_table] && !@changes.empty? %> +
    • + » History for <%= @type.singularize %>: + <%= link_to @options[:show_name] ? @changes.first.group_by_obj.pretty_name : @changes.first.group_by_id, :controller => @changes.first.get_group_by_controller, :action => "show", :id => @changes.first.group_by_id %> +
    • + <% end %> +
    +
    + +
    + + + + + + <% + ts = ApplicationHelper::TableScale.new + ts.add(4) if @type == "all" # type + ts.add(1) # box + if @options[:specific_table] + elsif @options[:show_name] + ts.add(15) # name + else + ts.add(4) # id + end + ts.add(5) # date + ts.add(10) # user + ts.add(80) # change + %> + <% if @type == "all" %> + + <% end %> + + <% if @options[:specific_table] %> + <% elsif @options[:show_name] %> + + <% else %> + + <% end %> + + + + + + + <% @changes.each do |change| %> + " id="r<%= change.id %>"> + <% if @type == "all" %> + + <% end %> + + + <% if not @options[:specific_table] %> + <% classes = ["id"] %> + <% classes << ["deleted"] if change.group_by_obj.class == Post && change.group_by_obj.status == "deleted" %> + <% classes << ["held"] if change.group_by_obj.class == Post && change.group_by_obj.is_held %> + + <% end %> + + + + + <% end %> + +
    Type<%= @type.singularize.capitalize %> + <% if @type == "all" %> + Id + <% else %> + <%= @type.singularize.capitalize %> + <% end %> + DateUserChange
    <%= change.group_by_table.singularize.humanize %>"><%= link_to (@options[:show_name] ? change.group_by_obj.pretty_name : change.group_by_id), + :controller => change.get_group_by_controller, + :action => change.get_group_by_action, + :id => change.group_by_id %><%= change.created_at.strftime("%b %e") %><%= link_to_unless change.user_id == nil, change.author, :controller => "user", :action => "show", :id => change.user_id %><%= format_changes(change, @options) %>
    +
    +
    +
    + <% form_tag({:action => @params[:action]}, :method => :get) do %> + <%= text_field_tag "search", params[:search], :id => "search", :size => 20 %> <%= submit_tag "Search" %> + <% end %> +
    +
    + + + <% content_for("subnavbar") do %> +
  • <%= link_to "All", :action => "index" %>
  • +
  • <%= link_to "Posts", :action => "post" %>
  • +
  • <%= link_to "Pools", :action => "pool" %>
  • +
  • <%= link_to "Tags", :action => "tag" %>
  • + <% end %> +
    + +<% content_for("post_cookie_javascripts") do %> + +<% end %> + +
    + <%= will_paginate(@changes) %> +
    diff --git a/app/views/inline/_footer.html.erb b/app/views/inline/_footer.html.erb new file mode 100644 index 00000000..7cb9fd45 --- /dev/null +++ b/app/views/inline/_footer.html.erb @@ -0,0 +1,5 @@ +<% content_for("subnavbar") do %> +
  • <%= link_to "List", :action => "index" %>
  • + <%= @content_for_footer %> + +<% end %> diff --git a/app/views/inline/crop.html.erb b/app/views/inline/crop.html.erb new file mode 100644 index 00000000..4d4eca29 --- /dev/null +++ b/app/views/inline/crop.html.erb @@ -0,0 +1,50 @@ +
    + Select the region to crop all images to and press enter. This operation can not be undone. +

    + +

    + <%= inline_image_tag(@image, {:use_sample => true}, {:id => "image"}) %> +
    + + <% form_tag({:action => "crop"}, :id => "crop", :level => :member) do %> + <%= hidden_field_tag "id", @image.id %> + <%= hidden_field_tag "left", 0 %> + <%= hidden_field_tag "right", 0 %> + <%= hidden_field_tag "top", 0 %> + <%= hidden_field_tag "bottom", 0 %> + <% end %> + + +
    diff --git a/app/views/inline/edit.html.erb b/app/views/inline/edit.html.erb new file mode 100644 index 00000000..00e5518b --- /dev/null +++ b/app/views/inline/edit.html.erb @@ -0,0 +1,182 @@ +<% if @inline.inline_images.length > 0 %> +<% block, script, inline_html_id = format_inline(@inline, 0, "inline", "") %> +<% end %> + +
    + Inline tag: image #<%= @inline.id %> + <% if @current_user.has_permission?(@inline) %> + + | Preview + + <% if @inline.inline_images.length < 9 %> + | Add an image + <% end %> + <% end %> +

    + + +

    +
    + +
    + <%= block %> +
    + +
    + <% for image in @inline.inline_images %> + <% form_tag({:action => "delete_image"}, :id => "delete-image-%i" % image.id) do %> + <%= hidden_field_tag "id", image.id %> + <% end %> + <% end %> + + <% form_tag(:action => "update") do %> + +
    + + + <%= format_text(@inline.description) %> + + <% if @inline.description.length == 0 %>click to add a description for this set<% end %> + +
    +
    + + +
    + + <% for image in @inline.inline_images %> +
    +
    + + <% if @current_user.has_permission?(@inline) %> + <%= link_to "Remove image", "#", :onclick => "if(confirm('Remove this image?')) $('delete-image-#{image.id}').submit(); return false;" %> + | <%= link_to_function "Move up", "orderShift(#{image.id}, -1)" %> + | <%= link_to_function "Move down", "orderShift(#{image.id}, +1)" %> + | + + <% if image.description.length == 0 %> + Add description + <% else %> + <%= h(image.description) %> + <% end %> + + + + <% else %> + <%= h(image.description) %> + <% end %> + +
    + + + <%= hidden_field_tag "image[#{image.id}][sequence]", image.sequence, :size => 10, :disabled => (not @current_user.has_permission?(@inline)), :class=>"inline-sequence" %> +
    +
    + +
    + <% end %> + + <%= hidden_field_tag "id", @inline.id %> + <%= submit_tag "Save changes", :disabled => (not @current_user.has_permission?(@inline)) %> + <% end %> +
    + + + + +<% form_tag({:action => "delete"}, :id => "delete-group") do %> + <%= hidden_field_tag "id", @inline.id %> +<% end %> +<% form_tag({:action => "copy"}, :id => "copy-group") do %> + <%= hidden_field_tag "id", @inline.id %> +<% end %> + +<% content_for("subnavbar") do %> + <% if @current_user.has_permission?(@inline) %> +
  • <%= link_to "Delete", "#", :onclick => "if(confirm('Delete this group of images?')) $('delete-group').submit(); return false;" %>
  • +
  • <%= link_to "Crop", :action => "crop", :id => @inline.id %>
  • + <% end %> + +
  • <%= link_to "Copy", "#", :onclick => "$('copy-group').submit(); return false;", :level => :member %>
  • +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/inline/index.html.erb b/app/views/inline/index.html.erb new file mode 100644 index 00000000..9138c41f --- /dev/null +++ b/app/views/inline/index.html.erb @@ -0,0 +1,45 @@ +
    + + + + + + + + + + + + <% @inlines.each do |p| %> + + + + + + + + <% end %> + +
    First imageDescriptionUserImagesCreated
    + + <% if p.inline_images.first %> + <%= image_tag(p.inline_images.first.preview_url, :alt => "thumb", :width=>p.inline_images.first.preview_dimensions[:width], :height=>p.inline_images.first.preview_dimensions[:height]) %> + <% else %> + (no images) + <% end %> + + <%= h(p.description) %><%= link_to h(p.user.pretty_name), :controller => "user", :action => "show", :id => p.user.id %><%= p.inline_images.length %><%= time_ago_in_words(p.created_at) %> ago
    +
    + +
    + <%= will_paginate(@inlines) %> +
    + +<% form_tag({:action => "create"}, :id => "create-new") do %> +<% end %> + +<% content_for("subnavbar") do %> +
  • <%= link_to "Create", "#", :level => :member, :onclick => "$('create-new').submit(); return false;" %>
  • +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/job_task/index.html.erb b/app/views/job_task/index.html.erb new file mode 100644 index 00000000..55a80b3e --- /dev/null +++ b/app/views/job_task/index.html.erb @@ -0,0 +1,32 @@ +

    Job Tasks

    + + + + + + + + + <% if @current_user.is_mod_or_higher? %> + + <% end %> + + + + <% @job_tasks.each do |job_task| %> + + + + + + <% if @current_user.is_mod_or_higher? %> + + <% end %> + + <% end %> + +
    IdTypeStatusDataMessage
    <%= job_task.id %><%= link_to h(job_task.task_type), :action => "show", :id => job_task.id %><%= h job_task.status %><%= h job_task.pretty_data rescue "ERROR" %><%= h job_task.status_message %>
    + +
    + <%= will_paginate(@job_tasks) %> +
    diff --git a/app/views/job_task/retry.html.erb b/app/views/job_task/retry.html.erb new file mode 100644 index 00000000..b708da21 --- /dev/null +++ b/app/views/job_task/retry.html.erb @@ -0,0 +1,6 @@ +

    Retry Job #<%= @job_task.id %>

    + +<% form_tag(:action => "retry") do %> + <%= submit_tag "Yes" %> + <%= button_to_function "No", "location.back()" %> +<% end %> diff --git a/app/views/job_task/show.html.erb b/app/views/job_task/show.html.erb new file mode 100644 index 00000000..8951c618 --- /dev/null +++ b/app/views/job_task/show.html.erb @@ -0,0 +1,15 @@ +

    Job #<%= @job_task.id %>

    + +
      +
    • Type: <%= @job_task.task_type %>
    • +
    • Status: <%= @job_task.status %>
    • +
    • Data: <%= @job_task.pretty_data rescue "ERROR" %>
    • +
    • Message: <%= @job_task.status_message %>
    • +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Listing", :action => "index" %>
  • + <% if @job_task.status == "error" %> +
  • <%= link_to "Retry", :action => "retry", :id => @job_task.id %>
  • + <% end %> +<% end %> diff --git a/app/views/layouts/_login.html.erb b/app/views/layouts/_login.html.erb new file mode 100644 index 00000000..1af4e044 --- /dev/null +++ b/app/views/layouts/_login.html.erb @@ -0,0 +1,93 @@ +<% return if CONFIG["enable_account_email_activation"] %> + + + + + + + diff --git a/app/views/layouts/_notice.html.erb b/app/views/layouts/_notice.html.erb new file mode 100644 index 00000000..0db725af --- /dev/null +++ b/app/views/layouts/_notice.html.erb @@ -0,0 +1,17 @@ + +<% content_for("post_cookie_javascripts") do %> + +<% end %> diff --git a/app/views/layouts/bare.html.erb b/app/views/layouts/bare.html.erb new file mode 100644 index 00000000..3d1ac140 --- /dev/null +++ b/app/views/layouts/bare.html.erb @@ -0,0 +1,31 @@ + + + + <%= @page_title %> + + + " href="/"> + <%= stylesheet_link_tag "default" %> + <%= javascript_include_tag :all, :cache => "application" %> + <%= CONFIG["custom_html_headers"] %> + + + + + <%= render :partial => "layouts/notice" %> +
    + <%= @content_for_layout %> +
    + <%= @content_for_post_cookie_javascripts %> + + diff --git a/app/views/layouts/default.html.erb b/app/views/layouts/default.html.erb new file mode 100644 index 00000000..2b72173c --- /dev/null +++ b/app/views/layouts/default.html.erb @@ -0,0 +1,122 @@ + + + + <%= @page_title %> + + " href="/"> + <%= @content_for_html_header %> + <%= auto_discovery_link_tag :atom, :controller => "post", :action => "atom", :tags => params[:tags] %> + <%= stylesheet_link_tag "default" %> + <%# The javascript-hide class is used to hide elements (eg. blacklisted posts) from JavaScript. %> + + + + + <%= javascript_include_tag :all, :cache => "application" %> + + <%= CONFIG["custom_html_headers"] %> + + + + + <%= render :partial => "layouts/login" %> + + <% if CONFIG["server_host"] == "moe.imouto.org" %> + <%# This is actually just to keyword "image board" and "danbooru" for searches like + "tinkle danbooru". Be careful, this looks very stupid in search results if taken + too far: "Sex hentai porn danbooru image board"; this should hint search engines, + not spam them. %> +
    Danbooru-based image board with a specialization in high-quality images.
    + <% end %> + + + <%= render :partial => "layouts/notice" %> + + + +
    + <%= @content_for_layout %> + <% if @content_for_subnavbar %> + + <% end %> +
    + + + + + <%= @content_for_post_cookie_javascripts %> + + diff --git a/app/views/note/_footer.html.erb b/app/views/note/_footer.html.erb new file mode 100644 index 00000000..74a0194b --- /dev/null +++ b/app/views/note/_footer.html.erb @@ -0,0 +1,7 @@ +<% content_for("subnavbar") do %> +
  • <%= link_to "List", :action => "index" %>
  • +
  • <%= link_to "Search", :action => "search" %>
  • +
  • <%= link_to "History", :action => "history" %>
  • +
  • <%= link_to "Translation requests", :controller => "post", :action => "index", :tags => "translation_request" %>
  • +
  • <%= link_to "Help", :controller => "help", :action => "notes" %>
  • +<% end %> diff --git a/app/views/note/_note.html.erb b/app/views/note/_note.html.erb new file mode 100644 index 00000000..aae5a9b7 --- /dev/null +++ b/app/views/note/_note.html.erb @@ -0,0 +1,7 @@ +
    +
    +
    + +
    + <%= h note.body %> +
    diff --git a/app/views/note/history.html.erb b/app/views/note/history.html.erb new file mode 100644 index 00000000..356c3035 --- /dev/null +++ b/app/views/note/history.html.erb @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + <% @notes.each do |note| %> + + + + + + + + + + <% end %> + +
    PostNoteBodyEdited ByDateOptions
    <%= link_to note.post_id, :controller => "post", :action => "show", :id => note.post_id %><%= link_to "#{note.note_id}.#{note.version}", :controller => "note", :action => "history", :id => note.note_id %><%= h(note.body) %> <% unless note.is_active? %>(deleted)<% end %><%= link_to h(note.author), :controller => "user", :action => "show", :id => note.user_id %><%= note.updated_at.strftime("%D") %><%= link_to "Revert", {:controller => "note", :action => "revert", :id => note.note_id, :version => note.version}, :method => :post, :confirm => "Do you really wish to revert to this note?" %>
    + +
    + <%= will_paginate(@notes) %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/note/index.html.erb b/app/views/note/index.html.erb new file mode 100644 index 00000000..5e795143 --- /dev/null +++ b/app/views/note/index.html.erb @@ -0,0 +1,9 @@ +
    + <%= render :partial => "post/posts", :locals => {:posts => @posts} %> + +
    + <%= will_paginate(@posts) %> +
    + + <%= render :partial => "footer" %> +
    diff --git a/app/views/note/search.html.erb b/app/views/note/search.html.erb new file mode 100644 index 00000000..df3c2627 --- /dev/null +++ b/app/views/note/search.html.erb @@ -0,0 +1,26 @@ +

    Search

    + +<% form_tag({:action => "search"}, :method => :get) do %> + <%= text_field_tag "query", params[:query], :size => 40 %> <%= submit_tag "Search" %> +<% end %> + +<% if @notes %> +
    + <% @notes.each do |note| %> +
    +
    + <%= link_to image_tag(note.post.preview_url), :controller => "post", :action => "show", :id => note.post_id %> +
    +
    + <%= hs note.formatted_body %> +
    +
    + <% end %> +
    + +
    + <%= will_paginate(@notes) %> +
    +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/pool/_footer.html.erb b/app/views/pool/_footer.html.erb new file mode 100644 index 00000000..9389480a --- /dev/null +++ b/app/views/pool/_footer.html.erb @@ -0,0 +1,6 @@ +<% content_for("subnavbar") do %> +
  • <%= link_to "List", :action => "index" %>
  • +
  • <%= link_to "Create", :action => "create" %>
  • + <%= @content_for_footer %> +
  • <%= link_to "Help", :controller => "help", :action => "pools" %>
  • +<% end %> \ No newline at end of file diff --git a/app/views/pool/add_post.html.erb b/app/views/pool/add_post.html.erb new file mode 100644 index 00000000..d8f260d3 --- /dev/null +++ b/app/views/pool/add_post.html.erb @@ -0,0 +1,29 @@ +

    Add to Pool

    + +<%= link_to(image_tag(@post.preview_url), :controller => "post", :action => "show", :id => @post.id) %> + +

    Select a pool to add this post to:

    + +<% form_tag(:action => "add_post") do %> + <%= hidden_field_tag("post_id", @post.id) %> + + + + + + + + + + + +
    + +
    <%= text_field "pool", "sequence", :size => 5, :value => "" %>
    + + <%= submit_tag "Add" %> <%= button_to_function "Cancel", "history.back()" %> +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/pool/create.html.erb b/app/views/pool/create.html.erb new file mode 100644 index 00000000..7746e0c8 --- /dev/null +++ b/app/views/pool/create.html.erb @@ -0,0 +1,25 @@ +

    Create Pool

    + +<% form_tag({:action => "create"}, :level => :member) do %> + + + + + + + + + + + + + + + + + + +
    <%= text_field "pool", "name" %>
    <%= check_box "pool", "is_public" %>
    <%= text_area "pool", "description", :size => "40x10" %>
    <%= submit_tag "Save" %> <%= button_to_function "Cancel", "history.back()" %>
    +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/pool/destroy.html.erb b/app/views/pool/destroy.html.erb new file mode 100644 index 00000000..f16fdbdf --- /dev/null +++ b/app/views/pool/destroy.html.erb @@ -0,0 +1,8 @@ +

    Delete Pool

    + +<% form_tag(:action => "destroy") do %> +

    Are you sure you wish to delete "<%= h(@pool.pretty_name) %>"?

    + <%= submit_tag "Yes" %> <%= button_to_function "No", "history.back()" %> +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/pool/import.html.erb b/app/views/pool/import.html.erb new file mode 100644 index 00000000..82a3021e --- /dev/null +++ b/app/views/pool/import.html.erb @@ -0,0 +1,36 @@ +

    Pool Import: <%= h(@pool.pretty_name) %>

    + +

    You can perform a tag search and import all the matching posts into this pool.

    + +
    + <% form_remote_tag(:url => {:action => "import", :format => "js"}, :method => :get) do %> + <%= text_field_tag "query", params[:query], :size => 50 %> <%= submit_tag "Search" %> + <% end %> +
    + +
    +
      + <% form_tag(:action => "import") do %> + <%= hidden_field_tag "id", @pool.id %> + +
      +
      + +
      + <%= submit_tag "Import" %> <%= button_to_function "Cancel", "history.back()" %> +
      + <% end %> +
    +
    + + diff --git a/app/views/pool/import.js.rjs b/app/views/pool/import.js.rjs new file mode 100644 index 00000000..6ad4774c --- /dev/null +++ b/app/views/pool/import.js.rjs @@ -0,0 +1,16 @@ +fields = "" +thumbnails = "" +@posts.each_index do |i| + p = @posts[i] + fields << hidden_field_tag("posts[#{p.id}]", "%05i" % i) + thumbnails << print_preview(p, :onclick => "return removePost(#{p.id})") +end + +delete_toggle = "" +delete_toggle << '
    ' +delete_toggle << check_box_tag("delete-mode") +delete_toggle << content_tag(:label, "Remove posts", :onclick => "Element.toggle('delete-mode-help')", :for => "delete-mode") +delete_toggle << content_tag(:p, content_tag(:em, "When delete mode is enabled, clicking on a thumbnail will remove that post from the import."), :style => "display: none;", :id => "delete-mode-help") +delete_toggle << '
    ' + +page.replace_html("posts", :inline => delete_toggle + fields + thumbnails) diff --git a/app/views/pool/index.html.erb b/app/views/pool/index.html.erb new file mode 100644 index 00000000..4d988b9a --- /dev/null +++ b/app/views/pool/index.html.erb @@ -0,0 +1,56 @@ +
    +
    + <% form_tag({:action => "index"}, :method => :get) do %> + <% if params.has_key?(:order) %> + <%= hidden_field_tag "order", params[:order] %> + <% end %> + <%= text_field_tag "query", params[:query], :size => 40 %> + <%= submit_tag "Search" %> + <% end %> +
    + + + + + + + + + + + + + + <% @pools.each do |p| %> + + + + + + + <% end %> + +
    NameCreatorPostsCreated
    <%= link_to h(p.pretty_name), :action => "show", :id => p.id %><%= h(p.user.pretty_name) %><%= p.post_count %><%= time_ago_in_words(p.created_at) %> ago
    +
    + +
    + <%= will_paginate(@pools) %> +
    + +<% content_for("post_cookie_javascripts") do %> + +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/pool/order.html.erb b/app/views/pool/order.html.erb new file mode 100644 index 00000000..cdb8cb42 --- /dev/null +++ b/app/views/pool/order.html.erb @@ -0,0 +1,70 @@ +

    Pool Ordering: <%= link_to @pool.pretty_name, :action => :show, :id => @pool.id %>

    +

    Lower numbers will appear first.

    + + + +<% form_tag(:action => "order") do %> + <%= hidden_field_tag "id", @pool.id %> + + + + + + + + + <% @pool_posts.each do |pp| %> + + + + + <% end %> + + + + + + +
    Order
    + <% if pp.post.can_be_seen_by?(@current_user) %> + <%= link_to(image_tag(pp.post.preview_url), {:controller => "post", :action => "show", :id => pp.post_id}, :title => pp.post.cached_tags)%> + <% end %> + + <%= text_field_tag "pool_post_sequence[#{pp.id}]", pp.sequence, :class => "pp", :size => 5, :tabindex => 1 %> + <%= link_to_function "+1", "orderShift(#{pp.id}, +1)", :class=>"text-button" %> + <%= link_to_function "-1", "orderShift(#{pp.id}, -1)", :class=>"text-button" %> +
    <%= submit_tag "Save", :tabindex => 2 %> <%= button_to_function "Auto Order", "orderAutoFill()", :tabindex => 2 %> <%= button_to_function "Reverse", "orderReverse()", :tabindex => 2 %> <%= button_to_function "Cancel", "history.back()", :tabindex => 2 %>
    +<% end %> diff --git a/app/views/pool/select.html.erb b/app/views/pool/select.html.erb new file mode 100644 index 00000000..55a3fd8f --- /dev/null +++ b/app/views/pool/select.html.erb @@ -0,0 +1,5 @@ +<% form_tag(:action => "add_post") do %> + <%= hidden_field_tag "post_id", params[:post_id] %> + <%= select_tag "pool_id", options_for_select(@pools.map {|x| [x.name.tr("_", " "), x.id]}, session[:last_pool_id]) %> + <%= button_to_function "Add", "Pool.add_post(#{params[:post_id]}, $F('pool_id'))", :level => :member %> +<% end %> diff --git a/app/views/pool/show.html.erb b/app/views/pool/show.html.erb new file mode 100644 index 00000000..c5f39def --- /dev/null +++ b/app/views/pool/show.html.erb @@ -0,0 +1,69 @@ +
    +

    Pool: <%= h(@pool.pretty_name) %>

    + <% unless @pool.description.blank? %> +
    <%= format_text @pool.description %>
    + <% end %> + <% if @current_user.can_change?(@pool, :posts) %> + + + + <% end %> +
    +
      + <% @posts.each do |post| %> + <%= print_preview(post, :onclick => "return remove_post_confirm(#{post.id}, #{@pool.id})", + :user => @current_user) %> + <% end %> +
    +
    +
    + +<%= render :partial => "post/hover" %> + +
    + <%= will_paginate(@posts) %> +
    + +<% content_for("footer") do %> + <% if CONFIG["pool_zips"] %> + <% zip_params = {} %> + <% zip_params[:originals] = true if @pool.has_originals? && params[:originals] %> + <% has_jpeg = CONFIG["jpeg_enable"] && @pool.has_jpeg_zip?(zip_params) %> + <% if has_jpeg %> +
  • <%= link_to_pool_zip params[:originals] ? "Download original JPGs":"Download JPGs", @pool, zip_params.merge({:jpeg => true}) %>
  • + <% end %> +
  • <%= link_to_pool_zip params[:originals] ? "Download originals":"Download", @pool, zip_params, {:has_jpeg => has_jpeg} %>
  • + <% end %> + <% if @pool.has_originals? %> + <% if params[:originals] == "1" %> +
  • <%= link_to "View edited", :id => params[:id], :originals => 0 %>
  • + <% else %> +
  • <%= link_to "View originals", :id => params[:id], :originals => 1 %>
  • + <% end %> + <% end %> + <% if @current_user.has_permission?(@pool) %> +
  • <%= link_to "Edit", :action => "update", :id => params[:id] %>
  • +
  • <%= link_to "Delete", :action => "destroy", :id => params[:id] %>
  • + <% end %> + <% if @current_user.can_change?(@pool, :posts) %> +
  • <%= link_to "Order", :action => "order", :id => params[:id] %>
  • +
  • <%= link_to "Import", :action => "import", :id => params[:id] %>
  • + <% end %> +
  • <%= link_to "History", :controller => "history", :action => "index", :search => "pool:#{params[:id]}" %>
  • +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/pool/update.html.erb b/app/views/pool/update.html.erb new file mode 100644 index 00000000..f21439f1 --- /dev/null +++ b/app/views/pool/update.html.erb @@ -0,0 +1,35 @@ +

    Edit Pool

    + +<% form_tag(:action => "update") do %> + + + + + + + + + + + + + + + + + + + + + + +
    <%= text_field "pool", "name", :value => @pool.pretty_name %>
    <%= text_area "pool", "description", :size => "40x10" %>
    + +

    Public pools allow anyone to add/remove posts.

    +
    <%= check_box "pool", "is_public" %>
    + +

    Inactive pools will no longer be selectable when adding a post.

    +
    <%= check_box "pool", "is_active" %>
    <%= submit_tag "Save" %> <%= button_to_function "Cancel", "history.back()" %>
    +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/post/_blacklists.html.erb b/app/views/post/_blacklists.html.erb new file mode 100644 index 00000000..4d1f9425 --- /dev/null +++ b/app/views/post/_blacklists.html.erb @@ -0,0 +1,34 @@ + + +<% content_for("post_cookie_javascripts") do %> + +<% end %> + diff --git a/app/views/post/_footer.html.erb b/app/views/post/_footer.html.erb new file mode 100644 index 00000000..04798aec --- /dev/null +++ b/app/views/post/_footer.html.erb @@ -0,0 +1,14 @@ +<% content_for("subnavbar") do %> +
  • <%= link_to "List", :controller => "post", :action => "index" %>
  • +
  • <%= link_to "Upload", :controller => "post", :action => "upload" %>
  • +
  • <%= link_to "My Favorites", "/", :id => "my-favorites" %>
  • +
  • <%= link_to "Random", :controller => "post", :tags => "order:random" %>
  • +
  • <%= link_to "Popular", :controller => "post", :action => "popular_recent" %>
  • +
  • <%= link_to "Image Search", :controller => "post", :action => "similar" %>
  • +
  • <%= link_to "History", :controller => "history", :action => "index" %>
  • + <% if @current_user.is_janitor_or_higher? %> +
  • <%= link_to "Moderate", :controller => "post", :action => "moderate" %>
  • + <% end %> + <%= @content_for_footer %> +
  • <%= link_to "Help", :controller => "help", :action => "posts" %>
  • +<% end %> diff --git a/app/views/post/_hover.html.erb b/app/views/post/_hover.html.erb new file mode 100644 index 00000000..f061e08a --- /dev/null +++ b/app/views/post/_hover.html.erb @@ -0,0 +1,36 @@ + + +<% content_for("post_cookie_javascripts") do %> + +<% end %> + diff --git a/app/views/post/_posts.html.erb b/app/views/post/_posts.html.erb new file mode 100644 index 00000000..233a6326 --- /dev/null +++ b/app/views/post/_posts.html.erb @@ -0,0 +1,39 @@ +
    + <% if posts.empty? %> +

    Nobody here but us chickens!

    + <% else %> +
      + <% posts.each do |post| %> + <% if post.instance_of?(Post) %> + <%= print_preview(post, :similarity=>(@similar[:similarity][post] rescue nil), :blacklisting => true) %> + <% else %> + <%= print_ext_similarity_preview(post, {:similarity=>(@similar[:similarity][post] rescue nil)}) %> + <% end %> + <% end %> +
    + + <%# Make sure this is done early, as lots of other scripts depend on this registration. %> + <% content_for_prefix("post_cookie_javascripts") do %> + + <% end %> + <% end %> + +<% content_for("post_cookie_javascripts") do %> + +<% end %> +
    diff --git a/app/views/post/_preload.html.erb b/app/views/post/_preload.html.erb new file mode 100644 index 00000000..0acb1e7a --- /dev/null +++ b/app/views/post/_preload.html.erb @@ -0,0 +1,3 @@ +<% posts.each do |post| %> + +<% end %> diff --git a/app/views/post/_search.html.erb b/app/views/post/_search.html.erb new file mode 100644 index 00000000..b7eef335 --- /dev/null +++ b/app/views/post/_search.html.erb @@ -0,0 +1,9 @@ +
    +
    Search
    + <% form_tag({:controller => "post", :action => "index"}, :method => "get") do %> +
    + <%= text_field_tag("tags", params[:tags], :size => 20) %> + <%= submit_tag "Search", :style => "display: none;" %> +
    + <% end %> +
    diff --git a/app/views/post/_tag_script.html.erb b/app/views/post/_tag_script.html.erb new file mode 100644 index 00000000..05bd245d --- /dev/null +++ b/app/views/post/_tag_script.html.erb @@ -0,0 +1,14 @@ + +<% content_for("post_cookie_javascripts") do %> + +<% end %> diff --git a/app/views/post/_tags.html.erb b/app/views/post/_tags.html.erb new file mode 100644 index 00000000..c6b582c6 --- /dev/null +++ b/app/views/post/_tags.html.erb @@ -0,0 +1,9 @@ +<% include_tag_hover_highlight ||= false %> +
    +
    Tags
    +
      + <%= tag_links(@tags[:exclude], :prefix => "-", :with_hover_highlight => true, :with_hover_highlight => include_tag_hover_highlight) %> + <%= tag_links(@tags[:include], :with_aliases => @include_tag_reverse_aliases, :with_hover_highlight => include_tag_hover_highlight) %> + <%= tag_links(Tag.find_related(@tags[:related].to_a), :with_hover_highlight => true, :with_hover_highlight => include_tag_hover_highlight) %> +
    +
    diff --git a/app/views/post/atom.html.erb b/app/views/post/atom.html.erb new file mode 100644 index 00000000..43765e5b --- /dev/null +++ b/app/views/post/atom.html.erb @@ -0,0 +1,34 @@ + + + + <%= h CONFIG["app_name"] %> + /post/atom" rel="self"/> + /post/index" rel="alternate"/> + http://<%= h CONFIG["server_host"] %>/post/atom?tags=<%= h params[:tags] %> + <% if @posts.any? %> + <%= @posts[0].created_at.gmtime.xmlschema %> + <% end %> + <%= h CONFIG["app_name"] %> + <% @posts.each do |post| %> + + <%= h post.cached_tags %> + /post/show/<%= post.id %>" rel="alternate"/> + <% if post.source =~ /^http/ %> + + <% end %> + http://<%= h CONFIG["server_host"] %>/post/show/<%= post.id %> + <%= post.created_at.gmtime.xmlschema %> + <%= h post.cached_tags %> + + + + + <%= h post.author %> + + + <% end %> + diff --git a/app/views/post/delete.html.erb b/app/views/post/delete.html.erb new file mode 100644 index 00000000..a22ddabb --- /dev/null +++ b/app/views/post/delete.html.erb @@ -0,0 +1,32 @@ +

    Delete Post

    + +<% if CONFIG["can_see_post"].call(@current_user, @post) %> + <%= image_tag @post.preview_url %> +<% end %> + +<% form_tag(:action => "destroy") do %> + <%= hidden_field_tag "id", params[:id] %> + <%= text_field_tag "reason" %> + <%= submit_tag "Delete" %> <%= submit_tag "Cancel" %> +<% end %> + +
    +<% if !@post.is_deleted? %> +
    +

    + <% if @post_parent %> + Votes will be transferred to the following parent post. + If this is incorrect, reparent this post before deleting it.

    + <% if CONFIG["can_see_post"].call(@current_user, @post_parent) %> +

      <%= print_preview(@post_parent, :hide_directlink=>true) %>
    + <% else %> + (parent post hidden due to access restrictions) + <% end %> + + <% else %> + This post has no parent. If this post has been replaced, reparent this post before deleting, and votes will be transferred.

    + <% end %> +<% end %> +

    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/post/deleted_index.html.erb b/app/views/post/deleted_index.html.erb new file mode 100644 index 00000000..740498fa --- /dev/null +++ b/app/views/post/deleted_index.html.erb @@ -0,0 +1,36 @@ +

    Deleted Posts

    + + + + + + + + + + <% if @current_user.is_mod_or_higher? %> + + <% end %> + + + + <% @posts.each do |post| %> + + + + + + + <% if @current_user.is_mod_or_higher? %> + + <% end %> + + <% end %> + +
    PostUserTagsReasonDeleted by
    <%= link_to post.id, :action => "show", :id => post.id %><%= link_to h(post.author), :controller => "user", :action => "show", :id => post.user_id %><%= h(post.cached_tags) %><%= h(post.flag_detail.reason) %><%= link_to h(post.flag_detail.author), :controller => "user", :action => "show", :id => post.flag_detail.user_id %>
    + +
    + <%= will_paginate(@posts) %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/post/favorites.html.erb b/app/views/post/favorites.html.erb new file mode 100644 index 00000000..6cbd84c1 --- /dev/null +++ b/app/views/post/favorites.html.erb @@ -0,0 +1,8 @@ +

    Favorited by

    +
      +<%- @users.each do |u| %> +
    • <%= link_to h(u.pretty_name), :controller => "post", :action => "index", :tags => "vote:3:#{u.name} order:vote" %>
    • +<%- end %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/post/index.html.erb b/app/views/post/index.html.erb new file mode 100644 index 00000000..22007a69 --- /dev/null +++ b/app/views/post/index.html.erb @@ -0,0 +1,151 @@ +
    + <% if @tag_suggestions && @tag_suggestions.any? %> +
    + Maybe you meant: <%= @tag_suggestions.map {|x| tag_link(x)}.to_sentence(:connector => "or") %> +
    + <% end %> + + +
    + <% if CONFIG["can_see_ads"].call(@current_user) %> + <%= CONFIG["ad_code_index_bottom"] %> + <% end %> + + <% if @ambiguous_tags.any? %> +
    + The following tags are ambiguous: <%= @ambiguous_tags.map {|x| link_to(h(x), :controller => "wiki", :action => "show", :title => x)} %> +
    + <% end %> + + + + <%= render :partial => "hover" %> + <%= render :partial => "posts", :locals => {:posts => @posts} %> + +
    + <%= will_paginate(@posts) %> +
    +
    +
    + +<% content_for("post_cookie_javascripts") do %> + +<% end %> + +<% content_for("html_header") do %> + <%= auto_discovery_link_tag_with_id :rss, {:controller => "post", :action => "piclens", :tags => params[:tags], :page => params[:page]}, {:id => 'pl'} %> + <%= navigation_links(@posts) %> +<% end %> + +<%= render :partial => "footer" %> + +<% if @content_for_subnavbar %> + +
    + + +
    + <% @content_for_subnavbar = nil %> +<% end %> + diff --git a/app/views/post/index.xml.erb b/app/views/post/index.xml.erb new file mode 100644 index 00000000..813bf9b5 --- /dev/null +++ b/app/views/post/index.xml.erb @@ -0,0 +1,6 @@ + + + <% @posts.each do |post| %> + <%= post.to_xml(:skip_instruct => true) %> + <% end %> + diff --git a/app/views/post/moderate.html.erb b/app/views/post/moderate.html.erb new file mode 100644 index 00000000..28ba687b --- /dev/null +++ b/app/views/post/moderate.html.erb @@ -0,0 +1,113 @@ +
    + <%= text_field_tag "query", "", :size => 40 %> + <%= submit_tag "Search" %> +
    + + + +
    +

    Pending

    +
    + <%= hidden_field_tag "reason", "" %> + +
    +

    Deletion Guidelines

    +

    As a general rule, you should not delete posts. Only approve of posts that you personally like. Posts that are not approved in three days will be automatically deleted.

    +
    + + + + + + + + + <% @pending_posts.each do |p| %> + + + + + + <% end %> + +
    + <%= button_to_function "Select all", "$$('.p').each(function (i) {i.checked = true; highlight_row(i)})" %> + <%= button_to_function "Invert selection", "$$('.p').each(function (i) {i.checked = !i.checked; highlight_row(i)})" %> + <%= submit_tag "Approve" %> + <%= submit_tag "Delete", :onclick => "var reason = prompt('Enter a reason'); if (reason != null) {$('reason').value = reason; return true} else {return false}" %> +
    <%= link_to image_tag(p.preview_url, :width => p.preview_dimensions[0], :height => p.preview_dimensions[1]), :controller => "post", :action => "show", :id => p.id %> +
      +
    • Uploaded by <%= link_to h(p.author), :controller => "user", :action => "show", :id => p.user_id %> <%= time_ago_in_words(p.created_at) %> ago (<%= link_to "mod", :action => "moderate", :query => "user:#{p.author}" %>)
    • +
    • Rating: <%= p.pretty_rating %>
    • + <% if p.parent_id %> +
    • Parent: <%= link_to p.parent_id, :action => "moderate", :query => "parent:#{p.parent_id}" %>
    • + <% end %> +
    • Tags: <%= h p.cached_tags %>
    • +
    • Score: <%= p.score %>
    • +
    +
    +
    +
    + +
    +

    Flagged

    +
    + <%= hidden_field_tag "reason2", "" %> + + + + + + + + + <% @flagged_posts.each do |p| %> + + + + + + <% end %> + +
    + <%= button_to_function "Select all", "$$('.f').each(function (i) {i.checked = true; highlight_row(i)})" %> + <%= button_to_function "Invert selection", "$$('.f').each(function (i) {i.checked = !i.checked; highlight_row(i)})" %> + <%= submit_tag "Approve" %> + <%= submit_tag "Delete", :onclick => "var reason = prompt('Enter a reason'); if (reason != null) {$('reason2').value = reason; return true} else {return false}" %> +
    <%= link_to image_tag(p.preview_url, :width => p.preview_dimensions[0], :height => p.preview_dimensions[1]), :controller => "post", :action => "show", :id => p.id %> +
      +
    • Uploaded by <%= link_to h(p.author), :controller => "user", :action => "show", :id => p.user_id %> <%= time_ago_in_words(p.created_at) %> ago (<%= link_to "mod", :action => "moderate", :query => "user:#{p.author}" %>)
    • +
    • Rating: <%= p.pretty_rating %>
    • + <% if p.parent_id %> +
    • Parent: <%= link_to p.parent_id, :action => "moderate", :query => "parent:#{p.parent_id}" %>
    • + <% end %> +
    • Tags: <%= h p.cached_tags %>
    • +
    • Score: <%= p.score %> (vote <%= link_to_function "down", "Post.vote(-1, #{p.id}, {})" %>)
    • +
    • Reason: <%= h p.flag_detail.reason %> (<%= link_to h(p.flag_detail.author), :controller => "user", :action => "show", :id => p.flag_detail.user_id %>)
    • +
    +
    +
    + + +
    + +<%= render :partial => "footer" %> diff --git a/app/views/post/piclens.html.erb b/app/views/post/piclens.html.erb new file mode 100644 index 00000000..43683cf3 --- /dev/null +++ b/app/views/post/piclens.html.erb @@ -0,0 +1,28 @@ + + + + <%= h CONFIG["app_name"] %>/<%= h params[:tags] %> + http://<%= h CONFIG["server_host"] %>/ + <%= h CONFIG["app_name"] %>PicLens RSS Feed + <% unless @posts.is_first_page? %> + <%= tag("atom:link", {:rel => "previous", :href => url_for(:only_path => false, :controller => "post", :action => "piclens", :page => @posts.previous_page, :tags => params[:tags])}, false) %> + <% end %> + <% unless @posts.is_last_page? %> + <%= tag("atom:link", {:rel => "next", :href => url_for(:only_path => false, :controller => "post", :action => "piclens", :page => @posts.next_page, :tags => params[:tags])}, false) %> + <% end %> + + <% @posts.each do |post| %> + + <%= h post.cached_tags %> + http://<%= h CONFIG["server_host"] %>/post/show/<%= post.id %> + http://<%= h CONFIG["server_host"] %>/post/show/<%= post.id %> + + <% if CONFIG["image_samples"] %> + + <% else %> + + <% end %> + + <% end %> + + diff --git a/app/views/post/popular_by_day.html.erb b/app/views/post/popular_by_day.html.erb new file mode 100644 index 00000000..171aada6 --- /dev/null +++ b/app/views/post/popular_by_day.html.erb @@ -0,0 +1,13 @@ +
    +

    <%= link_to '«', :controller => "post", :action => "popular_by_day", :year => @day.yesterday.year, :month => @day.yesterday.month, :day => @day.yesterday.day %> <%= @day.strftime("%B %d, %Y") %> <%= link_to_unless @day >= Time.now, '»', :controller => "post", :action => "popular_by_day", :year => @day.tomorrow.year, :month => @day.tomorrow.month, :day => @day.tomorrow.day %>

    + + <%= render :partial => "posts", :locals => {:posts => @posts} %> +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Popular", :controller => "post", :action => "popular_by_day", :month => @day.month, :day => @day.day, :year => @day.year %>
  • +
  • <%= link_to "Popular (by week)", :controller => "post", :action => "popular_by_week", :year => @day.year, :month => @day.month, :day => @day.day %>
  • +
  • <%= link_to "Popular (by month)", :controller => "post", :action => "popular_by_month", :year => @day.year, :month => @day.month %>
  • +<% end %> + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/post/popular_by_month.html.erb b/app/views/post/popular_by_month.html.erb new file mode 100644 index 00000000..920ac008 --- /dev/null +++ b/app/views/post/popular_by_month.html.erb @@ -0,0 +1,13 @@ +
    +

    <%= link_to '«', :controller => "post", :action => "popular_by_month", :year => @start.last_month.year, :month => @start.last_month.month %> <%= @start.strftime("%B %Y") %> <%= link_to_unless @start >= Time.now, '»', :controller => "post", :action => "popular_by_month", :year => @end.year, :month => @end.month %>

    + + <%= render :partial => "posts", :locals => {:posts => @posts} %> +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Popular", :controller => "post", :action => "popular_by_day", :month => @start.month, :day => @start.day, :year => @start.year %>
  • +
  • <%= link_to "Popular (by week)", :controller => "post", :action => "popular_by_week", :year => @start.year, :month => @start.month, :day => @start.day %>
  • +
  • <%= link_to "Popular (by month)", :controller => "post", :action => "popular_by_month", :year => @start.year, :month => @start.month %>
  • +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/post/popular_by_week.html.erb b/app/views/post/popular_by_week.html.erb new file mode 100644 index 00000000..036933e9 --- /dev/null +++ b/app/views/post/popular_by_week.html.erb @@ -0,0 +1,13 @@ +
    +

    <%= link_to '«', :controller => "post", :action => "popular_by_week", :year => 1.week.ago(@start).year, :month => 1.week.ago(@start).month, :day => 1.week.ago(@start).day %> <%= @start.strftime("%B %d, %Y") %> - <%= @end.strftime("%B %d, %Y") %> <%= link_to_unless @start >= Time.now, '»', :controller => "post", :action => "popular_by_week", :year => @start.next_week.year, :month => @start.next_week.month, :day => @start.next_week.day %>

    + + <%= render :partial => "posts", :locals => {:posts => @posts} %> +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Popular", :controller => "post", :action => "popular_by_day", :month => @start.month, :day => @start.day, :year => @start.year %>
  • +
  • <%= link_to "Popular (by week)", :controller => "post", :action => "popular_by_week", :year => @start.year, :month => @start.month, :day => @start.day %>
  • +
  • <%= link_to "Popular (by month)", :controller => "post", :action => "popular_by_month", :year => @start.year, :month => @start.month %>
  • +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/post/popular_recent.html.erb b/app/views/post/popular_recent.html.erb new file mode 100644 index 00000000..1c8b04de --- /dev/null +++ b/app/views/post/popular_recent.html.erb @@ -0,0 +1,19 @@ +
    +

    + <% ["1d","1w","1m","1y"].each do |period| %> + <% if @params[:period] == period %> + <%= @period_name.capitalize %> + <% else %> + <%= link_to period, :controller => "post", :action => "popular_recent", :period => period %> + <% end %> + <% end %> +

    + + <%= render :partial => "posts", :locals => {:posts => @posts} %> +
    + + diff --git a/app/views/post/recent_searches.html.erb b/app/views/post/recent_searches.html.erb new file mode 100644 index 00000000..3f1146e8 --- /dev/null +++ b/app/views/post/recent_searches.html.erb @@ -0,0 +1,28 @@ +
    +

    Recent Searches

    +
      + <% @recent_searches.each do |s| %> +
    • <%= link_to h(s), :action => "index", :tags => s %>
    • + <% end %> +
    +
    + +
    +

    Popular Searches

    + + + + + + + + + <% @popular_searches.to_a.sort {|a, b| b[1] <=> a[1]}.each do |tag, count| %> + + + + + <% end %> + +
    CountTag
    <%= count %><%= link_to h(tag), :action => "index", :tags => tag %>
    +
    \ No newline at end of file diff --git a/app/views/post/show.html.erb b/app/views/post/show.html.erb new file mode 100644 index 00000000..ecd62f04 --- /dev/null +++ b/app/views/post/show.html.erb @@ -0,0 +1,57 @@ +
    + <% if @post.nil? %> +

    Nobody here but us chickens!

    + <% else %> + <% if @post.can_be_seen_by?(@current_user) %> + + <% end %> + + <%= render :partial => "post/show_partials/status_notices" %> + + +
    + <%= print_advertisement("horizontal") %> + <%= render :partial => "post/show_partials/image" %> + <%= render :partial => "post/show_partials/image_footer" %> + <%= render :partial => "post/show_partials/edit" %> + <%= render :partial => "post/show_partials/comments" %> +
    + + <% content_for("post_cookie_javascripts") do %> + + <% end %> + <% end %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/post/show2.html.erb b/app/views/post/show2.html.erb new file mode 100644 index 00000000..6fbc9eeb --- /dev/null +++ b/app/views/post/show2.html.erb @@ -0,0 +1,25 @@ +
    + <% if @post.nil? %> +

    Nobody here but us chickens!

    + <% else %> + <% if @post.can_be_seen_by?(@current_user) %> + + <% end %> + + <%= render :partial => "post/show_partials/status_notices" %> + + +
    + <%= print_advertisement("horizontal") %> +
    + <% end %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/post/show_empty.html.erb b/app/views/post/show_empty.html.erb new file mode 100644 index 00000000..c8f50f01 --- /dev/null +++ b/app/views/post/show_empty.html.erb @@ -0,0 +1,15 @@ +
    + +
    +

    This post does not exist.

    +
    +
    diff --git a/app/views/post/show_partials/_comments.html.erb b/app/views/post/show_partials/_comments.html.erb new file mode 100644 index 00000000..ce8be8ce --- /dev/null +++ b/app/views/post/show_partials/_comments.html.erb @@ -0,0 +1,3 @@ +
    + <%= render :partial => "comment/comments", :locals => {:comments => Comment.find(:all, :conditions => ["post_id = ?", @post.id], :order => "id"), :post_id => @post.id, :hide => false} %> +
    diff --git a/app/views/post/show_partials/_edit.html.erb b/app/views/post/show_partials/_edit.html.erb new file mode 100644 index 00000000..9fb41c04 --- /dev/null +++ b/app/views/post/show_partials/_edit.html.erb @@ -0,0 +1,82 @@ + diff --git a/app/views/post/show_partials/_history_panel.html.erb b/app/views/post/show_partials/_history_panel.html.erb new file mode 100644 index 00000000..2ed7179a --- /dev/null +++ b/app/views/post/show_partials/_history_panel.html.erb @@ -0,0 +1,7 @@ +
    +
    History
    +
      +
    • <%= link_to "Tags", :controller => "history", :action => "index", :search => "post:#{@post.id}" %>
    • +
    • <%= link_to "Notes", :controller => "note", :action => "history", :post_id => @post.id %>
    • +
    +
    diff --git a/app/views/post/show_partials/_image.html.erb b/app/views/post/show_partials/_image.html.erb new file mode 100644 index 00000000..51e293f0 --- /dev/null +++ b/app/views/post/show_partials/_image.html.erb @@ -0,0 +1,41 @@ +<% if !@post.is_deleted? %> +
    + <% if !@post.can_be_seen_by?(@current_user) %> +

    You need a privileged account to see this image.

    + <% elsif @post.image? %> +
    + <% @post.active_notes.each do |note| %> +
    +
    +
    +
    <%= hs note.formatted_body %>
    + <% end %> +
    + <%= image_tag(@post.sample_url(@current_user), :alt => @post.cached_tags, :id => 'image', :onclick => "Note.toggle();", :width => @post.get_sample_width(@current_user), :height => @post.get_sample_height(@current_user), :orig_width => @post.width, :orig_height => @post.height) %> + <% elsif @post.flash? %> + + + + + +

    <%= link_to "Save this flash (right click and save)", @post.file_url %>

    + <% else %> +

    Download

    +

    You must download this file manually.

    + <% end %> +
    +
    +

    + +
    +<% end %> + diff --git a/app/views/post/show_partials/_image_footer.html.erb b/app/views/post/show_partials/_image_footer.html.erb new file mode 100644 index 00000000..b673226d --- /dev/null +++ b/app/views/post/show_partials/_image_footer.html.erb @@ -0,0 +1,6 @@ +
    +

    + <%= link_to_function "Edit", "$('comments').hide(); $('edit').show(); $('post_tags').focus(); Cookie.put('show_defaults_to_edit', 1);" %> | + <%= link_to_function "Respond", "$('edit').hide(); $('comments').show(); Cookie.put('show_defaults_to_edit', 0);" %> +

    +
    diff --git a/app/views/post/show_partials/_options_panel.html.erb b/app/views/post/show_partials/_options_panel.html.erb new file mode 100644 index 00000000..539bd184 --- /dev/null +++ b/app/views/post/show_partials/_options_panel.html.erb @@ -0,0 +1,33 @@ +
    +
    Options
    +
      +
    • <%= link_to_function "Edit", "$('comments').hide(); $('edit').show().scrollTo(); $('post_tags').focus(); Cookie.put('show_defaults_to_edit', 1);" %>
    • + <% if !@post.is_deleted? && @post.image? && @post.width && @post.width > 700 %> +
    • <%= link_to_function "Resize image", "Post.resize_image()" %>
    • + <% end %> + <% if @post.image? && @post.can_be_seen_by?(@current_user) %> +
    • <%= link_to("#{@post.has_sample? ? "Original image":"Image"} (#{number_to_human_size(@post.file_size)})", @post.file_url, :class => @post.has_sample? ? "original-file-changed":"original-file-unchanged", :id => "highres", :onclick => "Post.highres(); return false") %>
    • + <% end %> + <% if @post.can_user_delete?(@current_user) then %> +
    • <%= link_to "Delete", :action => "delete", :id => @post.id %>
    • + <% end %> + <% if @post.is_deleted? && @current_user.is_janitor_or_higher?%> +
    • <%= link_to "Undelete", :action => "undelete", :id => @post.id %>
    • + <% end %> + <% unless @post.is_flagged? || @post.is_deleted? %> +
    • <%= link_to_function "Flag for deletion", "Post.flag(#{@post.id})", :level => :member %>
    • + <% end %> + <% if !@post.is_deleted? && @post.image? && !@post.is_note_locked? %> +
    • <%= link_to_function "Add translation", "Note.create(#{@post.id})", :level => :member %>
    • + <% end %> +
    • <%= link_to_function "Add to favorites", "Post.vote(#{@post.id}, 3)" %>
    • +
    • <%= link_to_function "Remove from favorites", "Post.vote(#{@post.id}, 2)" %>
    • + <% if @post.is_pending? && @current_user.is_janitor_or_higher? %> +
    • <%= link_to_function "Approve", "if (confirm('Do you really want to approve this post?')) {Post.approve(#{@post.id})}" %>
    • + <% end %> + <% unless @post.is_deleted? %> +
    • <%= link_to_remote "Add to pool", :update => "add-to-pool", :url => {:controller => "pool", :action => "select", :post_id => @post.id}, :method => "get" %>
    • + <% end %> +
    • <%= link_to "Set avatar", :controller => "user", :action => "set_avatar", :id => @post.id %>
    • +
    +
    diff --git a/app/views/post/show_partials/_pool.html.erb b/app/views/post/show_partials/_pool.html.erb new file mode 100644 index 00000000..5ffd1249 --- /dev/null +++ b/app/views/post/show_partials/_pool.html.erb @@ -0,0 +1,25 @@ +
    +
    +

    + <% if pool_post.prev_post_id %> + <%= link_to "« Previous", :action => "show", :id => pool_post.prev_post_id %> + <% end %> + <% if pool_post.next_post_id %> + <%= link_to "Next »", :action => "show", :id => pool_post.next_post_id %> + <% end %> + This post is <%= h(pool_post.pretty_sequence) %> + in the <%= link_to h(pool.pretty_name), :controller => "pool", :action => "show", :id => pool.id %> pool + <% if pool_post.master_id %> + <% pooled_post_id = pool_post.master.post.id %> + through its child <%= link_to "post ##{pooled_post_id}", :id => pooled_post_id %> + <% else %> + <% pooled_post_id = @post.id %> + <% end %> + + <% if @current_user.can_change?(pool_post, :active) %> + (<%= link_to_function "remove", + "if(confirm('Are you sure you want to remove this post from #{escape_javascript(pool.pretty_name)}?')) Pool.remove_post(#{pooled_post_id}, #{pool.id})" + %>)<% end %>. +

    +
    +
    diff --git a/app/views/post/show_partials/_related_posts_panel.html.erb b/app/views/post/show_partials/_related_posts_panel.html.erb new file mode 100644 index 00000000..e016f98e --- /dev/null +++ b/app/views/post/show_partials/_related_posts_panel.html.erb @@ -0,0 +1,21 @@ +
    +
    Related Posts
    +
      +
    • <%= link_to "Previous", :controller => "post", :action => "show", :id => @post.id - 1 %>
    • +
    • <%= link_to "Next", :controller => "post", :action => "show", :id => @post.id + 1 %>
    • + <% if @post.parent_id %> +
    • <%= link_to "Parent", :controller => "post", :action => "show", :id => @post.parent_id %>
    • + <% end %> +
    • <%= link_to "Random", :controller => "post", :action => "random" %>
    • + <% if @current_user.is_member_or_higher? %> + <% unless @post.is_deleted? || !@post.image? %> +
    • Find dupes<%#= link_to "Find dupes", :controller => "post", :action => "similar", :id => @post.id, :services=>"local" %>
    • +
    • Find similar<%#= link_to "Find similar", :controller => "post", :action => "similar", :id => @post.id, :services=>"all" %>
    • + + <% end %> + <% end %> +
    +
    diff --git a/app/views/post/show_partials/_statistics_panel.html.erb b/app/views/post/show_partials/_statistics_panel.html.erb new file mode 100644 index 00000000..7599fed6 --- /dev/null +++ b/app/views/post/show_partials/_statistics_panel.html.erb @@ -0,0 +1,34 @@ +
    +
    Statistics
    +
      +
    • Id: <%= @post.id %>
    • +
    • Posted: <%= link_to time_ago_in_words(@post.created_at) + " ago", {:action => "index", :tags => "date:" + @post.created_at.strftime("%Y-%m-%d")}, :title => @post.created_at.strftime("%c") %> by <%= link_to_unless @post.user_id.nil?, h(@post.author), :controller => "user", :action => "show", :id => @post.user_id %>
    • + <% if @current_user.is_admin? && @post.approver %> +
    • Approver: <%= @post.approver.name %>
    • + <% end %> + <% if @post.image? %> +
    • Size: <%= @post.width %>x<%= @post.height %>
    • + <% end %> + <% unless @post.source.blank? %> + <% if @post.source[/^http/] %> +
    • Source: <%= link_to @post.source[7, 20] + "...", @post.normalized_source, :target => "_blank" %>
    • + <% else %> +
    • Source: <%= @post.source %>
    • + <% end %> + <% end %> +
    • Rating: <%= @post.pretty_rating %> <%= vote_tooltip_widget(@post) %>
    • + +
    • + Score: <%= @post.score %> + <%= vote_widget(@post, @current_user) %> +
    • + + <% content_for("post_cookie_javascripts") do %> + + <% end %> + +
    • Favorited by: <%= favorite_list(@post) %>
    • +
    +
    diff --git a/app/views/post/show_partials/_status_notices.html.erb b/app/views/post/show_partials/_status_notices.html.erb new file mode 100644 index 00000000..7c3d9453 --- /dev/null +++ b/app/views/post/show_partials/_status_notices.html.erb @@ -0,0 +1,65 @@ +<% if @post.is_flagged? %> +
    + This post was flagged for deletion by <%= h @post.flag_detail.author %>. Reason: <%= h @post.flag_detail.reason %> +
    +<% elsif @post.is_pending? %> +
    + This post is pending moderator approval. +
    +<% elsif @post.is_deleted? %> +
    + This post was deleted. + <% if @post.flag_detail %> + <% if @current_user.is_mod_or_higher? %> + By: <%= link_to h(@post.flag_detail.author), :controller => "user", :action => "show", :id => @post.flag_detail.user_id %> + <% end %> + + Reason: <%= h @post.flag_detail.reason %>. MD5: <%= @post.md5 %> + <% end %> +
    +<% end %> + +<% if @post.is_held %> +
    + This post has been temporarily held from the index by the poster. + <% if @current_user.has_permission?(@post) %> + (<%= link_to_function "activate this post", "Post.activate_post(#{ @post.id });" %>) + <% end %> +
    +<% end %> + +<% if !@post.is_deleted? && @post.use_sample?(@current_user) && @post.can_be_seen_by?(@current_user)%> + + +<% end %> + +<% if CONFIG["enable_parent_posts"] %> + <% if @post.parent_id %> +
    + This post belongs to a <%= link_to "parent post", :action => "show", :id => @post.parent_id %>. Child posts are often minor variations of the parent post (<%= link_to "learn more", :controller => "help", :action => "post_relationships" %>). +
    + <% end %> + + <% if @post.has_children? %> +
    + This post has <%= link_to "child posts", :action => "index", :tags => "parent:#{@post.id}" %>. Child posts are often minor variations of the parent post (<%= link_to "learn more", :controller => "help", :action => "post_relationships" %>). +
    + <% end %> +<% end %> + +<% @pools.each do |pool| %> + <%= render :partial => "post/show_partials/pool", :locals => {:pool => pool, :pool_post => PoolPost.find(:first, :conditions => ["pool_id = ? AND post_id = ?", pool.id, @post.id])} %> +<% end %> diff --git a/app/views/post/similar.html.erb b/app/views/post/similar.html.erb new file mode 100644 index 00000000..d39d437b --- /dev/null +++ b/app/views/post/similar.html.erb @@ -0,0 +1,191 @@ +
    + + <% if @initial %> +
    + Your post may be a duplicate. + Please read the <%= link_to "duplicate post guidelines", :controller => "wiki", :action => "show", :title => "duplicate post_guidelines" %>. +
      +
    • + If your post is a better version of an existing one, but the old post should remain, + <%= link_to_function( "reparent", "$('mode').value = 'reparent'; PostModeMenu.change();"); %> + the old post. +
    • + If your post is a better version of an existing one, and the old post should be deleted, + <%= link_to_function( "mark the old post as a duplicate", "$('mode').value = 'dupe'; PostModeMenu.change();"); %>. +
    • +
      "destroy", :name=>"destroy") %> id="destroy" method="post"> + <%= hidden_field_tag "id", params[:id], :id=>"destroy_id" %> + <%= hidden_field_tag "reason", "duplicate" %> + Otherwise, please + <%= link_to_function( "delete your post", nil) do |page| page.call "$('destroy').submit" end %>. +
      +
    + +
    + <% end %> +
    + + + <% unless @initial %> + <% form_tag({:controller => "post", :action => "similar"}, :multipart => true, :id => "similar-form") do %> + + + + + + + + + + + + + + + + + + + + + +
    <%= submit_tag "Search", :tabindex => 3, :accesskey => "s" %>
    + + + +
    + <% end %> + <% end %> + + <% if not @posts.nil? %> + <%= render :partial => "posts", :locals => {:posts => @posts} %> + <% end %> + +
    + + <% if params[:full_url] %> + + <% end %> +
    +
    +<% content_for("post_cookie_javascripts") do %> + +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/post/upload.html.erb b/app/views/post/upload.html.erb new file mode 100644 index 00000000..6105acdb --- /dev/null +++ b/app/views/post/upload.html.erb @@ -0,0 +1,136 @@ +
    + + + <% if @deleted_posts > 0 %> +
    + <%= @deleted_posts == 1? "One":"Some" %> of your posts <%= @deleted_posts == 1? "was":"were" %> + @current_user.id %>">recently deleted. + (<%= link_to_function "dismiss this message", 'Post.acknowledge_new_deleted_posts();' %>) +
    + <% end %> + + <% unless @current_user.is_privileged_or_higher? %> +
    +

    Upload Guidelines

    +

    Please keep the following guidelines in mind when uploading something. Consistently violating these rules will result in a ban.

    +
      +
    • Do not upload <%= link_to "furry", :controller => "wiki", :action => "show", :title => "furry" %>, <%= link_to "yaoi", :controller => "wiki", :action => "show", :title => "yaoi" %>, <%= link_to "guro", :controller => "wiki", :action => "show", :title => "guro" %>, <%= link_to "toon", :controller => "wiki", :action => "show", :title => "toon" %>, or <%= link_to "poorly drawn", :controller => "wiki", :action => "show", :title => "poorly_drawn" %> art
    • +
    • Do not upload things with <%= link_to "compression artifacts", :controller => "wiki", :action => "show", :title => "compression_artifacts" %>
    • +
    • Do not upload things with <%= link_to "obnoxious watermarks", :controller => "wiki", :action => "show", :title => "watermark" %>
    • +
    • <%= link_to "Group doujinshi, manga pages, and similar game CGs together", :controller => "help", :action => "post_relationships" %>
    • +
    • Read the <%= link_to "tagging guidelines", :controller => "help", :action => "tags" %>
    • +
    +

    You can only upload <%= pluralize CONFIG["member_post_limit"] - Post.count(:conditions => ["user_id = ? AND created_at > ?", @current_user.id, 1.day.ago]), "post" %> today.

    +
    + <% end %> + + <% form_tag({:controller => "post", :action => "create"}, :level => :member, :multipart => true, :id => "edit-form") do %> +
    + <% if params[:url] %> + <%= image_tag(params["url"], :title => "Preview", :id => "image") %> +

    + + <% end %> + + + + + + + + + + + + + + + + + + + + + + <% if CONFIG["enable_parent_posts"] %> + + + + + <% end %> + + + + + +
    + <%= submit_tag "Upload", :tabindex => 8, :accesskey => "s" %> +
    <%= file_field "post", "file", :size => 50, :tabindex => 1 %>
    + + <% unless @current_user.is_privileged_or_higher? %> +

    You can enter a URL here to download from a website.

    + <% end %> +
    + <%= text_field "post", "source", :value => params["url"], :size => 50, :tabindex => 2 %> + <% if CONFIG["enable_artists"] %> + <%= link_to_function("Find artist", "RelatedTags.find_artist($F('post_source'))") %> + <% end %> +
    + + <% unless @current_user.is_privileged_or_higher? %> +

    Separate tags with spaces. (<%= link_to "help", {:controller => "help", :action => "tags"}, :target => "_blank" %>)

    + <% end %> +
    + <%= text_area "post", "tags", :value => params[:tags], :size => "60x2", :tabindex => 3 %> + <%= link_to_function "Related tags", "RelatedTags.find('post_tags')" %> | + <%= link_to_function "Related artists", "RelatedTags.find('post_tags', 'artist')" %> | + <%= link_to_function "Related characters", "RelatedTags.find('post_tags', 'char')" %> | + <%= link_to_function "Related copyrights", "RelatedTags.find('post_tags', 'copyright')" %> | + <%= link_to_function "Related circles", "RelatedTags.find('post_tags', 'circle')" %> +
    <%= text_field "post", "parent_id", :value => params["parent"], :size => 5, :tabindex => 4 %>
    + + <% unless @current_user.is_privileged_or_higher? %> +

    Explicit tags include sex, pussy, penis, masturbation, blowjob, etc. (<%= link_to "help", {:controller => "help", :action => "ratings"}, :target => "_blank" %>)

    + <% end %> +
    + checked="checked"<% end %> tabindex="5"> + + + checked="checked"<% end %> tabindex="6"> + + + checked="checked"<% end %> tabindex="7"> + +
    + + +
    + <% end %> + +
    + + + +<% content_for("post_cookie_javascripts") do %> + +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/post2/_footer.html.erb b/app/views/post2/_footer.html.erb new file mode 100644 index 00000000..04798aec --- /dev/null +++ b/app/views/post2/_footer.html.erb @@ -0,0 +1,14 @@ +<% content_for("subnavbar") do %> +
  • <%= link_to "List", :controller => "post", :action => "index" %>
  • +
  • <%= link_to "Upload", :controller => "post", :action => "upload" %>
  • +
  • <%= link_to "My Favorites", "/", :id => "my-favorites" %>
  • +
  • <%= link_to "Random", :controller => "post", :tags => "order:random" %>
  • +
  • <%= link_to "Popular", :controller => "post", :action => "popular_recent" %>
  • +
  • <%= link_to "Image Search", :controller => "post", :action => "similar" %>
  • +
  • <%= link_to "History", :controller => "history", :action => "index" %>
  • + <% if @current_user.is_janitor_or_higher? %> +
  • <%= link_to "Moderate", :controller => "post", :action => "moderate" %>
  • + <% end %> + <%= @content_for_footer %> +
  • <%= link_to "Help", :controller => "help", :action => "posts" %>
  • +<% end %> diff --git a/app/views/post2/_posts.html.erb b/app/views/post2/_posts.html.erb new file mode 100644 index 00000000..ba6596f8 --- /dev/null +++ b/app/views/post2/_posts.html.erb @@ -0,0 +1,37 @@ +
    + <% if posts.empty? %> +

    Nobody here but us chickens!

    + <% else %> +
      + <% posts.each do |post| %> + <% if post.instance_of?(Post) %> + <%= print_preview(post, {:onclick => "return PostModeMenu.click(#{post.id})", + :onmouseover => "PostModeMenu.post_mouseover(#{post.id})", + :onmouseout => "PostModeMenu.post_mouseout(#{post.id})", + :similarity=>(@similar[:similarity][post] rescue nil)}) %> + <% else %> + <%= print_ext_similarity_preview(post, {:similarity=>(@similar[:similarity][post] rescue nil)}) %> + <% end %> + <% end %> +
    + + <% end %> + + +
    diff --git a/app/views/post2/_preload.html.erb b/app/views/post2/_preload.html.erb new file mode 100644 index 00000000..0acb1e7a --- /dev/null +++ b/app/views/post2/_preload.html.erb @@ -0,0 +1,3 @@ +<% posts.each do |post| %> + +<% end %> diff --git a/app/views/post2/_search.html.erb b/app/views/post2/_search.html.erb new file mode 100644 index 00000000..b7eef335 --- /dev/null +++ b/app/views/post2/_search.html.erb @@ -0,0 +1,9 @@ +
    +
    Search
    + <% form_tag({:controller => "post", :action => "index"}, :method => "get") do %> +
    + <%= text_field_tag("tags", params[:tags], :size => 20) %> + <%= submit_tag "Search", :style => "display: none;" %> +
    + <% end %> +
    diff --git a/app/views/post2/_tag_script.html.erb b/app/views/post2/_tag_script.html.erb new file mode 100644 index 00000000..a525150f --- /dev/null +++ b/app/views/post2/_tag_script.html.erb @@ -0,0 +1,10 @@ + + + diff --git a/app/views/post2/_tags.html.erb b/app/views/post2/_tags.html.erb new file mode 100644 index 00000000..3d1eb39e --- /dev/null +++ b/app/views/post2/_tags.html.erb @@ -0,0 +1,8 @@ +
    +
    Tags
    +
      + <%= tag_links(@tags[:exclude], :prefix => "-") %> + <%= tag_links(@tags[:include], :with_aliases => @include_tag_reverse_aliases) %> + <%= tag_links(Tag.find_related(@tags[:related].to_a)) %> +
    +
    diff --git a/app/views/post2/atom.html.erb b/app/views/post2/atom.html.erb new file mode 100644 index 00000000..43765e5b --- /dev/null +++ b/app/views/post2/atom.html.erb @@ -0,0 +1,34 @@ + + + + <%= h CONFIG["app_name"] %> + /post/atom" rel="self"/> + /post/index" rel="alternate"/> + http://<%= h CONFIG["server_host"] %>/post/atom?tags=<%= h params[:tags] %> + <% if @posts.any? %> + <%= @posts[0].created_at.gmtime.xmlschema %> + <% end %> + <%= h CONFIG["app_name"] %> + <% @posts.each do |post| %> + + <%= h post.cached_tags %> + /post/show/<%= post.id %>" rel="alternate"/> + <% if post.source =~ /^http/ %> + + <% end %> + http://<%= h CONFIG["server_host"] %>/post/show/<%= post.id %> + <%= post.created_at.gmtime.xmlschema %> + <%= h post.cached_tags %> + + + + + <%= h post.author %> + + + <% end %> + diff --git a/app/views/post2/delete.html.erb b/app/views/post2/delete.html.erb new file mode 100644 index 00000000..a22ddabb --- /dev/null +++ b/app/views/post2/delete.html.erb @@ -0,0 +1,32 @@ +

    Delete Post

    + +<% if CONFIG["can_see_post"].call(@current_user, @post) %> + <%= image_tag @post.preview_url %> +<% end %> + +<% form_tag(:action => "destroy") do %> + <%= hidden_field_tag "id", params[:id] %> + <%= text_field_tag "reason" %> + <%= submit_tag "Delete" %> <%= submit_tag "Cancel" %> +<% end %> + +
    +<% if !@post.is_deleted? %> +
    +

    + <% if @post_parent %> + Votes will be transferred to the following parent post. + If this is incorrect, reparent this post before deleting it.

    + <% if CONFIG["can_see_post"].call(@current_user, @post_parent) %> +

      <%= print_preview(@post_parent, :hide_directlink=>true) %>
    + <% else %> + (parent post hidden due to access restrictions) + <% end %> + + <% else %> + This post has no parent. If this post has been replaced, reparent this post before deleting, and votes will be transferred.

    + <% end %> +<% end %> +

    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/post2/deleted_index.html.erb b/app/views/post2/deleted_index.html.erb new file mode 100644 index 00000000..58fa7821 --- /dev/null +++ b/app/views/post2/deleted_index.html.erb @@ -0,0 +1,36 @@ +

    Deleted Posts

    + + + + + + + + + + <% if @current_user.is_mod_or_higher? %> + + <% end %> + + + + <% @posts.each do |post| %> + + + + + + + <% if @current_user.is_mod_or_higher? %> + + <% end %> + + <% end %> + +
    ResolvedPostUserTagsReasonDeleted by
    <%= post.flag_detail.is_resolved? %><%= link_to post.id, :action => "show", :id => post.id %><%= link_to h(post.author), :controller => "user", :action => "show", :id => post.user_id %><%= h(post.cached_tags) %><%= h(post.flag_detail.reason) %><%= link_to h(post.flag_detail.author), :controller => "user", :action => "show", :id => post.flag_detail.user_id %>
    + +
    + <%= will_paginate(@posts) %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/post2/favorites.html.erb b/app/views/post2/favorites.html.erb new file mode 100644 index 00000000..6cbd84c1 --- /dev/null +++ b/app/views/post2/favorites.html.erb @@ -0,0 +1,8 @@ +

    Favorited by

    +
      +<%- @users.each do |u| %> +
    • <%= link_to h(u.pretty_name), :controller => "post", :action => "index", :tags => "vote:3:#{u.name} order:vote" %>
    • +<%- end %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/post2/index.html.erb b/app/views/post2/index.html.erb new file mode 100644 index 00000000..75048561 --- /dev/null +++ b/app/views/post2/index.html.erb @@ -0,0 +1,135 @@ +
    + <% if @tag_suggestions && @tag_suggestions.any? %> +
    + Maybe you meant: <%= @tag_suggestions.map {|x| link_to(h(x), :action => "index", :tags => x)}.to_sentence(:connector => "or") %> +
    + <% end %> + + +
    + <% if CONFIG["can_see_ads"].call(@current_user) %> + <%= CONFIG["ad_code_index_bottom"] %> + <% end %> + + <% if @ambiguous_tags.any? %> +
    + The following tags are ambiguous: <%= @ambiguous_tags.map {|x| link_to(h(x), :controller => "wiki", :action => "show", :title => x)} %> +
    + <% end %> + + + + <%= render :partial => "posts", :locals => {:posts => @posts} %> + +
    + <%= will_paginate(@posts) %> +
    +
    +
    + +<% content_for("post_cookie_javascripts") do %> + +<% end %> + +<% content_for("html_header") do %> + <%= auto_discovery_link_tag_with_id :rss, {:controller => "post", :action => "piclens", :tags => params[:tags], :page => params[:page]}, {:id => 'pl'} %> + <%= navigation_links(@posts) %> +<% end %> + +<%= render :partial => "footer" %> + +<% if @content_for_subnavbar %> + +
    + + +
    + <% @content_for_subnavbar = nil %> +<% end %> + diff --git a/app/views/post2/index.xml.erb b/app/views/post2/index.xml.erb new file mode 100644 index 00000000..813bf9b5 --- /dev/null +++ b/app/views/post2/index.xml.erb @@ -0,0 +1,6 @@ + + + <% @posts.each do |post| %> + <%= post.to_xml(:skip_instruct => true) %> + <% end %> + diff --git a/app/views/post2/moderate.html.erb b/app/views/post2/moderate.html.erb new file mode 100644 index 00000000..aaf248ef --- /dev/null +++ b/app/views/post2/moderate.html.erb @@ -0,0 +1,113 @@ +
    + <%= text_field_tag "query", "", :size => 40 %> + <%= submit_tag "Search" %> +
    + + + +
    +

    Pending

    +
    + <%= hidden_field_tag "reason", "" %> + +
    +

    Deletion Guidelines

    +

    As a general rule, you should not delete posts. Only approve of posts that you personally like. Posts that are not approved in three days will be automatically deleted.

    +
    + + + + + + + + + <% @pending_posts.each do |p| %> + + + + + + <% end %> + +
    + <%= button_to_function "Select all", "$$('.p').each(function (i) {i.checked = true; highlight_row(i)})" %> + <%= button_to_function "Invert selection", "$$('.p').each(function (i) {i.checked = !i.checked; highlight_row(i)})" %> + <%= submit_tag "Approve" %> + <%= submit_tag "Delete", :onclick => "var reason = prompt('Enter a reason'); if (reason != null) {$('reason').value = reason; return true} else {return false}" %> +
    <%= link_to image_tag(p.preview_url, :width => p.preview_dimensions[0], :height => p.preview_dimensions[1]), :controller => "post", :action => "show", :id => p.id %> +
      +
    • Uploaded by <%= link_to h(p.author), :controller => "user", :action => "show", :id => p.user_id %> <%= time_ago_in_words(p.created_at) %> ago (<%= link_to "mod", :action => "moderate", :query => "user:#{p.author}" %>)
    • +
    • Rating: <%= p.pretty_rating %>
    • + <% if p.parent_id %> +
    • Parent: <%= link_to p.parent_id, :action => "moderate", :query => "parent:#{p.parent_id}" %>
    • + <% end %> +
    • Tags: <%= h p.cached_tags %>
    • +
    • Score: <%= p.score %>
    • +
    +
    +
    +
    + +
    +

    Flagged

    +
    + <%= hidden_field_tag "reason2", "" %> + + + + + + + + + <% @flagged_posts.each do |p| %> + + + + + + <% end %> + +
    + <%= button_to_function "Select all", "$$('.f').each(function (i) {i.checked = true; highlight_row(i)})" %> + <%= button_to_function "Invert selection", "$$('.f').each(function (i) {i.checked = !i.checked; highlight_row(i)})" %> + <%= submit_tag "Approve" %> + <%= submit_tag "Delete", :onclick => "var reason = prompt('Enter a reason'); if (reason != null) {$('reason2').value = reason; return true} else {return false}" %> +
    <%= link_to image_tag(p.preview_url, :width => p.preview_dimensions[0], :height => p.preview_dimensions[1]), :controller => "post", :action => "show", :id => p.id %> +
      +
    • Uploaded by <%= link_to h(p.author), :controller => "user", :action => "show", :id => p.user_id %> <%= time_ago_in_words(p.created_at) %> ago (<%= link_to "mod", :action => "moderate", :query => "user:#{p.author}" %>)
    • +
    • Rating: <%= p.pretty_rating %>
    • + <% if p.parent_id %> +
    • Parent: <%= link_to p.parent_id, :action => "moderate", :query => "parent:#{p.parent_id}" %>
    • + <% end %> +
    • Tags: <%= h p.cached_tags %>
    • +
    • Score: <%= p.score %> (vote <%= link_to_function "down", "Post.vote(-1, #{p.id}, {})" %>)
    • +
    • Reason: <%= format_text(p.flag_detail.reason, :skip_simple_format => true) %> (<%= link_to h(p.flag_detail.author), :controller => "user", :action => "show", :id => p.flag_detail.user_id %>)
    • +
    +
    +
    + + +
    + +<%= render :partial => "footer" %> diff --git a/app/views/post2/piclens.html.erb b/app/views/post2/piclens.html.erb new file mode 100644 index 00000000..43683cf3 --- /dev/null +++ b/app/views/post2/piclens.html.erb @@ -0,0 +1,28 @@ + + + + <%= h CONFIG["app_name"] %>/<%= h params[:tags] %> + http://<%= h CONFIG["server_host"] %>/ + <%= h CONFIG["app_name"] %>PicLens RSS Feed + <% unless @posts.is_first_page? %> + <%= tag("atom:link", {:rel => "previous", :href => url_for(:only_path => false, :controller => "post", :action => "piclens", :page => @posts.previous_page, :tags => params[:tags])}, false) %> + <% end %> + <% unless @posts.is_last_page? %> + <%= tag("atom:link", {:rel => "next", :href => url_for(:only_path => false, :controller => "post", :action => "piclens", :page => @posts.next_page, :tags => params[:tags])}, false) %> + <% end %> + + <% @posts.each do |post| %> + + <%= h post.cached_tags %> + http://<%= h CONFIG["server_host"] %>/post/show/<%= post.id %> + http://<%= h CONFIG["server_host"] %>/post/show/<%= post.id %> + + <% if CONFIG["image_samples"] %> + + <% else %> + + <% end %> + + <% end %> + + diff --git a/app/views/post2/popular_by_day.html.erb b/app/views/post2/popular_by_day.html.erb new file mode 100644 index 00000000..171aada6 --- /dev/null +++ b/app/views/post2/popular_by_day.html.erb @@ -0,0 +1,13 @@ +
    +

    <%= link_to '«', :controller => "post", :action => "popular_by_day", :year => @day.yesterday.year, :month => @day.yesterday.month, :day => @day.yesterday.day %> <%= @day.strftime("%B %d, %Y") %> <%= link_to_unless @day >= Time.now, '»', :controller => "post", :action => "popular_by_day", :year => @day.tomorrow.year, :month => @day.tomorrow.month, :day => @day.tomorrow.day %>

    + + <%= render :partial => "posts", :locals => {:posts => @posts} %> +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Popular", :controller => "post", :action => "popular_by_day", :month => @day.month, :day => @day.day, :year => @day.year %>
  • +
  • <%= link_to "Popular (by week)", :controller => "post", :action => "popular_by_week", :year => @day.year, :month => @day.month, :day => @day.day %>
  • +
  • <%= link_to "Popular (by month)", :controller => "post", :action => "popular_by_month", :year => @day.year, :month => @day.month %>
  • +<% end %> + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/post2/popular_by_month.html.erb b/app/views/post2/popular_by_month.html.erb new file mode 100644 index 00000000..920ac008 --- /dev/null +++ b/app/views/post2/popular_by_month.html.erb @@ -0,0 +1,13 @@ +
    +

    <%= link_to '«', :controller => "post", :action => "popular_by_month", :year => @start.last_month.year, :month => @start.last_month.month %> <%= @start.strftime("%B %Y") %> <%= link_to_unless @start >= Time.now, '»', :controller => "post", :action => "popular_by_month", :year => @end.year, :month => @end.month %>

    + + <%= render :partial => "posts", :locals => {:posts => @posts} %> +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Popular", :controller => "post", :action => "popular_by_day", :month => @start.month, :day => @start.day, :year => @start.year %>
  • +
  • <%= link_to "Popular (by week)", :controller => "post", :action => "popular_by_week", :year => @start.year, :month => @start.month, :day => @start.day %>
  • +
  • <%= link_to "Popular (by month)", :controller => "post", :action => "popular_by_month", :year => @start.year, :month => @start.month %>
  • +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/post2/popular_by_week.html.erb b/app/views/post2/popular_by_week.html.erb new file mode 100644 index 00000000..036933e9 --- /dev/null +++ b/app/views/post2/popular_by_week.html.erb @@ -0,0 +1,13 @@ +
    +

    <%= link_to '«', :controller => "post", :action => "popular_by_week", :year => 1.week.ago(@start).year, :month => 1.week.ago(@start).month, :day => 1.week.ago(@start).day %> <%= @start.strftime("%B %d, %Y") %> - <%= @end.strftime("%B %d, %Y") %> <%= link_to_unless @start >= Time.now, '»', :controller => "post", :action => "popular_by_week", :year => @start.next_week.year, :month => @start.next_week.month, :day => @start.next_week.day %>

    + + <%= render :partial => "posts", :locals => {:posts => @posts} %> +
    + +<% content_for("subnavbar") do %> +
  • <%= link_to "Popular", :controller => "post", :action => "popular_by_day", :month => @start.month, :day => @start.day, :year => @start.year %>
  • +
  • <%= link_to "Popular (by week)", :controller => "post", :action => "popular_by_week", :year => @start.year, :month => @start.month, :day => @start.day %>
  • +
  • <%= link_to "Popular (by month)", :controller => "post", :action => "popular_by_month", :year => @start.year, :month => @start.month %>
  • +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/post2/popular_recent.html.erb b/app/views/post2/popular_recent.html.erb new file mode 100644 index 00000000..1c8b04de --- /dev/null +++ b/app/views/post2/popular_recent.html.erb @@ -0,0 +1,19 @@ +
    +

    + <% ["1d","1w","1m","1y"].each do |period| %> + <% if @params[:period] == period %> + <%= @period_name.capitalize %> + <% else %> + <%= link_to period, :controller => "post", :action => "popular_recent", :period => period %> + <% end %> + <% end %> +

    + + <%= render :partial => "posts", :locals => {:posts => @posts} %> +
    + + diff --git a/app/views/post2/recent_searches.html.erb b/app/views/post2/recent_searches.html.erb new file mode 100644 index 00000000..3f1146e8 --- /dev/null +++ b/app/views/post2/recent_searches.html.erb @@ -0,0 +1,28 @@ +
    +

    Recent Searches

    +
      + <% @recent_searches.each do |s| %> +
    • <%= link_to h(s), :action => "index", :tags => s %>
    • + <% end %> +
    +
    + +
    +

    Popular Searches

    + + + + + + + + + <% @popular_searches.to_a.sort {|a, b| b[1] <=> a[1]}.each do |tag, count| %> + + + + + <% end %> + +
    CountTag
    <%= count %><%= link_to h(tag), :action => "index", :tags => tag %>
    +
    \ No newline at end of file diff --git a/app/views/post2/show.html.erb b/app/views/post2/show.html.erb new file mode 100644 index 00000000..c0c373d1 --- /dev/null +++ b/app/views/post2/show.html.erb @@ -0,0 +1,55 @@ +
    + <% if @post.nil? %> +

    Nobody here but us chickens!

    + <% else %> + <% if @post.can_be_seen_by?(@current_user) %> + + <% end %> + + <%= render :partial => "post/show_partials/status_notices" %> + + +
    + <%= print_advertisement("horizontal") %> + <%= render :partial => "post/show_partials/image" %> + <%= render :partial => "post/show_partials/image_footer" %> + <%= render :partial => "post/show_partials/edit" %> + <%= render :partial => "post/show_partials/comments" %> +
    + + <% content_for("post_cookie_javascripts") do %> + + <% end %> + <% end %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/post2/show2.html.erb b/app/views/post2/show2.html.erb new file mode 100644 index 00000000..c0c373d1 --- /dev/null +++ b/app/views/post2/show2.html.erb @@ -0,0 +1,55 @@ +
    + <% if @post.nil? %> +

    Nobody here but us chickens!

    + <% else %> + <% if @post.can_be_seen_by?(@current_user) %> + + <% end %> + + <%= render :partial => "post/show_partials/status_notices" %> + + +
    + <%= print_advertisement("horizontal") %> + <%= render :partial => "post/show_partials/image" %> + <%= render :partial => "post/show_partials/image_footer" %> + <%= render :partial => "post/show_partials/edit" %> + <%= render :partial => "post/show_partials/comments" %> +
    + + <% content_for("post_cookie_javascripts") do %> + + <% end %> + <% end %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/post2/show_empty.html.erb b/app/views/post2/show_empty.html.erb new file mode 100644 index 00000000..c8f50f01 --- /dev/null +++ b/app/views/post2/show_empty.html.erb @@ -0,0 +1,15 @@ +
    + +
    +

    This post does not exist.

    +
    +
    diff --git a/app/views/post2/show_partials/_comments.html.erb b/app/views/post2/show_partials/_comments.html.erb new file mode 100644 index 00000000..ce8be8ce --- /dev/null +++ b/app/views/post2/show_partials/_comments.html.erb @@ -0,0 +1,3 @@ +
    + <%= render :partial => "comment/comments", :locals => {:comments => Comment.find(:all, :conditions => ["post_id = ?", @post.id], :order => "id"), :post_id => @post.id, :hide => false} %> +
    diff --git a/app/views/post2/show_partials/_edit.html.erb b/app/views/post2/show_partials/_edit.html.erb new file mode 100644 index 00000000..86c0c990 --- /dev/null +++ b/app/views/post2/show_partials/_edit.html.erb @@ -0,0 +1,78 @@ + diff --git a/app/views/post2/show_partials/_history_panel.html.erb b/app/views/post2/show_partials/_history_panel.html.erb new file mode 100644 index 00000000..2ed7179a --- /dev/null +++ b/app/views/post2/show_partials/_history_panel.html.erb @@ -0,0 +1,7 @@ +
    +
    History
    +
      +
    • <%= link_to "Tags", :controller => "history", :action => "index", :search => "post:#{@post.id}" %>
    • +
    • <%= link_to "Notes", :controller => "note", :action => "history", :post_id => @post.id %>
    • +
    +
    diff --git a/app/views/post2/show_partials/_image.html.erb b/app/views/post2/show_partials/_image.html.erb new file mode 100644 index 00000000..51e293f0 --- /dev/null +++ b/app/views/post2/show_partials/_image.html.erb @@ -0,0 +1,41 @@ +<% if !@post.is_deleted? %> +
    + <% if !@post.can_be_seen_by?(@current_user) %> +

    You need a privileged account to see this image.

    + <% elsif @post.image? %> +
    + <% @post.active_notes.each do |note| %> +
    +
    +
    +
    <%= hs note.formatted_body %>
    + <% end %> +
    + <%= image_tag(@post.sample_url(@current_user), :alt => @post.cached_tags, :id => 'image', :onclick => "Note.toggle();", :width => @post.get_sample_width(@current_user), :height => @post.get_sample_height(@current_user), :orig_width => @post.width, :orig_height => @post.height) %> + <% elsif @post.flash? %> + + + + + +

    <%= link_to "Save this flash (right click and save)", @post.file_url %>

    + <% else %> +

    Download

    +

    You must download this file manually.

    + <% end %> +
    +
    +

    + +
    +<% end %> + diff --git a/app/views/post2/show_partials/_image_footer.html.erb b/app/views/post2/show_partials/_image_footer.html.erb new file mode 100644 index 00000000..b673226d --- /dev/null +++ b/app/views/post2/show_partials/_image_footer.html.erb @@ -0,0 +1,6 @@ +
    +

    + <%= link_to_function "Edit", "$('comments').hide(); $('edit').show(); $('post_tags').focus(); Cookie.put('show_defaults_to_edit', 1);" %> | + <%= link_to_function "Respond", "$('edit').hide(); $('comments').show(); Cookie.put('show_defaults_to_edit', 0);" %> +

    +
    diff --git a/app/views/post2/show_partials/_options_panel.html.erb b/app/views/post2/show_partials/_options_panel.html.erb new file mode 100644 index 00000000..91c81da8 --- /dev/null +++ b/app/views/post2/show_partials/_options_panel.html.erb @@ -0,0 +1,33 @@ +
    +
    Options
    +
      +
    • <%= link_to_function "Edit", "$('comments').hide(); $('edit').show().scrollTo(); $('post_tags').focus(); Cookie.put('show_defaults_to_edit', 1);" %>
    • + <% if !@post.is_deleted? && @post.image? && @post.width && @post.width > 700 %> +
    • <%= link_to_function "Resize image", "Post.resize_image()" %>
    • + <% end %> + <% if @post.image? && @post.can_be_seen_by?(@current_user) %> +
    • <%= link_to("#{@post.has_sample? ? "Original image":"Image"} (#{number_to_human_size(@post.file_size)})", @post.file_url, :class => @post.has_sample? ? "original-file-changed":"original-file-unchanged", :id => "highres", :onclick => "Post.highres(); return false") %>
    • + <% end %> + <% if @current_user.has_permission?(@post) then %> +
    • <%= link_to "Delete", :action => "delete", :id => @post.id %>
    • + <% end %> + <% if @post.is_deleted? && @current_user.is_janitor_or_higher?%> +
    • <%= link_to "Undelete", :action => "undelete", :id => @post.id %>
    • + <% end %> + <% unless @post.is_flagged? || @post.is_deleted? %> +
    • <%= link_to_function "Flag for deletion", "Post.flag(#{@post.id})", :level => :member %>
    • + <% end %> + <% if !@post.is_deleted? && @post.image? && !@post.is_note_locked? %> +
    • <%= link_to_function "Add translation", "Note.create(#{@post.id})", :level => :member %>
    • + <% end %> +
    • <%= link_to_function "Add to favorites", "Post.vote(#{@post.id}, 3)" %>
    • +
    • <%= link_to_function "Remove from favorites", "Post.vote(#{@post.id}, 2)" %>
    • + <% if @post.is_pending? && @current_user.is_janitor_or_higher? %> +
    • <%= link_to_function "Approve", "if (confirm('Do you really want to approve this post?')) {Post.approve(#{@post.id})}" %>
    • + <% end %> + <% unless @post.is_deleted? %> +
    • <%= link_to_remote "Add to pool", :update => "add-to-pool", :url => {:controller => "pool", :action => "select", :post_id => @post.id}, :method => "get" %>
    • + <% end %> +
    • <%= link_to "Set avatar", :controller => "user", :action => "set_avatar", :id => @post.id %>
    • +
    +
    diff --git a/app/views/post2/show_partials/_pool.html.erb b/app/views/post2/show_partials/_pool.html.erb new file mode 100644 index 00000000..210703f3 --- /dev/null +++ b/app/views/post2/show_partials/_pool.html.erb @@ -0,0 +1,18 @@ +
    +
    +

    + + <% if pool_post.prev_post_id %> + <%= link_to "« Previous", :action => "show", :id => pool_post.prev_post_id %> + <% end %> + <% if pool_post.next_post_id %> + <%= link_to "Next »", :action => "show", :id => pool_post.next_post_id %> + <% end %> + This post belongs to the <%= link_to h(pool.pretty_name), :controller => "pool", :action => "show", :id => pool.id %> pool + <% if @current_user.can_change?(pool_post, :active) %> + (<%= link_to_function "remove", + "if(confirm('Are you sure you want to remove this post from #{escape_javascript(pool.pretty_name)}?')) Pool.remove_post(#{@post.id}, #{pool.id})" + %>)<% end %>. +

    +
    +
    diff --git a/app/views/post2/show_partials/_related_posts_panel.html.erb b/app/views/post2/show_partials/_related_posts_panel.html.erb new file mode 100644 index 00000000..e016f98e --- /dev/null +++ b/app/views/post2/show_partials/_related_posts_panel.html.erb @@ -0,0 +1,21 @@ +
    +
    Related Posts
    +
      +
    • <%= link_to "Previous", :controller => "post", :action => "show", :id => @post.id - 1 %>
    • +
    • <%= link_to "Next", :controller => "post", :action => "show", :id => @post.id + 1 %>
    • + <% if @post.parent_id %> +
    • <%= link_to "Parent", :controller => "post", :action => "show", :id => @post.parent_id %>
    • + <% end %> +
    • <%= link_to "Random", :controller => "post", :action => "random" %>
    • + <% if @current_user.is_member_or_higher? %> + <% unless @post.is_deleted? || !@post.image? %> +
    • Find dupes<%#= link_to "Find dupes", :controller => "post", :action => "similar", :id => @post.id, :services=>"local" %>
    • +
    • Find similar<%#= link_to "Find similar", :controller => "post", :action => "similar", :id => @post.id, :services=>"all" %>
    • + + <% end %> + <% end %> +
    +
    diff --git a/app/views/post2/show_partials/_statistics_panel.html.erb b/app/views/post2/show_partials/_statistics_panel.html.erb new file mode 100644 index 00000000..617b1f1b --- /dev/null +++ b/app/views/post2/show_partials/_statistics_panel.html.erb @@ -0,0 +1,30 @@ +
    +
    Statistics
    +
      +
    • Id: <%= @post.id %>
    • +
    • Posted: <%= link_to time_ago_in_words(@post.created_at) + " ago", {:action => "index", :tags => "date:" + @post.created_at.strftime("%Y-%m-%d")}, :title => @post.created_at.strftime("%c") %> by <%= link_to_unless @post.user_id.nil?, h(@post.author), :controller => "user", :action => "show", :id => @post.user_id %>
    • + <% if @current_user.is_admin? && @post.approver %> +
    • Approver: <%= @post.approver.name %>
    • + <% end %> + <% if @post.image? %> +
    • Size: <%= @post.width %>x<%= @post.height %>
    • + <% end %> + <% unless @post.source.blank? %> +
    • Source: <%= source_link @post.normalized_source %>
    • + <% end %> +
    • Rating: <%= @post.pretty_rating %> <%= vote_tooltip_widget(@post) %>
    • + +
    • + Score: <%= @post.score %> + <%= vote_widget(@post, @current_user) %> +
    • + + <% content_for("post_cookie_javascripts") do %> + + <% end %> + +
    • Favorited by: <%= favorite_list(@post) %>
    • +
    +
    diff --git a/app/views/post2/show_partials/_status_notices.html.erb b/app/views/post2/show_partials/_status_notices.html.erb new file mode 100644 index 00000000..8203c8f8 --- /dev/null +++ b/app/views/post2/show_partials/_status_notices.html.erb @@ -0,0 +1,56 @@ +<% if @post.is_flagged? %> +
    + This post was flagged for deletion by <%= h @post.flag_detail.author %>. Reason: <%= format_text(@post.flag_detail.reason, :skip_simple_format => true) %> +
    +<% elsif @post.is_pending? %> +
    + This post is pending moderator approval. +
    +<% elsif @post.is_deleted? %> +
    + This post was deleted. + <% if @post.flag_detail %> + <% if @current_user.is_mod_or_higher? %> + By: <%= link_to h(@post.flag_detail.author), :controller => "user", :action => "show", :id => @post.flag_detail.user_id %> + <% end %> + + Reason: <%= format_text(@post.flag_detail.reason, :skip_simple_format => true) %>. MD5: <%= @post.md5 %> + <% end %> +
    +<% end %> + +<% if !@post.is_deleted? && @post.use_sample?(@current_user) && @post.can_be_seen_by?(@current_user)%> + + +<% end %> + +<% if CONFIG["enable_parent_posts"] %> + <% if @post.parent_id %> +
    + This post belongs to a <%= link_to "parent post", :action => "show", :id => @post.parent_id %>. Child posts are often minor variations of the parent post (<%= link_to "learn more", :controller => "help", :action => "post_relationships" %>). +
    + <% end %> + + <% if @post.has_children? %> +
    + This post has <%= link_to "child posts", :action => "index", :tags => "parent:#{@post.id}" %>. Child posts are often minor variations of the parent post (<%= link_to "learn more", :controller => "help", :action => "post_relationships" %>). +
    + <% end %> +<% end %> + +<% @pools.each do |pool| %> + <%= render :partial => "post/show_partials/pool", :locals => {:pool => pool, :pool_post => PoolPost.find(:first, :conditions => ["pool_id = ? AND post_id = ?", pool.id, @post.id])} %> +<% end %> diff --git a/app/views/post2/similar.html.erb b/app/views/post2/similar.html.erb new file mode 100644 index 00000000..5f36ea19 --- /dev/null +++ b/app/views/post2/similar.html.erb @@ -0,0 +1,202 @@ +
    + + <% if @initial %> +
    + Your post may be a duplicate. + Please read the <%= link_to "duplicate post guidelines", :controller => "wiki", :action => "show", :title => "duplicate post_guidelines" %>. +
      +
    • + If your post is a better version of an existing one, but the old post should remain, + <%= link_to_function( "reparent", "$('mode').value = 'reparent'; PostModeMenu.change();"); %> + the old post. +
    • + If your post is a better version of an existing one, and the old post should be deleted, + <%= link_to_function( "mark the old post as a duplicate", "$('mode').value = 'dupe'; PostModeMenu.change();"); %>. +
    • +
      "destroy", :name=>"destroy") %> id="destroy" method="post"> + <%= hidden_field_tag "id", params[:id], :id=>"destroy_id" %> + <%= hidden_field_tag "reason", "duplicate" %> + Otherwise, please + <%= link_to_function( "delete your post", nil) do |page| page.call "$('destroy').submit" end %>. +
      +
    + +
    + <% end %> +
    + + + <% unless @initial %> + <% form_tag({:controller => "post", :action => "similar"}, :multipart => true, :id => "similar-form") do %> + + + + + + + + + + + + + + + + + + + + + +
    <%= submit_tag "Search", :tabindex => 3, :accesskey => "s" %>
    + + + +
    + <% end %> + <% end %> + + <% if not @posts.nil? %> + <%= render :partial => "posts", :locals => {:posts => @posts} %> + <% end %> + +
    + + <% if params[:full_url] %> + + <% end %> +
    +
    + + +<%= render :partial => "footer" %> diff --git a/app/views/post2/upload.html.erb b/app/views/post2/upload.html.erb new file mode 100644 index 00000000..cb7b229e --- /dev/null +++ b/app/views/post2/upload.html.erb @@ -0,0 +1,127 @@ +
    + + + <% unless @current_user.is_privileged_or_higher? %> +
    +

    Upload Guidelines

    +

    Please keep the following guidelines in mind when uploading something. Consistently violating these rules will result in a ban.

    +
      +
    • Do not upload <%= link_to "furry", :controller => "wiki", :action => "show", :title => "furry" %>, <%= link_to "yaoi", :controller => "wiki", :action => "show", :title => "yaoi" %>, <%= link_to "guro", :controller => "wiki", :action => "show", :title => "guro" %>, <%= link_to "toon", :controller => "wiki", :action => "show", :title => "toon" %>, or <%= link_to "poorly drawn", :controller => "wiki", :action => "show", :title => "poorly_drawn" %> art
    • +
    • Do not upload things with <%= link_to "compression artifacts", :controller => "wiki", :action => "show", :title => "compression_artifacts" %>
    • +
    • Do not upload things with <%= link_to "obnoxious watermarks", :controller => "wiki", :action => "show", :title => "watermark" %>
    • +
    • <%= link_to "Group doujinshi, manga pages, and similar game CGs together", :controller => "help", :action => "post_relationships" %>
    • +
    • Read the <%= link_to "tagging guidelines", :controller => "help", :action => "tags" %>
    • +
    +

    You can only upload <%= pluralize CONFIG["member_post_limit"] - Post.count(:conditions => ["user_id = ? AND created_at > ?", @current_user.id, 1.day.ago]), "post" %> today.

    +
    + <% end %> + + <% form_tag({:controller => "post", :action => "create"}, :level => :member, :multipart => true, :id => "edit-form") do %> +
    + <% if params[:url] %> + <%= image_tag(params["url"], :title => "Preview", :id => "image") %> +

    + + <% end %> + + + + + + + + + + + + + + + + + + + + + <% if CONFIG["enable_parent_posts"] %> + + + + + <% end %> + + + + + +
    + <%= submit_tag "Upload", :tabindex => 8, :accesskey => "s" %> +
    <%= file_field "post", "file", :size => 50, :tabindex => 1 %>
    + + <% unless @current_user.is_privileged_or_higher? %> +

    You can enter a URL here to download from a website.

    + <% end %> +
    + <%= text_field "post", "source", :value => params["url"], :size => 50, :tabindex => 2 %> + <% if CONFIG["enable_artists"] %> + <%= link_to_function("Find artist", "RelatedTags.find_artist($F('post_source'))") %> + <% end %> +
    + + <% unless @current_user.is_privileged_or_higher? %> +

    Separate tags with spaces. (<%= link_to "help", {:controller => "help", :action => "tags"}, :target => "_blank" %>)

    + <% end %> +
    + <%= text_area "post", "tags", :value => params[:tags], :size => "60x2", :tabindex => 3 %> + <%= link_to_function "Related tags", "RelatedTags.find('post_tags')" %> | + <%= link_to_function "Related artists", "RelatedTags.find('post_tags', 'artist')" %> | + <%= link_to_function "Related characters", "RelatedTags.find('post_tags', 'char')" %> | + <%= link_to_function "Related copyrights", "RelatedTags.find('post_tags', 'copyright')" %> | + <%= link_to_function "Related circles", "RelatedTags.find('post_tags', 'circle')" %> +
    <%= text_field "post", "parent_id", :value => params["parent"], :size => 5, :tabindex => 4 %>
    + + <% unless @current_user.is_privileged_or_higher? %> +

    Explicit tags include sex, pussy, penis, masturbation, blowjob, etc. (<%= link_to "help", {:controller => "help", :action => "ratings"}, :target => "_blank" %>)

    + <% end %> +
    + checked="checked"<% end %> tabindex="5"> + + + checked="checked"<% end %> tabindex="6"> + + + checked="checked"<% end %> tabindex="7"> + +
    + +
    +
    + + +
    + <% end %> + +
    + + + +<% content_for("post_cookie_javascripts") do %> + +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/post_tag_history/index.html.erb b/app/views/post_tag_history/index.html.erb new file mode 100644 index 00000000..887cdbca --- /dev/null +++ b/app/views/post_tag_history/index.html.erb @@ -0,0 +1,80 @@ +
    +
    +
    +
    + +
    +
    + <% form_tag({:action => "index"}, :method => :get) do %> + <%= text_field_tag "user_name", params[:user_name], :id => "user_name", :size => 20 %> <%= submit_tag "Search" %> + <% end %> +
    +
    +
    +
    + +
    +
    + <% form_tag({:action => "index"}, :method => :get) do %> + <%= text_field_tag "post_id", params[:post_id], :id => "post_id", :size => 10 %> <%= submit_tag "Search" %> + <% end %> +
    +
    +
    +
    + + + + + + + + + + + + + + <% @change_list.each do |change| %> + + + + + + + + <% end %> + +
    PostDateUserTags
    <%= link_to change[:change].post_id, :controller => "post", :action => "show", :id => change[:change].post_id %><%= change[:change].created_at.strftime("%b %e") %><%= link_to change[:change].author, :controller => "user", :action => "show", :id => change[:change].user_id %> + <%= tag_list(change[:added_tags], :obsolete => change[:obsolete_added_tags], :prefix => "+") %> + <%= tag_list(change[:removed_tags], :obsolete=>change[:obsolete_removed_tags], :prefix=>"-") %> + <%= tag_list(change[:unchanged_tags], :prefix => "") %> +
    +
    + +
    + +<% content_for("post_cookie_javascripts") do %> + +<% end %> + +
    + <%= will_paginate(@changes) %> +
    diff --git a/app/views/post_tag_history/revert.html.erb b/app/views/post_tag_history/revert.html.erb new file mode 100644 index 00000000..7e3ac050 --- /dev/null +++ b/app/views/post_tag_history/revert.html.erb @@ -0,0 +1,11 @@ +

    Revert Tags

    + + + +

    Are you sure you want to revert the tags for this post to: <%= h @change.tags %>?

    + +<% form_tag(:action => "revert") do %> + <%= hidden_field_tag "id", params[:id] %> + <%= submit_tag "Yes" %> + <%= submit_tag "No" %> +<% end %> diff --git a/app/views/report/note_changes.html.erb b/app/views/report/note_changes.html.erb new file mode 100644 index 00000000..cf5e3425 --- /dev/null +++ b/app/views/report/note_changes.html.erb @@ -0,0 +1,28 @@ +

    Report: Note Changes

    + +
    +

    The following report shows note changes aggregated by user over the past three days.

    + +
    + +
    + +
    + + + + + + + + + <% @users.each do |user| %> + + + + + <% end %> + +
    UserChanges
    <%= link_to_unless user["user_id"].nil?, h(user["name"]), :controller => "user", :action => "show", :id => user["user_id"] %><%= link_to_unless user["user_id"].nil?, user["change_count"], :controller => "note", :action => "history", :user_id => user["user_id"] %>
    +
    +
    diff --git a/app/views/report/tag_changes.html.erb b/app/views/report/tag_changes.html.erb new file mode 100644 index 00000000..9e5ce2c7 --- /dev/null +++ b/app/views/report/tag_changes.html.erb @@ -0,0 +1,28 @@ +

    Report: Tag Changes

    + +
    +

    The following report shows tag changes aggregated by user over the past three days. This includes any post the user uploaded.

    + +
    + +
    + +
    + + + + + + + + + <% @users.each do |user| %> + + + + + <% end %> + +
    UserChanges
    <%= link_to_unless user["user_id"].nil?, h(user["name"]), :controller => "user", :action => "show", :id => user["user_id"] %><%= link_to_unless user["user_id"].nil?, user["change_count"], :controller => "history", :action => "index", :search => "user:#{user["name"]}" %>
    +
    +
    diff --git a/app/views/report/votes.html.erb b/app/views/report/votes.html.erb new file mode 100644 index 00000000..ca044966 --- /dev/null +++ b/app/views/report/votes.html.erb @@ -0,0 +1,42 @@ +

    Report: User Votes

    + +
    +

    The following report shows user votes over the past three days.

    + +
    + +
    + +
    + + + + + + + + + + <% @users.each do |user| %> + + + + + + <% end %> + +
    UserTotalVotes
    <%= link_to_unless user["user_id"].nil?, h(user["name"]), :controller => "user", :action => "show", :id => user["user_id"] %><%= link_to_unless user["user_id"].nil?, user["change_count"], :controller => "post", :action => "index", :tags => "vote:>=1:#{user["name"]} order:vote" %> + + <% (1..3).each do |vote| %> + <% count = (user["votes"] && user["votes"][vote]) || "0" %> + <% text = "#{count} " %> + <% if user["user_id"].nil? %> + <%= content_tag :span, text, :class => "star star-#{vote}" %> + <% else %> + <%= link_to text, {:controller => "post", :action => "index", :tags => "vote:>=#{vote}:#{user["name"]} order:vote"}, :class => "star star-#{vote}" %> + <% end %> + <% end %> + +
    +
    +
    diff --git a/app/views/report/wiki_changes.html.erb b/app/views/report/wiki_changes.html.erb new file mode 100644 index 00000000..173f839e --- /dev/null +++ b/app/views/report/wiki_changes.html.erb @@ -0,0 +1,28 @@ +

    Report: Wiki Changes

    + +
    +

    The following report shows wiki changes aggregated by user over the past three days.

    + +
    + +
    + +
    + + + + + + + + + <% @users.each do |user| %> + + + + + <% end %> + +
    UserChanges
    <%= link_to_unless user["user_id"].nil?, h(user["name"]), :controller => "user", :action => "show", :id => user["user_id"] %><%= link_to_unless user["user_id"].nil?, user["change_count"], :controller => "wiki", :action => "history", :user_id => user["user_id"] %>
    +
    +
    diff --git a/app/views/report_mailer/moderator_report.html.erb b/app/views/report_mailer/moderator_report.html.erb new file mode 100644 index 00000000..de0555aa --- /dev/null +++ b/app/views/report_mailer/moderator_report.html.erb @@ -0,0 +1,28 @@ +

    Moderator Report For <%= Date.today %>

    + + + + + + + + + + + + + + + <% User.find(:all, :conditions => ["level >= ?", CONFIG["user_levels"]["Janitor"]], :order => "level, name").each do |user| %> + + + + + + + + + + <% end %> + +
    NameLevelAppr 7Appr 14CommForum
    <%= h user.name %><%= h user.pretty_level %><%= Post.count(:conditions => ["created_at >= ? AND approver_id = ?", 7.days.ago, user.id]) %>/<%= Post.count(:conditions => ["created_at >= ? AND (approver_id IS NOT NULL OR status = 'pending')", 7.days.ago]) %><%= Post.count(:conditions => ["created_at >= ? AND approver_id = ?", 14.days.ago, user.id]) %>/<%= Post.count(:conditions => ["created_at >= ? AND (approver_id IS NOT NULL OR status = 'pending')", 14.days.ago]) %><%= Comment.count(:conditions => ["created_at >= ? AND user_id = ?", 7.days.ago, user.id]) %><%= ForumPost.count(:conditions => ["created_at >= ? AND creator_id = ?", 7.days.ago, user.id]) %>
    diff --git a/app/views/static/500.html.erb b/app/views/static/500.html.erb new file mode 100644 index 00000000..a6d96744 --- /dev/null +++ b/app/views/static/500.html.erb @@ -0,0 +1,7 @@ +

    <%= @ex.class.to_s %> exception raised

    +
      +
    • <%= @ex.message %>
    • + <%- @ex.backtrace.each do |b| -%> +
    • <%= b %>
    • + <%- end -%> +
    diff --git a/app/views/static/index.html.erb b/app/views/static/index.html.erb new file mode 100644 index 00000000..91885c85 --- /dev/null +++ b/app/views/static/index.html.erb @@ -0,0 +1,26 @@ +
    +

    <%= link_to(CONFIG['app_name'], "/") %>

    + +
    + <% form_tag({:controller => 'post', :action => 'index'}, :method => "get") do %> +
    + <%= text_field_tag "tags", "", :size => 30 %>
    + <%= submit_tag "Search", :name => 'searchDefault' %> +
    + <% end %> +
    +
    +

    + <% if @current_user %> + <%= mail_to CONFIG["admin_contact"], "Contact", :encode => "javascript" %> – + <% end %> + Serving <%= number_with_delimiter Post.fast_count %> posts – Running Danbooru <%= CONFIG["version"] %> +

    +
    +
    diff --git a/app/views/static/more.html.erb b/app/views/static/more.html.erb new file mode 100644 index 00000000..d6c8ca8a --- /dev/null +++ b/app/views/static/more.html.erb @@ -0,0 +1,124 @@ +
    +

    <%= link_to CONFIG['app_name'], '/' %>

    + +
    + + + <% if CONFIG["enable_artists"] %> + + <% end %> + +
    +
    + + + + +
    +
    + + <% if @current_user.is_admin_or_higher? %> + + <% end %> +
    +
    diff --git a/app/views/static/terms_of_service.html.erb b/app/views/static/terms_of_service.html.erb new file mode 100644 index 00000000..df362a4f --- /dev/null +++ b/app/views/static/terms_of_service.html.erb @@ -0,0 +1,54 @@ +
    +
    +

    Terms of Service

    +

    By accessing the "<%= CONFIG["app_name"] %>" website ("Site") you agree to the following terms of service. If you do not agree to these terms, then please do not access the Site.

    + +
      +
    • The Site reserves the right to change these terms at any time.
    • +
    • If you are a minor, then you will not use the Site.
    • +
    • The Site is presented to you AS IS, without any warranty, express or implied. You will not hold the Site or its staff members liable for damages caused by the use of the site.
    • +
    • The Site reserves the right to delete or modify your account, or any content you have posted to the site.
    • +
    • You will make a best faith effort to upload only high quality anime-related images.
    • +
    • You have read the <%= link_to "tagging guidelines", :controller => "help", :action => "tags" %>.
    • +
    + +
    +
    Prohibited Content
    +

    In addition, you may not use the Site to upload any of the following:

    +
      +
    • Child pornography: Any photograph or photorealistic drawing or movie that depicts children in a sexual manner. This includes nudity, explicit sex, and implied sex.
    • +
    • Bestiality: Any photograph or photorealistic drawing or movie that depicts humans having sex (either explicit or implied) with other non-human animals.
    • +
    • Furry: Any image or movie where a person's skin is made of fur or scales.
    • +
    • Watermarked: Any image where a person who is not the original copyright owner has placed a watermark on the image.
    • +
    • Poorly compressed: Any image where compression artifacts are easily visible.
    • +
    • Grotesque: Any depiction of extreme mutilation, extreme bodily distension, feces, or bodies that are far outside the realm of normal human proportion (for example, breasts that are as large as the body).
    • +
    +
    +
    + +
    +

    Copyright Infringement

    + +

    If you believe a post infringes upon your copyright, please send an email to the <%= mail_to CONFIG["admin_contact"], "webmaster", :encode => "hex" %> with the following pieces of information:

    +

    Keep in mind we only respect requests from original artists or copyright owners, not derivative works.

    +
      +
    • The URL of the infringing post.
    • +
    • Proof that you own the copyright.
    • +
    • An email address that will be provided to the person who uploaded the infringing post to facilitate communication.
    • +
    +
    + +
    +

    Privacy Policy

    + +

    The Site will not disclose the IP address or email address of any user except to the staff.

    +

    The Site is allowed to make public everything else, including but not limited to: uploaded posts, favorited posts, comments, forum posts, wiki edits, and note edits.

    +
    + +
    +

    Agreement

    +

    By clicking on the "I Agree" link, you have read all the terms and have agreed to them.

    +

    <%= link_to("I Agree", params[:url] || "/", :onclick => "Cookie.put('tos', '1')") %> | <%= link_to("Cancel", "/") %>

    +
    +
    + diff --git a/app/views/tag/_alpha_paginator.html.erb b/app/views/tag/_alpha_paginator.html.erb new file mode 100644 index 00000000..7ce1cffb --- /dev/null +++ b/app/views/tag/_alpha_paginator.html.erb @@ -0,0 +1,3 @@ +<% (?a..?z).each do |letter| %> + <%= link_to letter.chr, :action => "index", :type => params[:type], :order => params[:order], :letter => letter.chr %> +<% end %> diff --git a/app/views/tag/_footer.html.erb b/app/views/tag/_footer.html.erb new file mode 100644 index 00000000..b385f870 --- /dev/null +++ b/app/views/tag/_footer.html.erb @@ -0,0 +1,12 @@ +<% content_for("subnavbar") do %> +
  • <%= link_to "List", :controller => "tag", :action => "index" %>
  • +
  • <%= link_to "Popular", :controller => "tag", :action => "popular_by_day" %>
  • +
  • <%= link_to "Aliases", :controller => "tag_alias", :action => "index" %>
  • +
  • <%= link_to "Implications", :controller => "tag_implication", :action => "index" %>
  • + <% if @current_user.is_mod_or_higher? %> +
  • <%= link_to "Mass Edit", :controller => "tag", :action => "mass_edit" %>
  • + <% end %> +
  • <%= link_to "Edit", :controller => "tag", :action => "edit" %>
  • + <%= @content_for_footer %> +
  • <%= link_to "Help", :controller => "help", :action => "tags" %>
  • +<% end %> diff --git a/app/views/tag/cloud.html.erb b/app/views/tag/cloud.html.erb new file mode 100644 index 00000000..3f14c747 --- /dev/null +++ b/app/views/tag/cloud.html.erb @@ -0,0 +1,9 @@ +
    + <% if @tags.empty? %> +

    There are no tags.

    + <% end %> + + <%= cloud_view(@tags) %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/tag/edit.html.erb b/app/views/tag/edit.html.erb new file mode 100644 index 00000000..c16cca56 --- /dev/null +++ b/app/views/tag/edit.html.erb @@ -0,0 +1,21 @@ +<% form_tag(:action => "update") do %> + + + + + + + + + + + + + + + + +
    <%= text_field_with_auto_complete "tag", "name", {}, :min_chars => 3, :url => {:controller => "tag", :action => "auto_complete_for_tag_name"} %>
    <%= select "tag", "tag_type", CONFIG["tag_types"].keys.select {|x| x =~ /^[A-Z]/}.inject([]) {|all, x| all << [x, CONFIG["tag_types"][x]]} %>
    <%= check_box "tag", "is_ambiguous" %>
    <%= submit_tag "Save" %> <%= button_to_function "Cancel", "history.back()" %>
    +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/tag/edit_preview.html.erb b/app/views/tag/edit_preview.html.erb new file mode 100644 index 00000000..8a5b0a12 --- /dev/null +++ b/app/views/tag/edit_preview.html.erb @@ -0,0 +1,3 @@ +<% @posts.each do |post| %> + <%= link_to(image_tag(post.preview_url, :style => "margin: 2em;", :title => post.cached_tags), :controller => "post", :action => "show", :id => post.id) %> +<% end %> diff --git a/app/views/tag/index.html.erb b/app/views/tag/index.html.erb new file mode 100644 index 00000000..c0bccb8c --- /dev/null +++ b/app/views/tag/index.html.erb @@ -0,0 +1,62 @@ +
    + <% form_tag({:action => "index"}, :method => :get) do %> + + + + + + + + + + + + + + + + + + + + + +
    + +

    Use * as a wildcard.

    +
    <%= text_field_tag "name", params[:name], :size => 40 %>
    <%= select_tag "type", options_for_select([["Any", ""], *CONFIG["tag_types"].keys.select {|x| x =~ /^[A-Z]/}.inject([]) {|all, x| all << [x, CONFIG["tag_types"][x]]}], + [(params[:type].blank?) && "" || params[:type].to_i()]) %>
    <%= select_tag "order", options_for_select([["Name", "name"], ["Count", "count"], ["Date", "date"]], + [params[:order] || ""]) %>
    <%= submit_tag "Search" %>
    + <% end %> +
    + + + + + + + + + + + <% @tags.each do |tag| %> + + + + + + <% end %> + +
    PostsNameType
    <%= tag['post_count'] %> + ">? + "><%= h tag["name"] %> + + <%= tag.type_name + (tag['is_ambiguous'] ? ", ambiguous" : "") %> + (<%= link_to "edit", :action => "edit", :name => tag['name'] %>) +
    + +
    + <%= will_paginate(@tags) %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/tag/mass_edit.html.erb b/app/views/tag/mass_edit.html.erb new file mode 100644 index 00000000..9cad0243 --- /dev/null +++ b/app/views/tag/mass_edit.html.erb @@ -0,0 +1,10 @@ +<% form_tag({:action => "mass_edit"}, :onsubmit => "return confirm('Are you sure you wish to perform this tag edit?')") do %> + <%= text_field_tag "start", params[:source], :size => 60 %> + <%= text_field_tag "result", params[:name], :size => 60 %> + <%= button_to_function "Preview", "$('preview').innerHTML = 'Loading...'; new Ajax.Updater('preview', '/tag/edit_preview', {method: 'get', parameters: 'tags=' + $F('start')})" %><%= submit_tag "Save" %> +<% end %> + +<%= render :partial => "footer" %> + +
    +
    diff --git a/app/views/tag/popular_by_day.html.erb b/app/views/tag/popular_by_day.html.erb new file mode 100644 index 00000000..ba5ed1b3 --- /dev/null +++ b/app/views/tag/popular_by_day.html.erb @@ -0,0 +1,12 @@ +
    +

    <%= link_to '«', :controller => "tag", :action => "popular_by_day", :year => @day.yesterday.year, :month => @day.yesterday.month, :day => @day.yesterday.day %> <%= @day.strftime("%B %d, %Y") %> <%= link_to_unless @day >= Time.now, '»', :controller => "tag", :action => "popular_by_day", :year => @day.tomorrow.year, :month => @day.tomorrow.month, :day => @day.tomorrow.day %>

    + <%= cloud_view(@tags, 1.5) %> +
    + +<% content_for("footer") do %> +
  • <%= link_to "Popular (by day)", :action => "popular_by_day" %>
  • +
  • <%= link_to "Popular (by week)", :action => "popular_by_week" %>
  • +
  • <%= link_to "Popular (by month)", :action => "popular_by_month" %>
  • +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/tag/popular_by_month.html.erb b/app/views/tag/popular_by_month.html.erb new file mode 100644 index 00000000..b0304f27 --- /dev/null +++ b/app/views/tag/popular_by_month.html.erb @@ -0,0 +1,11 @@ +
    +

    <%= link_to '«', :controller => "tag", :action => "popular_by_month", :year => @day.last_month.year, :month => @day.last_month.month %> <%= @day.strftime("%B %Y") %> <%= link_to_unless @day >= Time.now, '»', :controller => "tag", :action => "popular_by_month", :year => @day.next_month.year, :month => @day.next_month.month %>

    + + <%= cloud_view(@tags, 4) %> +
    + +<% content_for("footer") do %> +

    <%= link_to "Popular by Day", :action => "popular_by_day" %> | <%= link_to "Popular by Week", :action => "popular_by_week" %> | <%= link_to "Popular by Month", :action => "popular_by_month" %>

    +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/tag/popular_by_week.html.erb b/app/views/tag/popular_by_week.html.erb new file mode 100644 index 00000000..1e5b6c4d --- /dev/null +++ b/app/views/tag/popular_by_week.html.erb @@ -0,0 +1,11 @@ +
    +

    <%= link_to '«', :controller => "tag", :action => "popular_by_week", :year => 1.week.ago(@day).year, :month => 1.week.ago(@day).month, :day => 1.week.ago(@day).day %> <%= @day.strftime("%B %d, %Y") %> - <%= @day.next_week.strftime("%B %d, %Y") %> <%= link_to_unless @day >= Time.now, '»', :controller => "tag", :action => "popular_by_week", :year => @day.next_week.year, :month => @day.next_week.month, :day => @day.next_week.day %>

    + + <%= cloud_view(@tags, 3) %> +
    + +<% content_for("footer") do %> +

    <%= link_to "Popular by Day", :action => "popular_by_day" %> | <%= link_to "Popular by Week", :action => "popular_by_week" %> | <%= link_to "Popular by Month", :action => "popular_by_month" %>

    +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/tag_alias/index.html.erb b/app/views/tag_alias/index.html.erb new file mode 100644 index 00000000..1925dd5d --- /dev/null +++ b/app/views/tag_alias/index.html.erb @@ -0,0 +1,85 @@ +
    + <% form_tag({:action => "index"}, :method => :get) do %> + <%= text_field_tag "query", params[:query] %> + <%= submit_tag "Search Aliases" %> + <%= submit_tag "Search Implications" %> + <% end %> +
    + +
    + <% form_tag(:action => "update") do %> + + + + + + + + + + + + + + + + <% @aliases.each do |a| %> + + + + + + + <% end %> + +
    AliasToReason
    + <% if @current_user.is_mod_or_higher? %> + <%= button_to_function "Select pending", "$$('.pending').each(function(x) {x.checked = true})" %> + <%= submit_tag "Approve" %> + <% end %> + <%= button_to_function "Delete", "$('reason-box').show(); $('reason').focus()" %> + <%= button_to_function "Add", "$('add-box').show().scrollTo(); $('tag_alias_name').focus()" %> + + +
    ><%= link_to h(a.name), :controller => "post", :action => "index", :tags => a.name %> (<%= Tag.find_by_name(a.name).post_count rescue 0 %>)<%= link_to h(a.alias_name), :controller => "post", :action => "index", :tags => a.alias_name %> (<%= Tag.find(a.alias_id).post_count rescue 0 %>)<%= h a.reason %>
    + <% end %> +
    + + + +
    + <%= will_paginate(@aliases) %> +
    + +<%= render :partial => "/tag/footer" %> diff --git a/app/views/tag_implication/index.html.erb b/app/views/tag_implication/index.html.erb new file mode 100644 index 00000000..4d255f2e --- /dev/null +++ b/app/views/tag_implication/index.html.erb @@ -0,0 +1,83 @@ +
    + <% form_tag({:action => "index"}, :method => :get) do %> + <%= text_field_tag "query", params[:query] %> + <%= submit_tag "Search Implications" %> + <%= submit_tag "Search Aliases" %> + <% end %> +
    + +<% form_tag(:action => "update") do %> + + + + + + + + + + + + + + + + <% @implications.each do |i| %> + + + + + + + <% end %> + +
    PredicateConsequentReason
    + <% if @current_user.is_mod_or_higher? %> + <%= button_to_function "Select pending", "$$('.pending').each(function(x) {x.checked = true})" %> + <%= submit_tag "Approve" %> + <% end %> + <%= button_to_function "Delete", "$('reason-box').show(); $('reason').focus()" %> + <%= button_to_function "Add", "$('add-box').show().scrollTo(); $('tag_implication_predicate').focus()" %> + + +
    ><%= link_to h(i.predicate.name), :controller => "post", :action => "index", :tags => i.predicate.name %> (<%= i.predicate.post_count %>)<%= link_to h(i.consequent.name), :controller => "post", :action => "index", :tags => i.consequent.name %> (<%= i.consequent.post_count %>)<%= h i.reason %>
    +<% end %> + + + +
    + <%= will_paginate(@implications) %> +
    + +<%= render :partial => "/tag/footer" %> diff --git a/app/views/user/_footer.html.erb b/app/views/user/_footer.html.erb new file mode 100644 index 00000000..8c1cffcb --- /dev/null +++ b/app/views/user/_footer.html.erb @@ -0,0 +1,5 @@ +<% if @content_for_footer %> + <% content_for("subnavbar") do %> + <%= @content_for_footer %> + <% end %> +<% end %> diff --git a/app/views/user/block.html.erb b/app/views/user/block.html.erb new file mode 100644 index 00000000..4d8b4aa9 --- /dev/null +++ b/app/views/user/block.html.erb @@ -0,0 +1,25 @@ +<% form_tag(:action => "block") do %> + <%= hidden_field "ban", "user_id" %> + + + + + + + + + + + + + + + + + +
    <%= submit_tag "Submit" %>
    <%= text_area "ban", "reason", :size => "40x5" %>
    + + <%= text_field "ban", "duration", :size => 10 %>
    +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/user/change_password.html.erb b/app/views/user/change_password.html.erb new file mode 100644 index 00000000..2d172d09 --- /dev/null +++ b/app/views/user/change_password.html.erb @@ -0,0 +1,21 @@ +
    + <% form_tag(:action => "update") do %> + + + + + + + + + + + + + + +
    <%= password_field "user", "password" %>
    <%= password_field "user", "password_confirmation" %>
    <%= submit_tag "Save" %> <%= submit_tag "Cancel" %>
    + <% end %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/user/edit.html.erb b/app/views/user/edit.html.erb new file mode 100644 index 00000000..4607a103 --- /dev/null +++ b/app/views/user/edit.html.erb @@ -0,0 +1,86 @@ +
    + <% form_tag(:controller => "user", :action => "update") do %> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <% if CONFIG["image_samples"] && !CONFIG["force_image_samples"] %> + + + + + <% end %> + +
    + <%= submit_tag "Save" %> <%= submit_tag "Cancel" %> +
    + +

    Any post containing all blacklisted tags on a line will be hidden. Separate tags with spaces.

    +
    + <%= text_area "user", "blacklisted_tags", :size => "80x6" %> +
    + + <% if CONFIG["enable_account_email_activation"] %> +

    An email address is required to activate your account.

    + <% else %> +

    This field is optional. It is useful if you ever forget your password and want to reset it.

    + <% end %> +
    + <%= text_field "user", "email", :size => 40 %> +
    + +

    Enter a list of tags you really like, whitespace delimited.

    +
    + <%= text_area "user", "favorite_tags_text", :size => "40x5" %> +
    + +

    These will be accessible when you upload or edit a post.

    +
    + <%= text_area "user", "my_tags", :size => "40x5" %> +
    + +

    If enabled, large images will always be resized to fit the screen.

    +
    + <%= check_box "user", "always_resize_images" %> +
    + +

    Receive emails when someone sends you a message.

    +
    + <%= check_box "user", "receive_dmails" %> +
    + +

    Show reduced large-resolution images.

    +
    + <%= check_box "user", "show_samples" %> +
    + <% end %> +
    + +<%= render :partial => "footer" %> diff --git a/app/views/user/home.html.erb b/app/views/user/home.html.erb new file mode 100644 index 00000000..a51e83ee --- /dev/null +++ b/app/views/user/home.html.erb @@ -0,0 +1,43 @@ +
    + <% if @current_user.is_anonymous? %> +

    You are not logged in.

    + + + <% else %> +

    Hello <%= h(@current_user.name) %>!

    +

    From here you can access account-specific options and features.

    + +
    + +
    + + <% if @current_user.is_janitor_or_higher? %> +
    +

    Moderator Tools

    + +
    + <% end %> + <% end %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/user/index.html.erb b/app/views/user/index.html.erb new file mode 100644 index 00000000..254265fa --- /dev/null +++ b/app/views/user/index.html.erb @@ -0,0 +1,70 @@ +

    Users

    + +<% form_tag({:action => "index"}, :method => :get) do %> + + + + + + + + + + + + + + + + + + + + +
    <%= submit_tag "Search" %>
    Name<%= text_field_tag "name", params[:name] %>
    Level<%= select_tag "level", options_for_select([["Any", "any"], *CONFIG["user_levels"].to_a], params[:level]) %>
    Order<%= select_tag "order", options_for_select([["Name", "name"], ["Posts", "posts"], ["Notes", "notes"], ["Date", "date"]], params[:order]) %>
    +<% end %> + + + + + + + + + + + + + + + + <% @users.each do |user| %> + + + + + <% if user.post_count > 100 %> + + + <% else %> + + + <% end %> + + + + + <% end %> + +
    NamePostsDeleted% Pos% NegNotesLevelJoined
    + <%= link_to h(user.pretty_name), :action => "show", :id => user.id %> + <% if user.invited_by %> + ← <%= link_to h(user.invited_by_name), :action => "show", :id => user.invited_by %> + <% end %> + <%= link_to user.post_count, :controller => "post", :action => "index", :tags => "user:#{user.name}" %><%= Post.count(:conditions => "user_id = #{user.id} and status = 'deleted'") %><%= number_to_percentage(100 * Post.count(:conditions => ["user_id = #{user.id} and status = 'active' and score > 1"]).to_f / user.post_count, :precision => 0) %><%= number_to_percentage(100 * Post.count(:conditions => ["user_id = #{user.id} and status = 'active' and score < -1"]).to_f / user.post_count, :precision => 0) %><%= link_to NoteVersion.count(:conditions => "user_id = #{user.id}"), :controller => "note", :action => "history", :user_id => user.id %><%= user.pretty_level %><%= time_ago_in_words user.created_at %> ago
    + +
    + <%= will_paginate(@users) %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/user/invites.html.erb b/app/views/user/invites.html.erb new file mode 100644 index 00000000..c41303ab --- /dev/null +++ b/app/views/user/invites.html.erb @@ -0,0 +1,51 @@ +

    Invites

    +

    You can vouch for existing members and invite them to contributor status.

    + +
    +
    Invite User
    + <% form_tag({:action => "invites"}, :onsubmit => "return confirm('Are you sure you want to invite ' + $F('user_name') + '?')") do %> + + + + + + + + + + + + + + + + +
    <%= submit_tag "Submit" %>
    <%= text_field_with_auto_complete "member", "name", {:value => params[:name]}, :min_chars => 3, :skip_style => true, :url => {:controller => "user", :action => "auto_complete_for_member_name"} %>
    <%= select "member", "level", [["Contributor", CONFIG["user_levels"]["Contributor"]], ["Privileged", CONFIG["user_levels"]["Privileged"]]] %>
    + <% end %> +
    + +
    +
    Current Invites
    +

    These are the users you have invited so far.

    + + + + + + + + + + + <% @invited_users.each do |user| %> + + + + + + <% end %> + +
    UserPostsFavorites
    <%= link_to h(user.pretty_name), :controller => "user", :action => "show", :id => user.id %><%= link_to Post.count(:conditions => "user_id = #{user.id}"), :controller => "post", :action => "index", :tags => "user:#{user.name}" %><%= link_to PostVotes.count(:conditions => "user_id = #{user.id} AND score = 3"), :controller => "post", :action => "index", :tags => "vote:3:#{user.name} order:vote" %>
    +
    + +<%= render :partial => "footer" %> diff --git a/app/views/user/login.html.erb b/app/views/user/login.html.erb new file mode 100644 index 00000000..a828dc2e --- /dev/null +++ b/app/views/user/login.html.erb @@ -0,0 +1,39 @@ +
    +

    Login

    + <% if @current_user.is_unactivated? %> +

    You have not yet activated your account. Click <%= link_to "here", :action => "resend_confirmation" %> to resend your confirmation email to <%= h @current_user.email %>.

    + <% else %> +

    + You need an account to access some parts of <%= h CONFIG["app_name"] %>. + <% unless @current_user.is_anonymous? %> + Click <%= link_to "here", :action => "reset_password" %> to reset your password. + <% end %> + <% if @current_user.is_anonymous? %> + <% if CONFIG["enable_signups"] %> + You can register for an account <%= link_to "here", :action => "signup" %>. + <% else %> + Registration is currently disabled. + <% end %> + <% end %> +

    + <% end %> + + <% form_tag({:action => "authenticate"}) do %> + <%= hidden_field_tag "url", params[:url] %> + + + + + + + + + + + + +
    <%= text_field "user", "name", :tabindex => 1 %>
    <%= password_field "user", "password", :tabindex => 1 %>
    <%= submit_tag "Login", :tabindex => 1 %>
    + <% end %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/user/logout.html.erb b/app/views/user/logout.html.erb new file mode 100644 index 00000000..413d7249 --- /dev/null +++ b/app/views/user/logout.html.erb @@ -0,0 +1,7 @@ +
    +

    Logoff

    +

    You are now logged out of the system...

    + <%= link_to "« login", "/user/login" %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/user/resend_confirmation.html.erb b/app/views/user/resend_confirmation.html.erb new file mode 100644 index 00000000..ca0c3e28 --- /dev/null +++ b/app/views/user/resend_confirmation.html.erb @@ -0,0 +1,26 @@ +
    +

    If you haven't received your confirmation email, make sure it wasn't caught by your spam filter.

    + <% form_tag(:action => "resend_confirmation") do %> + + + + + + + + + + + + +
    + + + <%= text_field_tag "email" %> +
    + <%= submit_tag "Submit" %> +
    + <% end %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/user/reset_password.html.erb b/app/views/user/reset_password.html.erb new file mode 100644 index 00000000..57d10c7e --- /dev/null +++ b/app/views/user/reset_password.html.erb @@ -0,0 +1,32 @@ +
    +

    Reset Password

    +

    If you supplied an email address when you signed up, you can have your password reset. You'll get an email containing your new password.

    + + <% form_tag(:action => "reset_password") do %> + + + + + + + + + + + + + + + + +
    + + + <%= text_field "user", "name" %> +
    <%= text_field "user", "email" %>
    + <%= submit_tag "Submit" %> +
    + <% end %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/user/set_avatar.html.erb b/app/views/user/set_avatar.html.erb new file mode 100644 index 00000000..15a45a82 --- /dev/null +++ b/app/views/user/set_avatar.html.erb @@ -0,0 +1,111 @@ +
    +
    + <%= image_tag(@post.sample_url, :id => "image", :width => @post.get_sample_width, :height => @post.get_sample_height) %> +
    + + <% form_tag({:controller => "user", :action => "set_avatar"}, :level => :member) do %> + <%= hidden_field_tag "post_id", @params[:id] %> + <% if @params[:user_id] %> + <%= hidden_field_tag "user_id", @params[:user_id] %> + <% end %> + <%= hidden_field_tag "left", 0 %> + <%= hidden_field_tag "right", 0 %> + <%= hidden_field_tag "top", 0 %> + <%= hidden_field_tag "bottom", 0 %> + +
    +
    +
    px; height: <%= CONFIG["avatar_max_height"] + 10 %>px;"> +
    +
    +
    +
    +
    + <%= submit_tag "Set avatar" %>
    Setting an avatar with Internet Explorer will not work. +
    + Setting an avatar with Internet Explorer will not work. +
    +
    +
    + <% end %> + + +
    diff --git a/app/views/user/show.html.erb b/app/views/user/show.html.erb new file mode 100644 index 00000000..bb8ed947 --- /dev/null +++ b/app/views/user/show.html.erb @@ -0,0 +1,177 @@ +<% if @user.has_avatar? then %> +
    +
    + <%= avatar(@user, 1) %> +
    +
    + <% if @current_user.has_permission?(@user) then %> +  <%= link_to "(edit)", :controller => "user", :action => "set_avatar", :id => @user.avatar_post.id.to_s, :user_id => @user.id %> + <% end %> +

    <%= h(@user.pretty_name) %>

    +
    +
    +<% else %> +

    <%= h(@user.pretty_name) %>

    +<% end %> + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <% if @user.invited_by %> + + + + + <% end %> + <% if CONFIG["starting_level"] < 30 %> + + + + + <% end %> + + + + + <% if @current_user.is_mod_or_higher? %> + + + + + <% end %> +
    Join Date<%= @user.created_at.strftime("%Y-%m-%d") %> +
    Level + <%= @user.pretty_level %> + <% if @user.is_blocked? && @user.ban %> + (reason: <%= h @user.ban.reason %>; expires: <%= @user.ban.expires_at.strftime("%Y-%m-%d") %>) + <% end %> +
    Favorite Tags + <% if @user.favorite_tags.empty? %> + None + <% else %> + <%= @user.favorite_tags.map {|x| link_to(h(x.tag_query), :controller => "post", :action => "index", :tags => x.tag_query)}.join(", ") %> + <% end %> + + <% if @current_user.id == @user.id %> + (<%= link_to "edit", :action => "edit" %>) + <% end %> +
    Posts<%= link_to Post.count(:all, :conditions => "user_id = #{@user.id}"), :controller => "post", :action => "index", :tags => "user:#{@user.name}" %>
    Deleted Posts<%= link_to Post.count(:all, :conditions => "status = 'deleted' and user_id = #{@user.id}"), :controller => "post", :action => "deleted_index", :user_id => @user.id %>
    Votes + + <% (1..3).each do |i| %> + "index", :tags => "vote:>=#{i}:#{@user.name} order:vote" %>"> + <%= PostVotes.count(:all, :conditions => "user_id = #{@user.id} AND score = #{i}") %> + + + <% end %> + +
    Comments<%= Comment.count(:all, :conditions => "user_id = #{@user.id}") %>
    Edits<%= link_to History.count(:all, :conditions => "user_id = #{@user.id}"), :controller => "history", :action => "index", :search => "user:#{@user.name}" %>
    Tag Edits<%= link_to History.count(:all, :conditions => "user_id = #{@user.id} AND group_by_table = 'posts'"), :controller => "history", :action => "post", :search => "user:#{@user.name}" %>
    Note Edits<%= link_to NoteVersion.count(:all, :conditions => "user_id = #{@user.id}"), :controller => "note", :action => "history", :user_id => @user.id %>
    Wiki Edits<%= WikiPageVersion.count(:all, :conditions => "user_id = #{@user.id}") %>
    Forum Posts<%= ForumPost.count(:all, :conditions => "creator_id = #{@user.id}") %>
    Invited By<%= link_to h(User.find(@user.invited_by).name), :action => "show", :id => @user.invited_by %>
    Recent Invites<%= User.find(:all, :conditions => ["invited_by = ?", @user.id], :order => "id desc", :select => "name, id", :limit => 5).map {|x| link_to(h(x.pretty_name), :action => "show", :id => x.id)}.join(", ") %>
    Record + <% if !UserRecord.exists?(["user_id = ?", @user.id]) %> + None + <% else %> + <%= UserRecord.count(:all, :conditions => ["user_id = ? AND is_positive = true", @user.id]) - UserRecord.count(:all, :conditions => ["user_id = ? AND is_positive = false", @user.id]) %> + <% end %> + (<%= link_to "add", :controller => "user_record", :action => "index", :user_id => @user.id %>) +
    IPs + <% @user_ips[0,5].each do |ip| %> + <%= ip %> + <% end %> + <% if @user_ips.length > 5 %>(more)<% end %> +
    +
    + +
    + + <% CONFIG["tag_types"].select {|k, v| k =~ /^[A-Z]/ && k != "General" && k != "Faults"}.each do |name, value| %> + + + + + <% end %> + + + + + <% CONFIG["tag_types"].select {|k, v| k =~ /^[A-Z]/ && k != "General" && k != "Faults"}.each do |name, value| %> + + + + + <% end %> +
    Favorite <%= name.pluralize %><%= @user.voted_tags(:type => value).map {|tag| link_to h(tag["tag"].tr("_", " ")), :controller => "post", :action => "index", :tags => "vote:3:#{@user.name} #{tag['tag']} order:vote"}.join(", ")%>
    Uploaded Tags<%= @user.uploaded_tags.map {|tag| link_to h(tag["tag"].tr("_", " ")), :controller => "post", :action => "index", :tags => "user:#{@user.name} #{tag['tag']}"}.join(", ")%>
    Uploaded <%= name.pluralize %><%= @user.uploaded_tags(:type => value).map {|tag| link_to h(tag["tag"].tr("_", " ")), :controller => "post", :action => "index", :tags => "user:#{@user.name} #{tag['tag']}"}.join(", ")%>
    +
    + +
    +

    <%= link_to "Favorite Tags", :controller => "post", :action => "index", :tags => "favtag:#{@user.name}" %>

    + <%= render :partial => "post/posts", :locals => {:posts => @user.favorite_tag_posts(5).select {|x| CONFIG["can_see_post"].call(@current_user, x)}} %> +
    + +
    +

    <%= link_to "Favorites", :controller => "post", :action => "index", :tags => "vote:3:#{@user.name} order:vote" %>

    + <%= render :partial => "post/posts", :locals => {:posts => @user.recent_favorite_posts.select {|x| CONFIG["can_see_post"].call(@current_user, x)}} %> +
    + +
    +

    <%= link_to "Uploads", :controller => "post", :action => "index", :tags => "user:#{@user.name}" %>

    + <%= render :partial => "post/posts", :locals => {:posts => @user.recent_uploaded_posts.select {|x| CONFIG["can_see_post"].call(@current_user, x)}} %> +
    + +<% content_for("footer") do %> +
  • <%= link_to "List", :controller => "user", :action => "index" %>
  • + <% if @current_user.is_mod_or_higher? %> +
  • <%= link_to "Ban", :controller => "user", :action => "block", :id => @user.id %>
  • + <% end %> + <% if @current_user.is_janitor_or_higher? && @user.is_member_or_lower? %> +
  • <%= link_to "Invite", :controller => "user", :action => "invites", :name => @user.name %>
  • + <% end %> +
  • <%= link_to "Send message", :controller => "dmail", :action => "compose", :to => @user.name %>
  • +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/user/show_blocked_users.html.erb b/app/views/user/show_blocked_users.html.erb new file mode 100644 index 00000000..64e9b600 --- /dev/null +++ b/app/views/user/show_blocked_users.html.erb @@ -0,0 +1,90 @@ +
    +
    Blocked Users
    + <% form_tag(:action => "unblock") do %> + + + + + + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + <% end %> + +
    UserExpiresReason
    <%= submit_tag "Unblock" %>
    <%= check_box_tag "user[#{user.id}]" %><%= link_to h(user.pretty_name), :controller => "user", :action => "show", :id => user.id %><%= time_ago_in_words(user.ban.expires_at) %><%= h user.ban.reason %>
    + <% end %> + +
    Blocked IPs
    + <% form_tag(:controller => "blocks", :action => "unblock_ip") do %> + + + + + + + + + + + + + + + + + <% @ip_bans.each do |ban| %> + + + + + + + + <% end %> + +
    IPExpiresBanned byReason
    <%= submit_tag "Unblock" %>
    <%= check_box_tag "ip_ban[#{ban.id}]" %><%= h ban.ip_addr %><%= if ban.expires_at then time_ago_in_words(ban.expires_at) else "never" end %><%= link_to h(ban.user.pretty_name), :controller => "user", :action => "show", :id => ban.user.id %><%= h ban.reason %>
    + <% end %> + <% form_tag(:controller => "blocks", :action => "block_ip") do %> + + + + + + + + + + + + + + + + + + + + + +
    <%= submit_tag "Submit" %>
    +

    IP masks may be used, such as 127.0.0.1/24

    <%= text_field "ban", "ip_addr", :size => "40" %>
    <%= text_area "ban", "reason", :size => "40x5" %>
    + + <%= text_field "ban", "duration", :size => 10 %>
    + <% end %> +
    + +<%= render :partial => "footer" %> \ No newline at end of file diff --git a/app/views/user/signup.html.erb b/app/views/user/signup.html.erb new file mode 100644 index 00000000..5bfb690d --- /dev/null +++ b/app/views/user/signup.html.erb @@ -0,0 +1,59 @@ +
    +

    Signup

    + +<% if !CONFIG["enable_signups"] %> +

    Signups are currently disabled.

    +<% else %> +

    By creating an account, you are agreeing to the terms of service. Remember that this site is open to web crawlers, so people will be able to easily search your name.

    + + <% form_tag(:controller => "user", :action => "create") do %> + + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + +

    Please remember your name will be easy to Google on this site.

    +
    + <%= text_field "user", "name", :size => 30 %> +
    + +

    Optional, for email notifications and password resets.

    +
    + <%= text_field "user", "email", :size => 30 %> +
    + +

    Minimum of five characters.

    +
    + <%= password_field "user", "password", :size => 30 %> +
    + + + <%= password_field "user", "password_confirmation", :size => 30 %> +
    + <% end %> +<% end %> +
    +<%= render :partial => "footer" %> diff --git a/app/views/user_mailer/confirmation_email.text.html.erb b/app/views/user_mailer/confirmation_email.text.html.erb new file mode 100644 index 00000000..a7d784d8 --- /dev/null +++ b/app/views/user_mailer/confirmation_email.text.html.erb @@ -0,0 +1 @@ +

    Hello, <%= h(@user.pretty_name) %>. You need to activate your account by visiting <%= link_to "this link", :controller => "user", :action => "activate_user", :hash => User.confirmation_hash(@user.name), :only_path => false, :host => CONFIG["server_host"] %>.

    diff --git a/app/views/user_mailer/confirmation_email.text.plain.erb b/app/views/user_mailer/confirmation_email.text.plain.erb new file mode 100644 index 00000000..c03ca255 --- /dev/null +++ b/app/views/user_mailer/confirmation_email.text.plain.erb @@ -0,0 +1,3 @@ +Hello, <%= @user.pretty_name %>. You need to activate your account by visiting: + + <%= url_for :controller => "user", :action => "activate_user", :hash => User.confirmation_hash(@user.name), :only_path => false, :host => CONFIG["server_host"] %> diff --git a/app/views/user_mailer/dmail.html.erb b/app/views/user_mailer/dmail.html.erb new file mode 100644 index 00000000..a3a239f1 --- /dev/null +++ b/app/views/user_mailer/dmail.html.erb @@ -0,0 +1,5 @@ +

    <%= @sender.name %> said:

    + +
    + <%= format_text(@body) %> +
    diff --git a/app/views/user_mailer/new_password.text.html.erb b/app/views/user_mailer/new_password.text.html.erb new file mode 100644 index 00000000..43254db3 --- /dev/null +++ b/app/views/user_mailer/new_password.text.html.erb @@ -0,0 +1,3 @@ +

    Hello, <%= h(@user.pretty_name) %>. Your password has been reset to <%= @password %>.

    + +

    You can login to <%= link_to(CONFIG["app_name"], :controller => "user", :action => "login", :only_path => false, :host => CONFIG["server_host"]) %> and change your password to something else.

    diff --git a/app/views/user_mailer/new_password.text.plain.erb b/app/views/user_mailer/new_password.text.plain.erb new file mode 100644 index 00000000..bc78667f --- /dev/null +++ b/app/views/user_mailer/new_password.text.plain.erb @@ -0,0 +1,5 @@ +Hello, <%= @user.pretty_name %>. Your password has been reset to: + + <%= @password %> + +You can login to <%= url_for(:controller => "user", :action => "login", :only_path => false, :host => CONFIG["server_host"]) %> and change your password to something else. diff --git a/app/views/user_record/_footer.html.erb b/app/views/user_record/_footer.html.erb new file mode 100644 index 00000000..38ecc277 --- /dev/null +++ b/app/views/user_record/_footer.html.erb @@ -0,0 +1,7 @@ +<% if @user %> + <% content_for("subnavbar") do %> +
  • <%= link_to "Add", :action => "create", :user_id => @user.id %>
  • +
  • <%= link_to "List for user", :action => "index", :user_id => @user.id %>
  • +
  • <%= link_to "List for all", :action => "index", :user_id => nil %>
  • + <% end %> +<% end %> diff --git a/app/views/user_record/create.html.erb b/app/views/user_record/create.html.erb new file mode 100644 index 00000000..8d0c5a15 --- /dev/null +++ b/app/views/user_record/create.html.erb @@ -0,0 +1,22 @@ +

    Add Record for <%= h(@user.pretty_name) %>

    + +<% form_tag(:action => "create") do %> + <%= hidden_field_tag "user_id", @user.id %> + + + + + + + + + + + + + + + + +
    <%= check_box "user_record", "is_positive" %>
    <%= text_area "user_record", "body", :size => "20x8" %>
    <%= submit_tag "Submit" %> <%= button_to_function "Cancel", "location.back()" %>
    +<% end %> diff --git a/app/views/user_record/destroy.html.erb b/app/views/user_record/destroy.html.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/views/user_record/index.html.erb b/app/views/user_record/index.html.erb new file mode 100644 index 00000000..098047d1 --- /dev/null +++ b/app/views/user_record/index.html.erb @@ -0,0 +1,42 @@ +
    +

    Record

    + + + + + + + + + + + + + <% @user_records.each do |rec| %> + + + + + + + + <% end %> + +
    UserReporterWhenBody
    + <% if @user %> + <%= link_to h(rec.user.pretty_name), :controller => "user", :action => "show", :id => rec.user_id %> + <% else %> + <%= link_to h(rec.user.pretty_name), :action => "index", :user_id => rec.user_id %> + <% end %> + <%= h(rec.reporter.pretty_name) %><%= time_ago_in_words(rec.created_at) %> ago<%= h rec.body %> + <% if @current_user.is_mod_or_higher? || @current_user.id == rec.reported_by %> + <%= link_to_function "Delete", "UserRecord.destroy(#{rec.id})" %> + <% end %> +
    + +
    + <%= will_paginate(@user_records) %> +
    + + <%= render :partial => "footer" %> +
    diff --git a/app/views/wiki/_footer.html.erb b/app/views/wiki/_footer.html.erb new file mode 100644 index 00000000..0aec74ff --- /dev/null +++ b/app/views/wiki/_footer.html.erb @@ -0,0 +1,6 @@ +<% content_for("subnavbar") do %> +
  • <%= link_to "List", :action => "index" %>
  • +
  • <%= link_to "New page", :action => "add" %>
  • + <%= @content_for_footer %> +
  • <%= link_to "Help", :controller => "help", :action => "wiki" %>
  • +<% end %> diff --git a/app/views/wiki/_recently_revised.html.erb b/app/views/wiki/_recently_revised.html.erb new file mode 100644 index 00000000..27de6b8a --- /dev/null +++ b/app/views/wiki/_recently_revised.html.erb @@ -0,0 +1,8 @@ +
    +
    Recent Changes (<%= link_to "all", :action => "index", :order => "date" %>)
    +
      + <%- WikiPage.find(:all, :limit => 25, :order => "updated_at desc").each do |page| -%> +
    • <%= link_to h(page.pretty_title), :controller => "wiki", :action => "show", :title => page.title %>
    • + <%- end -%> +
    +
    diff --git a/app/views/wiki/_sidebar.html.erb b/app/views/wiki/_sidebar.html.erb new file mode 100644 index 00000000..1bdb47af --- /dev/null +++ b/app/views/wiki/_sidebar.html.erb @@ -0,0 +1,10 @@ + diff --git a/app/views/wiki/add.html.erb b/app/views/wiki/add.html.erb new file mode 100644 index 00000000..352261d4 --- /dev/null +++ b/app/views/wiki/add.html.erb @@ -0,0 +1,39 @@ +<%= render :partial => "sidebar" %> + +
    +
    +
    + <% form_tag({:action => "create"}, :level=>:member) do %> + <%= text_field "wiki_page", "title" %> + <%= text_area("wiki_page", "body", :size => "60x30") %>
    + <%= submit_tag("Save", :name => "save") -%><%= button_to_function("Cancel", "history.back()") -%><%= button_to_function("Preview", "$('wiki-view').innerHTML = 'Loading preview...'; new Ajax.Updater('wiki-view', '/wiki/preview', {parameters: 'body=' + encodeURIComponent($('wiki_page_body').value)})") %> + <% end %> +
    + +
    +

    Reference

    +
    +A paragraph.
    +
    +Followed by another.
    +
    +h4. A header
    +
    +* List item 1
    +* List item 2
    +* List item 3
    +
    +Linebreaks are important between lists, 
    +headers, and paragraphs.
    +
    +A "conventional link":http://www.google.com
    +
    +A [[wiki link]] (underscores are not needed)
    +
    +An aliased [[real page|wiki link]]
    +
    +Read more.
    +  
    +
    + +<%= render :partial => "footer" %> diff --git a/app/views/wiki/diff.html.erb b/app/views/wiki/diff.html.erb new file mode 100644 index 00000000..db9943b3 --- /dev/null +++ b/app/views/wiki/diff.html.erb @@ -0,0 +1,17 @@ +<%= render :partial => "sidebar" %> + +
    +

    <%= h @oldpage.pretty_title %>

    +

    + Comparing versions + <%= link_to h(params[:from]), {:action => "show", :title => params[:title], :version => params[:from]} %> + and + <%= link_to h(params[:to]), {:action => "show", :title => params[:title], :version => params[:to]} %>. +

    +

    Legend: old text new text

    +
    + <%= @difference %> +
    +
    + +<%= render :partial => "footer" %> diff --git a/app/views/wiki/edit.html.erb b/app/views/wiki/edit.html.erb new file mode 100644 index 00000000..ab358838 --- /dev/null +++ b/app/views/wiki/edit.html.erb @@ -0,0 +1,42 @@ +<%= render :partial => "sidebar" %> + +
    +

    <%= h(@wiki_page.pretty_title) %> (Editing)

    +
    +
    + <% form_tag({:action => "update"}, :level=>:member) do %> + <%= hidden_field "wiki_page", "title", :value => @wiki_page.title %> + <%= text_area("wiki_page", "body", :size => "60x30") %> + <%= submit_tag("Save", :name => "save") %> + <%= button_to_function("Cancel", "location.pathname = '/wiki/show?title=#{@wiki_page.title}'") %> + <%= button_to_function("Preview", "$('wiki-view').innerHTML = 'Loading preview...'; new Ajax.Updater('wiki-view', '/wiki/preview', {parameters: 'body=' + encodeURIComponent($('wiki_page_body').value)})") %> + <% end %> +
    + +
    +

    Reference

    +
    +A paragraph.
    +
    +Followed by another.
    +
    +h4. A header
    +
    +* List item 1
    +* List item 2
    +* List item 3
    +
    +Linebreaks are important between lists, 
    +headers, and paragraphs.
    +
    +URLs are automatically linked: http://www.google.com
    +
    +A [[wiki link]] (underscores are not needed).
    +
    +A {{post link}}.
    +
    +Read more.
    +  
    +
    + +<%= render :partial => "footer" %> diff --git a/app/views/wiki/history.html.erb b/app/views/wiki/history.html.erb new file mode 100644 index 00000000..db410ffe --- /dev/null +++ b/app/views/wiki/history.html.erb @@ -0,0 +1,64 @@ +<%= render :partial => "sidebar" %> + +
    + <% form_tag({:action => "diff"}, :method => :get) do %> + <%= hidden_field_tag "title", params[:title] %> + + + + + + + + + + + + + + + + <% @wiki_pages.each_with_index do |wiki_page, i| %> + + + + + + <% end %> + +
    FromToLast edited
    <%= submit_tag "Compare" %>
    <%= radio_button_tag "from", wiki_page.version, i==1, :id => "from_#{wiki_page.version}" %><%= radio_button_tag "to", wiki_page.version, i==0, :id => "to_#{wiki_page.version}" %><%= link_to wiki_page.updated_at.strftime("%m/%d %I:%M"), :action => "show", :title => wiki_page.title, :version => wiki_page.version %> by <%= h wiki_page.author %>
    + <% end %> + + +
    + + +<%= render :partial => "footer" %> diff --git a/app/views/wiki/index.html.erb b/app/views/wiki/index.html.erb new file mode 100644 index 00000000..2c9b2c0a --- /dev/null +++ b/app/views/wiki/index.html.erb @@ -0,0 +1,22 @@ +
    + <%= render :partial => "sidebar" %> + + + + + + + <%- @wiki_pages.each do |wiki_page| -%> + + + + + <%- end -%> +
    TitleLast edited
    <%= link_to h(wiki_page.pretty_title), :controller => "wiki", :action => "show", :title => wiki_page.title, :nordirect => 1 %><%= wiki_page.updated_at.strftime("%m/%d %I:%M") %> by <%= h wiki_page.author %>
    + +
    + <%= will_paginate(@wiki_pages) %> +
    +
    + +<%= render :partial => "footer" %> diff --git a/app/views/wiki/recent_changes.html.erb b/app/views/wiki/recent_changes.html.erb new file mode 100644 index 00000000..158059cb --- /dev/null +++ b/app/views/wiki/recent_changes.html.erb @@ -0,0 +1,27 @@ +<%= render :partial => "sidebar" %> + +
    + + + + + + + + + <% @wiki_pages.each do |wiki_page| %> + + + + + <% end %> + +
    PageLast edited
    <%= link_to h(wiki_page.pretty_title), :controller => "wiki", :action => "show", :title => wiki_page.title %><%= wiki_page.updated_at.strftime("%m/%d %I:%M") %> by <%= h wiki_page.author %>
    + +
    + <%= will_paginate(@wiki_pages) %> +
    +
    + + +<%= render :partial => "footer" %> diff --git a/app/views/wiki/rename.html.erb b/app/views/wiki/rename.html.erb new file mode 100644 index 00000000..26c7eb77 --- /dev/null +++ b/app/views/wiki/rename.html.erb @@ -0,0 +1,7 @@ +<% form_tag(:action => "update") do %> + <%= hidden_field_tag "title", params[:title] %> + <%= text_field "wiki_page", "title" %>
    + <%= submit_tag "Save" %> <%= button_to_function "Cancel", "history.back()" %> +<% end %> + +<%= render :partial => "footer" %> diff --git a/app/views/wiki/show.html.erb b/app/views/wiki/show.html.erb new file mode 100644 index 00000000..efb94d67 --- /dev/null +++ b/app/views/wiki/show.html.erb @@ -0,0 +1,130 @@ +<%#= render :partial => "sidebar" %> + +
    +

    + <% if @tag %> + <%= h @tag.pretty_type_name %>: + <% end %> + + <% if @page.nil? %> + <%= h params[:title].tr("_", " ") %> + <% else %> + <%= h @page.pretty_title %> <%- unless @page.last_version? -%>(Version <%= @page.version %>)<%- end -%> + <% end %> +

    + + <% if @page.nil? && @artist.nil? %> +

    No page currently exists.

    + <% end %> + + <% unless @page.nil? %> +
    + <%= format_inlines(format_text(@page.body), 1) %> +
    + <% end %> + + <% if @artist %> +
    + + + <% @artist.artist_urls.each do |artist_url| %> + + + + + <% end %> + <% if @artist.alias_id %> + + + + + <% end %> + <% if @artist.aliases.any? %> + + + + + <% end %> + <% if @artist.group_id %> + + + + + <% end %> + <% if @artist.members.any? %> + + + + + <% end %> + +
    URL + <%= link_to h(artist_url.url), h(artist_url.url) %> + <% if @current_user.is_mod_or_higher? %> + (<%= link_to "mass edit", :controller => "tag", :action => "mass_edit", :source => "-#{@artist.name} source:" + ArtistUrl.normalize_for_search(artist_url.url), :name => @artist.name %>) + <% end %> +
    Alias for<%= link_to h(@artist.alias_name), :action => "show", :title => @artist.alias_name %>
    Aliases<%= @artist.aliases.map {|x| link_to(h(x.name), :action => "show", :title => x.name)}.join(", ") %>
    Member of<%= link_to h(@artist.group_name), :action => "show", :title => @artist.group_name %>
    Members<%= @artist.members.map {|x| link_to(h(x.name), :action => "show", :title => x.name)}.join(", ") %>
    +
    + <% end %> + + <% unless @posts.nil? %> +
      + <% @posts.each do |p| %> + <%= print_preview(p) %> + <% end %> +
    + <% end %> + + <% unless @page.nil? %> +
    Updated by <%= link_to h(@page.author), :controller => "user", :action => "show", :id => @page.user_id %> <%= time_ago_in_words(@page.updated_at) %> ago
    + <% end %> +
    + + + + + diff --git a/config/.local_config.rb.swp b/config/.local_config.rb.swp new file mode 100644 index 00000000..b0bc2a87 Binary files /dev/null and b/config/.local_config.rb.swp differ diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 00000000..cd21fb9e --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,109 @@ +# Don't change this file! +# Configure your app in config/environment.rb and config/environments/*.rb + +RAILS_ROOT = "#{File.dirname(__FILE__)}/.." unless defined?(RAILS_ROOT) + +module Rails + class << self + def boot! + unless booted? + preinitialize + pick_boot.run + end + end + + def booted? + defined? Rails::Initializer + end + + def pick_boot + (vendor_rails? ? VendorBoot : GemBoot).new + end + + def vendor_rails? + File.exist?("#{RAILS_ROOT}/vendor/rails") + end + + def preinitialize + load(preinitializer_path) if File.exist?(preinitializer_path) + end + + def preinitializer_path + "#{RAILS_ROOT}/config/preinitializer.rb" + end + end + + class Boot + def run + load_initializer + Rails::Initializer.run(:set_load_path) + end + end + + class VendorBoot < Boot + def load_initializer + require "#{RAILS_ROOT}/vendor/rails/railties/lib/initializer" + Rails::Initializer.run(:install_gem_spec_stubs) + end + end + + class GemBoot < Boot + def load_initializer + self.class.load_rubygems + load_rails_gem + require 'initializer' + end + + def load_rails_gem + if version = self.class.gem_version + gem 'rails', version + else + gem 'rails' + end + rescue Gem::LoadError => load_error + $stderr.puts %(Missing the Rails #{version} gem. Please `gem install -v=#{version} rails`, update your RAILS_GEM_VERSION setting in config/environment.rb for the Rails version you do have installed, or comment out RAILS_GEM_VERSION to use the latest version installed.) + exit 1 + end + + class << self + def rubygems_version + Gem::RubyGemsVersion if defined? Gem::RubyGemsVersion + end + + def gem_version + if defined? RAILS_GEM_VERSION + RAILS_GEM_VERSION + elsif ENV.include?('RAILS_GEM_VERSION') + ENV['RAILS_GEM_VERSION'] + else + parse_gem_version(read_environment_rb) + end + end + + def load_rubygems + require 'rubygems' + + unless rubygems_version >= '0.9.4' + $stderr.puts %(Rails requires RubyGems >= 0.9.4 (you have #{rubygems_version}). Please `gem update --system` and try again.) + exit 1 + end + + rescue LoadError + $stderr.puts %(Rails requires RubyGems >= 0.9.4. Please install RubyGems and try again: http://rubygems.rubyforge.org) + exit 1 + end + + def parse_gem_version(text) + $1 if text =~ /^[^#]*RAILS_GEM_VERSION\s*=\s*["']([!~<>=]*\s*[\d.]+)["']/ + end + + private + def read_environment_rb + File.read("#{RAILS_ROOT}/config/environment.rb") + end + end + end +end + +# All that for this: +Rails.boot! diff --git a/config/core_extensions.rb b/config/core_extensions.rb new file mode 100644 index 00000000..53d05c66 --- /dev/null +++ b/config/core_extensions.rb @@ -0,0 +1,54 @@ +class ActiveRecord::Base + class << self + public :sanitize_sql + end + + %w(execute select_value select_values select_all).each do |method_name| + define_method("#{method_name}_sql") do |sql, *params| + ActiveRecord::Base.connection.__send__(method_name, self.class.sanitize_sql([sql, *params])) + end + + self.class.__send__(:define_method, "#{method_name}_sql") do |sql, *params| + ActiveRecord::Base.connection.__send__(method_name, ActiveRecord::Base.sanitize_sql([sql, *params])) + end + end +end + +class NilClass + def id + raise NoMethodError + end +end + +class String + def to_escaped_for_sql_like + # NOTE: gsub(/\\/, '\\\\') is a NOP, you need gsub(/\\/, '\\\\\\') if you want to turn \ into \\; or you can duplicate the matched text + return self.gsub(/\\/, '\0\0').gsub(/%/, '\\%').gsub(/_/, '\\_').gsub(/\*/, '%') + end + + def to_escaped_js + return self.gsub(/\\/, '\0\0').gsub(/['"]/) {|m| "\\#{m}"}.gsub(/\r\n|\r|\n/, '\\n') + end +end + +class Hash + def included(m) + m.alias_method :to_xml_orig, :to_xml + end + + def to_xml(options = {}) + if false == options.delete(:no_children) + to_xml_orig(options) + else + options[:indent] ||= 2 + options[:no_children] ||= true + options[:root] ||= "hash" + dasherize = !options.has_key?(:dasherize) || options[:dasherize] + root = dasherize ? options[:root].dasherize : options[:root] + options.reverse_merge!({:builder => Builder::XmlMarkup.new(:indent => options[:indent]), :root => root}) + options[:builder].instruct! unless options.delete(:skip_instruct) + options[:builder].tag!(root, self) + end + end +end + diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 00000000..33d93e51 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,34 @@ +# Copy this to "database.yml" and adjust +# the fields accordingly. + +development: + adapter: postgresql + database: moe + #username: devdanbooru + username: moe + host: 127.0.0.1 + +test: + adapter: postgresql + database: moe + username: moe + host: 127.0.0.1 + +production: + adapter: postgresql + database: moe + #username: devdanbooru + username: moe + host: 127.0.0.1 + +production_with_logging: + adapter: postgresql + database: moe + username: moe + host: 127.0.0.1 + +job_task: + adapter: postgresql + database: moe + username: moe + host: 127.0.0.1 diff --git a/config/database.yml.example b/config/database.yml.example new file mode 100644 index 00000000..109a6a73 --- /dev/null +++ b/config/database.yml.example @@ -0,0 +1,20 @@ +# Copy this to "database.yml" and adjust +# the fields accordingly. + +development: + adapter: postgresql + database: danbooru + username: albert + host: 127.0.0.1 + +test: + adapter: postgresql + database: danbooru + username: albert + host: 127.0.0.1 + +production: + adapter: postgresql + database: danbooru + username: albert + host: 127.0.0.1 diff --git a/config/default_config.rb b/config/default_config.rb new file mode 100644 index 00000000..4e9c28bb --- /dev/null +++ b/config/default_config.rb @@ -0,0 +1,269 @@ +CONFIG = {} + +# The version of this Danbooru. +CONFIG["version"] = "1.15.0" + +# The default name to use for anyone who isn't logged in. +CONFIG["default_guest_name"] = "Anonymous" + +# Set to true to require an e-mail address to register. +CONFIG["enable_account_email_activation"] = false + +# This is a salt used to make dictionary attacks on account passwords harder. +CONFIG["password_salt"] = "choujin-steiner" + +# Set to true to allow new account signups. +CONFIG["enable_signups"] = true + +# Newly created users start at this level. Set this to 30 if you want everyone +# to start out as a privileged member. +CONFIG["starting_level"] = 20 + +# What method to use to store images. +# local_flat: Store every image in one directory. +# local_hierarchy: Store every image in a hierarchical directory, based on the post's MD5 hash. On some file systems this may be faster. +# local_flat_with_amazon_s3_backup: Store every image in a flat directory, but also save to an Amazon S3 account for backup. +# amazon_s3: Save files to an Amazon S3 account. +# remote_hierarchy: Some images will be stored on separate image servers using a hierarchical directory. +CONFIG["image_store"] = :local_flat + +# Only used when image_store == :remote_hierarchy. An array of image servers (use http://domain.com format). +# +# If nozipfile is set, the mirror won't be used for ZIP mirroring. +CONFIG["image_servers"] = [ +# { :server => "http://domain.com", :traffic => 0.5 }, +# { :server => "http://domain.com", :traffic => 0.5, :nozipfile => true }, +] + +# Set to true to enable downloading whole pools as ZIPs. This requires mod_zipfile +# for lighttpd. +CONFIG["pool_zips"] = false + +# List of servers to mirror image data to. This is run from the task processor. +# An unpassworded SSH key must be set up to allow direct ssh/scp commands to be +# run on the remote host. data_dir should point to the equivalent of public/data, +# and should usually be listed in CONFIG["image_servers"] unless this is a backup- +# only host. +CONFIG["mirrors"] = [ + # { :user => "danbooru", :host => "example.com", :data_dir => "/home/danbooru/public/data" }, +] + +# Enables image samples for large images. NOTE: if you enable this, you must manually create a public/data/sample directory. +CONFIG["image_samples"] = true + +# The maximum dimensions and JPEG quality of sample images. +CONFIG["sample_width"] = 1400 +CONFIG["sample_height"] = 1000 # Set to nil if you never want to scale an image to fit on the screen vertically +CONFIG["sample_quality"] = 95 + +# The maximum dimensions of inline images for the forums and wiki. +CONFIG["inline_sample_width"] = 800 +CONFIG["inline_sample_height"] = 600 + +# Resample the image only if the image is larger than sample_ratio * sample_dimensions. +CONFIG["sample_ratio"] = 1.25 + +# A prefix to prepend to sample files +CONFIG["sample_filename_prefix"] = "" + +# Enables creating JPEGs for PNGs. +CONFIG["jpeg_enable"] = false + +# Scale JPEGs to fit in these dimensions. +CONFIG["jpeg_width"] = 3500 +CONFIG["jpeg_height"] = 3500 + +# Resample the image only if the image is larger than jpeg_ratio * jpeg_dimensions. If +# not, PNGs can still have a JPEG generated, but no resampling will be done. +CONFIG["jpeg_ratio"] = 1.25 +CONFIG["jpeg_quality"] = { :min => 94, :max => 97, :filesize => 1024*1024*4 } + +# If enabled, URLs will be of the form: +# http://host/image/00112233445566778899aabbccddeeff/12345 tag tag2 tag3.jpg +# +# This allows images to be saved with a useful filename, and hides the MD5 hierarchy (if +# any). This does not break old links; links to the old URLs are still valid. This +# requires URL rewriting (not redirection!) in your webserver. The rules for lighttpd are: +# +# url.rewrite = ( +# "^/image/([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{28})(/.*)?(\.[a-z]*)" => "/data/$1/$2/$1$2$3$5", +# "^/sample/([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{28})(/.*)?(\.[a-z]*)" => "/data/sample/$1/$2/$1$2$3$5" +# ) +# +CONFIG["use_pretty_image_urls"] = false + +# If use_pretty_image_urls is true, sets a prefix to prepend to all filenames. This +# is only present in the generated URL, and is useful to allow your downloaded files +# to be distinguished from other sites; for example, "moe 12345 tags.jpg" vs. +# "kc 54321 tags.jpg". If set, this should end with a space. +CONFIG["download_filename_prefix"] = "" + +# Files over this size will always generate a sample, even if already within +# the above dimensions. +CONFIG["sample_always_generate_size"] = 512*1024 + +# These three configs are only relevant if you're using the Amazon S3 image store. +CONFIG["amazon_s3_access_key_id"] = "" +CONFIG["amazon_s3_secret_access_key"] = "" +CONFIG["amazon_s3_bucket_name"] = "" + +# This enables various caching mechanisms. You must have memcache (and the memcache-client ruby gem) installed in order for caching to work. +CONFIG["enable_caching"] = false + +# Enabling this will cause Danbooru to cache things longer: +# - On post/index, any page after the first 10 will be cached for 3-7 days. +# - post/show is cached +CONFIG["enable_aggressive_caching"] = false + +# The server and port where the memcache client can be accessed. Only relevant if you enable caching. +CONFIG["memcache_servers"] = ["localhost:4000"] + +# Any post rated safe or questionable that has one of the following tags will automatically be rated explicit. +CONFIG["explicit_tags"] = %w(pussy penis cum anal vibrator dildo masturbation oral_sex sex paizuri penetration guro rape asshole footjob handjob blowjob cunnilingus anal_sex) + +# After a post receives this many posts, new comments will no longer bump the post in comment/index. +CONFIG["comment_threshold"] = 40 + +# Members cannot post more than X posts in a day. +CONFIG["member_post_limit"] = 16 + +# Members cannot post more than X comments in an hour. +CONFIG["member_comment_limit"] = 2 + +# This sets the minimum and maximum value a user can record as a vote. +CONFIG["vote_record_min"] = 0 +CONFIG["vote_record_max"] = 3 + +# Descriptions for the various vote levels. +CONFIG["vote_descriptions"] = { + 3 => "Favorite", + 2 => "Great", + 1 => "Good", + 0 => "Neutral", + -1 => "Bad" +} + +# The maximum image size that will be downloaded by a URL. +CONFIG["max_image_size"] = 1024*1024*256 + +# This allows posts to have parent-child relationships. However, this requires manually updating the post counts stored in table_data by periodically running the script/maintenance script. +CONFIG["enable_parent_posts"] = false + +# Show only the first page of post/index to visitors. +CONFIG["show_only_first_page"] = false + +CONFIG["enable_reporting"] = false + +# Enable some web server specific optimizations. Possible values include: apache, nginx, lighttpd. +CONFIG["web_server"] = "apache" + +# Show a link to Trac. +CONFIG["enable_trac"] = true + +# The image service name of this host, if any. +CONFIG["local_image_service"] = "" + +# List of image services available for similar image searching. +CONFIG["image_service_list"] = { + "danbooru.donmai.us" => "http://haruhidoujins.yi.org/multi-search.xml", + "moe.imouto.org" => "http://haruhidoujins.yi.org/multi-search.xml", + "konachan.com" => "http://haruhidoujins.yi.org/multi-search.xml", +} + +# If true, image services receive a URL to the thumbnail for searching, which +# is faster. If false, the file is sent directly. Set to false if using image +# services that don't have access to your image URLs. +CONFIG["image_service_local_searches_use_urls"] = true + +# If true, run a dupe check on new uploads using the image search +# for local_image_service. +CONFIG["dupe_check_on_upload"] = false + +# Defines the various user levels. You should not remove any of the default ones. When Danbooru starts up, the User model will have several methods automatically defined based on what this config contains. For this reason you should only use letters, numbers, and spaces (spaces will be replaced with underscores). Example: is_member?, is_member_or_lower?, is_member_or_higher? +CONFIG["user_levels"] = { + "Unactivated" => 0, + "Blocked" => 10, + "Member" => 20, + "Privileged" => 30, + "Contributor" => 33, + "Janitor" => 35, + "Mod" => 40, + "Admin" => 50 +} + +# Defines the various tag types. You can also define shortcuts. +CONFIG["tag_types"] = { + "General" => 0, + "Artist" => 1, + "Copyright" => 3, + "Character" => 4, + + "general" => 0, + "artist" => 1, + "copyright" => 3, + "character" => 4, + "art" => 1, + "copy" => 3, + "char" => 4 +} + +# Tag type IDs to not list in recent tag summaries, such as on the side of post/index: +CONFIG["exclude_from_tag_sidebar"] = [0] + +# If set, email_from is the address the site sends emails as. If left alone, emails +# are sent from CONFIG["admin_contact"]. +CONFIG["email_from"] = lambda do CONFIG["admin_contact"] end + +# Determine who can see a post. Note that since this is a block, return won't work. Use break. +CONFIG["can_see_post"] = lambda do |user, post| + # By default, no posts are hidden. + true + + # Some examples: + # + # Hide post if user isn't privileged and post is not safe: + # post.rating != "e" || user.is_privileged_or_higher? + # + # Hide post if user isn't a mod and post has the loli tag: + # !post.has_tag?("loli") || user.is_mod_or_higher? +end + +# Determines who can see ads. Note that since this is a block, return won't work. Use break. +CONFIG["can_see_ads"] = lambda do |user| + # By default, only show ads to non-priv users. + user.is_member_or_lower? + + # Show no ads at all + # false +end + +# Defines the default blacklists for new users. +CONFIG["default_blacklists"] = [ +# "rating:e loli", +# "rating:e shota", +] + +# Enable the artists interface. +CONFIG["enable_artists"] = true + +# This is required for Rails 2.0. +CONFIG["session_secret_key"] = "This should be at least 30 characters long" + +# Users cannot search for more than X regular tags at a time. +CONFIG["tag_query_limit"] = 6 + +# Set this to insert custom CSS or JavaScript files into your app. +CONFIG["custom_html_headers"] = nil + +# Set this to true to hand off time consuming tasks (downloading files, resizing images, any sort of heavy calculation) to a separate process. In general, if a user sees a page where a task was handed off, an HTTP status code of 503 will be returned. You need beanstalkd installed in order for this to work. This is only necessary if you are getting heavy traffic or you are doing several heavy calculations. +CONFIG["enable_asynchronous_tasks"] = false + +CONFIG["avatar_max_width"] = 125 +CONFIG["avatar_max_height"] = 125 + +# If you want to redirect traffic when the server load average spikes (for the 5min interval), initialize this setting. Set to false if you want to disable this feature. +# CONFIG["load_average_threshold"] = 2 +CONFIG["load_average_threshold"] = false + +CONFIG["favorite_tag_limit"] = 60 + diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 00000000..5c96842e --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,29 @@ +RAILS_GEM_VERSION = "2.1.0" + +require File.join(File.dirname(__FILE__), 'boot') + +Rails::Initializer.run do |config| + # Skip frameworks you're not going to use + config.frameworks -= [:action_web_service] + + # Add additional load paths for your own custom dirs + config.load_paths += ["#{RAILS_ROOT}/app/models/post", "#{RAILS_ROOT}/app/models/post/image_store"] + + # Force all environments to use the same logger level + # (by default production uses :info, the others :debug + config.log_level = :info + + # Enable page/fragment caching by setting a file-based store + # (remember to create the caching directory and make it readable to the application) + # config.action_controller.fragment_cache_store = :file_store, "#{RAILS_ROOT}/cache" + + # Activate observers that should always be running + # config.active_record.observers = :cacher, :garbage_collector + + # Make Active Record use UTC-base instead of local time + # config.active_record.default_timezone = :utc + + # Use Active Record's schema dumper instead of SQL when creating the test database + # (enables use of different database adapters for development and test environments) + config.active_record.schema_format = :sql +end diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 00000000..edaaef2f --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,17 @@ +# 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 webserver 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.action_controller.consider_all_requests_local = true +config.action_controller.perform_caching = false + +# Don't care if the mailer can't send +config.action_mailer.raise_delivery_errors = false + +config.log_level = :debug + diff --git a/config/environments/job_task.rb b/config/environments/job_task.rb new file mode 100644 index 00000000..edaaef2f --- /dev/null +++ b/config/environments/job_task.rb @@ -0,0 +1,17 @@ +# 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 webserver 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.action_controller.consider_all_requests_local = true +config.action_controller.perform_caching = false + +# Don't care if the mailer can't send +config.action_mailer.raise_delivery_errors = false + +config.log_level = :debug + diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 00000000..a904bcff --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,20 @@ +# The production environment is meant for finished, "live" apps. +# Code is not reloaded between requests +config.cache_classes = true + +# Use a different logger for distributed setups +# config.logger = SyslogLogger.new + +# Full error reports are disabled and caching is turned on +config.action_controller.consider_all_requests_local = false +config.action_controller.perform_caching = true + +# Enable serving of images, stylesheets, and javascripts from an asset server +# config.action_controller.asset_host = "http://assets.example.com" + +# Disable delivery errors if you bad email addresses should just be ignored +# config.action_mailer.raise_delivery_errors = false + +config.log_path = "#{RAILS_ROOT}/log/production.log" +config.log_level = :error +#config.log_level = :debug diff --git a/config/environments/production_with_logging.rb b/config/environments/production_with_logging.rb new file mode 100644 index 00000000..d1e276b7 --- /dev/null +++ b/config/environments/production_with_logging.rb @@ -0,0 +1,20 @@ +# The production environment is meant for finished, "live" apps. +# Code is not reloaded between requests +config.cache_classes = true + +# Use a different logger for distributed setups +# config.logger = SyslogLogger.new + +# Full error reports are disabled and caching is turned on +config.action_controller.consider_all_requests_local = false +config.action_controller.perform_caching = true + +# Enable serving of images, stylesheets, and javascripts from an asset server +# config.action_controller.asset_host = "http://assets.example.com" + +# Disable delivery errors if you bad email addresses should just be ignored +# config.action_mailer.raise_delivery_errors = false + +config.log_path = "#{RAILS_ROOT}/log/production_with_logging.log" +#config.log_level = :error +config.log_level = :debug diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 00000000..31a6a12b --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,25 @@ +# 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 = true + +# Log error messages when you accidentally call methods on nil. +config.whiny_nils = true + +# Show full error reports and disable caching +config.action_controller.consider_all_requests_local = true +config.action_controller.perform_caching = false + +# Tell ActionMailer 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 + +# Overwrite the default settings for fixtures in tests. See Fixtures +# for more details about these settings. +# config.transactional_fixtures = true +# config.instantiated_fixtures = false +# config.pre_loaded_fixtures = false + +config.log_level = :debug \ No newline at end of file diff --git a/config/initializers/000_load_config.rb b/config/initializers/000_load_config.rb new file mode 100644 index 00000000..3524ba2b --- /dev/null +++ b/config/initializers/000_load_config.rb @@ -0,0 +1,36 @@ +require "#{RAILS_ROOT}/config/default_config" +require "#{RAILS_ROOT}/config/local_config" + +CONFIG["url_base"] ||= "http://" + CONFIG["server_host"] + +%w(session_secret_key user_password_salt).each do |key| + CONFIG[key] = ServerKey[key] if ServerKey[key] +end + +ActionController::Base.session = {:session_key => CONFIG["app_name"], :secret => CONFIG["session_secret_key"]} + +require 'post_save' +require 'base64' +require 'diff/lcs/array' +require 'image_size' +require 'ipaddr' +require 'open-uri' +require 'socket' +require 'time' +require 'uri' +require 'net/http' +require 'aws/s3' if [:amazon_s3, :local_flat_with_amazon_s3_backup].include?(CONFIG["image_store"]) +require 'danbooru_image_resizer/danbooru_image_resizer' +require 'html_4_tags' +require 'google_chart' if CONFIG["enable_reporting"] +require 'core_extensions' +require 'json' +require 'json/add/core' +require 'json/add/rails' +require 'fix_form_tag' +require 'download' +require 'sys/cpu' if CONFIG["load_average_threshold"] +require 'fileutils' +require 'versioning' +require 'error_logging' +require 'dtext' diff --git a/config/initializers/001_action_mailer.rb b/config/initializers/001_action_mailer.rb new file mode 100644 index 00000000..1acf7c11 --- /dev/null +++ b/config/initializers/001_action_mailer.rb @@ -0,0 +1,11 @@ +ActionMailer::Base.default_charset = "utf-8" +#ActionMailer::Base.delivery_method = :sendmail +ActionMailer::Base.delivery_method = :smtp +ActionMailer::Base.raise_delivery_errors = true +ActionMailer::Base.perform_deliveries = true + +ActionMailer::Base.smtp_settings = { + :address => "localhost", + :port => 25, + :domain => CONFIG["server_host"] +} diff --git a/config/initializers/002_caching.rb b/config/initializers/002_caching.rb new file mode 100644 index 00000000..72680fb4 --- /dev/null +++ b/config/initializers/002_caching.rb @@ -0,0 +1,14 @@ +if CONFIG["enable_caching"] + require 'memcache_util' + require 'cache' + require 'memcache_util_store' +else + require 'cache_dummy' +end + + CACHE = MemCache.new :c_threshold => 10_000, :compression => true, :debug => false, :namespace => CONFIG["app_name"], :readonly => false, :urlencode => false + CACHE.servers = CONFIG["memcache_servers"] + begin + CACHE.flush_all + rescue MemCache::MemCacheError + end diff --git a/config/initializers/003_clear_js_cache.rb b/config/initializers/003_clear_js_cache.rb new file mode 100644 index 00000000..8931099e --- /dev/null +++ b/config/initializers/003_clear_js_cache.rb @@ -0,0 +1,3 @@ +require "asset_cache" + +AssetCache.clear_js_cache diff --git a/config/initializers/004_exception_notifier.rb b/config/initializers/004_exception_notifier.rb new file mode 100644 index 00000000..0f5b7fd0 --- /dev/null +++ b/config/initializers/004_exception_notifier.rb @@ -0,0 +1,3 @@ +#ExceptionNotifier.exception_recipients = [CONFIG["admin_contact"]] +#ExceptionNotifier.sender_address = CONFIG["admin_contact"] +#ExceptionNotifier.email_prefix = "[" + CONFIG["app_name"] + "] " diff --git a/config/initializers/005_mime_types.rb b/config/initializers/005_mime_types.rb new file mode 100644 index 00000000..01ebdc2a --- /dev/null +++ b/config/initializers/005_mime_types.rb @@ -0,0 +1 @@ +# Mime::Type.register("application/json", :js) diff --git a/config/initializers/006_check_javascripts_writable.rb b/config/initializers/006_check_javascripts_writable.rb new file mode 100644 index 00000000..63486ee3 --- /dev/null +++ b/config/initializers/006_check_javascripts_writable.rb @@ -0,0 +1,9 @@ +if true + path = "" + path += "#{RAILS_ROOT}/" if defined?(RAILS_ROOT) + path += "public/javascripts" + + if not File.writable?(path) + raise "Path must be writable: %s" % path + end +end diff --git a/config/local_config.rb b/config/local_config.rb new file mode 100644 index 00000000..b01f95e3 --- /dev/null +++ b/config/local_config.rb @@ -0,0 +1,81 @@ +# This is the file you use to overwrite the default config values. +# Look at default_config.rb and copy over any settings you want to change. + +# You MUST configure these settings for your own server! +CONFIG["app_name"] = "moe.imouto" +CONFIG["server_host"] = "moe.imouto.org" +#CONFIG["server_host"] = "76.73.1.90" +CONFIG["url_base"] = "http://" + CONFIG["server_host"] # set this to "" to get relative image urls +CONFIG["admin_contact"] = "dobacco@gmail.com" +CONFIG["email_from"] = "noreply@moe.imouto.org" +CONFIG["image_store"] = :remote_hierarchy +#CONFIG["image_servers"] = ["http://moe.imouto.org", "http://ranka.imouto.org", "http://moe.e-n-m.net"] +CONFIG["image_servers"] = [ + #{ :server => "http://elis.imouto.org", :traffic => 2, }, + { :server => "http://yotsuba.imouto.org", :traffic => 2, :nopreview => false }, +# { :server => "http://elis.imouto.org", :traffic => 3, :nopreview => true }, +# { :server => "http://ranka.imouto.org", :traffic => 1, :previews_only => true } #:nozipfile => true, :nopreview => true } +] +#CONFIG["image_servers"] = ["http://sheryl.imouto.org", "http://ranka.imouto.org", "http://moe.e-n-m.net"] +CONFIG["mirrors"] = [ + { :user => "moe", :host => "ranka.imouto.org", :data_dir => "/home/moe/moe-live/public/data" }, +# { :user => "moe", :host => "208.43.138.197", :data_dir => "/home/moe/moe-live/public/data" }, +# { :user => "moe", :host => "shana.imouto.org", :data_dir => "/home/moe/moe-live/public/data" }, +# { :user => "moe", :host => "elis.imouto.org", :data_dir => "/home/moe/moe-live/public/data" }, +# { :user => "moe", :host => "188.95.50.2", :data_dir => "/home/moe/moe-live/public/data" }, +# { :user => "moe", :host => "85.12.23.35", :data_dir => "/home/moe/data" }, +] +CONFIG["dupe_check_on_upload"] = true +CONFIG["enable_caching"] = true +CONFIG["enable_anonymous_comment_access"] = true +CONFIG["enable_anonymous_safe_post_mode"] = false +CONFIG["use_pretty_image_urls"] = true +CONFIG["download_filename_prefix"] = "moe" +CONFIG["member_post_limit"] = 99 +CONFIG["member_comment_limit"] = 20 +CONFIG["enable_parent_posts"] = true +CONFIG["starting_level"] = 30 +CONFIG["memcache_servers"] = ["localhost:11211"] +CONFIG["hide_loli_posts"] = false +CONFIG["enable_reporting"] = true +CONFIG["web_server"] = "lighttpd" +CONFIG["enable_trac"] = false +CONFIG["sample_ratio"] = 1 +CONFIG["tag_types"]["Circle"] = 5 +CONFIG["tag_types"]["cir"] = 5 +CONFIG["tag_types"]["circle"] = 5 +CONFIG["tag_types"]["Faults"] = 6 +CONFIG["tag_types"]["faults"] = 6 +CONFIG["tag_types"]["fault"] = 6 +CONFIG["tag_types"]["flt"] = 6 +CONFIG["exclude_from_tag_sidebar"] = [0, 6] +CONFIG["local_image_service"] = "moe.imouto.org" +CONFIG["default_blacklists"] = [ + "rating:e loli", + "rating:e shota", + "extreme_content", +] +# List of image services available for similar image searching. +CONFIG["image_service_list"] = { + "danbooru.donmai.us" => "http://iqdb.hanyuu.net/index.xml", + "moe.imouto.org" => "http://iqdb.hanyuu.net/index.xml", + "konachan.com" => "http://iqdb.hanyuu.net/index.xml", + "e-shuushuu.net" => "http://iqdb.hanyuu.net/index.xml", + "gelbooru.com" => "http://iqdb.hanyuu.net/index.xml", +} +# This sets the minimum and maximum value a single user's vote can affect the post's total score. +CONFIG["vote_sum_max"] = 1 +CONFIG["vote_sum_min"] = 0 +CONFIG["can_see_ads"] = lambda do |user| + +user.is_privileged_or_lower? + +end + +CONFIG["pool_zips"] = true +CONFIG["comment_threshold"] = 9999 +CONFIG["image_samples"] = true +CONFIG["jpeg_enable"] = true + +#ActionMailer::Base.delivery_method = :smtp + diff --git a/config/local_config.rb.example b/config/local_config.rb.example new file mode 100644 index 00000000..c38f0a8d --- /dev/null +++ b/config/local_config.rb.example @@ -0,0 +1,13 @@ +# This is the file you use to overwrite the default config values. +# Look at default_config.rb and copy over any settings you want to change. + +# You MUST configure these settings for your own server! +CONFIG["app_name"] = "DAN_SITENAME" +CONFIG["server_host"] = "DAN_HOSTNAME" +CONFIG["admin_contact"] = "webmaster@" + CONFIG["server_host"] +# CONFIG["session_secret_key"] = "This should be at least 30 characters long" + +CONFIG["url_base"] = "http://" + CONFIG["server_host"] # set this to "" to get relative image urls +CONFIG["admin_contact"] = "webmaster@" + CONFIG["server_host"] +CONFIG["local_image_service"] = CONFIG["app_name"] +# CONFIG["image_service_list"][CONFIG["local_image_service"]] = "http://127.0.0.1/iqdb/iqdb-xml.php" diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 00000000..3eed414b --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,9 @@ +ActionController::Routing::Routes.draw do |map| + map.connect "", :controller => "static", :action => "index" + map.connect "post/show/:id/:tag_title", :controller => "post", :action => "show", :requirements => {:id => /\d+/} + map.connect "pool/zip/:id/:filename", :controller => "pool", :action => "zip", :requirements => {:id => /\d+/, :filename => /.*/} + map.connect ":controller/:action/:id.:format", :requirements => {:id => /[-\d]+/} + map.connect ":controller/:action/:id", :requirements => {:id => /[-\d]+/} + map.connect ":controller/:action.:format" + map.connect ":controller/:action" +end diff --git a/db/migrate/001_add_post_links.rb b/db/migrate/001_add_post_links.rb new file mode 100644 index 00000000..94a4e60b --- /dev/null +++ b/db/migrate/001_add_post_links.rb @@ -0,0 +1,13 @@ +class AddPostLinks < ActiveRecord::Migration + def self.up + execute("ALTER TABLE posts ADD COLUMN next_post_id INTEGER REFERENCES posts ON DELETE SET NULL") + execute("ALTER TABLE posts ADD COLUMN prev_post_id INTEGER REFERENCES posts ON DELETE SET NULL") + execute("UPDATE posts SET next_post_id = (SELECT _.id FROM posts _ WHERE _.id > posts.id ORDER BY _.id LIMIT 1)") + execute("UPDATE posts SET prev_post_id = (SELECT _.id FROM posts _ WHERE _.id < posts.id ORDER BY _.id DESC LIMIT 1)") + end + + def self.down + execute("ALTER TABLE posts DROP COLUMN next_post_id") + execute("ALTER TABLE posts DROP COLUMN prev_post_id") + end +end diff --git a/db/migrate/002_create_artists.rb b/db/migrate/002_create_artists.rb new file mode 100644 index 00000000..be1dc79a --- /dev/null +++ b/db/migrate/002_create_artists.rb @@ -0,0 +1,23 @@ +class CreateArtists < ActiveRecord::Migration + def self.up + execute(<<-EOS) + CREATE TABLE artists ( + id SERIAL, + japanese_name TEXT, + personal_name TEXT, + handle_name TEXT, + circle_name TEXT, + site_name TEXT, + site_url TEXT, + image_url TEXT + ) + EOS + execute("CREATE INDEX idx_artists__image_url ON artists (image_url)") + execute("CREATE INDEX idx_artists__personal_name ON artists (personal_name) WHERE personal_name IS NOT NULL") + execute("CREATE INDEX idx_artists__handle_name ON artists (handle_name) WHERE handle_name IS NOT NULL") + end + + def self.down + execute("DROP TABLE artists") + end +end diff --git a/db/migrate/003_extend_post_tag_histories.rb b/db/migrate/003_extend_post_tag_histories.rb new file mode 100644 index 00000000..8be31de8 --- /dev/null +++ b/db/migrate/003_extend_post_tag_histories.rb @@ -0,0 +1,13 @@ +class ExtendPostTagHistories < ActiveRecord::Migration + def self.up + execute "ALTER TABLE post_tag_histories ADD COLUMN user_id INTEGER REFERENCES users ON DELETE SET NULL" + execute "ALTER TABLE post_tag_histories ADD COLUMN ip_addr TEXT" + execute "ALTER TABLE post_tag_histories ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now()" + end + + def self.down + execute "ALTER TABLE post_tag_histories DROP COLUMN user_id" + execute "ALTER TABLE post_tag_histories DROP COLUMN ip_addr" + execute "ALTER TABLE post_tag_histories DROP COLUMN created_at" + end +end diff --git a/db/migrate/004_post_tag_history_constraints.rb b/db/migrate/004_post_tag_history_constraints.rb new file mode 100644 index 00000000..063e9b7e --- /dev/null +++ b/db/migrate/004_post_tag_history_constraints.rb @@ -0,0 +1,13 @@ +class PostTagHistoryConstraints < ActiveRecord::Migration + def self.up + execute("UPDATE post_tag_histories SET created_at = now() WHERE created_at IS NULL") + execute("UPDATE post_tag_histories SET ip_addr = '' WHERE ip_addr IS NULL") + execute("ALTER TABLE post_tag_histories ALTER COLUMN created_at SET NOT NULL") + execute("ALTER TABLE post_tag_histories ALTER COLUMN ip_addr SET NOT NULL") + end + + def self.down + execute("ALTER TABLE post_tag_histories ALTER COLUMN created_at DROP NOT NULL") + execute("ALTER TABLE post_tag_histories ALTER COLUMN ip_addr DROP NOT NULL") + end +end diff --git a/db/migrate/005_create_forum_post.rb b/db/migrate/005_create_forum_post.rb new file mode 100644 index 00000000..60fd2012 --- /dev/null +++ b/db/migrate/005_create_forum_post.rb @@ -0,0 +1,19 @@ +class CreateForumPost < ActiveRecord::Migration + def self.up + execute(<<-EOS) + CREATE TABLE forum_posts ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + creator_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + parent_id INTEGER REFERENCES forum_posts ON DELETE CASCADE + ) + EOS + end + + def self.down + execute("DROP TABLE forum_posts") + end +end diff --git a/db/migrate/006_create_forum_posts.rb b/db/migrate/006_create_forum_posts.rb new file mode 100644 index 00000000..8928c278 --- /dev/null +++ b/db/migrate/006_create_forum_posts.rb @@ -0,0 +1,7 @@ +class CreateForumPosts < ActiveRecord::Migration + def self.up + end + + def self.down + end +end diff --git a/db/migrate/007_add_spam_field_to_comments.rb b/db/migrate/007_add_spam_field_to_comments.rb new file mode 100644 index 00000000..da0ebc27 --- /dev/null +++ b/db/migrate/007_add_spam_field_to_comments.rb @@ -0,0 +1,11 @@ +class AddSpamFieldToComments < ActiveRecord::Migration + def self.up + execute("ALTER TABLE comments DROP COLUMN signal_level") + execute("ALTER TABLE comments ADD COLUMN is_spam BOOLEAN") + end + + def self.down + execute("ALTER TABLE comments ADD COLUMN signal_level") + execute("ALTER TABLE comments DROP COLUMN is_spam BOOLEAN") + end +end diff --git a/db/migrate/008_upgrade_forums.rb b/db/migrate/008_upgrade_forums.rb new file mode 100644 index 00000000..9b50d330 --- /dev/null +++ b/db/migrate/008_upgrade_forums.rb @@ -0,0 +1,15 @@ +class UpgradeForums < ActiveRecord::Migration + def self.up + execute "ALTER TABLE forum_posts ADD COLUMN reply_count INTEGER NOT NULL DEFAULT 0" + execute "ALTER TABLE forum_posts ADD COLUMN last_updated_by INTEGER REFERENCES users ON DELETE SET NULL" + execute "ALTER TABLE forum_posts ADD COLUMN is_sticky BOOLEAN NOT NULL DEFAULT FALSE" + execute "ALTER TABLE users ADD COLUMN last_seen_forum_post_id INTEGER REFERENCES forum_posts ON DELETE SET NULL" + end + + def self.down + execute "ALTER TABLE forum_posts DROP COLUMN reply_count" + execute "ALTER TABLE forum_posts DROP COLUMN last_updated_by" + execute "ALTER TABLE forum_posts DROP COLUMN is_sticky" + execute "ALTER TABLE users DROP COLUMN last_seen_forum_post_id" + end +end diff --git a/db/migrate/009_add_last_seen_forum_post_date.rb b/db/migrate/009_add_last_seen_forum_post_date.rb new file mode 100644 index 00000000..b4c39aec --- /dev/null +++ b/db/migrate/009_add_last_seen_forum_post_date.rb @@ -0,0 +1,11 @@ +class AddLastSeenForumPostDate < ActiveRecord::Migration + def self.up + execute "ALTER TABLE users DROP COLUMN last_seen_forum_post_id" + execute "ALTER TABLE users ADD COLUMN last_seen_forum_post_date TIMESTAMP NOT NULL DEFAULT now()" + end + + def self.down + execute "ALTER TABLE users ADD COLUMN last_seen_forum_post_id INTEGER REFERENCES users ON DELETE SET NULL" + execute "ALTER TABLE users DROP COLUMN last_seen_forum_post_date" + end +end diff --git a/db/migrate/010_add_user_fields.rb b/db/migrate/010_add_user_fields.rb new file mode 100644 index 00000000..bbb93696 --- /dev/null +++ b/db/migrate/010_add_user_fields.rb @@ -0,0 +1,17 @@ +class AddUserFields < ActiveRecord::Migration + def self.up + execute "ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''" + execute "ALTER TABLE users ADD COLUMN tag_blacklist TEXT NOT NULL DEFAULT ''" + execute "ALTER TABLE users ADD COLUMN user_blacklist TEXT NOT NULL DEFAULT ''" + execute "ALTER TABLE users ADD COLUMN my_tags TEXT NOT NULL DEFAULT ''" + execute "ALTER TABLE users ADD COLUMN post_threshold INTEGER NOT NULL DEFAULT -100" + end + + def self.down + execute "ALTER TABLE users DROP COLUMN email" + execute "ALTER TABLE users DROP COLUMN tag_blacklist" + execute "ALTER TABLE users DROP COLUMN user_blacklist" + execute "ALTER TABLE users DROP COLUMN my_tags" + execute "ALTER TABLE users DROP COLUMN post_threshold" + end +end diff --git a/db/migrate/011_add_invites.rb b/db/migrate/011_add_invites.rb new file mode 100644 index 00000000..999b4659 --- /dev/null +++ b/db/migrate/011_add_invites.rb @@ -0,0 +1,18 @@ +class AddInvites < ActiveRecord::Migration + def self.up + execute "ALTER TABLE users ADD COLUMN invite_count INTEGER NOT NULL DEFAULT 0" + execute <<-EOS + CREATE TABLE invites ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + activation_key TEXT NOT NULL, + invite_email TEXT NOT NULL + ) + EOS + end + + def self.down + execute "ALTER TABLE users DROP COLUMN invite_count" + execute "DROP TABLE invites" + end +end diff --git a/db/migrate/012_rename_invite_email_field.rb b/db/migrate/012_rename_invite_email_field.rb new file mode 100644 index 00000000..7a6e57cd --- /dev/null +++ b/db/migrate/012_rename_invite_email_field.rb @@ -0,0 +1,9 @@ +class RenameInviteEmailField < ActiveRecord::Migration + def self.up + execute "ALTER TABLE invites RENAME COLUMN invite_email TO email" + end + + def self.down + execute "ALTER TABLE invites RENAME COLUMN email TO invite_email" + end +end diff --git a/db/migrate/013_drop_is_ambiguous_field_from_tags.rb b/db/migrate/013_drop_is_ambiguous_field_from_tags.rb new file mode 100644 index 00000000..b016d405 --- /dev/null +++ b/db/migrate/013_drop_is_ambiguous_field_from_tags.rb @@ -0,0 +1,7 @@ +class DropIsAmbiguousFieldFromTags < ActiveRecord::Migration + def self.up + end + + def self.down + end +end diff --git a/db/migrate/014_add_pending_to_aliases_and_implications.rb b/db/migrate/014_add_pending_to_aliases_and_implications.rb new file mode 100644 index 00000000..2681db23 --- /dev/null +++ b/db/migrate/014_add_pending_to_aliases_and_implications.rb @@ -0,0 +1,11 @@ +class AddPendingToAliasesAndImplications < ActiveRecord::Migration + def self.up + execute "ALTER TABLE tag_aliases ADD COLUMN is_pending BOOLEAN NOT NULL DEFAULT FALSE" + execute "ALTER TABLE tag_implications ADD COLUMN is_pending BOOLEAN NOT NULL DEFAULT FALSE" + end + + def self.down + execute "ALTER TABLE tag_aliases DROP COLUMN is_pending" + execute "ALTER TABLE tag_implications DROP COLUMN is_pending" + end +end diff --git a/db/migrate/015_rename_implication_fields.rb b/db/migrate/015_rename_implication_fields.rb new file mode 100644 index 00000000..641a714b --- /dev/null +++ b/db/migrate/015_rename_implication_fields.rb @@ -0,0 +1,11 @@ +class RenameImplicationFields < ActiveRecord::Migration + def self.up + execute "ALTER TABLE tag_implications RENAME COLUMN parent_id TO consequent_id" + execute "ALTER TABLE tag_implications RENAME COLUMN child_id TO predicate_id" + end + + def self.down + execute "ALTER TABLE tag_implications RENAME COLUMN consequent_id TO parent_id" + execute "ALTER TABLE tag_implications RENAME COLUMN predicate_id TO child_id" + end +end diff --git a/db/migrate/016_add_forum_posts_user_views.rb b/db/migrate/016_add_forum_posts_user_views.rb new file mode 100644 index 00000000..46a60500 --- /dev/null +++ b/db/migrate/016_add_forum_posts_user_views.rb @@ -0,0 +1,18 @@ +class AddForumPostsUserViews < ActiveRecord::Migration + def self.up + execute <<-EOS + CREATE TABLE forum_posts_user_views ( + forum_post_id INTEGER NOT NULL REFERENCES forum_posts ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + last_viewed_at TIMESTAMP NOT NULL + ) + EOS + + execute "CREATE INDEX forum_posts_user_views__forum_post_id__idx ON forum_posts_user_views (forum_post_id)" + execute "CREATE INDEX forum_posts_user_views__user_id__idx ON forum_posts_user_views (user_id)" + end + + def self.down + execute "DROP TABLE forum_posts_user_views" + end +end diff --git a/db/migrate/017_drop_last_seen_forum_post_date_from_users.rb b/db/migrate/017_drop_last_seen_forum_post_date_from_users.rb new file mode 100644 index 00000000..f3b9cf7f --- /dev/null +++ b/db/migrate/017_drop_last_seen_forum_post_date_from_users.rb @@ -0,0 +1,9 @@ +class DropLastSeenForumPostDateFromUsers < ActiveRecord::Migration + def self.up + execute "ALTER TABLE users DROP COLUMN last_seen_forum_post_date" + end + + def self.down + execute "ALTER TABLE users ADD COLUMN last_seen_forum_post_date TIMESTAMP NOT NULL DEFAULT now()" + end +end diff --git a/db/migrate/018_add_constraints_to_forum_posts_user_views.rb b/db/migrate/018_add_constraints_to_forum_posts_user_views.rb new file mode 100644 index 00000000..d41b3906 --- /dev/null +++ b/db/migrate/018_add_constraints_to_forum_posts_user_views.rb @@ -0,0 +1,11 @@ +class AddConstraintsToForumPostsUserViews < ActiveRecord::Migration + def self.up + execute "ALTER TABLE forum_posts_user_views ADD CONSTRAINT forum_posts_user_views__unique_forum_post_id_user_id UNIQUE (forum_post_id, user_id)" + execute "CREATE INDEX forum_posts__parent_id_idx ON forum_posts (parent_id) WHERE parent_id IS NULL" + end + + def self.down + execute "ALTER TABLE forum_posts_user_views DROP CONSTRAINT forum_posts_user_views__unique_forum_post_id_user_id" + execute "DROP INDEX forum_posts__parent_id_idx" + end +end diff --git a/db/migrate/019_add_id_to_forum_posts_user_views.rb b/db/migrate/019_add_id_to_forum_posts_user_views.rb new file mode 100644 index 00000000..a365c66f --- /dev/null +++ b/db/migrate/019_add_id_to_forum_posts_user_views.rb @@ -0,0 +1,9 @@ +class AddIdToForumPostsUserViews < ActiveRecord::Migration + def self.up + execute "ALTER TABLE forum_posts_user_views ADD COLUMN id SERIAL PRIMARY KEY" + end + + def self.down + execute "ALTER TABLE forum_posts_user_views DROP COLUMN id" + end +end diff --git a/db/migrate/020_change_artists_a.rb b/db/migrate/020_change_artists_a.rb new file mode 100644 index 00000000..de2de0e2 --- /dev/null +++ b/db/migrate/020_change_artists_a.rb @@ -0,0 +1,16 @@ +class ChangeArtistsA < ActiveRecord::Migration + def self.up + execute "ALTER TABLE artists ADD PRIMARY KEY (id)" + execute "ALTER TABLE artists ADD COLUMN alias_id INTEGER REFERENCES artists ON DELETE SET NULL" + execute "ALTER TABLE artists ADD COLUMN group_id INTEGER REFERENCES artists ON DELETE SET NULL" + execute "ALTER TABLE artists RENAME COLUMN site_url TO url_a" + execute "ALTER TABLE artists RENAME COLUMN image_url TO url_b" + execute "ALTER TABLE artists ADD COLUMN url_c TEXT" + execute "ALTER TABLE artists ADD COLUMN name TEXT NOT NULL DEFAULT ''" + execute "ALTER TABLE artists ALTER COLUMN name DROP DEFAULT" + end + + def self.down + raise ActiveRecord::IrreversibleMigration.new + end +end diff --git a/db/migrate/021_change_artists_b.rb b/db/migrate/021_change_artists_b.rb new file mode 100644 index 00000000..94efb361 --- /dev/null +++ b/db/migrate/021_change_artists_b.rb @@ -0,0 +1,18 @@ +class ChangeArtistsB < ActiveRecord::Migration + def self.up + execute "ALTER TABLE artists DROP COLUMN japanese_name" + execute "ALTER TABLE artists DROP COLUMN personal_name" + execute "ALTER TABLE artists DROP COLUMN handle_name" + execute "ALTER TABLE artists DROP COLUMN circle_name" + execute "ALTER TABLE artists DROP COLUMN site_name" + execute "DELETE FROM artists WHERE name = ''" + execute "ALTER TABLE artists ADD CONSTRAINT artists_name_uniq UNIQUE (name)" + execute "CREATE INDEX artists_url_a_idx ON artists (url_a)" + execute "CREATE INDEX artists_url_b_idx ON artists (url_b) WHERE url_b IS NOT NULL" + execute "CREATE INDEX artists_url_c_idx ON artists (url_c) WHERE url_c IS NOT NULL" + end + + def self.down + raise ActiveRecord::IrreversibleMigration.new + end +end diff --git a/db/migrate/022_add_notes_to_artists.rb b/db/migrate/022_add_notes_to_artists.rb new file mode 100644 index 00000000..65f43a9a --- /dev/null +++ b/db/migrate/022_add_notes_to_artists.rb @@ -0,0 +1,9 @@ +class AddNotesToArtists < ActiveRecord::Migration + def self.up + execute "alter table artists add column notes text not null default ''" + end + + def self.down + execute "alter table artists drop column notes" + end +end diff --git a/db/migrate/023_add_updated_at_to_artists.rb b/db/migrate/023_add_updated_at_to_artists.rb new file mode 100644 index 00000000..1b3b81a9 --- /dev/null +++ b/db/migrate/023_add_updated_at_to_artists.rb @@ -0,0 +1,9 @@ +class AddUpdatedAtToArtists < ActiveRecord::Migration + def self.up + execute "ALTER TABLE artists ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT now()" + end + + def self.down + execute "ALTER TABLE artists DROP COLUMN updated_at" + end +end diff --git a/db/migrate/024_drop_extra_indexes_on_artists.rb b/db/migrate/024_drop_extra_indexes_on_artists.rb new file mode 100644 index 00000000..3e701a11 --- /dev/null +++ b/db/migrate/024_drop_extra_indexes_on_artists.rb @@ -0,0 +1,11 @@ +class DropExtraIndexesOnArtists < ActiveRecord::Migration + def self.up + execute "DROP INDEX idx_artists__image_url" + execute "DROP INDEX idx_favorites__post_user" + end + + def self.down + execute "CREATE INDEX idx_artists__image_url ON artists (url_b)" + execute "CREATE INDEX idx_favorites__post_user ON favorites (post_id, user_id)" + end +end diff --git a/db/migrate/025_add_updater_id_to_artists.rb b/db/migrate/025_add_updater_id_to_artists.rb new file mode 100644 index 00000000..209494a1 --- /dev/null +++ b/db/migrate/025_add_updater_id_to_artists.rb @@ -0,0 +1,9 @@ +class AddUpdaterIdToArtists < ActiveRecord::Migration + def self.up + execute "ALTER TABLE artists ADD COLUMN updater_id INTEGER REFERENCES users ON DELETE SET NULL" + end + + def self.down + execute "ALTER TABLE artists DROP COLUMN updater_id" + end +end diff --git a/db/migrate/026_add_ambiguous_field_to_tags.rb b/db/migrate/026_add_ambiguous_field_to_tags.rb new file mode 100644 index 00000000..4affbc3a --- /dev/null +++ b/db/migrate/026_add_ambiguous_field_to_tags.rb @@ -0,0 +1,10 @@ +class AddAmbiguousFieldToTags < ActiveRecord::Migration + def self.up + execute "alter table tags add column is_ambiguous boolean not null default false" + execute "update tags set is_ambiguous = true where tag_type = 2" + execute "update tags set tag_type = 0 where tag_type = 2" + end + + def self.down + end +end diff --git a/db/migrate/027_add_response_count_to_forum.rb b/db/migrate/027_add_response_count_to_forum.rb new file mode 100644 index 00000000..5e535214 --- /dev/null +++ b/db/migrate/027_add_response_count_to_forum.rb @@ -0,0 +1,9 @@ +class AddResponseCountToForum < ActiveRecord::Migration + def self.up + execute "ALTER TABLE forum_posts ADD COLUMN response_count INTEGER NOT NULL DEFAULT 0" + end + + def self.down + execute "ALTER TABLE forum_posts DROP COLUMN response_count" + end +end diff --git a/db/migrate/028_add_image_resize_user_setting.rb b/db/migrate/028_add_image_resize_user_setting.rb new file mode 100644 index 00000000..4d819563 --- /dev/null +++ b/db/migrate/028_add_image_resize_user_setting.rb @@ -0,0 +1,9 @@ +class AddImageResizeUserSetting < ActiveRecord::Migration + def self.up + execute "ALTER TABLE users ADD COLUMN always_resize_images BOOLEAN NOT NULL DEFAULT FALSE" + end + + def self.down + execute "ALTER TABLE users DROP COLUMN always_resize_images" + end +end diff --git a/db/migrate/029_add_safe_post_count_to_tags.rb b/db/migrate/029_add_safe_post_count_to_tags.rb new file mode 100644 index 00000000..039223dd --- /dev/null +++ b/db/migrate/029_add_safe_post_count_to_tags.rb @@ -0,0 +1,53 @@ +class AddSafePostCountToTags < ActiveRecord::Migration + def self.up + execute "ALTER TABLE tags ADD COLUMN safe_post_count INTEGER NOT NULL DEFAULT 0" + execute "UPDATE tags SET safe_post_count = (SELECT COUNT(*) FROM posts p, posts_tags pt WHERE p.id = pt.post_id AND pt.tag_id = tags.id AND p.rating = 's')" + execute "DROP TRIGGER trg_posts_tags__delete ON posts_tags" + execute "DROP TRIGGER trg_posts_tags__insert ON posts_tags" + execute "INSERT INTO table_data (name, row_count) VALUES ('safe_posts', (SELECT COUNT(*) FROM posts WHERE rating = 's'))" + execute <<-EOS + CREATE OR REPLACE FUNCTION trg_posts_tags__delete() RETURNS "trigger" AS $$ + BEGIN + UPDATE tags SET post_count = post_count - 1 WHERE tags.id = OLD.tag_id; + UPDATE tags SET safe_post_count = safe_post_count - 1 FROM posts WHERE tags.id = OLD.tag_id AND OLD.post_id = posts.id AND posts.rating = 's'; + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + EOS + execute <<-EOS + CREATE OR REPLACE FUNCTION trg_posts_tags__insert() RETURNS "trigger" AS $$ + BEGIN + UPDATE tags SET post_count = post_count + 1 WHERE tags.id = NEW.tag_id; + UPDATE tags SET safe_post_count = safe_post_count + 1 FROM posts WHERE tags.id = NEW.tag_id AND NEW.post_id = posts.id AND posts.rating = 's'; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + EOS + execute "CREATE TRIGGER trg_posts_tags__delete BEFORE DELETE ON posts_tags FOR EACH ROW EXECUTE PROCEDURE trg_posts_tags__delete()" + execute "CREATE TRIGGER trg_posts_tags__insert BEFORE INSERT ON posts_tags FOR EACH ROW EXECUTE PROCEDURE trg_posts_tags__insert()" + end + + def self.down + execute "ALTER TABLE tags DROP COLUMN safe_post_count" + execute "DROP TRIGGER trg_posts_tags__delete ON posts_tags" + execute "DROP TRIGGER trg_posts_tags__insert ON posts_tags" + execute <<-EOS + CREATE OR REPLACE FUNCTION trg_posts_tags__delete() RETURNS "trigger" AS $$ + BEGIN + UPDATE tags SET post_count = post_count - 1 WHERE tags.id = OLD.tag_id; + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + EOS + execute <<-EOS + CREATE OR REPLACE FUNCTION trg_posts_tags__insert() RETURNS "trigger" AS $$ + BEGIN + UPDATE tags SET post_count = post_count + 1 WHERE tags.id = NEW.tag_id; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + EOS + execute "CREATE TRIGGER trg_posts_tags__delete BEFORE DELETE ON posts_tags FOR EACH ROW EXECUTE PROCEDURE trg_posts_tags__delete()" + execute "CREATE TRIGGER trg_posts_tags__insert BEFORE INSERT ON posts_tags FOR EACH ROW EXECUTE PROCEDURE trg_posts_tags__insert()" + end +end diff --git a/db/migrate/030_add_invited_by_to_users.rb b/db/migrate/030_add_invited_by_to_users.rb new file mode 100644 index 00000000..76688e99 --- /dev/null +++ b/db/migrate/030_add_invited_by_to_users.rb @@ -0,0 +1,9 @@ +class AddInvitedByToUsers < ActiveRecord::Migration + def self.up + execute "ALTER TABLE users ADD COLUMN invited_by INTEGER" + end + + def self.down + execute "ALTER TABLE users DROP COLUMN invited_by" + end +end diff --git a/db/migrate/031_create_news_updates.rb b/db/migrate/031_create_news_updates.rb new file mode 100644 index 00000000..0c45be6d --- /dev/null +++ b/db/migrate/031_create_news_updates.rb @@ -0,0 +1,18 @@ +class CreateNewsUpdates < ActiveRecord::Migration + def self.up + execute <<-EOS + CREATE TABLE news_updates ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT NOT NULL + ) + EOS + end + + def self.down + execute "DROP TABLE news_updates" + end +end diff --git a/db/migrate/032_forum_posts_fix_creator_id.rb b/db/migrate/032_forum_posts_fix_creator_id.rb new file mode 100644 index 00000000..15ad860c --- /dev/null +++ b/db/migrate/032_forum_posts_fix_creator_id.rb @@ -0,0 +1,10 @@ +class ForumPostsFixCreatorId < ActiveRecord::Migration + def self.up + execute "alter table forum_posts drop constraint forum_posts_creator_id_fkey" + execute "alter table forum_posts alter column creator_id drop not null" + execute "alter table forum_posts add foreign key (creator_id) references users on delete set null" + end + + def self.down + end +end diff --git a/db/migrate/033_posts_add_is_flagged.rb b/db/migrate/033_posts_add_is_flagged.rb new file mode 100644 index 00000000..c9119526 --- /dev/null +++ b/db/migrate/033_posts_add_is_flagged.rb @@ -0,0 +1,9 @@ +class PostsAddIsFlagged < ActiveRecord::Migration + def self.up + execute "alter table posts add column is_flagged boolean not null default false" + end + + def self.down + execute "alter table posts drop column is_flagged" + end +end diff --git a/db/migrate/034_pools_create.rb b/db/migrate/034_pools_create.rb new file mode 100644 index 00000000..3ce5ced1 --- /dev/null +++ b/db/migrate/034_pools_create.rb @@ -0,0 +1,72 @@ +class PoolsCreate < ActiveRecord::Migration + def self.up + ActiveRecord::Base.transaction do + execute <<-EOS + CREATE TABLE pools ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + is_public BOOLEAN NOT NULL DEFAULT FALSE, + post_count INTEGER NOT NULL DEFAULT 0, + description TEXT NOT NULL DEFAULT '' + ) + EOS + execute <<-EOS + CREATE TABLE pools_posts ( + id SERIAL PRIMARY KEY, + sequence INTEGER NOT NULL DEFAULT 0, + pool_id INTEGER NOT NULL REFERENCES pools ON DELETE CASCADE, + post_id INTEGER NOT NULL REFERENCES posts ON DELETE CASCADE + ) + EOS + execute <<-EOS + CREATE OR REPLACE FUNCTION pools_posts_delete_trg() RETURNS "trigger" AS $$ + BEGIN + UPDATE pools SET post_count = post_count - 1 WHERE id = OLD.pool_id; + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + EOS + execute <<-EOS + CREATE OR REPLACE FUNCTION pools_posts_insert_trg() RETURNS "trigger" AS $$ + BEGIN + UPDATE pools SET post_count = post_count + 1 WHERE id = NEW.pool_id; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + EOS + execute <<-EOS + CREATE TRIGGER pools_posts_insert_trg + BEFORE INSERT ON pools_posts + FOR EACH ROW + EXECUTE PROCEDURE pools_posts_insert_trg(); + EOS + execute <<-EOS + CREATE TRIGGER pools_posts_delete_trg + BEFORE DELETE ON pools_posts + FOR EACH ROW + EXECUTE PROCEDURE pools_posts_delete_trg(); + EOS + execute <<-EOS + CREATE INDEX pools_user_id_idx ON pools (user_id) + EOS + execute <<-EOS + CREATE INDEX pools_posts_pool_id_idx ON pools_posts (pool_id) + EOS + execute <<-EOS + CREATE INDEX pools_posts_post_id_idx ON pools_posts (post_id) + EOS + end + end + + def self.down + ActiveRecord::Base.transaction do + execute "DROP TABLE pools_posts" + execute "DROP TABLE pools" + execute "DROP FUNCTION pools_posts_insert_trg()" + execute "DROP FUNCTION pools_posts_delete_trg()" + end + end +end diff --git a/db/migrate/035_users_rename_password.rb b/db/migrate/035_users_rename_password.rb new file mode 100644 index 00000000..a272999c --- /dev/null +++ b/db/migrate/035_users_rename_password.rb @@ -0,0 +1,9 @@ +class UsersRenamePassword < ActiveRecord::Migration + def self.up + rename_column :users, :password, :password_hash + end + + def self.down + rename_column :users, :password_hash, :password + end +end diff --git a/db/migrate/036_users_update_level.rb b/db/migrate/036_users_update_level.rb new file mode 100644 index 00000000..ffa5a585 --- /dev/null +++ b/db/migrate/036_users_update_level.rb @@ -0,0 +1,8 @@ +class UsersUpdateLevel < ActiveRecord::Migration + def self.up + execute "update users set level = 3 where level = 2" + end + + def self.down + end +end diff --git a/db/migrate/037_users_add_created_at.rb b/db/migrate/037_users_add_created_at.rb new file mode 100644 index 00000000..80726ca7 --- /dev/null +++ b/db/migrate/037_users_add_created_at.rb @@ -0,0 +1,9 @@ +class UsersAddCreatedAt < ActiveRecord::Migration + def self.up + execute "ALTER TABLE users ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT now()" + end + + def self.down + execute "ALTER TABLE users DROP COLUMN created_at" + end +end diff --git a/db/migrate/038_favorites_add_created_at.rb b/db/migrate/038_favorites_add_created_at.rb new file mode 100644 index 00000000..8f70fb88 --- /dev/null +++ b/db/migrate/038_favorites_add_created_at.rb @@ -0,0 +1,10 @@ +class FavoritesAddCreatedAt < ActiveRecord::Migration + def self.up + execute "alter table favorites add column created_at timestamp not null default now()" + execute "update favorites set created_at = (select created_at from posts where id = favorites.post_id)" + end + + def self.down + execute "alter table favorites drop column created_at" + end +end diff --git a/db/migrate/039_posts_add_is_pending.rb b/db/migrate/039_posts_add_is_pending.rb new file mode 100644 index 00000000..38a0b9ac --- /dev/null +++ b/db/migrate/039_posts_add_is_pending.rb @@ -0,0 +1,9 @@ +class PostsAddIsPending < ActiveRecord::Migration + def self.up + execute "alter table posts add column is_pending boolean not null default false" + end + + def self.down + execute "alter table posts drop column is_pending" + end +end diff --git a/db/migrate/040_cleanup.rb b/db/migrate/040_cleanup.rb new file mode 100644 index 00000000..e10b864f --- /dev/null +++ b/db/migrate/040_cleanup.rb @@ -0,0 +1,19 @@ +class Cleanup < ActiveRecord::Migration + def self.up + remove_column :forum_posts, :reply_count + remove_column :users, :user_blacklist + remove_column :users, :post_threshold + drop_table :invites + drop_table :news_updates + end + + def self.down + add_column :forum_posts, :reply_count, :integer, :null => false, :default => 0 + add_column :users, :user_blacklist, :text, :null => false, :default => "" + add_column :users, :post_threshold, :integer, :null => false, :default => -100 + create_table :invites do |t| + end + create_table :news_updates do |t| + end + end +end diff --git a/db/migrate/041_users_add_ip_addr.rb b/db/migrate/041_users_add_ip_addr.rb new file mode 100644 index 00000000..497cd46d --- /dev/null +++ b/db/migrate/041_users_add_ip_addr.rb @@ -0,0 +1,11 @@ +class UsersAddIpAddr < ActiveRecord::Migration + def self.up + execute "alter table users add column ip_addr text not null default ''" + execute "alter table users add column last_logged_in_at timestamp not null default now()" + end + + def self.down + execute "alter table users drop column ip_addr" + execute "alter table users drop column last_logged_in_at" + end +end diff --git a/db/migrate/042_note_versions_add_index_on_user_id.rb b/db/migrate/042_note_versions_add_index_on_user_id.rb new file mode 100644 index 00000000..27a64cd2 --- /dev/null +++ b/db/migrate/042_note_versions_add_index_on_user_id.rb @@ -0,0 +1,9 @@ +class NoteVersionsAddIndexOnUserId < ActiveRecord::Migration + def self.up + add_index :note_versions, :user_id + end + + def self.down + remove_index :note_versions, :user_id + end +end diff --git a/db/migrate/043_flagged_posts_create.rb b/db/migrate/043_flagged_posts_create.rb new file mode 100644 index 00000000..a1c13bb3 --- /dev/null +++ b/db/migrate/043_flagged_posts_create.rb @@ -0,0 +1,19 @@ +class FlaggedPostsCreate < ActiveRecord::Migration + def self.up + execute <<-EOS + create table flagged_posts ( + id serial primary key, + created_at timestamp not null default now(), + post_id integer not null references posts on delete cascade, + reason text not null + ) + EOS + + execute "alter table posts drop column is_flagged" + end + + def self.down + execute "alter table posts add column is_flagged boolean not null default false" + execute "drop table flagged_posts" + end +end diff --git a/db/migrate/044_forum_add_is_locked.rb b/db/migrate/044_forum_add_is_locked.rb new file mode 100644 index 00000000..b5a05e3e --- /dev/null +++ b/db/migrate/044_forum_add_is_locked.rb @@ -0,0 +1,30 @@ +class ForumAddIsLocked < ActiveRecord::Migration + def self.up + transaction do + add_column :forum_posts, :is_locked, :boolean, :null => false, :default => false + execute "alter table users add column last_forum_topic_read_at timestamp not null default '1960-01-01'" + drop_table :forum_posts_user_views + add_index :forum_posts, :updated_at + end + end + + def self.down + transaction do + remove_column :forum_posts, :is_locked + remove_column :users, :last_forum_topic_read_at + remove_index :forum_posts, :updated_at + execute <<-EOS + CREATE TABLE forum_posts_user_views ( + id serial primary key, + forum_post_id INTEGER NOT NULL REFERENCES forum_posts ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + last_viewed_at TIMESTAMP NOT NULL + ) + EOS + + execute "CREATE INDEX forum_posts_user_views__forum_post_id__idx ON forum_posts_user_views (forum_post_id)" + execute "CREATE INDEX forum_posts_user_views__user_id__idx ON forum_posts_user_views (user_id)" + execute "ALTER TABLE forum_posts_user_views ADD CONSTRAINT forum_posts_user_views__unique_forum_post_id_user_id UNIQUE (forum_post_id, user_id)" + end + end +end diff --git a/db/migrate/045_user_records_create.rb b/db/migrate/045_user_records_create.rb new file mode 100644 index 00000000..5eb48f30 --- /dev/null +++ b/db/migrate/045_user_records_create.rb @@ -0,0 +1,18 @@ +class UserRecordsCreate < ActiveRecord::Migration + def self.up + execute <<-EOS + create table user_records ( + id serial primary key, + user_id integer not null references users on delete cascade, + reported_by integer not null references users on delete cascade, + created_at timestamp not null default now(), + is_positive boolean not null default true, + body text not null + ) + EOS + end + + def self.down + drop_table :user_records + end +end diff --git a/db/migrate/046_posts_tags_add_foreign_keys.rb b/db/migrate/046_posts_tags_add_foreign_keys.rb new file mode 100644 index 00000000..c0818ee1 --- /dev/null +++ b/db/migrate/046_posts_tags_add_foreign_keys.rb @@ -0,0 +1,30 @@ +class PostsTagsAddForeignKeys < ActiveRecord::Migration + def self.up + transaction do + execute "update tags set post_count = (select count(*) from posts_tags pt where pt.tag_id = tags.id)" + execute "update tags set safe_post_count = (select count(*) from posts_tags pt, posts p where pt.tag_id = tags.id and pt.post_id = p.id and p.rating = 's')" + + begin + execute "alter table posts_tags add constraint fk_posts_tags__post foreign key (post_id) references posts on delete cascade" + rescue Exception + end + + begin + execute "alter table posts_tags add constraint fk_posts_tags__tag foreign key (tag_id) references tags on delete cascade" + rescue Exception + end + end + end + + def self.down + begin + execute "alter table posts_tags drop constraint fk_posts_tags__post" + rescue Exception + end + + begin + execute "alter table posts_tags drop constraint fk_posts_tags__tag" + rescue Exception + end + end +end diff --git a/db/migrate/047_posts_add_parent_id.rb b/db/migrate/047_posts_add_parent_id.rb new file mode 100644 index 00000000..d1a1fb80 --- /dev/null +++ b/db/migrate/047_posts_add_parent_id.rb @@ -0,0 +1,10 @@ +class PostsAddParentId < ActiveRecord::Migration + def self.up + execute "alter table posts add column parent_id integer references posts on delete set null" + execute "create index idx_posts_parent_id on posts (parent_id) where parent_id is not null" + end + + def self.down + execute "alter table posts drop column parent_id" + end +end diff --git a/db/migrate/048_posts_add_has_children.rb b/db/migrate/048_posts_add_has_children.rb new file mode 100644 index 00000000..277478c7 --- /dev/null +++ b/db/migrate/048_posts_add_has_children.rb @@ -0,0 +1,9 @@ +class PostsAddHasChildren < ActiveRecord::Migration + def self.up + execute "alter table posts add column has_children boolean not null default false" + end + + def self.down + execute "alter table posts drop column has_children" + end +end diff --git a/db/migrate/049_flagged_posts_drop_foreign_key.rb b/db/migrate/049_flagged_posts_drop_foreign_key.rb new file mode 100644 index 00000000..966dff23 --- /dev/null +++ b/db/migrate/049_flagged_posts_drop_foreign_key.rb @@ -0,0 +1,9 @@ +class FlaggedPostsDropForeignKey < ActiveRecord::Migration + def self.up + execute "alter table flagged_posts drop constraint flagged_posts_post_id_fkey" + end + + def self.down + execute "alter table flagged_posts add constraint flagged_posts_post_id_fkey foreign key (post_id) references posts (id) on delete cascade" + end +end diff --git a/db/migrate/050_posts_tags_fix_foreign_keys.rb b/db/migrate/050_posts_tags_fix_foreign_keys.rb new file mode 100644 index 00000000..eb52ae11 --- /dev/null +++ b/db/migrate/050_posts_tags_fix_foreign_keys.rb @@ -0,0 +1,25 @@ +class PostsTagsFixForeignKeys < ActiveRecord::Migration + def self.up + begin + execute "alter table posts_tags add constraint fk_posts_tags__post foreign key (post_id) references posts on delete cascade" + rescue Exception + end + + begin + execute "alter table posts_tags add constraint fk_posts_tags__tag foreign key (tag_id) references tags on delete cascade" + rescue Exception + end + end + + def self.down + begin + execute "alter table posts_tags drop constraint fk_posts_tags__post" + rescue Exception + end + + begin + execute "alter table posts_tags drop constraint fk_posts_tags__tag" + rescue Exception + end + end +end diff --git a/db/migrate/051_posts_drop_has_children.rb b/db/migrate/051_posts_drop_has_children.rb new file mode 100644 index 00000000..42297fec --- /dev/null +++ b/db/migrate/051_posts_drop_has_children.rb @@ -0,0 +1,9 @@ +class PostsDropHasChildren < ActiveRecord::Migration + def self.up + #execute "alter table posts drop column has_children" + end + + def self.down + #execute "alter table posts add column has_children boolean not null default false" + end +end diff --git a/db/migrate/052_flagged_posts_add_user_id.rb b/db/migrate/052_flagged_posts_add_user_id.rb new file mode 100644 index 00000000..72292fd2 --- /dev/null +++ b/db/migrate/052_flagged_posts_add_user_id.rb @@ -0,0 +1,12 @@ +class FlaggedPostsAddUserId < ActiveRecord::Migration + def self.up + execute "alter table flagged_posts add column user_id integer references users on delete cascade" + execute "alter table flagged_posts add column is_resolved boolean not null default false" + execute "update flagged_posts set is_resolved = false" + end + + def self.down + execute "alter table flagged_posts drop column user_id" + execute "alter table flagged_posts drop column is_resolved" + end +end diff --git a/db/migrate/053_convert_ip_text_to_inet.rb b/db/migrate/053_convert_ip_text_to_inet.rb new file mode 100644 index 00000000..a6da2f52 --- /dev/null +++ b/db/migrate/053_convert_ip_text_to_inet.rb @@ -0,0 +1,35 @@ +class ConvertIpTextToInet < ActiveRecord::Migration + def self.up + transaction do + execute "update posts set last_voter_ip = null where last_voter_ip = ''" + execute "update post_tag_histories set ip_addr = '127.0.0.1' where ip_addr = ''" + execute "alter table users alter column ip_addr drop default" + execute "update users set ip_addr = '127.0.0.1' where ip_addr = ''" + execute "update comments set ip_addr = '127.0.0.1' where ip_addr = 'unknown'" + execute "alter table posts alter column last_voter_ip type inet using inet(last_voter_ip)" + execute "alter table posts alter column ip_addr type inet using inet(ip_addr)" + execute "alter table comments alter column ip_addr type inet using inet(ip_addr)" + execute "alter table note_versions alter column ip_addr type inet using inet(ip_addr)" + execute "alter table notes alter column ip_addr type inet using inet(ip_addr)" + execute "alter table post_tag_histories alter column ip_addr type inet using inet(ip_addr)" + execute "alter table users alter column ip_addr type inet using inet(ip_addr)" + execute "alter table wiki_page_versions alter column ip_addr type inet using inet(ip_addr)" + execute "alter table wiki_pages alter column ip_addr type inet using inet(ip_addr)" + end + end + + def self.down + transaction do + execute "alter table posts alter column last_voter_ip type text" + execute "alter table posts alter column ip_addr type text" + execute "alter table comments alter column ip_addr type text" + execute "alter table note_versions alter column ip_addr type text" + execute "alter table notes alter column ip_addr type text" + execute "alter table post_tag_histories alter column ip_addr type text" + execute "alter table users alter column ip_addr type text" + execute "alter table wiki_page_versions alter column ip_addr type text" + execute "alter table wiki_pages alter column ip_addr type text" + execute "alter table users alter column ip_addr set default ''" + end + end +end diff --git a/db/migrate/054_posts_add_status.rb b/db/migrate/054_posts_add_status.rb new file mode 100644 index 00000000..d079304e --- /dev/null +++ b/db/migrate/054_posts_add_status.rb @@ -0,0 +1,20 @@ +class PostsAddStatus < ActiveRecord::Migration + def self.up + transaction do + execute "create type post_status as enum ('deleted', 'flagged', 'pending', 'active')" + execute "alter table posts add column status post_status not null default 'active'" + execute "update posts set status = 'pending' where is_pending = true" + execute "alter table posts drop column is_pending" + execute "update posts set status = 'flagged' where id in (select post_id from flagged_posts)" + execute "alter table posts add column deletion_reason text not null default ''" + execute "update posts set deletion_reason = (select reason from flagged_posts where post_id = posts.id) where id in (select post_id from flagged_posts)" + execute "drop table flagged_posts" + execute "create index post_status_idx on posts (status) where status < 'active'" + end + end + + def self.down + # I'm lazy + raise IrreversibleMigration + end +end diff --git a/db/migrate/055_add_full_text_search.rb b/db/migrate/055_add_full_text_search.rb new file mode 100644 index 00000000..e9b4715f --- /dev/null +++ b/db/migrate/055_add_full_text_search.rb @@ -0,0 +1,24 @@ +class AddFullTextSearch < ActiveRecord::Migration + def self.up + transaction do + execute "alter table notes add column text_search_index tsvector" + execute "update notes set text_search_index = to_tsvector('english', body)" + execute "create trigger trg_note_search_update before insert or update on notes for each row execute procedure tsvector_update_trigger(text_search_index, 'pg_catalog.english', body)" + execute "create index notes_text_search_idx on notes using gin(text_search_index)" + + execute "alter table wiki_pages add column text_search_index tsvector" + execute "update wiki_pages set text_search_index = to_tsvector('english', title || ' ' || body)" + execute "create trigger trg_wiki_page_search_update before insert or update on wiki_pages for each row execute procedure tsvector_update_trigger(text_search_index, 'pg_catalog.english', title, body)" + execute "create index wiki_pages_search_idx on wiki_pages using gin(text_search_index)" + + execute "alter table forum_posts add column text_search_index tsvector" + execute "update forum_posts set text_search_index = to_tsvector('english', title || ' ' || body)" + execute "create trigger trg_forum_post_search_update before insert or update on forum_posts for each row execute procedure tsvector_update_trigger(text_search_index, 'pg_catalog.english', title, body)" + execute "create index forum_posts_search_idx on forum_posts using gin(text_search_index)" + end + end + + def self.down + raise IrreversibleMigration + end +end diff --git a/db/migrate/056_add_text_search_to_versions.rb b/db/migrate/056_add_text_search_to_versions.rb new file mode 100644 index 00000000..d848179d --- /dev/null +++ b/db/migrate/056_add_text_search_to_versions.rb @@ -0,0 +1,10 @@ +class AddTextSearchToVersions < ActiveRecord::Migration + def self.up + execute "alter table note_versions add column text_search_index tsvector" + execute "alter table wiki_page_versions add column text_search_index tsvector" + end + + def self.down + raise IrreversibleMigration + end +end diff --git a/db/migrate/057_drop_post_count_triggers.rb b/db/migrate/057_drop_post_count_triggers.rb new file mode 100644 index 00000000..5db53b35 --- /dev/null +++ b/db/migrate/057_drop_post_count_triggers.rb @@ -0,0 +1,39 @@ +class DropPostCountTriggers < ActiveRecord::Migration + def self.up + execute "drop trigger trg_posts__insert on posts" + execute "drop trigger trg_posts_delete on posts" + execute "drop function trg_posts__insert()" + execute "drop function trg_posts__delete()" + execute "drop trigger trg_users_delete on users" + execute "drop trigger trg_users_insert on users" + execute "drop function trg_users__delete()" + execute "drop function trg_users__insert()" + execute "insert into table_data (name, row_count) values ('non-explicit_posts', (select count(*) from posts where rating <> 'e'))" + execute "delete from table_data where name = 'safe_posts'" + execute "drop trigger trg_posts_tags__delete on posts_tags" + execute "drop trigger trg_posts_tags__insert on posts_tags" + execute <<-EOS + CREATE OR REPLACE FUNCTION trg_posts_tags__delete() RETURNS "trigger" AS $$ + BEGIN + UPDATE tags SET post_count = post_count - 1 WHERE tags.id = OLD.tag_id; + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + EOS + execute <<-EOS + CREATE OR REPLACE FUNCTION trg_posts_tags__insert() RETURNS "trigger" AS $$ + BEGIN + UPDATE tags SET post_count = post_count + 1 WHERE tags.id = NEW.tag_id; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + EOS + execute "CREATE TRIGGER trg_posts_tags__delete BEFORE DELETE ON posts_tags FOR EACH ROW EXECUTE PROCEDURE trg_posts_tags__delete()" + execute "CREATE TRIGGER trg_posts_tags__insert BEFORE INSERT ON posts_tags FOR EACH ROW EXECUTE PROCEDURE trg_posts_tags__insert()" + execute "alter table tags drop column safe_post_count" + end + + def self.down + raise IrreversibleMigration + end +end diff --git a/db/migrate/058_remove_notes_from_artists.rb b/db/migrate/058_remove_notes_from_artists.rb new file mode 100644 index 00000000..99aa2931 --- /dev/null +++ b/db/migrate/058_remove_notes_from_artists.rb @@ -0,0 +1,20 @@ +class RemoveNotesFromArtists < ActiveRecord::Migration + def self.up + Artist.find(:all, :conditions => ["notes <> '' and notes is not null"]).each do |artist| + page = WikiPage.find_by_title(artist.name) + notes = artist.__send__(:read_attribute, :notes) + + if page + page.update_attributes(:body => notes, :ip_addr => '127.0.0.1', :user_id => 1) + else + page = WikiPage.create(:title => artist.name, :body => notes, :ip_addr => '127.0.0.1', :user_id => 1) + end + end + + remove_column :artists, :notes + end + + def self.down + add_column :artists, :notes, :text, :default => "", :null => false + end +end diff --git a/db/migrate/059_create_dmails.rb b/db/migrate/059_create_dmails.rb new file mode 100644 index 00000000..f750bf12 --- /dev/null +++ b/db/migrate/059_create_dmails.rb @@ -0,0 +1,26 @@ +class CreateDmails < ActiveRecord::Migration + def self.up + transaction do + create_table :dmails do |t| + t.column :from_id, :integer, :null => false + t.foreign_key :from_id, :users, :id, :on_delete => :cascade + t.column :to_id, :integer, :null => false + t.foreign_key :to_id, :users, :id, :on_delete => :cascade + t.column :title, :text, :null => false + t.column :body, :text, :null => false + t.column :created_at, :timestamp, :null => false + t.column :has_seen, :boolean, :null => false, :default => false + end + + add_index :dmails, :from_id + add_index :dmails, :to_id + + add_column :users, :has_mail, :boolean, :default => false, :null => false + end + end + + def self.down + drop_table :dmails + remove_column :users, :has_mail + end +end diff --git a/db/migrate/060_add_receive_mails_to_users.rb b/db/migrate/060_add_receive_mails_to_users.rb new file mode 100644 index 00000000..31a3e67f --- /dev/null +++ b/db/migrate/060_add_receive_mails_to_users.rb @@ -0,0 +1,9 @@ +class AddReceiveMailsToUsers < ActiveRecord::Migration + def self.up + add_column :users, :receive_dmails, :boolean, :default => false, :null => false + end + + def self.down + remove_column :users, :receive_dmails + end +end diff --git a/db/migrate/061_enhance_dmails.rb b/db/migrate/061_enhance_dmails.rb new file mode 100644 index 00000000..b4b4e9d7 --- /dev/null +++ b/db/migrate/061_enhance_dmails.rb @@ -0,0 +1,11 @@ +class EnhanceDmails < ActiveRecord::Migration + def self.up + add_column :dmails, :parent_id, :integer + add_foreign_key :dmails, :parent_id, :dmails, :id + add_index :dmails, :parent_id + end + + def self.down + remove_column :dmails, :parent_id + end +end diff --git a/db/migrate/062_create_bans.rb b/db/migrate/062_create_bans.rb new file mode 100644 index 00000000..724165a8 --- /dev/null +++ b/db/migrate/062_create_bans.rb @@ -0,0 +1,23 @@ +class CreateBans < ActiveRecord::Migration + def self.up + create_table :bans do |t| + t.column :user_id, :integer, :null => false + t.foreign_key :user_id, :users, :id, :on_delete => :cascade + t.column :reason, :text, :null => false + t.column :expires_at, :datetime, :null => false + t.column :banned_by, :integer, :null => false + t.foreign_key :banned_by, :users, :id, :on_delete => :cascade + end + + add_index :bans, :user_id + + User.find(:all, :conditions => ["level = 0 or level = 1"]).each do |user| + user.update_attribute(:level, User::LEVEL_BLOCKED) + Ban.create(:user_id => user.id, :reason => "Grandfathered", :banned_by => 1, :expires_at => 7.days.from_now) + end + end + + def self.down + drop_table :bans + end +end diff --git a/db/migrate/063_add_blacklisted_tags_to_users.rb b/db/migrate/063_add_blacklisted_tags_to_users.rb new file mode 100644 index 00000000..a458b5d3 --- /dev/null +++ b/db/migrate/063_add_blacklisted_tags_to_users.rb @@ -0,0 +1,9 @@ +class AddBlacklistedTagsToUsers < ActiveRecord::Migration + def self.up + add_column :users, :blacklisted_tags, :text, :null => false, :default => "" + end + + def self.down + remove_column :users, :blacklisted_tags + end +end diff --git a/db/migrate/064_remove_neighbor_constraints.rb b/db/migrate/064_remove_neighbor_constraints.rb new file mode 100644 index 00000000..de005f77 --- /dev/null +++ b/db/migrate/064_remove_neighbor_constraints.rb @@ -0,0 +1,11 @@ +class RemoveNeighborConstraints < ActiveRecord::Migration + def self.up + remove_foreign_key :posts, :posts_next_post_id_fkey + remove_foreign_key :posts, :posts_prev_post_id_fkey + end + + def self.down + add_foreign_key :posts, :next_post_id, :posts, :id, :on_delete => :set_null + add_foreign_key :posts, :prev_post_id, :posts, :id, :on_delete => :set_null + end +end diff --git a/db/migrate/065_remove_yaml.rb b/db/migrate/065_remove_yaml.rb new file mode 100644 index 00000000..21388dc6 --- /dev/null +++ b/db/migrate/065_remove_yaml.rb @@ -0,0 +1,14 @@ +require 'yaml' + +class RemoveYaml < ActiveRecord::Migration + def self.up + Tag.find(:all).each do |tag| + mapping = YAML::load(tag.cached_related) + tag.cached_related = mapping.flatten.join(",") + tag.save + end + end + + def self.down + end +end diff --git a/db/migrate/066_fix_user_levels.rb b/db/migrate/066_fix_user_levels.rb new file mode 100644 index 00000000..802403e5 --- /dev/null +++ b/db/migrate/066_fix_user_levels.rb @@ -0,0 +1,19 @@ +class FixUserLevels < ActiveRecord::Migration + def self.up + execute("UPDATE users SET level = 50 WHERE level = 20") + execute("UPDATE users SET level = 40 WHERE level = 10") + execute("UPDATE users SET level = 30 WHERE level = 3") + execute("UPDATE users SET level = 20 WHERE level = 2") + execute("UPDATE users SET level = 10 WHERE level = 0") + execute("UPDATE users SET level = 0 WHERE Level = -1") + end + + def self.down + execute("UPDATE users SET level = -1 WHERE level = 0") + execute("UPDATE users SET level = 0 WHERE level = 10") + execute("UPDATE users SET level = 2 WHERE level = 20") + execute("UPDATE users SET level = 3 WHERE level = 30") + execute("UPDATE users SET level = 10 WHERE level = 40") + execute("UPDATE users SET level = 20 WHERE Level = 50") + end +end diff --git a/db/migrate/067_create_artist_urls.rb b/db/migrate/067_create_artist_urls.rb new file mode 100644 index 00000000..bf76e926 --- /dev/null +++ b/db/migrate/067_create_artist_urls.rb @@ -0,0 +1,34 @@ +class Artist < ActiveRecord::Base +end + +class CreateArtistUrls < ActiveRecord::Migration + def self.up + create_table :artist_urls do |t| + t.column :artist_id, :integer, :null => false + t.column :url, :text, :null => false + t.column :normalized_url, :text, :null => false + end + + add_index :artist_urls, :artist_id + add_index :artist_urls, :url + add_index :artist_urls, :normalized_url + + add_foreign_key :artist_urls, :artist_id, :artists, :id + + Artist.find(:all, :order => "id").each do |artist| + [:url_a, :url_b, :url_c].each do |field| + unless artist[field].blank? + ArtistUrl.create(:artist_id => artist.id, :url => artist[field]) + end + end + end + + remove_column :artists, :url_a + remove_column :artists, :url_b + remove_column :artists, :url_c + end + + def self.down + drop_table :artist_urls + end +end diff --git a/db/migrate/068_add_pixiv_to_artists.rb b/db/migrate/068_add_pixiv_to_artists.rb new file mode 100644 index 00000000..39fcda8c --- /dev/null +++ b/db/migrate/068_add_pixiv_to_artists.rb @@ -0,0 +1,10 @@ +class AddPixivToArtists < ActiveRecord::Migration + def self.up + add_column :artists, :pixiv_id, :integer + add_index :artists, :pixiv_id + end + + def self.down + remove_column :artists, :pixiv_id + end +end diff --git a/db/migrate/069_clean_up_users.rb b/db/migrate/069_clean_up_users.rb new file mode 100644 index 00000000..b2ea7517 --- /dev/null +++ b/db/migrate/069_clean_up_users.rb @@ -0,0 +1,13 @@ +class CleanUpUsers < ActiveRecord::Migration + def self.up + remove_column :users, :ip_addr + remove_column :users, :tag_blacklist + remove_column :users, :login_count + end + + def self.down + execute "ALTER TABLE users ADD COLUMN ip_addr inet NOT NULL" + add_column :users, :tag_blacklist, :text, :null => false, :default => "" + add_column :users, :login_count, :integer, :null => false, :default => 0 + end +end diff --git a/db/migrate/070_add_approved_by_to_posts.rb b/db/migrate/070_add_approved_by_to_posts.rb new file mode 100644 index 00000000..c26d4aa6 --- /dev/null +++ b/db/migrate/070_add_approved_by_to_posts.rb @@ -0,0 +1,10 @@ +class AddApprovedByToPosts < ActiveRecord::Migration + def self.up + add_column :posts, :approved_by, :integer + add_foreign_key :posts, :approved_by, :users, :id + end + + def self.down + remove_column :posts, :approved_by + end +end diff --git a/db/migrate/071_create_flagged_post_details.rb b/db/migrate/071_create_flagged_post_details.rb new file mode 100644 index 00000000..a8a003c2 --- /dev/null +++ b/db/migrate/071_create_flagged_post_details.rb @@ -0,0 +1,36 @@ +class Post < ActiveRecord::Base +end + +class FlaggedPostDetail < ActiveRecord::Base +end + +class CreateFlaggedPostDetails < ActiveRecord::Migration + def self.up + remove_column :posts, :approved_by + + create_table :flagged_post_details do |t| + t.column :created_at, :datetime, :null => false + t.column :post_id, :integer, :null => false + t.column :reason, :text, :null => false + t.column :user_id, :integer, :null => false + t.column :is_resolved, :boolean, :null => false + end + + add_index :flagged_post_details, :post_id + add_foreign_key :flagged_post_details, :post_id, :posts, :id + add_foreign_key :flagged_post_details, :user_id, :users, :id + + Post.find(:all, :conditions => "deletion_reason <> ''", :select => "deletion_reason, id, status").each do |post| + FlaggedPostDetail.create(:post_id => post.id, :reason => post.deletion_reason, :user_id => 1, :is_resolved => (post.status == 'deleted')) + end + + remove_column :posts, :deletion_reason + end + + def self.down + add_column :posts, :approved_by, :integer + add_foreign_key :posts, :approved_by, :users, :id + drop_table :flagged_post_details + add_column :posts, :deletion_reason, :text, :null => false, :default => "" + end +end diff --git a/db/migrate/072_add_reason_to_aliases_and_implications.rb b/db/migrate/072_add_reason_to_aliases_and_implications.rb new file mode 100644 index 00000000..dde3d44f --- /dev/null +++ b/db/migrate/072_add_reason_to_aliases_and_implications.rb @@ -0,0 +1,11 @@ +class AddReasonToAliasesAndImplications < ActiveRecord::Migration + def self.up + add_column :tag_aliases, :reason, :text, :null => false, :default => "" + add_column :tag_implications, :reason, :text, :null => false, :default => "" + end + + def self.down + remove_column :tag_aliases, :reason + remove_column :tag_implications, :reason + end +end diff --git a/db/migrate/073_fix_flagged_post_details_foreign_keys.rb b/db/migrate/073_fix_flagged_post_details_foreign_keys.rb new file mode 100644 index 00000000..1e9e4d6c --- /dev/null +++ b/db/migrate/073_fix_flagged_post_details_foreign_keys.rb @@ -0,0 +1,15 @@ +class FixFlaggedPostDetailsForeignKeys < ActiveRecord::Migration + def self.up + remove_foreign_key :flagged_post_details, :flagged_post_details_post_id_fkey + remove_foreign_key :flagged_post_details, :flagged_post_details_user_id_fkey + add_foreign_key :flagged_post_details, :post_id, :posts, :id, :on_delete => :cascade + add_foreign_key :flagged_post_details, :user_id, :users, :id, :on_delete => :cascade + end + + def self.down + remove_foreign_key :flagged_post_details, :flagged_post_details_post_id_fkey + remove_foreign_key :flagged_post_details, :flagged_post_details_user_id_fkey + add_foreign_key :flagged_post_details, :post_id, :posts, :id + add_foreign_key :flagged_post_details, :user_id, :users, :id + end +end diff --git a/db/migrate/074_remove_pixiv_field.rb b/db/migrate/074_remove_pixiv_field.rb new file mode 100644 index 00000000..f817c1ea --- /dev/null +++ b/db/migrate/074_remove_pixiv_field.rb @@ -0,0 +1,9 @@ +class RemovePixivField < ActiveRecord::Migration + def self.up + remove_column :artists, :pixiv_id + end + + def self.down + add_column :artists, :pixiv_id, :integer + end +end diff --git a/db/migrate/075_add_sample_columns.rb b/db/migrate/075_add_sample_columns.rb new file mode 100644 index 00000000..0ebfb4ff --- /dev/null +++ b/db/migrate/075_add_sample_columns.rb @@ -0,0 +1,14 @@ +class AddSampleColumns < ActiveRecord::Migration + def self.up + add_column :posts, :sample_width, :integer + add_column :posts, :sample_height, :integer + add_column :users, :show_samples, :boolean + end + + def self.down + remove_column :posts, :sample_width + remove_column :posts, :sample_height + remove_column :users, :show_samples + end +end + diff --git a/db/migrate/076_create_user_blacklisted_tags.rb b/db/migrate/076_create_user_blacklisted_tags.rb new file mode 100644 index 00000000..df7d55d0 --- /dev/null +++ b/db/migrate/076_create_user_blacklisted_tags.rb @@ -0,0 +1,35 @@ +class User < ActiveRecord::Base +end + +class UserBlacklistedTags < ActiveRecord::Base +end + +class CreateUserBlacklistedTags < ActiveRecord::Migration + def self.up + create_table :user_blacklisted_tags do |t| + t.column :user_id, :integer, :null => false + t.column :tags, :text, :null => false + end + + add_index :user_blacklisted_tags, :user_id + + add_foreign_key :user_blacklisted_tags, :user_id, :users, :id, :on_delete => :cascade + UserBlacklistedTags.reset_column_information + + User.find(:all, :order => "id").each do |user| + unless user[:blacklisted_tags].blank? + tags = user[:blacklisted_tags].scan(/\S+/).each do |tag| + UserBlacklistedTags.create(:user_id => user.id, :tags => tag) + end + end + end + + remove_column :users, :blacklisted_tags + end + + def self.down + drop_table :user_blacklisted_tags + add_column :users, :blacklisted_tags, :text, :null => false, :default => "" + end +end + diff --git a/db/migrate/077_create_server_keys.rb b/db/migrate/077_create_server_keys.rb new file mode 100644 index 00000000..32c2f9f9 --- /dev/null +++ b/db/migrate/077_create_server_keys.rb @@ -0,0 +1,22 @@ +require 'digest/sha1' + +class CreateServerKeys < ActiveRecord::Migration + def self.up + create_table :server_keys do |t| + t.column :name, :string, :null => false + t.column :value, :text + end + + add_index :server_keys, :name, :unique => true + + session_secret_key = CONFIG["session_secret_key"] || Digest::SHA1.hexdigest(rand(10 ** 32)) + user_password_salt = CONFIG["password_salt"] || Digest::SHA1.hexdigest(rand(10 ** 32)) + + execute "insert into server_keys (name, value) values ('session_secret_key', '#{session_secret_key}')" + execute "insert into server_keys (name, value) values ('user_password_salt', '#{user_password_salt}')" + end + + def self.down + drop_table :server_keys + end +end diff --git a/db/migrate/078_add_neighbors_to_pools.rb b/db/migrate/078_add_neighbors_to_pools.rb new file mode 100644 index 00000000..0573b23c --- /dev/null +++ b/db/migrate/078_add_neighbors_to_pools.rb @@ -0,0 +1,34 @@ +class PoolPost < ActiveRecord::Base + set_table_name "pools_posts" + belongs_to :pool +end + +class Pool < ActiveRecord::Base + has_many :pool_posts, :class_name => "PoolPost", :order => "sequence" +end + +class AddNeighborsToPools < ActiveRecord::Migration + def self.up + add_column :pools_posts, :next_post_id, :integer + add_column :pools_posts, :prev_post_id, :integer + add_foreign_key :pools_posts, :next_post_id, :posts, :id, :on_delete => :set_null + add_foreign_key :pools_posts, :prev_post_id, :posts, :id, :on_delete => :set_null + + PoolPost.reset_column_information + + Pool.find(:all).each do |pool| + pp = pool.pool_posts + + pp.each_index do |i| + pp[i].next_post_id = pp[i + 1].post_id unless i == pp.size - 1 + pp[i].prev_post_id = pp[i - 1].post_id unless i == 0 + pp[i].save + end + end + end + + def self.down + remove_column :pools_posts, :next_post_id + remove_column :pools_posts, :prev_post_id + end +end diff --git a/db/migrate/079_create_post_change_seq.rb b/db/migrate/079_create_post_change_seq.rb new file mode 100644 index 00000000..3b71a0bc --- /dev/null +++ b/db/migrate/079_create_post_change_seq.rb @@ -0,0 +1,14 @@ +class CreatePostChangeSeq < ActiveRecord::Migration + def self.up + execute "CREATE SEQUENCE post_change_seq INCREMENT BY 1 CACHE 10;" + execute "ALTER TABLE posts ADD COLUMN change_seq INTEGER DEFAULT nextval('post_change_seq'::regclass) NOT NULL;" + execute "ALTER SEQUENCE post_change_seq OWNED BY posts.change_seq" + add_index :posts, :change_seq + end + + def self.down + remove_index :posts, :change_seq + execute "ALTER TABLE posts DROP COLUMN change_seq;" + end +end + diff --git a/db/migrate/080_remove_neighbor_fields_from_posts.rb b/db/migrate/080_remove_neighbor_fields_from_posts.rb new file mode 100644 index 00000000..44d0726f --- /dev/null +++ b/db/migrate/080_remove_neighbor_fields_from_posts.rb @@ -0,0 +1,11 @@ +class RemoveNeighborFieldsFromPosts < ActiveRecord::Migration + def self.up + remove_column :posts, :next_post_id + remove_column :posts, :prev_post_id + end + + def self.down + add_column :posts, :next_post_id, :integer + add_column :posts, :prev_post_id, :integer + end +end diff --git a/db/migrate/081_disable_change_seq_cache.rb b/db/migrate/081_disable_change_seq_cache.rb new file mode 100644 index 00000000..2f5a28e6 --- /dev/null +++ b/db/migrate/081_disable_change_seq_cache.rb @@ -0,0 +1,12 @@ +class DisableChangeSeqCache < ActiveRecord::Migration + def self.up + execute "ALTER SEQUENCE post_change_seq CACHE 1" + execute "ALTER TABLE posts ALTER COLUMN change_seq DROP NOT NULL" + end + + def self.down + execute "ALTER SEQUENCE post_change_seq CACHE 10" + execute "ALTER TABLE posts ALTER COLUMN change_seq SET NOT NULL" + end +end + diff --git a/db/migrate/082_add_post_votes.rb b/db/migrate/082_add_post_votes.rb new file mode 100644 index 00000000..f2172770 --- /dev/null +++ b/db/migrate/082_add_post_votes.rb @@ -0,0 +1,33 @@ +require 'activerecord.rb' + +class AddPostVotes < ActiveRecord::Migration + def self.up + create_table :post_votes do |t| + t.column :user_id, :integer, :null => false + t.foreign_key :user_id, :users, :id, :on_delete => :cascade + t.column :post_id, :integer, :null => false + t.foreign_key :post_id, :posts, :id, :on_delete => :cascade + t.column :score, :integer, :null => false, :default => 0 + t.column :updated_at, :timestamp, :null => false, :default => "now()" + end + + # This should probably be the primary key, but ActiveRecord assumes the primary + # key is a single column. + execute "ALTER TABLE post_votes ADD UNIQUE (user_id, post_id)" + + add_index :post_votes, :user_id + add_index :post_votes, :post_id + + add_column :posts, :last_vote, :integer, :null => false, :default => 0 + add_column :posts, :anonymous_votes, :integer, :null => false, :default => 0 + + # Set anonymous_votes = score - num favorited + execute "UPDATE posts SET anonymous_votes = posts.score - (SELECT COUNT(*) FROM favorites f WHERE f.post_id = posts.id)" + end + def self.down + drop_table :post_votes + remove_column :posts, :last_vote + remove_column :posts, :anonymous_votes + end +end + diff --git a/db/migrate/083_add_user_id_to_aliases_and_implicatons.rb b/db/migrate/083_add_user_id_to_aliases_and_implicatons.rb new file mode 100644 index 00000000..e4b3ab56 --- /dev/null +++ b/db/migrate/083_add_user_id_to_aliases_and_implicatons.rb @@ -0,0 +1,13 @@ +class AddUserIdToAliasesAndImplicatons < ActiveRecord::Migration + def self.up + add_column "tag_aliases", "creator_id", :integer + add_column "tag_implications", "creator_id", :integer + add_foreign_key "tag_aliases", "creator_id", "users", "id", :on_delete => :cascade + add_foreign_key "tag_implications", "creator_id", "users", "id", :on_delete => :cascade + end + + def self.down + remove_column "tag_aliases", "creator_id" + remove_column "tag_implications", "creator_id" + end +end diff --git a/db/migrate/084_add_user_id_index_on_post_tag_histories.rb b/db/migrate/084_add_user_id_index_on_post_tag_histories.rb new file mode 100644 index 00000000..fabc4f23 --- /dev/null +++ b/db/migrate/084_add_user_id_index_on_post_tag_histories.rb @@ -0,0 +1,9 @@ +class AddUserIdIndexOnPostTagHistories < ActiveRecord::Migration + def self.up + add_index "post_tag_histories", "user_id" + end + + def self.down + remove_index "post_tag_histories", "user_id" + end +end diff --git a/db/migrate/085_revert_post_votes.rb b/db/migrate/085_revert_post_votes.rb new file mode 100644 index 00000000..0e8705ae --- /dev/null +++ b/db/migrate/085_revert_post_votes.rb @@ -0,0 +1,8 @@ +class RevertPostVotes < ActiveRecord::Migration + # bad change disabled + def self.down + end + + def self.up + end +end diff --git a/db/migrate/086_add_is_active_field_to_pools.rb b/db/migrate/086_add_is_active_field_to_pools.rb new file mode 100644 index 00000000..16d90de8 --- /dev/null +++ b/db/migrate/086_add_is_active_field_to_pools.rb @@ -0,0 +1,9 @@ +class AddIsActiveFieldToPools < ActiveRecord::Migration + def self.up + add_column :pools, :is_active, :boolean, :null => false, :default => true + end + + def self.down + remove_column :pools, :is_active + end +end diff --git a/db/migrate/087_add_dimensions_index_on_posts.rb b/db/migrate/087_add_dimensions_index_on_posts.rb new file mode 100644 index 00000000..15913e9c --- /dev/null +++ b/db/migrate/087_add_dimensions_index_on_posts.rb @@ -0,0 +1,13 @@ +class AddDimensionsIndexOnPosts < ActiveRecord::Migration + def self.up + add_index "posts", "width" + add_index "posts", "height" + execute "CREATE INDEX posts_mpixels ON posts ((width*height/1000000.0))" + end + + def self.down + remove_index "posts", "width" + remove_index "posts", "height" + execute "DROP INDEX posts_mpixels" + end +end diff --git a/db/migrate/088_add_approver_id_to_posts.rb b/db/migrate/088_add_approver_id_to_posts.rb new file mode 100644 index 00000000..ebae0ba8 --- /dev/null +++ b/db/migrate/088_add_approver_id_to_posts.rb @@ -0,0 +1,10 @@ +class AddApproverIdToPosts < ActiveRecord::Migration + def self.up + add_column :posts, :approver_id, :integer + add_foreign_key :posts, :approver_id, :users, :id, :on_delete => :set_null + end + + def self.down + remove_column :posts, :approver_id + end +end diff --git a/db/migrate/089_add_index_on_post_source.rb b/db/migrate/089_add_index_on_post_source.rb new file mode 100644 index 00000000..4dae2aeb --- /dev/null +++ b/db/migrate/089_add_index_on_post_source.rb @@ -0,0 +1,9 @@ +class AddIndexOnPostSource < ActiveRecord::Migration + def self.up + add_index :posts, :source + end + + def self.down + remove_index :posts, :source + end +end diff --git a/db/migrate/090_add_rating_to_tag_history.rb b/db/migrate/090_add_rating_to_tag_history.rb new file mode 100644 index 00000000..9259b846 --- /dev/null +++ b/db/migrate/090_add_rating_to_tag_history.rb @@ -0,0 +1,9 @@ +class AddRatingToTagHistory < ActiveRecord::Migration + def self.up +# execute "ALTER TABLE post_tag_histories ADD COLUMN rating CHARACTER" + end + + def self.down +# remove_column :post_tag_histories, :rating + end +end diff --git a/db/migrate/091_migrate_users_to_contributor.rb b/db/migrate/091_migrate_users_to_contributor.rb new file mode 100644 index 00000000..83c302cf --- /dev/null +++ b/db/migrate/091_migrate_users_to_contributor.rb @@ -0,0 +1,17 @@ +class MigrateUsersToContributor < ActiveRecord::Migration + def self.up + User.find(:all, :conditions => "level = 30").each do |user| + post_count = Post.count(:conditions => ["user_id = ? AND status <> 'deleted'", user.id]) + + if post_count > 50 + user.update_attribute(:level, 33) + end + end + + User.update_all("invite_count = 0", "level < 35") + end + + def self.down + User.update_all("level = 30", "level = 33") + end +end diff --git a/db/migrate/092_create_job_tasks.rb b/db/migrate/092_create_job_tasks.rb new file mode 100644 index 00000000..81c79e6d --- /dev/null +++ b/db/migrate/092_create_job_tasks.rb @@ -0,0 +1,15 @@ +class CreateJobTasks < ActiveRecord::Migration + def self.up + create_table :job_tasks do |t| + t.column :task_type, :string, :null => false + t.column :data_as_json, :string, :null => false + t.column :status, :string, :null => false + t.column :status_message, :text + t.timestamps + end + end + + def self.down + drop_table :job_tasks + end +end diff --git a/db/migrate/093_change_type_of_data_in_job_tasks.rb b/db/migrate/093_change_type_of_data_in_job_tasks.rb new file mode 100644 index 00000000..3e2043da --- /dev/null +++ b/db/migrate/093_change_type_of_data_in_job_tasks.rb @@ -0,0 +1,11 @@ +class ChangeTypeOfDataInJobTasks < ActiveRecord::Migration + def self.up + remove_column :job_tasks, :data_as_json + add_column :job_tasks, :data_as_json, :text, :null => false, :default => "{}" + end + + def self.down + remove_column :job_tasks, :data_as_json + add_column :job_tasks, :data_as_json, :string, :null => false, :default => "{}" + end +end diff --git a/db/migrate/094_favorite_tags.rb b/db/migrate/094_favorite_tags.rb new file mode 100644 index 00000000..cd186c0b --- /dev/null +++ b/db/migrate/094_favorite_tags.rb @@ -0,0 +1,15 @@ +class FavoriteTags < ActiveRecord::Migration + def self.up + create_table :favorite_tags do |t| + t.column :user_id, :integer, :null => false + t.column :tag_query, :text, :null => false + t.column :cached_post_ids, :text, :null => false, :default => "" + end + + add_index :favorite_tags, :user_id + end + + def self.down + drop_table :favorite_tags + end +end diff --git a/db/migrate/095_add_repeat_count_to_job_tasks.rb b/db/migrate/095_add_repeat_count_to_job_tasks.rb new file mode 100644 index 00000000..f514bef1 --- /dev/null +++ b/db/migrate/095_add_repeat_count_to_job_tasks.rb @@ -0,0 +1,11 @@ +class AddRepeatCountToJobTasks < ActiveRecord::Migration + def self.up + add_column :job_tasks, :repeat_count, :integer, :null => false, :default => 0 + JobTask.create(:task_type => "calculate_favorite_tags", :status => "pending", :repeat_count => -1) + end + + def self.down + remove_column :job_tasks, :repeat_count + JobTask.destroy_all(["task_type = 'calculate_favorite_tags'"]) + end +end diff --git a/db/migrate/096_create_advertisements.rb b/db/migrate/096_create_advertisements.rb new file mode 100644 index 00000000..bcbfe601 --- /dev/null +++ b/db/migrate/096_create_advertisements.rb @@ -0,0 +1,27 @@ +class CreateAdvertisements < ActiveRecord::Migration + def self.up + create_table :advertisements do |t| + t.column :image_url, :string, :null => false + t.column :referral_url, :string, :null => false + t.column :ad_type, :string, :null => false + t.column :status, :string, :null => false + t.column :hit_count, :integer, :null => false, :default => 0 + t.column :width, :integer, :null => false + t.column :height, :integer, :null => false + end + + execute "insert into advertisements (image_url, referral_url, ad_type, status, hit_count, width, height) values ('/images/180x300_1.jpg', 'http://affiliates.jlist.com/click/2253?url=http://www.jlist.com/index.html', 'vertical', 'active', 0, 180, 300)" + execute "insert into advertisements (image_url, referral_url, ad_type, status, hit_count, width, height) values ('/images/180x300_2.jpg', 'http://affiliates.jlist.com/click/2253?url=http://www.jlist.com/index.html', 'vertical', 'active', 0, 180, 300)" + execute "insert into advertisements (image_url, referral_url, ad_type, status, hit_count, width, height) values ('/images/180x300_3.jpg', 'http://affiliates.jlist.com/click/2253?url=http://www.jlist.com/index.html', 'vertical', 'active', 0, 180, 300)" + + execute "insert into advertisements (image_url, referral_url, ad_type, status, hit_count, width, height) values ('/images/728x90_1.jpg', 'http://affiliates.jlist.com/click/2253?url=http://www.jlist.com/index.html', 'horizontal', 'active', 0, 728, 90)" + execute "insert into advertisements (image_url, referral_url, ad_type, status, hit_count, width, height) values ('/images/728x90_2.jpg', 'http://affiliates.jlist.com/click/2253?url=http://www.jlist.com/index.html', 'horizontal', 'active', 0, 728, 90)" + execute "insert into advertisements (image_url, referral_url, ad_type, status, hit_count, width, height) values ('/images/728x90_3.jpg', 'http://affiliates.jlist.com/click/2253?url=http://www.jlist.com/index.html', 'horizontal', 'active', 0, 728, 90)" + execute "insert into advertisements (image_url, referral_url, ad_type, status, hit_count, width, height) values ('/images/728x90_4.jpg', 'http://affiliates.jlist.com/click/2253?url=http://www.jlist.com/index.html', 'horizontal', 'active', 0, 728, 90)" + + end + + def self.down + drop_table :advertisements + end +end diff --git a/db/migrate/20080901000000_no_really_add_post_votes.rb b/db/migrate/20080901000000_no_really_add_post_votes.rb new file mode 100644 index 00000000..12baa2b5 --- /dev/null +++ b/db/migrate/20080901000000_no_really_add_post_votes.rb @@ -0,0 +1,38 @@ +require 'activerecord.rb' + +# Upstream 085 removes post votes. Ours doesn't. Ours is right. If the site was +# migrated with our migrations, leave things alone; we're all set. If the site was +# migrated with upstream 085, the post_votes table is missing and needs to be +# recreated. +class NoReallyAddPostVotes < ActiveRecord::Migration + def self.up + return if select_value_sql "SELECT 1 FROM information_schema.tables WHERE table_name = 'post_votes'" + + # We don't have this table, so this migration is needed. + create_table :post_votes do |t| + t.column :user_id, :integer, :null => false + t.foreign_key :user_id, :users, :id, :on_delete => :cascade + t.column :post_id, :integer, :null => false + t.foreign_key :post_id, :posts, :id, :on_delete => :cascade + t.column :score, :integer, :null => false, :default => 0 + t.column :updated_at, :timestamp, :null => false, :default => "now()" + end + + # This should probably be the primary key, but ActiveRecord assumes the primary + # key is a single column. + execute "ALTER TABLE post_votes ADD UNIQUE (user_id, post_id)" + + add_index :post_votes, :user_id + add_index :post_votes, :post_id + + add_column :posts, :last_vote, :integer, :null => false, :default => 0 + add_column :posts, :anonymous_votes, :integer, :null => false, :default => 0 + + # Set anonymous_votes = score - num favorited + execute "UPDATE posts SET anonymous_votes = posts.score - (SELECT COUNT(*) FROM favorites f WHERE f.post_id = posts.id)" + end + + def self.down + end +end + diff --git a/db/migrate/20080927145957_make_wiki_titles_unique.rb b/db/migrate/20080927145957_make_wiki_titles_unique.rb new file mode 100644 index 00000000..e10fbbbd --- /dev/null +++ b/db/migrate/20080927145957_make_wiki_titles_unique.rb @@ -0,0 +1,11 @@ +class MakeWikiTitlesUnique < ActiveRecord::Migration + def self.up + execute "DROP INDEX idx_wiki_pages__title" + execute "CREATE UNIQUE INDEX idx_wiki_pages__title ON wiki_pages (title)" + end + + def self.down + execute "DROP INDEX idx_wiki_pages__title" + execute "CREATE INDEX idx_wiki_pages__title ON wiki_pages (title)" + end +end diff --git a/db/migrate/20081015004825_create_user_log.rb b/db/migrate/20081015004825_create_user_log.rb new file mode 100644 index 00000000..83eb2da8 --- /dev/null +++ b/db/migrate/20081015004825_create_user_log.rb @@ -0,0 +1,19 @@ +class CreateUserLog < ActiveRecord::Migration + def self.up + execute <<-EOS + CREATE TABLE user_logs ( + id SERIAL PRIMARY KEY, + user_id integer NOT NULL REFERENCES users ON DELETE CASCADE, + created_at timestamp NOT NULL DEFAULT now(), + ip_addr inet NOT NULL + ) + EOS + + add_index :user_logs, :user_id + add_index :user_logs, :created_at + end + + def self.down + drop_table :user_logs + end +end diff --git a/db/migrate/20081015004855_add_random_to_posts.rb b/db/migrate/20081015004855_add_random_to_posts.rb new file mode 100644 index 00000000..d6af9692 --- /dev/null +++ b/db/migrate/20081015004855_add_random_to_posts.rb @@ -0,0 +1,10 @@ +class AddRandomToPosts < ActiveRecord::Migration + def self.up + execute "ALTER TABLE posts ADD COLUMN random REAL DEFAULT RANDOM() NOT NULL;" + add_index :posts, :random + end + + def self.down + execute "ALTER TABLE posts DROP COLUMN random;" + end +end diff --git a/db/migrate/20081015004938_convert_favorites_to_votes.rb b/db/migrate/20081015004938_convert_favorites_to_votes.rb new file mode 100644 index 00000000..cf801bbc --- /dev/null +++ b/db/migrate/20081015004938_convert_favorites_to_votes.rb @@ -0,0 +1,24 @@ +require 'post' + +class ConvertFavoritesToVotes < ActiveRecord::Migration + def self.up + # Favorites doesn't have a dupe constraint and post_votes does, so make sure + # there are no dupes before we copy. + execute "DELETE FROM favorites " + + "WHERE id IN (" + + "SELECT f.id FROM favorites f, favorites f2 " + + " WHERE f.user_id = f2.user_id AND " + + " f.post_id = f2.post_id AND " + + " f.id <> f2.id AND f.id > f2.id)" + execute "DELETE FROM post_votes pv WHERE pv.id IN " + + " (SELECT pv.id FROM post_votes pv JOIN favorites f ON (pv.user_id = f.user_id AND pv.post_id = f.post_id))" + execute "INSERT INTO post_votes (user_id, post_id, score, updated_at) " + + " SELECT f.user_id, f.post_id, 3, f.created_at FROM favorites f" + p Post + debugger + Post.recalculate_score + end + + def self.down + end +end diff --git a/db/migrate/20081015005018_create_ip_bans.rb b/db/migrate/20081015005018_create_ip_bans.rb new file mode 100644 index 00000000..b490bd45 --- /dev/null +++ b/db/migrate/20081015005018_create_ip_bans.rb @@ -0,0 +1,20 @@ +class CreateIpBans < ActiveRecord::Migration + def self.up + execute <<-EOS + CREATE TABLE ip_bans ( + id SERIAL PRIMARY KEY, + created_at timestamp NOT NULL DEFAULT now(), + expires_at timestamp, + ip_addr inet NOT NULL, + reason text NOT NULL, + banned_by integer NOT NULL + ) + EOS + add_foreign_key "ip_bans", "banned_by", "users", "id", :on_delete => :cascade + add_index :ip_bans, :ip_addr + end + + def self.down + execute "DROP TABLE ip_bans" + end +end diff --git a/db/migrate/20081015005051_add_avatar_to_user.rb b/db/migrate/20081015005051_add_avatar_to_user.rb new file mode 100644 index 00000000..81a4b947 --- /dev/null +++ b/db/migrate/20081015005051_add_avatar_to_user.rb @@ -0,0 +1,25 @@ +class AddAvatarToUser < ActiveRecord::Migration + def self.up + execute "ALTER TABLE users ADD COLUMN avatar_post_id INTEGER" + execute "ALTER TABLE users ADD COLUMN avatar_width REAL" + execute "ALTER TABLE users ADD COLUMN avatar_height REAL" + execute "ALTER TABLE users ADD COLUMN avatar_top REAL" + execute "ALTER TABLE users ADD COLUMN avatar_bottom REAL" + execute "ALTER TABLE users ADD COLUMN avatar_left REAL" + execute "ALTER TABLE users ADD COLUMN avatar_right REAL" + execute "ALTER TABLE users ADD COLUMN avatar_timestamp TIMESTAMP" + + add_foreign_key "users", "avatar_post_id", "posts", "id", :on_delete => :set_null + add_index :users, :avatar_post_id + end + + def self.down + execute "ALTER TABLE users DROP COLUMN avatar_post_id" + execute "ALTER TABLE users DROP COLUMN avatar_top" + execute "ALTER TABLE users DROP COLUMN avatar_bottom" + execute "ALTER TABLE users DROP COLUMN avatar_left" + execute "ALTER TABLE users DROP COLUMN avatar_right" + execute "ALTER TABLE users DROP COLUMN avatar_width" + execute "ALTER TABLE users DROP COLUMN avatar_height" + end +end diff --git a/db/migrate/20081015005124_add_last_comment_read_at_to_user.rb b/db/migrate/20081015005124_add_last_comment_read_at_to_user.rb new file mode 100644 index 00000000..ed9da3b5 --- /dev/null +++ b/db/migrate/20081015005124_add_last_comment_read_at_to_user.rb @@ -0,0 +1,9 @@ +class AddLastCommentReadAtToUser < ActiveRecord::Migration + def self.up + execute "alter table users add column last_comment_read_at timestamp not null default '1960-01-01'" + end + + def self.down + remove_column :forum_posts, :is_locked + end +end diff --git a/db/migrate/20081015005201_add_history_table.rb b/db/migrate/20081015005201_add_history_table.rb new file mode 100644 index 00000000..05310a7e --- /dev/null +++ b/db/migrate/20081015005201_add_history_table.rb @@ -0,0 +1,60 @@ +class AddHistoryTable < ActiveRecord::Migration + def self.up + execute <<-EOS + CREATE TABLE history_changes ( + id SERIAL PRIMARY KEY, + field TEXT NOT NULL, + remote_id INTEGER NOT NULL, + table_name TEXT NOT NULL, + value TEXT, + history_id INTEGER NOT NULL, + previous_id INTEGER + ) + EOS + + execute <<-EOS + CREATE TABLE histories ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT now(), + user_id INTEGER, + group_by_id INTEGER NOT NULL, + group_by_table TEXT NOT NULL + ) + EOS + + # cleanup_history entries can be deleted by a rule (see update_versioned_tables). When + # the last change for a history is deleted, delete the history, so it doesn't show up + # as an empty line in the history list. + execute <<-EOS + CREATE OR REPLACE FUNCTION trg_purge_histories() RETURNS "trigger" AS $$ + BEGIN + DELETE FROM histories h WHERE h.id = OLD.history_id AND + (SELECT COUNT(*) FROM history_changes hc WHERE hc.history_id = OLD.history_id LIMIT 1) = 0; + RETURN OLD; + END; + $$ LANGUAGE plpgsql; + EOS + execute "CREATE TRIGGER trg_cleanup_history AFTER DELETE ON history_changes FOR EACH ROW EXECUTE PROCEDURE trg_purge_histories()" + + add_foreign_key :history_changes, :history_id, :histories, :id, :on_delete => :cascade + add_foreign_key :history_changes, :previous_id, :history_changes, :id, :on_delete => :set_null + + add_index :histories, :group_by_table + add_index :histories, :group_by_id + add_index :histories, :user_id + add_index :histories, :created_at + add_index :history_changes, :table_name + add_index :history_changes, :remote_id + add_index :history_changes, :history_id + + add_column :pools_posts, :active, :boolean, :default => true, :null => false + add_index :pools_posts, :active + end + + def self.down + execute "DROP TABLE history_changes CASCADE" + execute "DROP TABLE histories" + remove_column :pools_posts, :active + end +end + diff --git a/db/migrate/20081015005919_import_post_tag_histories.rb b/db/migrate/20081015005919_import_post_tag_histories.rb new file mode 100644 index 00000000..d8c82430 --- /dev/null +++ b/db/migrate/20081015005919_import_post_tag_histories.rb @@ -0,0 +1,8 @@ +class ImportPostTagHistories < ActiveRecord::Migration + def self.up + ActiveRecord::Base.import_post_tag_history + end + + def self.down + end +end diff --git a/db/migrate/20081015010657_update_histories.rb b/db/migrate/20081015010657_update_histories.rb new file mode 100644 index 00000000..dff4df82 --- /dev/null +++ b/db/migrate/20081015010657_update_histories.rb @@ -0,0 +1,8 @@ +class UpdateHistories < ActiveRecord::Migration + def self.up + ActiveRecord::Base.update_all_versioned_tables + end + + def self.down + end +end diff --git a/db/migrate/20081016002814_post_source_not_null.rb b/db/migrate/20081016002814_post_source_not_null.rb new file mode 100644 index 00000000..d67807e5 --- /dev/null +++ b/db/migrate/20081016002814_post_source_not_null.rb @@ -0,0 +1,11 @@ +class PostSourceNotNull < ActiveRecord::Migration + def self.up + execute "UPDATE posts SET source='' WHERE source IS NULL" + execute "UPDATE history_changes SET value='' WHERE table_name='posts' AND field='source' AND value IS NULL" + execute "ALTER TABLE posts ALTER COLUMN source SET NOT NULL" + end + + def self.down + execute "ALTER TABLE posts ALTER COLUMN source DROP NOT NULL" + end +end diff --git a/db/migrate/20081018175545_post_source_default.rb b/db/migrate/20081018175545_post_source_default.rb new file mode 100644 index 00000000..89b4eb01 --- /dev/null +++ b/db/migrate/20081018175545_post_source_default.rb @@ -0,0 +1,9 @@ +class PostSourceDefault < ActiveRecord::Migration + def self.up + execute "ALTER TABLE posts ALTER COLUMN source SET DEFAULT ''" + end + + def self.down + execute "ALTER TABLE posts ALTER COLUMN source DROP DEFAULT" + end +end diff --git a/db/migrate/20081023224739_add_mirror_posts_to_job_tasks.rb b/db/migrate/20081023224739_add_mirror_posts_to_job_tasks.rb new file mode 100644 index 00000000..ffca571d --- /dev/null +++ b/db/migrate/20081023224739_add_mirror_posts_to_job_tasks.rb @@ -0,0 +1,9 @@ +class AddMirrorPostsToJobTasks < ActiveRecord::Migration + def self.up + JobTask.create(:task_type => "upload_posts_to_mirrors", :status => "pending", :repeat_count => -1) + end + + def self.down + JobTask.destroy_all(["task_type = 'upload_posts_to_mirrors'"]) + end +end diff --git a/db/migrate/20081024083115_pools_default_to_public.rb b/db/migrate/20081024083115_pools_default_to_public.rb new file mode 100644 index 00000000..8eaf1473 --- /dev/null +++ b/db/migrate/20081024083115_pools_default_to_public.rb @@ -0,0 +1,9 @@ +class PoolsDefaultToPublic < ActiveRecord::Migration + def self.up + execute "ALTER TABLE pools ALTER COLUMN is_public SET DEFAULT TRUE" + end + + def self.down + execute "ALTER TABLE pools ALTER COLUMN is_public SET DEFAULT FALSE" + end +end diff --git a/db/migrate/20081024223856_add_old_level_to_bans.rb b/db/migrate/20081024223856_add_old_level_to_bans.rb new file mode 100644 index 00000000..4e307774 --- /dev/null +++ b/db/migrate/20081024223856_add_old_level_to_bans.rb @@ -0,0 +1,9 @@ +class AddOldLevelToBans < ActiveRecord::Migration + def self.up + add_column :bans, :old_level, :integer + end + + def self.down + remove_column :bans, :old_level + end +end diff --git a/db/migrate/20081025222424_add_fts_to_comments.rb b/db/migrate/20081025222424_add_fts_to_comments.rb new file mode 100644 index 00000000..2888069a --- /dev/null +++ b/db/migrate/20081025222424_add_fts_to_comments.rb @@ -0,0 +1,13 @@ +class AddFtsToComments < ActiveRecord::Migration + def self.up + execute "alter table comments add column text_search_index tsvector" + execute "update comments set text_search_index = to_tsvector('english', body)" + execute "create trigger trg_comment_search_update before insert or update on comments for each row execute procedure tsvector_update_trigger(text_search_index, 'pg_catalog.english', body)" + execute "create index comments_text_search_idx on comments using gin(text_search_index)" + end + + def self.down + execute "drop trigger trg_comment_search_update on comments" + execute "alter table comments drop column text_search_index" + end +end diff --git a/db/migrate/20081105030832_add_periodic_maintenance_to_job_tasks.rb b/db/migrate/20081105030832_add_periodic_maintenance_to_job_tasks.rb new file mode 100644 index 00000000..1e3ad239 --- /dev/null +++ b/db/migrate/20081105030832_add_periodic_maintenance_to_job_tasks.rb @@ -0,0 +1,9 @@ +class AddPeriodicMaintenanceToJobTasks < ActiveRecord::Migration + def self.up + JobTask.create(:task_type => "periodic_maintenance", :status => "pending", :repeat_count => -1) + end + + def self.down + JobTask.destroy_all(["task_type = 'periodic_maintenance'"]) + end +end diff --git a/db/migrate/20081122055610_add_last_deleted_post_seen_at.rb b/db/migrate/20081122055610_add_last_deleted_post_seen_at.rb new file mode 100644 index 00000000..3d394d47 --- /dev/null +++ b/db/migrate/20081122055610_add_last_deleted_post_seen_at.rb @@ -0,0 +1,14 @@ +class AddLastDeletedPostSeenAt < ActiveRecord::Migration + def self.up + execute "ALTER TABLE users ADD COLUMN last_deleted_post_seen_at timestamp not null default '1960-01-01'" + add_index :flagged_post_details, :created_at + + # Set all existing users to now, so we don't notify everyone of previous deletions. + execute "UPDATE users SET last_deleted_post_seen_at=now()" + end + + def self.down + remove_column :users, :last_deleted_post_seen_at + remove_index :flagged_post_details, :created_at + end +end diff --git a/db/migrate/20081130190723_add_file_size_to_posts.rb b/db/migrate/20081130190723_add_file_size_to_posts.rb new file mode 100644 index 00000000..723e6422 --- /dev/null +++ b/db/migrate/20081130190723_add_file_size_to_posts.rb @@ -0,0 +1,21 @@ +class AddFileSizeToPosts < ActiveRecord::Migration + def self.up + execute "ALTER TABLE posts ADD COLUMN file_size INTEGER NOT NULL DEFAULT 0" + execute "ALTER TABLE posts ADD COLUMN sample_size INTEGER NOT NULL DEFAULT 0" + + p "Updating file sizes..." + Post.find(:all, :order => "ID ASC").each do |post| + update = [] + update << "file_size=#{File.size(post.file_path) rescue 0}" + if post.has_sample? + update << "sample_size=#{File.size(post.sample_path) rescue 0}" + end + execute "UPDATE posts SET #{update.join(",")} WHERE id=#{post.id}" + end + end + + def self.down + execute "ALTER TABLE posts DROP COLUMN file_size" + execute "ALTER TABLE posts DROP COLUMN sample_size" + end +end diff --git a/db/migrate/20081130191226_add_crc32_to_posts.rb b/db/migrate/20081130191226_add_crc32_to_posts.rb new file mode 100644 index 00000000..70c15603 --- /dev/null +++ b/db/migrate/20081130191226_add_crc32_to_posts.rb @@ -0,0 +1,15 @@ +class AddCrc32ToPosts < ActiveRecord::Migration + def self.up + execute "ALTER TABLE posts ADD COLUMN crc32 BIGINT" + execute "ALTER TABLE posts ADD COLUMN sample_crc32 BIGINT" + execute "ALTER TABLE pools ADD COLUMN zip_created_at TIMESTAMP" + execute "ALTER TABLE pools ADD COLUMN zip_is_warehoused BOOLEAN NOT NULL DEFAULT FALSE" + end + + def self.down + execute "ALTER TABLE posts DROP COLUMN crc32" + execute "ALTER TABLE posts DROP COLUMN sample_crc32" + execute "ALTER TABLE pools DROP COLUMN zip_created_at" + execute "ALTER TABLE pools DROP COLUMN zip_is_warehoused" + end +end diff --git a/db/migrate/20081203035506_add_is_held_to_posts.rb b/db/migrate/20081203035506_add_is_held_to_posts.rb new file mode 100644 index 00000000..dd7d1170 --- /dev/null +++ b/db/migrate/20081203035506_add_is_held_to_posts.rb @@ -0,0 +1,13 @@ +class AddIsHeldToPosts < ActiveRecord::Migration + def self.up + execute "ALTER TABLE posts ADD COLUMN is_held BOOLEAN NOT NULL DEFAULT FALSE" + execute "ALTER TABLE posts ADD COLUMN index_timestamp TIMESTAMP NOT NULL DEFAULT now()" + execute "UPDATE posts SET index_timestamp = created_at" + add_index :posts, :is_held + end + + def self.down + execute "ALTER TABLE posts DROP COLUMN is_held" + execute "ALTER TABLE posts DROP COLUMN index_timestamp" + end +end diff --git a/db/migrate/20081204062728_add_shown_to_posts.rb b/db/migrate/20081204062728_add_shown_to_posts.rb new file mode 100644 index 00000000..0b87cf04 --- /dev/null +++ b/db/migrate/20081204062728_add_shown_to_posts.rb @@ -0,0 +1,11 @@ +class AddShownToPosts < ActiveRecord::Migration + def self.up + execute "ALTER TABLE posts ADD COLUMN is_shown_in_index BOOLEAN NOT NULL DEFAULT TRUE" + ActiveRecord::Base.update_versioned_tables Post, :attrs => [:is_shown_in_index] + end + + def self.down + execute "ALTER TABLE posts DROP COLUMN is_shown_in_index" + execute "DELETE FROM history_changes WHERE table_name = 'posts' AND field = 'is_shown_in_index'" + end +end diff --git a/db/migrate/20081205061033_add_natural_sort_to_pools.rb b/db/migrate/20081205061033_add_natural_sort_to_pools.rb new file mode 100644 index 00000000..a4fe6e46 --- /dev/null +++ b/db/migrate/20081205061033_add_natural_sort_to_pools.rb @@ -0,0 +1,34 @@ +class AddNaturalSortToPools < ActiveRecord::Migration + def self.up + execute <<-EOS + CREATE OR REPLACE FUNCTION nat_sort_pad(t text) RETURNS text IMMUTABLE AS $$ + DECLARE + match text; + BEGIN + IF t ~ '[0-9]' THEN + match := '0000000000' || t; + match := SUBSTRING(match FROM '^0*([0-9]{10}[0-9]*)$'); + return match; + END IF; + return t; + END; + $$ LANGUAGE plpgsql; + EOS + + execute <<-EOS + CREATE OR REPLACE FUNCTION nat_sort(t text) RETURNS text IMMUTABLE AS $$ + BEGIN + return array_to_string(array(select nat_sort_pad((regexp_matches(t, '([0-9]+|[^0-9]+)', 'g'))[1])), ''); + END; + $$ LANGUAGE plpgsql; + EOS + + execute "CREATE INDEX idx_pools__name_nat ON pools (nat_sort(name))" + end + + def self.down + execute "DROP INDEX idx_pools__name_nat" + execute "DROP FUNCTION nat_sort_pad(t text)" + execute "DROP FUNCTION nat_sort(t text)" + end +end diff --git a/db/migrate/20081205072029_add_index_timestamp_index.rb b/db/migrate/20081205072029_add_index_timestamp_index.rb new file mode 100644 index 00000000..f56f2cc9 --- /dev/null +++ b/db/migrate/20081205072029_add_index_timestamp_index.rb @@ -0,0 +1,9 @@ +class AddIndexTimestampIndex < ActiveRecord::Migration + def self.up + add_index :posts, :index_timestamp + end + + def self.down + remove_index :posts, :index_timestamp + end +end diff --git a/db/migrate/20081208220020_pool_sequence_as_string.rb b/db/migrate/20081208220020_pool_sequence_as_string.rb new file mode 100644 index 00000000..8f4865c2 --- /dev/null +++ b/db/migrate/20081208220020_pool_sequence_as_string.rb @@ -0,0 +1,11 @@ +class PoolSequenceAsString < ActiveRecord::Migration + def self.up + execute "ALTER TABLE pools_posts ALTER COLUMN sequence TYPE TEXT" + execute "CREATE INDEX idx_pools_posts__sequence_nat ON pools_posts (nat_sort(sequence))" + end + + def self.down + execute "DROP INDEX idx_pools_posts__sequence_nat" + execute "ALTER TABLE pools_posts ALTER COLUMN sequence TYPE INTEGER USING sequence::integer" + end +end diff --git a/db/migrate/20081209221550_add_slave_pool_posts.rb b/db/migrate/20081209221550_add_slave_pool_posts.rb new file mode 100644 index 00000000..e859d7cd --- /dev/null +++ b/db/migrate/20081209221550_add_slave_pool_posts.rb @@ -0,0 +1,21 @@ +require 'pool_post' + +class AddSlavePoolPosts < ActiveRecord::Migration + def self.up + execute "ALTER TABLE pools_posts ADD COLUMN master_id INTEGER REFERENCES pools_posts ON DELETE SET NULL" + execute "ALTER TABLE pools_posts ADD COLUMN slave_id INTEGER REFERENCES pools_posts ON DELETE SET NULL" + + PoolPost.find(:all).each { |pp| + pp.need_slave_update = true + pp.copy_changes_to_slave + } + + #execute "CREATE INDEX idx_pools_posts_child_id on pools_posts (child_id) WHERE child_id IS NOT NULL" + end + + def self.down + execute "DELETE FROM pools_posts WHERE master_id IS NOT NULL" + execute "ALTER TABLE pools_posts DROP COLUMN master_id" + execute "ALTER TABLE pools_posts DROP COLUMN slave_id" + end +end diff --git a/db/migrate/20081210193125_add_index_history_changes_previous_id.rb b/db/migrate/20081210193125_add_index_history_changes_previous_id.rb new file mode 100644 index 00000000..cf392b88 --- /dev/null +++ b/db/migrate/20081210193125_add_index_history_changes_previous_id.rb @@ -0,0 +1,10 @@ +class AddIndexHistoryChangesPreviousId < ActiveRecord::Migration + def self.up + # We need an index on this for its ON DELETE SET NULL. + add_index :history_changes, :previous_id + end + + def self.down + remove_index :history_changes, :previous_id + end +end diff --git a/db/migrate/20090215000207_add_inline_images.rb b/db/migrate/20090215000207_add_inline_images.rb new file mode 100644 index 00000000..e3bfc002 --- /dev/null +++ b/db/migrate/20090215000207_add_inline_images.rb @@ -0,0 +1,33 @@ +class AddInlineImages < ActiveRecord::Migration + def self.up + execute <<-EOS + CREATE TABLE inlines ( + id SERIAL PRIMARY KEY, + user_id integer REFERENCES users ON DELETE SET NULL, + created_at timestamp NOT NULL DEFAULT now(), + description text NOT NULL DEFAULT '' + ) + EOS + execute <<-EOS + CREATE TABLE inline_images ( + id SERIAL PRIMARY KEY, + inline_id integer NOT NULL REFERENCES inlines ON DELETE CASCADE, + md5 text NOT NULL, + file_ext text NOT NULL, + description text NOT NULL DEFAULT '', + sequence INTEGER NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + sample_width INTEGER, + sample_height INTEGER + ) + EOS + + add_index :inline_images, :inline_id + end + + def self.down + drop_table :inlines + drop_table :inline_images + end +end diff --git a/db/migrate/20090903232732_update_post_text.rb b/db/migrate/20090903232732_update_post_text.rb new file mode 100644 index 00000000..4b46d187 --- /dev/null +++ b/db/migrate/20090903232732_update_post_text.rb @@ -0,0 +1,21 @@ +class UpdatePostText < ActiveRecord::Migration + def self.up + Comment.find(:all, :conditions => ["body ILIKE '%%%%' OR body ILIKE '%%%%'"]).each { |comment| + comment.body = comment.body.gsub(//i, "[i]") + comment.body = comment.body.gsub(/<\/i>/i, "[/i]") + comment.body = comment.body.gsub(//i, "[b]") + comment.body = comment.body.gsub(/<\/b>/i, "[/b]") + comment.save! + } + end + + def self.down + Comment.find(:all, :conditions => ["body ILIKE '%%[i]%%' OR body ILIKE '%%[b]%%'"]).each { |comment| + comment.body = comment.body.gsub(/[i]/i, "") + comment.body = comment.body.gsub(/[\/i]/i, "") + comment.body = comment.body.gsub(/[b]/i, "") + comment.body = comment.body.gsub(/[\/b]/i, "") + comment.save! + } + end +end diff --git a/db/migrate/20091228170149_add_jpeg_columns.rb b/db/migrate/20091228170149_add_jpeg_columns.rb new file mode 100644 index 00000000..525fb766 --- /dev/null +++ b/db/migrate/20091228170149_add_jpeg_columns.rb @@ -0,0 +1,16 @@ +class AddJpegColumns < ActiveRecord::Migration + def self.up + add_column :posts, :jpeg_width, :integer + add_column :posts, :jpeg_height, :integer + add_column :posts, :jpeg_size, :integer, :default => 0, :null => false + add_column :posts, :jpeg_crc32, :bigint + end + + def self.down + remove_column :posts, :jpeg_width + remove_column :posts, :jpeg_height + remove_column :posts, :jpeg_size + remove_column :posts, :jpeg_crc32 + end +end + diff --git a/db/migrate/20100101225942_constrain_user_logs.rb b/db/migrate/20100101225942_constrain_user_logs.rb new file mode 100644 index 00000000..e9ad7dbf --- /dev/null +++ b/db/migrate/20100101225942_constrain_user_logs.rb @@ -0,0 +1,55 @@ +class ConstrainUserLogs < ActiveRecord::Migration + def self.up + execute <<-EOS + CREATE TEMPORARY TABLE user_logs_new ( + id SERIAL PRIMARY KEY, + user_id integer NOT NULL, + created_at timestamp NOT NULL DEFAULT now(), + ip_addr inet NOT NULL, + CONSTRAINT user_logs_new_user_ip UNIQUE (user_id, ip_addr) + ) + EOS + + execute <<-EOS + INSERT INTO user_logs_new (user_id, ip_addr, created_at) + SELECT user_id, ip_addr, MAX(created_at) FROM user_logs GROUP BY user_id, ip_addr; + EOS + + execute "DELETE FROM user_logs;" + + execute <<-EOS + INSERT INTO user_logs (user_id, ip_addr, created_at) + SELECT user_id, ip_addr, created_at FROM user_logs_new; + EOS + + # Make user_logs user/ip pairs unique. + execute "ALTER TABLE user_logs ADD CONSTRAINT user_logs_user_ip UNIQUE (user_id, ip_addr);" + + # If a log for a user/ip pair exists, update its timestamp. Otherwise, create a new + # record. Updating an existing record is the fast path. + execute <<-EOS + CREATE OR REPLACE FUNCTION user_logs_touch(new_user_id integer, new_ip inet) RETURNS VOID AS $$ + BEGIN + FOR i IN 1..3 LOOP + UPDATE user_logs SET created_at = now() where user_id = new_user_id and ip_addr = new_ip; + IF found THEN + RETURN; + END IF; + + BEGIN + INSERT INTO user_logs (user_id, ip_addr) VALUES (new_user_id, new_ip); + RETURN; + EXCEPTION WHEN unique_violation THEN + -- Try again. + END; + END LOOP; + END; + $$ LANGUAGE plpgsql; + EOS + end + + def self.down + execute "ALTER TABLE user_logs DROP CONSTRAINT user_logs_user_ip;" + execute "DROP FUNCTION user_logs_touch(integer, inet);" + end +end diff --git a/db/postgres.sql b/db/postgres.sql new file mode 100644 index 00000000..e48a27b9 --- /dev/null +++ b/db/postgres.sql @@ -0,0 +1,1108 @@ +-- +-- PostgreSQL database dump +-- + +SET client_encoding = 'SQL_ASCII'; +SET standard_conforming_strings = off; +SET check_function_bodies = false; +SET client_min_messages = warning; +SET escape_string_warning = off; + +-- +-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON SCHEMA public IS 'Standard public schema'; + + +-- +-- Name: plpgsql; Type: PROCEDURAL LANGUAGE; Schema: -; Owner: - +-- + +CREATE PROCEDURAL LANGUAGE plpgsql; + + +SET search_path = public, pg_catalog; + +-- +-- Name: trg_posts__delete(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION trg_posts__delete() RETURNS "trigger" + AS $$ +BEGIN + UPDATE table_data SET row_count = row_count - 1 WHERE name = 'posts'; + RETURN OLD; +END; +$$ + LANGUAGE plpgsql; + + +-- +-- Name: trg_posts__insert(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION trg_posts__insert() RETURNS "trigger" + AS $$ +BEGIN + UPDATE table_data SET row_count = row_count + 1 WHERE name = 'posts'; + RETURN NEW; +END; +$$ + LANGUAGE plpgsql; + + +-- +-- Name: trg_posts_tags__delete(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION trg_posts_tags__delete() RETURNS "trigger" + AS $$ +BEGIN + UPDATE tags SET post_count = post_count - 1 WHERE tags.id = OLD.tag_id; + RETURN OLD; +END; +$$ + LANGUAGE plpgsql; + + +-- +-- Name: trg_posts_tags__insert(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION trg_posts_tags__insert() RETURNS "trigger" + AS $$ +BEGIN + UPDATE tags SET post_count = post_count + 1 WHERE tags.id = NEW.tag_id; + RETURN NEW; +END; +$$ + LANGUAGE plpgsql; + + +-- +-- Name: trg_users__delete(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION trg_users__delete() RETURNS "trigger" + AS $$ +BEGIN + UPDATE table_data SET row_count = row_count - 1 WHERE name = 'users'; + RETURN OLD; +END; +$$ + LANGUAGE plpgsql; + + +-- +-- Name: trg_users__insert(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION trg_users__insert() RETURNS "trigger" + AS $$ +BEGIN + UPDATE table_data SET row_count = row_count + 1 WHERE name = 'users'; + RETURN NEW; +END; +$$ + LANGUAGE plpgsql; + + +SET default_tablespace = ''; + +SET default_with_oids = false; + +-- +-- Name: comments; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE comments ( + id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + post_id integer NOT NULL, + user_id integer, + body text NOT NULL, + ip_addr text NOT NULL, + signal_level smallint DEFAULT 1 NOT NULL +); + + +-- +-- Name: comments_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE comments_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: comments_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE comments_id_seq OWNED BY comments.id; + + +-- +-- Name: favorites; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE favorites ( + id integer NOT NULL, + post_id integer NOT NULL, + user_id integer NOT NULL +); + + +-- +-- Name: favorites_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE favorites_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: favorites_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE favorites_id_seq OWNED BY favorites.id; + + +-- +-- Name: note_versions; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE note_versions ( + id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + x integer NOT NULL, + y integer NOT NULL, + width integer NOT NULL, + height integer NOT NULL, + body text NOT NULL, + version integer NOT NULL, + ip_addr text NOT NULL, + is_active boolean DEFAULT true NOT NULL, + note_id integer NOT NULL, + post_id integer NOT NULL, + user_id integer +); + + +-- +-- Name: note_versions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE note_versions_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: note_versions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE note_versions_id_seq OWNED BY note_versions.id; + + +-- +-- Name: notes; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE notes ( + id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + user_id integer, + x integer NOT NULL, + y integer NOT NULL, + width integer NOT NULL, + height integer NOT NULL, + ip_addr text NOT NULL, + version integer DEFAULT 1 NOT NULL, + is_active boolean DEFAULT true NOT NULL, + post_id integer NOT NULL, + body text NOT NULL +); + + +-- +-- Name: notes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE notes_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: notes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE notes_id_seq OWNED BY notes.id; + + +-- +-- Name: post_tag_histories; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE post_tag_histories ( + id integer NOT NULL, + post_id integer NOT NULL, + tags text NOT NULL +); + + +-- +-- Name: post_tag_histories_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE post_tag_histories_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: post_tag_histories_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE post_tag_histories_id_seq OWNED BY post_tag_histories.id; + + +-- +-- Name: posts; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE posts ( + id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + user_id integer, + score integer DEFAULT 0 NOT NULL, + source text NOT NULL, + md5 text NOT NULL, + last_commented_at timestamp without time zone, + rating character(1) DEFAULT 'q'::bpchar NOT NULL, + width integer, + height integer, + is_warehoused boolean DEFAULT false NOT NULL, + last_voter_ip text, + ip_addr text NOT NULL, + cached_tags text DEFAULT ''::text NOT NULL, + is_note_locked boolean DEFAULT false NOT NULL, + fav_count integer DEFAULT 0 NOT NULL, + file_ext text DEFAULT ''::text NOT NULL, + last_noted_at timestamp without time zone, + is_rating_locked boolean DEFAULT false NOT NULL +); + + +-- +-- Name: posts_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE posts_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: posts_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE posts_id_seq OWNED BY posts.id; + + +-- +-- Name: posts_tags; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE posts_tags ( + post_id integer NOT NULL, + tag_id integer NOT NULL +); + + +-- +-- Name: table_data; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE table_data ( + name text NOT NULL, + row_count integer NOT NULL +); + + +-- +-- Name: tag_aliases; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE tag_aliases ( + id integer NOT NULL, + name text NOT NULL, + alias_id integer NOT NULL +); + + +-- +-- Name: tag_aliases_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE tag_aliases_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: tag_aliases_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE tag_aliases_id_seq OWNED BY tag_aliases.id; + + +-- +-- Name: tag_implications; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE tag_implications ( + id integer NOT NULL, + parent_id integer NOT NULL, + child_id integer NOT NULL +); + + +-- +-- Name: tag_implications_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE tag_implications_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: tag_implications_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE tag_implications_id_seq OWNED BY tag_implications.id; + + +-- +-- Name: tags; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE tags ( + id integer NOT NULL, + name text NOT NULL, + post_count integer DEFAULT 0 NOT NULL, + cached_related text DEFAULT '[]'::text NOT NULL, + cached_related_expires_on timestamp without time zone DEFAULT now() NOT NULL, + tag_type smallint DEFAULT 0 NOT NULL +); + + +-- +-- Name: tags_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE tags_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: tags_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE tags_id_seq OWNED BY tags.id; + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE users ( + id integer NOT NULL, + name text NOT NULL, + "password" text NOT NULL, + "level" integer DEFAULT 0 NOT NULL, + login_count integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE users_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE users_id_seq OWNED BY users.id; + + +-- +-- Name: wiki_page_versions; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE wiki_page_versions ( + id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + version integer DEFAULT 1 NOT NULL, + title text NOT NULL, + body text NOT NULL, + user_id integer, + ip_addr text NOT NULL, + wiki_page_id integer NOT NULL, + is_locked boolean DEFAULT false NOT NULL +); + + +-- +-- Name: wiki_page_versions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE wiki_page_versions_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: wiki_page_versions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE wiki_page_versions_id_seq OWNED BY wiki_page_versions.id; + + +-- +-- Name: wiki_pages; Type: TABLE; Schema: public; Owner: -; Tablespace: +-- + +CREATE TABLE wiki_pages ( + id integer NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + version integer DEFAULT 1 NOT NULL, + title text NOT NULL, + body text NOT NULL, + user_id integer, + ip_addr text NOT NULL, + is_locked boolean DEFAULT false NOT NULL +); + + +-- +-- Name: wiki_pages_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE wiki_pages_id_seq + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + + +-- +-- Name: wiki_pages_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE wiki_pages_id_seq OWNED BY wiki_pages.id; + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE comments ALTER COLUMN id SET DEFAULT nextval('comments_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE favorites ALTER COLUMN id SET DEFAULT nextval('favorites_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE note_versions ALTER COLUMN id SET DEFAULT nextval('note_versions_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE notes ALTER COLUMN id SET DEFAULT nextval('notes_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE post_tag_histories ALTER COLUMN id SET DEFAULT nextval('post_tag_histories_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE posts ALTER COLUMN id SET DEFAULT nextval('posts_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE tag_aliases ALTER COLUMN id SET DEFAULT nextval('tag_aliases_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE tag_implications ALTER COLUMN id SET DEFAULT nextval('tag_implications_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE tags ALTER COLUMN id SET DEFAULT nextval('tags_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE users ALTER COLUMN id SET DEFAULT nextval('users_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE wiki_page_versions ALTER COLUMN id SET DEFAULT nextval('wiki_page_versions_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE wiki_pages ALTER COLUMN id SET DEFAULT nextval('wiki_pages_id_seq'::regclass); + + +-- +-- Name: comments_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY comments + ADD CONSTRAINT comments_pkey PRIMARY KEY (id); + + +-- +-- Name: favorites_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY favorites + ADD CONSTRAINT favorites_pkey PRIMARY KEY (id); + + +-- +-- Name: note_versions_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY note_versions + ADD CONSTRAINT note_versions_pkey PRIMARY KEY (id); + + +-- +-- Name: notes_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY notes + ADD CONSTRAINT notes_pkey PRIMARY KEY (id); + + +-- +-- Name: post_tag_histories_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY post_tag_histories + ADD CONSTRAINT post_tag_histories_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: table_data_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY table_data + ADD CONSTRAINT table_data_pkey PRIMARY KEY (name); + + +-- +-- Name: tag_aliases_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY tag_aliases + ADD CONSTRAINT tag_aliases_pkey PRIMARY KEY (id); + + +-- +-- Name: tag_implications_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY tag_implications + ADD CONSTRAINT tag_implications_pkey PRIMARY KEY (id); + + +-- +-- Name: tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY tags + ADD CONSTRAINT tags_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: wiki_page_versions_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY wiki_page_versions + ADD CONSTRAINT wiki_page_versions_pkey PRIMARY KEY (id); + + +-- +-- Name: wiki_pages_pkey; Type: CONSTRAINT; Schema: public; Owner: -; Tablespace: +-- + +ALTER TABLE ONLY wiki_pages + ADD CONSTRAINT wiki_pages_pkey PRIMARY KEY (id); + + +-- +-- Name: idx_comments__post; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_comments__post ON comments USING btree (post_id); + + +-- +-- Name: idx_favorites__post; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_favorites__post ON favorites USING btree (post_id); + + +-- +-- Name: idx_favorites__post_user; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX idx_favorites__post_user ON favorites USING btree (post_id, user_id); + + +-- +-- Name: idx_favorites__user; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_favorites__user ON favorites USING btree (user_id); + + +-- +-- Name: idx_note_versions__post; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_note_versions__post ON note_versions USING btree (post_id); + + +-- +-- Name: idx_notes__note; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_notes__note ON note_versions USING btree (note_id); + + +-- +-- Name: idx_notes__post; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_notes__post ON notes USING btree (post_id); + + +-- +-- Name: idx_post_tag_histories__post; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_post_tag_histories__post ON post_tag_histories USING btree (post_id); + + +-- +-- Name: idx_posts__created_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_posts__created_at ON posts USING btree (created_at); + + +-- +-- Name: idx_posts__last_commented_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_posts__last_commented_at ON posts USING btree (last_commented_at) WHERE last_commented_at IS NOT NULL; + +-- +-- Name: idx_posts__last_noted_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_posts__last_noted_at ON posts USING btree (last_noted_at) WHERE last_noted_at IS NOT NULL; + + +-- +-- Name: idx_posts__md5; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX idx_posts__md5 ON posts USING btree (md5); + + +-- +-- Name: idx_posts__user; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_posts__user ON posts USING btree (user_id) WHERE user_id IS NOT NULL; + + +-- +-- Name: idx_posts_tags__post; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_posts_tags__post ON posts_tags USING btree (post_id); + + +-- +-- Name: idx_posts_tags__tag; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_posts_tags__tag ON posts_tags USING btree (tag_id); + + +-- +-- Name: idx_tag_aliases__name; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX idx_tag_aliases__name ON tag_aliases USING btree (name); + + +-- +-- Name: idx_tag_implications__child; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_tag_implications__child ON tag_implications USING btree (child_id); + + +-- +-- Name: idx_tag_implications__parent; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_tag_implications__parent ON tag_implications USING btree (parent_id); + + +-- +-- Name: idx_tags__name; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE UNIQUE INDEX idx_tags__name ON tags USING btree (name); + + +-- +-- Name: idx_tags__post_count; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_tags__post_count ON tags USING btree (post_count); + + +-- +-- Name: idx_users__name; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_users__name ON users USING btree (lower(name)); + + +-- +-- Name: idx_wiki_page_versions__wiki_page; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_wiki_page_versions__wiki_page ON wiki_page_versions USING btree (wiki_page_id); + + +-- +-- Name: idx_wiki_pages__title; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_wiki_pages__title ON wiki_pages USING btree (lower(title)); + + +-- +-- Name: idx_wiki_pages__updated_at; Type: INDEX; Schema: public; Owner: -; Tablespace: +-- + +CREATE INDEX idx_wiki_pages__updated_at ON wiki_pages USING btree (updated_at); + + +-- +-- Name: trg_posts__insert; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_posts__insert + BEFORE INSERT ON posts + FOR EACH ROW + EXECUTE PROCEDURE trg_posts__insert(); + + +-- +-- Name: trg_posts_delete; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_posts_delete + BEFORE DELETE ON posts + FOR EACH ROW + EXECUTE PROCEDURE trg_posts__delete(); + + +-- +-- Name: trg_posts_tags__delete; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_posts_tags__delete + BEFORE DELETE ON posts_tags + FOR EACH ROW + EXECUTE PROCEDURE trg_posts_tags__delete(); + + +-- +-- Name: trg_posts_tags__insert; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_posts_tags__insert + BEFORE INSERT ON posts_tags + FOR EACH ROW + EXECUTE PROCEDURE trg_posts_tags__insert(); + + +-- +-- Name: trg_users_delete; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_users_delete + BEFORE DELETE ON users + FOR EACH ROW + EXECUTE PROCEDURE trg_users__delete(); + + +-- +-- Name: trg_users_insert; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_users_insert + BEFORE INSERT ON users + FOR EACH ROW + EXECUTE PROCEDURE trg_users__insert(); + + +-- +-- Name: fk_comments__post; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY comments + ADD CONSTRAINT fk_comments__post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE; + + +-- +-- Name: fk_comments__user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY comments + ADD CONSTRAINT fk_comments__user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + + +-- +-- Name: fk_favorites__post; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY favorites + ADD CONSTRAINT fk_favorites__post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE; + + +-- +-- Name: fk_favorites__user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY favorites + ADD CONSTRAINT fk_favorites__user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + + +-- +-- Name: fk_note_versions__note; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY note_versions + ADD CONSTRAINT fk_note_versions__note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE; + + +-- +-- Name: fk_note_versions__post; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY note_versions + ADD CONSTRAINT fk_note_versions__post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE; + + +-- +-- Name: fk_note_versions__user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY note_versions + ADD CONSTRAINT fk_note_versions__user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + + +-- +-- Name: fk_notes__post; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY notes + ADD CONSTRAINT fk_notes__post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE; + + +-- +-- Name: fk_notes__user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY notes + ADD CONSTRAINT fk_notes__user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + + +-- +-- Name: fk_post_tag_histories__post; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY post_tag_histories + ADD CONSTRAINT fk_post_tag_histories__post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE; + + +-- +-- Name: fk_posts__user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY posts + ADD CONSTRAINT fk_posts__user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + + +-- +-- Name: fk_tag_aliases__alias; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY tag_aliases + ADD CONSTRAINT fk_tag_aliases__alias FOREIGN KEY (alias_id) REFERENCES tags(id) ON DELETE CASCADE; + + +-- +-- Name: fk_tag_implications__child; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY tag_implications + ADD CONSTRAINT fk_tag_implications__child FOREIGN KEY (child_id) REFERENCES tags(id) ON DELETE CASCADE; + + +-- +-- Name: fk_tag_implications__parent; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY tag_implications + ADD CONSTRAINT fk_tag_implications__parent FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE CASCADE; + + +-- +-- Name: fk_wiki_page_versions__user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY wiki_page_versions + ADD CONSTRAINT fk_wiki_page_versions__user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + + +-- +-- Name: fk_wiki_page_versions__wiki_page; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY wiki_page_versions + ADD CONSTRAINT fk_wiki_page_versions__wiki_page FOREIGN KEY (wiki_page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE; + + +-- +-- Name: fk_wiki_pages__user; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY wiki_pages + ADD CONSTRAINT fk_wiki_pages__user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + + +INSERT INTO table_data (name, row_count) VALUES ('posts', 0), ('users', 0); + +-- +-- Name: public; Type: ACL; Schema: -; Owner: - +-- + +REVOKE ALL ON SCHEMA public FROM PUBLIC; +REVOKE ALL ON SCHEMA public FROM postgres; +GRANT ALL ON SCHEMA public TO postgres; +GRANT ALL ON SCHEMA public TO PUBLIC; + + +-- +-- PostgreSQL database dump complete +-- + diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 00000000..5900c610 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,284 @@ +# This file is auto-generated from the current state of the database. Instead of editing this file, +# please use the migrations feature of ActiveRecord 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 => 72) do + + create_table "amazon_keywords", :force => true do |t| + t.string "keywords", :null => false + t.datetime "expires_on", :null => false + end + + create_table "amazon_results", :force => true do |t| + t.integer "amazon_keyword_id", :null => false + t.string "asin", :null => false + t.string "title", :default => "Unknown" + t.string "author", :default => "Unknown" + t.string "image_url", :default => "unknown.jpg" + t.string "detail_url", :null => false + t.string "price", :default => "Unknown" + t.datetime "date_relased" + t.string "company", :default => "Unknown" + end + + create_table "artist_urls", :force => true do |t| + t.integer "artist_id", :null => false + t.text "url", :null => false + t.text "normalized_url", :null => false + end + + add_index "artist_urls", ["artist_id"], :name => "index_artist_urls_on_artist_id" + add_index "artist_urls", ["normalized_url"], :name => "index_artist_urls_on_normalized_url" + add_index "artist_urls", ["url"], :name => "index_artist_urls_on_url" + + create_table "artists", :force => true do |t| + t.integer "alias_id" + t.integer "group_id" + t.text "name", :null => false + t.datetime "updated_at", :null => false + t.integer "updater_id" + t.integer "pixiv_id" + end + + add_index "artists", ["name"], :name => "artists_name_uniq", :unique => true + add_index "artists", ["pixiv_id"], :name => "index_artists_on_pixiv_id" + + create_table "bans", :force => true do |t| + t.integer "user_id", :null => false + t.text "reason", :null => false + t.datetime "expires_at", :null => false + t.integer "banned_by", :null => false + end + + add_index "bans", ["user_id"], :name => "index_bans_on_user_id" + + create_table "coefficients", :id => false, :force => true do |t| + t.integer "post_id" + t.string "color", :limit => 1 + t.integer "bin" + t.integer "v" + t.integer "x" + t.integer "y" + end + + create_table "comments", :force => true do |t| + t.datetime "created_at", :null => false + t.integer "post_id", :null => false + t.integer "user_id" + t.text "body", :null => false + t.string "ip_addr", :limit => nil, :null => false + t.boolean "is_spam" + end + + add_index "comments", ["post_id"], :name => "idx_comments__post" + + create_table "dmails", :force => true do |t| + t.integer "from_id", :null => false + t.integer "to_id", :null => false + t.text "title", :null => false + t.text "body", :null => false + t.datetime "created_at", :null => false + t.boolean "has_seen", :default => false, :null => false + t.integer "parent_id" + end + + add_index "dmails", ["from_id"], :name => "index_dmails_on_from_id" + add_index "dmails", ["parent_id"], :name => "index_dmails_on_parent_id" + add_index "dmails", ["to_id"], :name => "index_dmails_on_to_id" + + create_table "favorites", :force => true do |t| + t.integer "post_id", :null => false + t.integer "user_id", :null => false + t.datetime "created_at", :null => false + end + + add_index "favorites", ["post_id"], :name => "idx_favorites__post" + add_index "favorites", ["user_id"], :name => "idx_favorites__user" + + create_table "flagged_post_details", :force => true do |t| + t.datetime "created_at", :null => false + t.integer "post_id", :null => false + t.text "reason", :null => false + t.integer "user_id", :null => false + t.boolean "is_resolved", :null => false + end + + add_index "flagged_post_details", ["post_id"], :name => "index_flagged_post_details_on_post_id" + + create_table "flagged_posts", :force => true do |t| + t.datetime "created_at", :null => false + t.integer "post_id", :null => false + t.text "reason", :null => false + t.integer "user_id" + t.boolean "is_resolved", :default => false, :null => false + end + +# Could not dump table "forum_posts" because of following StandardError +# Unknown type 'tsvector' for column 'text_search_index' + +# Could not dump table "note_versions" because of following StandardError +# Unknown type 'tsvector' for column 'text_search_index' + +# Could not dump table "notes" because of following StandardError +# Unknown type 'tsvector' for column 'text_search_index' + + create_table "pools", :force => true do |t| + t.text "name", :null => false + t.datetime "created_at", :null => false + t.datetime "updated_at", :null => false + t.integer "user_id", :null => false + t.boolean "is_public", :default => false, :null => false + t.integer "post_count", :default => 0, :null => false + t.text "description", :default => "", :null => false + end + + add_index "pools", ["user_id"], :name => "pools_user_id_idx" + + create_table "pools_posts", :force => true do |t| + t.integer "sequence", :default => 0, :null => false + t.integer "pool_id", :null => false + t.integer "post_id", :null => false + end + + add_index "pools_posts", ["pool_id"], :name => "pools_posts_pool_id_idx" + add_index "pools_posts", ["post_id"], :name => "pools_posts_post_id_idx" + + create_table "post_tag_histories", :force => true do |t| + t.integer "post_id", :null => false + t.text "tags", :null => false + t.integer "user_id" + t.string "ip_addr", :limit => nil + t.datetime "created_at", :null => false + end + + add_index "post_tag_histories", ["post_id"], :name => "idx_post_tag_histories__post" + +# Could not dump table "posts" because of following StandardError +# Unknown type 'post_status' for column 'status' + + create_table "posts_tags", :id => false, :force => true do |t| + t.integer "post_id", :null => false + t.integer "tag_id", :null => false + end + + add_index "posts_tags", ["post_id"], :name => "idx_posts_tags__post" + add_index "posts_tags", ["tag_id"], :name => "idx_posts_tags__tag" + + create_table "table_data", :id => false, :force => true do |t| + t.text "name", :null => false + t.integer "row_count", :null => false + end + + create_table "tag_aliases", :force => true do |t| + t.text "name", :null => false + t.integer "alias_id", :null => false + t.boolean "is_pending", :default => false, :null => false + t.text "reason", :default => "", :null => false + end + + add_index "tag_aliases", ["name"], :name => "idx_tag_aliases__name", :unique => true + + create_table "tag_implications", :force => true do |t| + t.integer "consequent_id", :null => false + t.integer "predicate_id", :null => false + t.boolean "is_pending", :default => false, :null => false + t.text "reason", :default => "", :null => false + end + + add_index "tag_implications", ["predicate_id"], :name => "idx_tag_implications__child" + add_index "tag_implications", ["consequent_id"], :name => "idx_tag_implications__parent" + + create_table "tags", :force => true do |t| + t.text "name", :null => false + t.integer "post_count", :default => 0, :null => false + t.text "cached_related", :default => "[]", :null => false + t.datetime "cached_related_expires_on", :null => false + t.integer "tag_type", :default => 0, :null => false + t.boolean "is_ambiguous", :default => false, :null => false + t.integer "safe_post_count", :default => 0, :null => false + end + + add_index "tags", ["name"], :name => "idx_tags__name", :unique => true + add_index "tags", ["post_count"], :name => "idx_tags__post_count" + + create_table "user_records", :force => true do |t| + t.integer "user_id", :null => false + t.integer "reported_by", :null => false + t.datetime "created_at", :null => false + t.boolean "is_positive", :default => true, :null => false + t.text "body", :null => false + end + + create_table "users", :force => true do |t| + t.text "name", :null => false + t.text "password_hash", :null => false + t.integer "level", :default => 0, :null => false + t.text "email", :default => "", :null => false + t.text "my_tags", :default => "", :null => false + t.integer "invite_count", :default => 0, :null => false + t.boolean "always_resize_images", :default => false, :null => false + t.integer "invited_by" + t.datetime "created_at", :null => false + t.datetime "last_logged_in_at", :null => false + t.datetime "last_forum_topic_read_at", :default => '1960-01-01 00:00:00', :null => false + t.boolean "has_mail", :default => false, :null => false + t.boolean "receive_dmails", :default => false, :null => false + t.text "blacklisted_tags", :default => "", :null => false + t.boolean "show_samples", :default => true + end + +# Could not dump table "wiki_page_versions" because of following StandardError +# Unknown type 'tsvector' for column 'text_search_index' + +# Could not dump table "wiki_pages" because of following StandardError +# Unknown type 'tsvector' for column 'text_search_index' + + add_foreign_key "artist_urls", ["artist_id"], "artists", ["id"], :name => "artist_urls_artist_id_fkey" + + add_foreign_key "artists", ["alias_id"], "artists", ["id"], :on_delete => :set_null, :name => "artists_alias_id_fkey" + add_foreign_key "artists", ["group_id"], "artists", ["id"], :on_delete => :set_null, :name => "artists_group_id_fkey" + add_foreign_key "artists", ["updater_id"], "users", ["id"], :on_delete => :set_null, :name => "artists_updater_id_fkey" + + add_foreign_key "bans", ["banned_by"], "users", ["id"], :on_delete => :cascade, :name => "bans_banned_by_fkey" + add_foreign_key "bans", ["user_id"], "users", ["id"], :on_delete => :cascade, :name => "bans_user_id_fkey" + + add_foreign_key "comments", ["post_id"], "posts", ["id"], :on_delete => :cascade, :name => "fk_comments__post" + add_foreign_key "comments", ["user_id"], "users", ["id"], :on_delete => :set_null, :name => "fk_comments__user" + + add_foreign_key "dmails", ["from_id"], "users", ["id"], :on_delete => :cascade, :name => "dmails_from_id_fkey" + add_foreign_key "dmails", ["parent_id"], "dmails", ["id"], :name => "dmails_parent_id_fkey" + add_foreign_key "dmails", ["to_id"], "users", ["id"], :on_delete => :cascade, :name => "dmails_to_id_fkey" + + add_foreign_key "favorites", ["post_id"], "posts", ["id"], :on_delete => :cascade, :name => "fk_favorites__post " + add_foreign_key "favorites", ["user_id"], "users", ["id"], :on_delete => :cascade, :name => "fk_favorites__user" + + add_foreign_key "flagged_post_details", ["post_id"], "posts", ["id"], :name => "flagged_post_details_post_id_fkey" + add_foreign_key "flagged_post_details", ["user_id"], "users", ["id"], :name => "flagged_post_details_user_id_fkey" + + add_foreign_key "flagged_posts", ["user_id"], "users", ["id"], :on_delete => :cascade, :name => "flagged_posts_user_id_fkey" + + add_foreign_key "pools", ["user_id"], "users", ["id"], :on_delete => :cascade, :name => "pools_user_id_fkey" + + add_foreign_key "pools_posts", ["pool_id"], "pools", ["id"], :on_delete => :cascade, :name => "pools_posts_pool_id_fkey" + add_foreign_key "pools_posts", ["post_id"], "posts", ["id"], :on_delete => :cascade, :name => "pools_posts_post_id_fkey" + + add_foreign_key "post_tag_histories", ["user_id"], "users", ["id"], :on_delete => :set_null, :name => "post_tag_histories_user_id_fkey" + + add_foreign_key "posts_tags", ["tag_id"], "tags", ["id"], :on_delete => :cascade, :name => "fk_posts_tags__tag" + + add_foreign_key "tag_aliases", ["alias_id"], "tags", ["id"], :on_delete => :cascade, :name => "fk_tag_aliases__alias" + + add_foreign_key "tag_implications", ["predicate_id"], "tags", ["id"], :on_delete => :cascade, :name => "fk_tag_implications__child" + add_foreign_key "tag_implications", ["consequent_id"], "tags", ["id"], :on_delete => :cascade, :name => "fk_tag_implications__parent" + + add_foreign_key "user_records", ["reported_by"], "users", ["id"], :on_delete => :cascade, :name => "user_records_reported_by_fkey" + add_foreign_key "user_records", ["user_id"], "users", ["id"], :on_delete => :cascade, :name => "user_records_user_id_fkey" + +end diff --git a/lib/asset_cache.rb b/lib/asset_cache.rb new file mode 100644 index 00000000..3cfd03d8 --- /dev/null +++ b/lib/asset_cache.rb @@ -0,0 +1,54 @@ +require "action_view/helpers/tag_helper.rb" +require "action_view/helpers/asset_tag_helper.rb" + +# Fix a bug in expand_javascript_sources: if the cache file exists, but the server +# is started in development, the old cache will be included among all of the individual +# source files. +module ActionView + module Helpers + module AssetTagHelper + private + alias_method :orig_expand_javascript_sources, :expand_javascript_sources + def expand_javascript_sources(sources) + x = orig_expand_javascript_sources sources + x.delete("application") + x + end + end + end +end + +# Fix another bug: if the javascript sources are changed, the cache is never +# regenerated. Call on init. +module AssetCache + # This is dumb. How do I call this function without wrapping it in a class? + class RegenerateJavascriptCache + include ActionView::Helpers::TagHelper + include ActionView::Helpers::AssetTagHelper + end + + def clear_js_cache + # Don't do anything if caching is disabled; we won't use the file anyway, and + # if we're in a rake script, we'll delete the file and then not regenerate it. + return if not ActionController::Base.perform_caching + + # Overwrite the file atomically, so nothing breaks if a user requests the file + # before we finish writing it. + path = (defined?(RAILS_ROOT) ? "#{RAILS_ROOT}/public" : "public") + # HACK: Many processes will do this simultaneously, and they'll pick up + # the temporary application-new-12345 file being created by other processes + # as a regular Javascript file and try to include it in their own, causing + # weird race conditions. Write the file in the parent directory. + cache_temp = "../../tmp/application-new-#{$PROCESS_ID}" + temp = "#{path}/javascripts/#{cache_temp}.js" + file = "#{path}/javascripts/application.js" + File.unlink(temp) if File.exist?(temp) + c = RegenerateJavascriptCache.new + c.javascript_include_tag(:all, :cache => cache_temp) + + FileUtils.mv(temp, file) + end + + module_function :clear_js_cache +end + diff --git a/lib/cache.rb b/lib/cache.rb new file mode 100644 index 00000000..486b608f --- /dev/null +++ b/lib/cache.rb @@ -0,0 +1,28 @@ +module Cache + def expire(options = {}) + if CONFIG["enable_caching"] + tags = options[:tags] + cache_version = Cache.get("$cache_version").to_i + + Cache.put("$cache_version", cache_version + 1) + + if tags + tags.scan(/\S+/).each do |x| + key = "tag:#{x}" + key_version = Cache.get(key).to_i + Cache.put(key, key_version + 1) + end + end + end + end + + def incr(key) + if CONFIG["enable_caching"] + val = Cache.get(key) + Cache.put(key, val.to_i + 1) + end + end + + module_function :expire + module_function :incr +end diff --git a/lib/cache_dummy.rb b/lib/cache_dummy.rb new file mode 100644 index 00000000..0f062587 --- /dev/null +++ b/lib/cache_dummy.rb @@ -0,0 +1,13 @@ +class MemCache + def flush_all + end +end + +module Cache + def self.get(key, expiry = 0) + if block_given? then + yield + end + end +end + diff --git a/lib/danbooru_image_resizer/ConvertToRGB.cpp b/lib/danbooru_image_resizer/ConvertToRGB.cpp new file mode 100644 index 00000000..eb1e950d --- /dev/null +++ b/lib/danbooru_image_resizer/ConvertToRGB.cpp @@ -0,0 +1,66 @@ +#include +#include +#include +#include "ConvertToRGB.h" +#include "Filter.h" +#include +using namespace std; + +ConvertToRGB::ConvertToRGB(auto_ptr pCompressor): + m_pCompressor(pCompressor) +{ + m_pBuffer = NULL; +} + +ConvertToRGB::~ConvertToRGB() +{ + delete[] m_pBuffer; +} + +bool ConvertToRGB::Init(int iSourceWidth, int iSourceHeight, int iBPP) +{ + m_iSourceWidth = iSourceWidth; + // m_iSourceHeight = iSourceHeight; + m_iBPP = iBPP; + m_pBuffer = new uint8_t[iSourceWidth * 3]; + assert(m_iBPP == 1 || m_iBPP == 3 || m_iBPP == 4); // greyscale, RGB or RGBA + + return m_pCompressor->Init(iSourceWidth, iSourceHeight, 3); +} + +bool ConvertToRGB::WriteRow(uint8_t *pNewRow) +{ + if(m_iBPP == 3) + return m_pCompressor->WriteRow(pNewRow); + if(m_iBPP == 1) + { + uint8_t *pBuffer = m_pBuffer; + for(int i = 0; i < m_iSourceWidth; ++i) + { + *pBuffer++ = *pNewRow; + *pBuffer++ = *pNewRow; + *pBuffer++ = *pNewRow; + ++pNewRow; + } + } + else if(m_iBPP == 4) + { + uint8_t *pBuffer = m_pBuffer; + for(int i = 0; i < m_iSourceWidth; ++i) + { + uint8_t iR = *pNewRow++; + uint8_t iG = *pNewRow++; + uint8_t iB = *pNewRow++; + uint8_t iA = *pNewRow++; + iR = uint8_t((iR * iA) / 255.0f); + iG = uint8_t((iG * iA) / 255.0f); + iB = uint8_t((iB * iA) / 255.0f); + *pBuffer++ = iR; + *pBuffer++ = iG; + *pBuffer++ = iB; + } + } + + return m_pCompressor->WriteRow(m_pBuffer); +} + diff --git a/lib/danbooru_image_resizer/ConvertToRGB.h b/lib/danbooru_image_resizer/ConvertToRGB.h new file mode 100644 index 00000000..be117c90 --- /dev/null +++ b/lib/danbooru_image_resizer/ConvertToRGB.h @@ -0,0 +1,27 @@ +#ifndef CONVERT_TO_RGB_H +#define CONVERT_TO_RGB_H + +#include "Filter.h" +#include +using namespace std; + +class ConvertToRGB: public Filter +{ +public: + ConvertToRGB(auto_ptr pCompressor); + ~ConvertToRGB(); + + bool Init(int iSourceWidth, int iSourceHeight, int BPP); + bool WriteRow(uint8_t *pNewRow); + bool Finish() { return true; } + + const char *GetError() const { return NULL; } + +private: + uint8_t *m_pBuffer; + auto_ptr m_pCompressor; + int m_iSourceWidth; + int m_iBPP; +}; + +#endif diff --git a/lib/danbooru_image_resizer/Crop.cpp b/lib/danbooru_image_resizer/Crop.cpp new file mode 100644 index 00000000..bfc11c5c --- /dev/null +++ b/lib/danbooru_image_resizer/Crop.cpp @@ -0,0 +1,39 @@ +#include "Crop.h" + +Crop::Crop(auto_ptr pOutput): + m_pOutput(pOutput) +{ + m_iRow = 0; +} + +void Crop::SetCrop(int iTop, int iBottom, int iLeft, int iRight) +{ + m_iTop = iTop; + m_iBottom = iBottom; + m_iLeft = iLeft; + m_iRight = iRight; +} + +bool Crop::Init(int iWidth, int iHeight, int iBPP) +{ + m_iSourceWidth = iWidth; + m_iSourceHeight = iHeight; + m_iSourceBPP = iBPP; + + return m_pOutput->Init(m_iRight - m_iLeft, m_iBottom - m_iTop, iBPP); +} + +bool Crop::WriteRow(uint8_t *pNewRow) +{ + if(m_iRow >= m_iTop && m_iRow < m_iBottom) + { + pNewRow += m_iLeft * m_iSourceBPP; + if(!m_pOutput->WriteRow(pNewRow)) + return false; + } + + ++m_iRow; + + return true; +} + diff --git a/lib/danbooru_image_resizer/Crop.h b/lib/danbooru_image_resizer/Crop.h new file mode 100644 index 00000000..11be870a --- /dev/null +++ b/lib/danbooru_image_resizer/Crop.h @@ -0,0 +1,31 @@ +#ifndef CROP_H +#define CROP_H + +#include "Filter.h" +#include +using namespace std; + +class Crop: public Filter +{ +public: + Crop(auto_ptr pOutput); + void SetCrop(int iTop, int iBottom, int iLeft, int iRight); + bool Init(int iWidth, int iHeight, int iBPP); + bool WriteRow(uint8_t *pNewRow); + bool Finish() { return m_pOutput->Finish(); } + const char *GetError() const { return m_pOutput->GetError(); } + +private: + auto_ptr m_pOutput; + + int m_iRow; + int m_iTop; + int m_iBottom; + int m_iLeft; + int m_iRight; + int m_iSourceWidth; + int m_iSourceHeight; + int m_iSourceBPP; +}; + +#endif diff --git a/lib/danbooru_image_resizer/Filter.h b/lib/danbooru_image_resizer/Filter.h new file mode 100644 index 00000000..03b1093e --- /dev/null +++ b/lib/danbooru_image_resizer/Filter.h @@ -0,0 +1,16 @@ +#ifndef FILTER_H +#define FILTER_H + +#include + +class Filter +{ +public: + virtual ~Filter() { } + virtual bool Init(int iSourceWidth, int iSourceHeight, int iSourceBPP) = 0; + virtual bool WriteRow(uint8_t *row) = 0; + virtual bool Finish() = 0; + virtual const char *GetError() const = 0; +}; + +#endif diff --git a/lib/danbooru_image_resizer/GIFReader.cpp b/lib/danbooru_image_resizer/GIFReader.cpp new file mode 100644 index 00000000..b4bbc9eb --- /dev/null +++ b/lib/danbooru_image_resizer/GIFReader.cpp @@ -0,0 +1,60 @@ +#include +#include +#include +#include "GIFReader.h" +#include "Resize.h" + +bool GIF::Read(FILE *f, Filter *pOutput, char error[1024]) +{ + bool Ret = false; + gdImage *image = gdImageCreateFromGif(f); + + if(!image) + { + strcpy(error, "couldn't read GIF"); + return false; + } + + uint8_t *pBuf = NULL; + pBuf = (uint8_t *) malloc(image->sx * 3); + if(pBuf == NULL) + { + strcpy(error, "out of memory"); + goto cleanup; + } + + pOutput->Init(image->sx, image->sy, 3); + for(int y = 0; y < image->sy; ++y) + { + uint8_t *p = pBuf; + + for(int x = 0; x < image->sx; ++x) + { + int c = gdImageGetTrueColorPixel(image, x, y); + (*p++) = gdTrueColorGetRed(c); + (*p++) = gdTrueColorGetGreen(c); + (*p++) = gdTrueColorGetBlue(c); + } + + if(!pOutput->WriteRow(pBuf)) + { + strcpy(error, pOutput->GetError()); + goto cleanup; + } + } + + if(!pOutput->Finish()) + { + strcpy(error, pOutput->GetError()); + goto cleanup; + } + + Ret = true; + +cleanup: + if(pBuf != NULL) + free(pBuf); + + gdImageDestroy(image); + return Ret; +} diff --git a/lib/danbooru_image_resizer/GIFReader.h b/lib/danbooru_image_resizer/GIFReader.h new file mode 100644 index 00000000..42488c1b --- /dev/null +++ b/lib/danbooru_image_resizer/GIFReader.h @@ -0,0 +1,12 @@ +#ifndef GIF_READER_H +#define GIF_READER_H + +#include "Reader.h" +class Filter; +class GIF: public Reader +{ +public: + bool Read(FILE *f, Filter *pOutput, char error[1024]); +}; + +#endif diff --git a/lib/danbooru_image_resizer/JPEGReader.cpp b/lib/danbooru_image_resizer/JPEGReader.cpp new file mode 100644 index 00000000..8e45a2fc --- /dev/null +++ b/lib/danbooru_image_resizer/JPEGReader.cpp @@ -0,0 +1,175 @@ +#include +#include +#include "JPEGReader.h" +#include "Resize.h" +#include +using namespace std; + +static void jpeg_error_exit(j_common_ptr CInfo) +{ + jpeg_error *myerr = (jpeg_error *) CInfo->err; + (*CInfo->err->format_message) (CInfo, myerr->buffer); + longjmp(myerr->setjmp_buffer, 1); +} + +static void jpeg_warning(j_common_ptr cinfo, int msg_level) +{ +} + +JPEGCompressor::JPEGCompressor(FILE *f) +{ + m_File = f; + memset(&m_CInfo, 0, sizeof(m_CInfo)); +} + +JPEGCompressor::~JPEGCompressor() +{ + jpeg_destroy_compress(&m_CInfo); +} + +const char *JPEGCompressor::GetError() const +{ + return m_JErr.buffer; +} + +void JPEGCompressor::SetQuality(int quality) +{ + m_iQuality = quality; +} + +bool JPEGCompressor::Init(int width, int height, int bpp) +{ + assert(bpp == 3); + m_CInfo.err = jpeg_std_error(&m_JErr.pub); + + m_JErr.pub.error_exit = jpeg_error_exit; + m_JErr.pub.emit_message = jpeg_warning; + + if(setjmp(m_JErr.setjmp_buffer)) + return false; + + jpeg_create_compress(&m_CInfo); + + jpeg_stdio_dest(&m_CInfo, m_File); + + m_CInfo.image_width = width; + m_CInfo.image_height = height; + m_CInfo.input_components = 3; /* # of color components per pixel */ + m_CInfo.in_color_space = JCS_RGB; /* colorspace of input image */ + + jpeg_set_defaults(&m_CInfo); + jpeg_set_quality(&m_CInfo, m_iQuality, TRUE); // limit to baseline-JPEG values + + /* For high-quality compression, disable color subsampling. */ + if(m_iQuality >= 95) + { + m_CInfo.comp_info[0].h_samp_factor = 1; + m_CInfo.comp_info[0].v_samp_factor = 1; + m_CInfo.comp_info[1].h_samp_factor = 1; + m_CInfo.comp_info[1].v_samp_factor = 1; + m_CInfo.comp_info[2].h_samp_factor = 1; + m_CInfo.comp_info[2].v_samp_factor = 1; + } + + jpeg_start_compress(&m_CInfo, TRUE); + + return true; +} + +int JPEGCompressor::GetWidth() const +{ + return m_CInfo.image_width; +} + +int JPEGCompressor::GetHeight() const +{ + return m_CInfo.image_height; +} + +bool JPEGCompressor::WriteRow(uint8_t *row) +{ + if(setjmp(m_JErr.setjmp_buffer)) + return false; + + jpeg_write_scanlines(&m_CInfo, (JSAMPLE **) &row, 1); + return true; +} + +bool JPEGCompressor::Finish() +{ + if(setjmp(m_JErr.setjmp_buffer)) + return false; + + jpeg_finish_compress(&m_CInfo); + return true; +} + +bool JPEG::Read(FILE *f, Filter *pOutput, char error[1024]) +{ + // JMSG_LENGTH_MAX <= sizeof(error) + m_pOutputFilter = pOutput; + + struct jpeg_decompress_struct CInfo; + CInfo.err = jpeg_std_error(&m_JErr.pub); + m_JErr.pub.error_exit = jpeg_error_exit; + m_JErr.pub.emit_message = jpeg_warning; + + bool Ret = false; + uint8_t *pBuf = NULL; + if(setjmp(m_JErr.setjmp_buffer)) + { + memcpy(error, m_JErr.buffer, JMSG_LENGTH_MAX); + goto cleanup; + } + + jpeg_create_decompress(&CInfo); + + jpeg_stdio_src(&CInfo, f); + jpeg_read_header(&CInfo, TRUE); + CInfo.out_color_space = JCS_RGB; + + jpeg_start_decompress(&CInfo); + + if(!m_pOutputFilter->Init(CInfo.output_width, CInfo.output_height, 3)) + { + strncpy(error, m_pOutputFilter->GetError(), sizeof(error)); + error[sizeof(error)-1] = 0; + goto cleanup; + } + + pBuf = (uint8_t *) malloc(CInfo.output_width * 3); + if(pBuf == NULL) + { + strcpy(error, "out of memory"); + goto cleanup; + } + + while(CInfo.output_scanline < CInfo.output_height) + { + jpeg_read_scanlines(&CInfo, &pBuf, 1); + + if(!m_pOutputFilter->WriteRow(pBuf)) + { + strcpy(error, m_pOutputFilter->GetError()); + goto cleanup; + } + } + + if(!m_pOutputFilter->Finish()) + { + strcpy(error, m_pOutputFilter->GetError()); + goto cleanup; + } + + jpeg_finish_decompress(&CInfo); + + Ret = true; + +cleanup: + if(pBuf != NULL) + free(pBuf); + jpeg_destroy_decompress(&CInfo); + + return Ret; +} + diff --git a/lib/danbooru_image_resizer/JPEGReader.h b/lib/danbooru_image_resizer/JPEGReader.h new file mode 100644 index 00000000..06c8625c --- /dev/null +++ b/lib/danbooru_image_resizer/JPEGReader.h @@ -0,0 +1,50 @@ +#ifndef JPEG_READER_H +#define JPEG_READER_H + +#include +#include +#include +#include "jpeglib-extern.h" +#include "Reader.h" +#include "Filter.h" + +struct jpeg_error +{ + struct jpeg_error_mgr pub; + jmp_buf setjmp_buffer; + char buffer[JMSG_LENGTH_MAX]; +}; + +class JPEG: public Reader +{ +public: + bool Read(FILE *f, Filter *pOutput, char error[1024]); + +private: + Filter *m_pOutputFilter; + struct jpeg_error m_JErr; +}; + +class JPEGCompressor: public Filter +{ +public: + JPEGCompressor(FILE *f); + ~JPEGCompressor(); + + bool Init(int iSourceWidth, int iSourceHeight, int iBPP); + void SetQuality(int quality); + bool WriteRow(uint8_t *row); + bool Finish(); + + int GetWidth() const; + int GetHeight() const; + const char *GetError() const; + +private: + FILE *m_File; + int m_iQuality; + struct jpeg_compress_struct m_CInfo; + struct jpeg_error m_JErr; +}; + +#endif diff --git a/lib/danbooru_image_resizer/Makefile b/lib/danbooru_image_resizer/Makefile new file mode 100644 index 00000000..6a7e68e0 --- /dev/null +++ b/lib/danbooru_image_resizer/Makefile @@ -0,0 +1,149 @@ + +SHELL = /bin/sh + +#### Start of system configuration section. #### + +srcdir = . +topdir = /usr/lib/ruby/1.8/x86_64-linux +hdrdir = $(topdir) +VPATH = $(srcdir):$(topdir):$(hdrdir) +prefix = $(DESTDIR)/usr +exec_prefix = $(prefix) +sitedir = $(DESTDIR)/usr/local/lib/site_ruby +rubylibdir = $(libdir)/ruby/$(ruby_version) +docdir = $(datarootdir)/doc/$(PACKAGE) +dvidir = $(docdir) +datarootdir = $(prefix)/share +archdir = $(rubylibdir)/$(arch) +sbindir = $(exec_prefix)/sbin +psdir = $(docdir) +localedir = $(datarootdir)/locale +htmldir = $(docdir) +datadir = $(datarootdir) +includedir = $(prefix)/include +infodir = $(prefix)/share/info +sysconfdir = $(DESTDIR)/etc +mandir = $(prefix)/share/man +libdir = $(exec_prefix)/lib +sharedstatedir = $(prefix)/com +oldincludedir = $(DESTDIR)/usr/include +pdfdir = $(docdir) +sitearchdir = $(sitelibdir)/$(sitearch) +bindir = $(exec_prefix)/bin +localstatedir = $(DESTDIR)/var +sitelibdir = $(sitedir)/$(ruby_version) +libexecdir = $(prefix)/lib/ruby1.8 + +CC = g++ +LIBRUBY = $(LIBRUBY_SO) +LIBRUBY_A = lib$(RUBY_SO_NAME)-static.a +LIBRUBYARG_SHARED = -l$(RUBY_SO_NAME) +LIBRUBYARG_STATIC = -l$(RUBY_SO_NAME)-static + +RUBY_EXTCONF_H = +CFLAGS = -fPIC -O2 -Wall +INCFLAGS = -I. -I. -I/usr/lib/ruby/1.8/x86_64-linux -I. -I/usr/local/include +CPPFLAGS = -DHAVE_GD_H -DHAVE_GDIMAGECREATEFROMGIF -DHAVE_GDIMAGEJPEG -DHAVE_JPEG_SET_QUALITY -DHAVE_PNG_SET_EXPAND_GRAY_1_2_4_TO_8 +CXXFLAGS = $(CFLAGS) +DLDFLAGS = -L. -Wl,-Bsymbolic-functions -rdynamic -Wl,-export-dynamic +LDSHARED = $(CC) -shared +AR = ar +EXEEXT = + +RUBY_INSTALL_NAME = ruby1.8 +RUBY_SO_NAME = ruby1.8 +arch = x86_64-linux +sitearch = x86_64-linux +ruby_version = 1.8 +ruby = /usr/bin/ruby1.8 +RUBY = $(ruby) +RM = rm -f +MAKEDIRS = mkdir -p +INSTALL = /usr/bin/install -c +INSTALL_PROG = $(INSTALL) -m 0755 +INSTALL_DATA = $(INSTALL) -m 644 +COPY = cp + +#### End of system configuration section. #### + +preload = + +libpath = . $(libdir) +LIBPATH = -L"." -L"$(libdir)" +DEFFILE = + +CLEANFILES = mkmf.log +DISTCLEANFILES = + +extout = +extout_prefix = +target_prefix = +LOCAL_LIBS = +LIBS = $(LIBRUBYARG_SHARED) -lpng -ljpeg -lgd -lpthread -ldl -lcrypt -lm -lc +SRCS = ConvertToRGB.cpp GIFReader.cpp Resize.cpp JPEGReader.cpp Crop.cpp danbooru_image_resizer.cpp PNGReader.cpp +OBJS = ConvertToRGB.o GIFReader.o Resize.o JPEGReader.o Crop.o danbooru_image_resizer.o PNGReader.o +TARGET = danbooru_image_resizer +DLLIB = $(TARGET).so +EXTSTATIC = +STATIC_LIB = + +RUBYCOMMONDIR = $(sitedir)$(target_prefix) +RUBYLIBDIR = $(sitelibdir)$(target_prefix) +RUBYARCHDIR = $(sitearchdir)$(target_prefix) + +TARGET_SO = $(DLLIB) +CLEANLIBS = $(TARGET).so $(TARGET).il? $(TARGET).tds $(TARGET).map +CLEANOBJS = *.o *.a *.s[ol] *.pdb *.exp *.bak + +all: $(DLLIB) +static: $(STATIC_LIB) + +clean: + @-$(RM) $(CLEANLIBS) $(CLEANOBJS) $(CLEANFILES) + +distclean: clean + @-$(RM) Makefile $(RUBY_EXTCONF_H) conftest.* mkmf.log + @-$(RM) core ruby$(EXEEXT) *~ $(DISTCLEANFILES) + +realclean: distclean +install: install-so install-rb + +install-so: $(RUBYARCHDIR) +install-so: $(RUBYARCHDIR)/$(DLLIB) +$(RUBYARCHDIR)/$(DLLIB): $(DLLIB) + $(INSTALL_PROG) $(DLLIB) $(RUBYARCHDIR) +install-rb: pre-install-rb install-rb-default +install-rb-default: pre-install-rb-default +pre-install-rb: Makefile +pre-install-rb-default: Makefile +$(RUBYARCHDIR): + $(MAKEDIRS) $@ + +site-install: site-install-so site-install-rb +site-install-so: install-so +site-install-rb: install-rb + +.SUFFIXES: .c .m .cc .cxx .cpp .C .o + +.cc.o: + $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) -c $< + +.cxx.o: + $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) -c $< + +.cpp.o: + $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) -c $< + +.C.o: + $(CXX) $(INCFLAGS) $(CPPFLAGS) $(CXXFLAGS) -c $< + +.c.o: + $(CC) $(INCFLAGS) $(CPPFLAGS) $(CFLAGS) -c $< + +$(DLLIB): $(OBJS) + @-$(RM) $@ + $(LDSHARED) -o $@ $(OBJS) $(LIBPATH) $(DLDFLAGS) $(LOCAL_LIBS) $(LIBS) + + + +$(OBJS): ruby.h defines.h diff --git a/lib/danbooru_image_resizer/PNGReader.cpp b/lib/danbooru_image_resizer/PNGReader.cpp new file mode 100644 index 00000000..7fa1bd7a --- /dev/null +++ b/lib/danbooru_image_resizer/PNGReader.cpp @@ -0,0 +1,139 @@ +#include +#include +#include "PNGReader.h" +#include "Resize.h" +#include +using namespace std; + +void PNG::Error(png_struct *png, const char *error) +{ + png_error_info *info = (png_error_info *) png->error_ptr; + strncpy(info->err, error, 1024); + info->err[1023] = 0; + longjmp(png->jmpbuf, 1); +} + +void PNG::Warning(png_struct *png, const char *warning) +{ +} + +void PNG::InfoCallback(png_struct *png, png_info *info_ptr) +{ + PNG *data = (PNG *) png_get_progressive_ptr(png); + + png_uint_32 width, height; + int bit_depth, color_type; + png_get_IHDR(png, info_ptr, &width, &height, &bit_depth, &color_type, NULL, NULL, NULL); + + png_set_palette_to_rgb(png); + png_set_tRNS_to_alpha(png); + png_set_filler(png, 0xFF, PNG_FILLER_AFTER); + if(bit_depth < 8) + png_set_packing(png); + if(color_type == PNG_COLOR_TYPE_GRAY && bit_depth < 8) + png_set_expand_gray_1_2_4_to_8(png); + if(bit_depth == 16) + png_set_strip_16(png); + data->m_Passes = png_set_interlace_handling(png); + + if (color_type == PNG_COLOR_TYPE_GRAY || color_type == PNG_COLOR_TYPE_GRAY_ALPHA) + png_set_gray_to_rgb(png); + + if(!data->m_Rows.Init(width, height, 4)) + Error(png, "out of memory"); + + png_read_update_info(png, info_ptr); + + data->m_pOutputFilter->Init(width, height, 4); +} + +void PNG::RowCallback(png_struct *png, png_byte *new_row, png_uint_32 row_num, int pass) +{ + PNG *data = (PNG *) png_get_progressive_ptr(png); + + uint8_t *p = data->m_Rows.GetRow(row_num); + if(p == NULL) + Error(png, "out of memory"); + + png_progressive_combine_row(png, p, new_row); + + if(pass != data->m_Passes - 1) + return; + + /* We've allocated data->m_RowsAllocated, but if we're doing multiple passes, only + * rows 0 to row_num will actually have usable data. */ + if(!data->m_pOutputFilter->WriteRow(p)) + Error(png, data->m_pOutputFilter->GetError()); + + /* If we're interlaced, never discard rows. */ + if(data->m_Passes == 1) + data->m_Rows.DiscardRows(row_num+1); +} + +void PNG::EndCallback(png_struct *png, png_info *info) +{ + PNG *data = (PNG *) png_get_progressive_ptr(png); + data->m_Done = true; +} + + +bool PNG::Read(FILE *f, Filter *pOutput, char error[1024]) +{ + m_pOutputFilter = pOutput; + + png_error_info err; + err.err = error; + + png_struct *png = png_create_read_struct(PNG_LIBPNG_VER_STRING, &err, Error, Warning); + if(png == NULL) + { + sprintf(error, "creating png_create_read_struct failed"); + return false; + } + + png_info *info_ptr = png_create_info_struct(png); + if(info_ptr == NULL) + { + png_destroy_read_struct(&png, NULL, NULL); + sprintf(error, "creating png_create_info_struct failed"); + return false; + } + + if(setjmp(png->jmpbuf)) + { + png_destroy_read_struct(&png, &info_ptr, NULL); + return false; + } + + png_set_progressive_read_fn(png, this, InfoCallback, RowCallback, EndCallback); + + while(1) + { + png_byte buf[1024*16]; + int ret = fread(buf, 1, sizeof(buf), f); + if(ret == 0) + break; + if(ferror(f)) + { + strcpy(error, strerror(errno)); + png_destroy_read_struct(&png, &info_ptr, NULL); + return false; + } + + png_process_data(png, info_ptr, buf, ret); + } + + if(!m_pOutputFilter->Finish()) + Error(png, m_pOutputFilter->GetError()); + + if(!m_Done) + { + strcpy(error, "incomplete file"); + png_destroy_read_struct(&png, &info_ptr, NULL); + return false; + } + + png_destroy_read_struct(&png, &info_ptr, NULL); + return true; +} + diff --git a/lib/danbooru_image_resizer/PNGReader.h b/lib/danbooru_image_resizer/PNGReader.h new file mode 100644 index 00000000..a065152d --- /dev/null +++ b/lib/danbooru_image_resizer/PNGReader.h @@ -0,0 +1,38 @@ +#ifndef PNG_READER_H +#define PNG_READER_H + +#include +#include "Reader.h" +#include "Filter.h" +#include "RowBuffer.h" + +struct png_error_info +{ + char *err; +}; + +class PNG: public Reader +{ +public: + PNG() + { + m_Done = false; + } + + bool Read(FILE *f, Filter *pOutput, char error[1024]); + +private: + RowBuffer m_Rows; + Filter *m_pOutputFilter; + + bool m_Done; + int m_Passes; + + static void Error(png_struct *png, const char *error); + static void Warning(png_struct *png, const char *warning); + static void InfoCallback(png_struct *png, png_info *info_ptr); + static void RowCallback(png_struct *png, png_byte *new_row, png_uint_32 row_num, int pass); + static void EndCallback(png_struct *png, png_info *info); +}; + +#endif diff --git a/lib/danbooru_image_resizer/Reader.h b/lib/danbooru_image_resizer/Reader.h new file mode 100644 index 00000000..eb217ccf --- /dev/null +++ b/lib/danbooru_image_resizer/Reader.h @@ -0,0 +1,14 @@ +#ifndef READER_H +#define READER_H + +#include + +class Filter; +class Reader +{ +public: + virtual ~Reader() { } + virtual bool Read(FILE *f, Filter *rp, char errorbuf[1024]) = 0; +}; + +#endif diff --git a/lib/danbooru_image_resizer/Resize.cpp b/lib/danbooru_image_resizer/Resize.cpp new file mode 100644 index 00000000..353f0b8a --- /dev/null +++ b/lib/danbooru_image_resizer/Resize.cpp @@ -0,0 +1,286 @@ +#include +#include +#include +#include +#include +#include "Resize.h" +#include "Filter.h" +#include +using namespace std; + +namespace +{ + inline float sincf(float x) + { + if(fabsf(x) < 1e-9) + return 1.0; + + return sinf(x) / x; + } + + inline double fract(double f) + { + return f - floor(f); + } +} + +static const int KERNEL_SIZE = 3; + +LanczosFilter::LanczosFilter() +{ + m_pFilters = NULL; +} + +LanczosFilter::~LanczosFilter() +{ + delete[] m_pFilters; +} + +void LanczosFilter::Init(float fFactor) +{ + /* If we're reducing the image, each output pixel samples each input pixel in the + * range once, so we step one pixel. If we're enlarging it by 2x, each output pixel + * samples each input pixel twice, so we step half a pixel. */ + m_fStep = 1; + if(fFactor > 1.0) + m_fStep = 1.0 / fFactor; + + /* If we're sampling each pixel twice (m_fStep is .5), then we need twice as many taps + * to sample KERNEL_SIZE pixels. */ + m_iTaps = (int) ceil(KERNEL_SIZE / m_fStep) * 2; + + delete[] m_pFilters; + m_pFilters = NULL; // in case of exception + m_pFilters = new float[m_iTaps * 256]; + + float *pOutput = m_pFilters; + for(int i=0; i < 256; ++i) + { + float fOffset = i / 256.0f; + + float fSum = 0; + for(int i = 0; i < m_iTaps; ++i) + { + float fPos = -(m_iTaps/2-1) - fOffset + i; + fPos *= m_fStep; + + float fValue = 0; + if(fabs(fPos) < KERNEL_SIZE) + fValue = sincf(M_PI*fPos) * sincf(M_PI / KERNEL_SIZE * fPos); + + pOutput[i] = fValue; + fSum += fValue; + } + + /* Scale the filter so it sums to 1. */ + for(int i = 0; i pOutput): + m_pCompressor(pOutput) +{ + m_DestWidth = -1; + m_DestHeight = -1; + m_CurrentY = 0; + m_OutBuf = NULL; + m_szError = NULL; + m_iInputY = 0; +} + +Resizer::~Resizer() +{ + if(m_OutBuf) + free(m_OutBuf); +} + +const char *Resizer::GetError() const +{ + if(m_szError != NULL) + return m_szError; + return m_pCompressor->GetError(); +} + +bool Resizer::Init(int iSourceWidth, int iSourceHeight, int iBPP) +{ + assert(m_DestWidth != -1); + assert(m_DestHeight != -1); + assert(iBPP == 3); + m_SourceWidth = iSourceWidth; + m_SourceHeight = iSourceHeight; + m_SourceBPP = iBPP; + + float fXFactor = float(m_SourceWidth) / m_DestWidth; + m_XFilter.Init(fXFactor); + + float fYFactor = float(m_SourceHeight) / m_DestHeight; + m_YFilter.Init(fYFactor); + + if(!m_Rows.Init(m_DestWidth, m_SourceHeight, m_SourceBPP, m_YFilter.m_iTaps)) + { + m_szError = "out of memory"; + return false; + } + + m_OutBuf = (uint8_t *) malloc(m_DestWidth * m_SourceBPP); + if(m_OutBuf == NULL) + { + m_szError = "out of memory"; + return false; + } + + return m_pCompressor->Init(m_DestWidth, m_DestHeight, m_SourceBPP); +} + +void Resizer::SetDest(int iDestWidth, int iDestHeight) +{ + m_DestWidth = iDestWidth; + m_DestHeight = iDestHeight; +} + +static uint8_t *PadRow(const uint8_t *pSourceRow, int iWidth, int iBPP, int iPadding) +{ + uint8_t *pRow = new uint8_t[(iWidth + iPadding*2) * iBPP]; + uint8_t *pDest = pRow; + for(int x = 0; x < iPadding; ++x) + { + for(int i = 0; i < iBPP; ++i) + pDest[i] = pSourceRow[i]; + pDest += iBPP; + } + + memcpy(pDest, pSourceRow, iWidth*iBPP*sizeof(uint8_t)); + pDest += iWidth*iBPP; + + for(int x = 0; x < iPadding; ++x) + { + for(int i = 0; i < iBPP; ++i) + pDest[i] = pSourceRow[i]; + pDest += iBPP; + } + + return pRow; +} + +bool Resizer::WriteRow(uint8_t *pNewRow) +{ + if(m_SourceWidth == m_DestWidth && m_SourceHeight == m_DestHeight) + { + ++m_CurrentY; + + /* We don't actually have any resizing to do, so short-circuit. */ + if(!m_pCompressor->WriteRow((uint8_t *) pNewRow)) + return false; + + if(m_CurrentY != m_DestHeight) + return true; + + return m_pCompressor->Finish(); + } + + /* Make a copy of pNewRow with the first and last pixel duplicated, so we don't have to do + * bounds checking in the inner loop below. */ + uint8_t *pActualPaddedRow = PadRow(pNewRow, m_SourceWidth, m_SourceBPP, m_XFilter.m_iTaps/2); + const uint8_t *pPaddedRow = pActualPaddedRow + (m_XFilter.m_iTaps/2)*m_SourceBPP; + + const float fXFactor = float(m_SourceWidth) / m_DestWidth; + const float fYFactor = float(m_SourceHeight) / m_DestHeight; + + /* Run the horizontal filter on the incoming row, and drop the result into m_Rows. */ + { + float *pRow = m_Rows.GetRow(m_iInputY); + ++m_iInputY; + + float *pOutput = pRow; + for(int x = 0; x < m_DestWidth; ++x) + { + const double fSourceX = (x + 0.5f) * fXFactor; + const double fOffset = fract(fSourceX + 0.5); + const float *pFilter = m_XFilter.GetFilter(fOffset); + const int iStartX = lrint(fSourceX - m_XFilter.m_iTaps/2 + 1e-6); + + const uint8_t *pSource = pPaddedRow + iStartX*3; + + float fR = 0, fG = 0, fB = 0; + for(int i = 0; i < m_XFilter.m_iTaps; ++i) + { + float fWeight = *pFilter++; + + fR += pSource[0] * fWeight; + fG += pSource[1] * fWeight; + fB += pSource[2] * fWeight; + pSource += 3; + } + + pOutput[0] = fR; + pOutput[1] = fG; + pOutput[2] = fB; + + pOutput += m_SourceBPP; + } + } + delete[] pActualPaddedRow; + + const float *const *pSourceRows = m_Rows.GetRows(); + while(m_CurrentY < m_DestHeight) + { + const double fSourceY = (m_CurrentY + 0.5) * fYFactor; + const double fOffset = fract(fSourceY + 0.5); + const int iStartY = lrint(fSourceY - m_YFilter.m_iTaps/2 + 1e-6); + + /* iStartY is the first row we'll need, and we never move backwards. Discard rows + * before it to save memory. */ + m_Rows.DiscardRows(iStartY); + + if(m_iInputY != m_SourceHeight && iStartY+m_YFilter.m_iTaps >= m_iInputY) + return true; + + /* Process the next output row. */ + uint8_t *pOutput = m_OutBuf; + for(int x = 0; x < m_DestWidth; ++x) + { + const float *pFilter = m_YFilter.GetFilter(fOffset); + + float fR = 0, fG = 0, fB = 0; + for(int i = 0; i < m_YFilter.m_iTaps; ++i) + { + const float *pSource = pSourceRows[iStartY+i]; + pSource += x * m_SourceBPP; + + float fWeight = *pFilter++; + fR += pSource[0] * fWeight; + fG += pSource[1] * fWeight; + fB += pSource[2] * fWeight; + } + + pOutput[0] = (uint8_t) max(0, min(255, (int) lrintf(fR))); + pOutput[1] = (uint8_t) max(0, min(255, (int) lrintf(fG))); + pOutput[2] = (uint8_t) max(0, min(255, (int) lrintf(fB))); + + pOutput += 3; + } + + if(!m_pCompressor->WriteRow((uint8_t *) m_OutBuf)) + return false; + ++m_CurrentY; + } + + if(m_CurrentY == m_DestHeight) + { + if(!m_pCompressor->Finish()) + return false; + } + + return true; +} + diff --git a/lib/danbooru_image_resizer/Resize.h b/lib/danbooru_image_resizer/Resize.h new file mode 100644 index 00000000..946daada --- /dev/null +++ b/lib/danbooru_image_resizer/Resize.h @@ -0,0 +1,56 @@ +#ifndef RESIZE_H +#define RESIZE_H + +#include "RowBuffer.h" +#include "Filter.h" +#include +using namespace std; +#include + +struct LanczosFilter +{ + LanczosFilter(); + ~LanczosFilter(); + void Init(float fFactor); + const float *GetFilter(float fOffset) const; + + float m_fStep; + int m_iTaps; + float *m_pFilters; +}; + +class Resizer: public Filter +{ +public: + Resizer(auto_ptr pCompressor); + ~Resizer(); + + // BPP is 3 or 4, indicating RGB or RGBA. + bool Init(int iSourceWidth, int iSourceHeight, int BPP); + void SetDest(int iDestWidth, int iDestHeight); + bool WriteRow(uint8_t *pNewRow); + bool Finish() { return true; } + + const char *GetError() const; + +private: + auto_ptr m_pCompressor; + uint8_t *m_OutBuf; + RowBuffer m_Rows; + const char *m_szError; + + int m_SourceWidth; + int m_SourceHeight; + int m_SourceBPP; + + int m_DestWidth; + int m_DestHeight; + + LanczosFilter m_XFilter; + LanczosFilter m_YFilter; + + int m_iInputY; + int m_CurrentY; +}; + +#endif diff --git a/lib/danbooru_image_resizer/RowBuffer.h b/lib/danbooru_image_resizer/RowBuffer.h new file mode 100644 index 00000000..2c961830 --- /dev/null +++ b/lib/danbooru_image_resizer/RowBuffer.h @@ -0,0 +1,137 @@ +#ifndef ROW_BUFFER_H +#define ROW_BUFFER_H + +#include +#include +#include +#include +#include "RowBuffer.h" +#include +using namespace std; + +template +class RowBuffer +{ +public: + RowBuffer() + { + m_Rows = NULL; + m_ActualRows = NULL; + m_StartRow = 0; + m_EndRow = 0; + m_BPP = 0; + m_Height = 0; + } + + ~RowBuffer() + { + for(int i = 0; i < m_Height; ++i) + delete [] m_Rows[i]; + + delete [] m_ActualRows; + } + + /* + * If iVertPadding is non-zero, simulate padding on the top and bottom of the image. After + * row 0 is written, rows [-1 ... -iVertPadding] will point to the same row. After the bottom + * row is written, the following iVertPadding will also point to the last row. These rows + * are discarded when the row they refer to is discarded. + */ + bool Init(int iWidth, int iHeight, int iBPP, int iVertPadding = 0) + { + m_Width = iWidth; + m_Height = iHeight; + m_BPP = iBPP; + m_iVertPadding = iVertPadding; + + m_ActualRows = new T *[iHeight + iVertPadding*2]; + m_Rows = m_ActualRows + iVertPadding; + memset(m_ActualRows, 0, sizeof(T *) * (iHeight + iVertPadding*2)); + + return true; + } + + /* Return row, allocating if necessary. */ + T *GetRow(int Row) + { + assert(m_BPP > 0); + + if(m_Rows[Row] == NULL) + { + m_Rows[Row] = new T[m_Width*m_BPP]; + if(Row == 0) + { + for(int i = -m_iVertPadding; i < 0; ++i) + m_Rows[i] = m_Rows[0]; + } + if(Row == m_Height - 1) + { + for(int i = m_Height; i < m_Height + m_iVertPadding; ++i) + m_Rows[i] = m_Rows[m_Height - 1]; + } + if(m_Rows[Row] == NULL) + return NULL; + if(m_StartRow == m_EndRow) + { + m_StartRow = Row; + m_EndRow = m_StartRow + 1; + } + } + + if(int(Row) == m_StartRow+1) + { + while(m_StartRow != 0 && m_Rows[m_StartRow-1]) + --m_StartRow; + } + + if(int(Row) == m_EndRow) + { + while(m_EndRow < m_Height && m_Rows[m_EndRow]) + ++m_EndRow; + } + return m_Rows[Row]; + } + + // Free rows [0,DiscardRow). + void DiscardRows(int DiscardRow) + { + assert(m_BPP > 0); + if(DiscardRow > m_Height) + DiscardRow = m_Height; + + for(int i = m_StartRow; i < DiscardRow; ++i) + { + delete [] m_Rows[i]; + m_Rows[i] = NULL; + } + + m_StartRow = max(m_StartRow, DiscardRow); + m_EndRow = max(m_EndRow, DiscardRow); + } + + /* Get a range of rows allocated in m_Rows: [m_StartRow,m_EndRow). If + * more than one allocated range exists, which range is returned is undefined. */ + int GetStartRow() const { return m_StartRow; } + int GetEndRow() const { return m_EndRow; } + const T *const *GetRows() const { return m_Rows; } + +private: + /* Array of image rows. These are allocated as needed. */ + T **m_Rows; + + /* The actual pointer m_Rows is contained in. m_Rows may be offset from this to + * implement padding. */ + T **m_ActualRows; + + /* in m_Rows is allocated: */ + int m_StartRow; + int m_EndRow; + + int m_Width; + int m_Height; + int m_BPP; + int m_iVertPadding; +}; + +#endif + diff --git a/lib/danbooru_image_resizer/danbooru_image_resizer.bundle b/lib/danbooru_image_resizer/danbooru_image_resizer.bundle new file mode 100644 index 00000000..06c8d74e Binary files /dev/null and b/lib/danbooru_image_resizer/danbooru_image_resizer.bundle differ diff --git a/lib/danbooru_image_resizer/danbooru_image_resizer.cpp b/lib/danbooru_image_resizer/danbooru_image_resizer.cpp new file mode 100644 index 00000000..f77dbbc2 --- /dev/null +++ b/lib/danbooru_image_resizer/danbooru_image_resizer.cpp @@ -0,0 +1,106 @@ +#include +#include +#include +#include +using namespace std; +#include "PNGReader.h" +#include "GIFReader.h" +#include "JPEGReader.h" +#include "Resize.h" +#include "Crop.h" +#include "ConvertToRGB.h" + +static VALUE danbooru_module; + +static VALUE danbooru_resize_image(VALUE module, VALUE file_ext_val, VALUE read_path_val, VALUE write_path_val, + VALUE output_width_val, VALUE output_height_val, + VALUE crop_top_val, VALUE crop_bottom_val, VALUE crop_left_val, VALUE crop_right_val, + VALUE output_quality_val) +{ + const char * file_ext = StringValueCStr(file_ext_val); + const char * read_path = StringValueCStr(read_path_val); + const char * write_path = StringValueCStr(write_path_val); + int output_width = NUM2INT(output_width_val); + int output_height = NUM2INT(output_height_val); + int output_quality = NUM2INT(output_quality_val); + int crop_top = NUM2INT(crop_top_val); + int crop_bottom = NUM2INT(crop_bottom_val); + int crop_left = NUM2INT(crop_left_val); + int crop_right = NUM2INT(crop_right_val); + + FILE *read_file = fopen(read_path, "rb"); + if(read_file == NULL) + rb_raise(rb_eIOError, "can't open %s\n", read_path); + + FILE *write_file = fopen(write_path, "wb"); + if(write_file == NULL) + { + fclose(read_file); + rb_raise(rb_eIOError, "can't open %s\n", write_path); + } + + bool ret = false; + char error[1024]; + + try + { + auto_ptr pReader(NULL); + if (!strcmp(file_ext, "jpg") || !strcmp(file_ext, "jpeg")) + pReader.reset(new JPEG); + else if (!strcmp(file_ext, "gif")) + pReader.reset(new GIF); + else if (!strcmp(file_ext, "png")) + pReader.reset(new PNG); + else + { + strcpy(error, "unknown filetype"); + goto cleanup; + } + + auto_ptr pFilter(NULL); + + { + auto_ptr pCompressor(new JPEGCompressor(write_file)); + pCompressor->SetQuality(output_quality); + pFilter.reset(pCompressor.release()); + } + + { + auto_ptr pResizer(new Resizer(pFilter)); + pResizer->SetDest(output_width, output_height); + pFilter.reset(pResizer.release()); + } + + if(crop_bottom > crop_top && crop_right > crop_left) + { + auto_ptr pCropper(new Crop(pFilter)); + pCropper->SetCrop(crop_top, crop_bottom, crop_left, crop_right); + pFilter.reset(pCropper.release()); + } + + { + auto_ptr pConverter(new ConvertToRGB(pFilter)); + pFilter.reset(pConverter.release()); + } + + ret = pReader->Read(read_file, pFilter.get(), error); + } + catch(const std::bad_alloc &e) + { + strcpy(error, "out of memory"); + } + +cleanup: + fclose(read_file); + fclose(write_file); + + if(!ret) + rb_raise(rb_eException, "%s", error); + + return INT2FIX(0); +} + +extern "C" void Init_danbooru_image_resizer() { + danbooru_module = rb_define_module("Danbooru"); + rb_define_module_function(danbooru_module, "resize_image", (VALUE(*)(...))danbooru_resize_image, 10); +} diff --git a/lib/danbooru_image_resizer/danbooru_image_resizer.rb b/lib/danbooru_image_resizer/danbooru_image_resizer.rb new file mode 100644 index 00000000..74726e42 --- /dev/null +++ b/lib/danbooru_image_resizer/danbooru_image_resizer.rb @@ -0,0 +1,83 @@ +require 'danbooru_image_resizer/danbooru_image_resizer.so' + +module Danbooru + class ResizeError < Exception; end + + # If output_quality is an integer, it specifies the JPEG output quality to use. + # + # If it's a hash, it's of this form: + # { :min => 90, :max => 100, :filesize => 1048576 } + # + # This will search for the highest quality compression under :filesize between 90 and 100. + # This allows cleanly filtered images to receive a high compression ratio, but allows lowering + # the compression on noisy images. + def resize(file_ext, read_path, write_path, output_size, output_quality) + if output_quality.class == Fixnum + output_quality = { :min => output_quality, :max => output_quality, :filesize => 1024*1024*1024 } + end + + # A binary search is a poor fit here: we'd always have to do at least two compressions + # to find out whether the conversion we've done is the maximum fit, and most images will + # generally fit with maximum-quality compression anyway. Just search linearly from :max + # down. + quality = output_quality[:max] + begin + while true + # If :crop is set, crop between [crop_top,crop_bottom) and [crop_left,crop_right) + # before resizing. + Danbooru.resize_image(file_ext, read_path, write_path, output_size[:width], output_size[:height], + output_size[:crop_top] || 0, output_size[:crop_bottom] || 0, output_size[:crop_left] || 0, output_size[:crop_right] || 0, + quality) + + # If the file is small enough, or if we're at the lowest allowed quality setting + # already, finish. + return if !output_quality[:filesize].nil? && File.size(write_path) <= output_quality[:filesize] + return if quality <= output_quality[:min] + quality -= 1 + end + rescue IOError + raise + rescue Exception => e + raise ResizeError, e.to_s + end + end + + # If allow_enlarge is true, always scale to fit, even if the source area is + # smaller than max_size. + def reduce_to(size, max_size, ratio = 1, allow_enlarge = false) + ret = size.dup + + if allow_enlarge + if ret[:width] < max_size[:width] + scale = max_size[:width].to_f / ret[:width].to_f + ret[:width] = ret[:width] * scale + ret[:height] = ret[:height] * scale + end + + if max_size[:height] && (ret[:height] < ratio * max_size[:height]) + scale = max_size[:height].to_f / ret[:height].to_f + ret[:width] = ret[:width] * scale + ret[:height] = ret[:height] * scale + end + end + + if ret[:width] > ratio * max_size[:width] + scale = max_size[:width].to_f / ret[:width].to_f + ret[:width] = ret[:width] * scale + ret[:height] = ret[:height] * scale + end + + if max_size[:height] && (ret[:height] > ratio * max_size[:height]) + scale = max_size[:height].to_f / ret[:height].to_f + ret[:width] = ret[:width] * scale + ret[:height] = ret[:height] * scale + end + + ret[:width] = ret[:width].round + ret[:height] = ret[:height].round + ret + end + + module_function :resize + module_function :reduce_to +end diff --git a/lib/danbooru_image_resizer/danbooru_image_resizer.so b/lib/danbooru_image_resizer/danbooru_image_resizer.so new file mode 100755 index 00000000..c51db064 Binary files /dev/null and b/lib/danbooru_image_resizer/danbooru_image_resizer.so differ diff --git a/lib/danbooru_image_resizer/extconf.rb b/lib/danbooru_image_resizer/extconf.rb new file mode 100644 index 00000000..78db2ac7 --- /dev/null +++ b/lib/danbooru_image_resizer/extconf.rb @@ -0,0 +1,28 @@ +#!/bin/env ruby + +require 'mkmf' + +CONFIG['CC'] = "g++" +CONFIG['LDSHARED'] = CONFIG['LDSHARED'].sub(/^cc /,'g++ ') # otherwise we would not link with the C++ runtime +$INCFLAGS << " -I/usr/local/include" + +dir_config("gd") +dir_config("jpeg") +dir_config("png") + +have_header("gd.h") + +have_library("gd") +have_library("jpeg") +have_library("png") + +have_func("gdImageCreateFromGif", "gd.h") +have_func("gdImageJpeg", "gd.h") +have_func("jpeg_set_quality", ["stdlib.h", "stdio.h", "jpeglib-extern.h"]) +have_func("png_set_expand_gray_1_2_4_to_8", "png.h") + +#with_cflags("-O0 -g -Wall") {true} +with_cflags("-O2 -Wall") {true} +#with_cflags("-O0 -g -fno-exceptions -Wall") {true} + +create_makefile("danbooru_image_resizer") diff --git a/lib/danbooru_image_resizer/jpeglib-extern.h b/lib/danbooru_image_resizer/jpeglib-extern.h new file mode 100644 index 00000000..de92d400 --- /dev/null +++ b/lib/danbooru_image_resizer/jpeglib-extern.h @@ -0,0 +1,16 @@ +// Needed for OS X + +#ifndef JPEGLIB_EXTERN_H +#define JPEGLIB_EXTERN_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/lib/danbooru_image_resizer/test-out-95.jpg b/lib/danbooru_image_resizer/test-out-95.jpg new file mode 100644 index 00000000..5e9601a8 Binary files /dev/null and b/lib/danbooru_image_resizer/test-out-95.jpg differ diff --git a/lib/danbooru_image_resizer/test-out-96.jpg b/lib/danbooru_image_resizer/test-out-96.jpg new file mode 100644 index 00000000..bf4147b6 Binary files /dev/null and b/lib/danbooru_image_resizer/test-out-96.jpg differ diff --git a/lib/danbooru_image_resizer/test-out-97.jpg b/lib/danbooru_image_resizer/test-out-97.jpg new file mode 100644 index 00000000..fa857bc2 Binary files /dev/null and b/lib/danbooru_image_resizer/test-out-97.jpg differ diff --git a/lib/danbooru_image_resizer/test-out-98.jpg b/lib/danbooru_image_resizer/test-out-98.jpg new file mode 100644 index 00000000..96c89349 Binary files /dev/null and b/lib/danbooru_image_resizer/test-out-98.jpg differ diff --git a/lib/danbooru_image_resizer/test.png b/lib/danbooru_image_resizer/test.png new file mode 100644 index 00000000..629db7b4 Binary files /dev/null and b/lib/danbooru_image_resizer/test.png differ diff --git a/lib/danbooru_image_resizer/test.rb b/lib/danbooru_image_resizer/test.rb new file mode 100644 index 00000000..aab00c33 --- /dev/null +++ b/lib/danbooru_image_resizer/test.rb @@ -0,0 +1,7 @@ +#!/usr/local/bin/ruby +require 'danbooru_image_resizer.so' +[95,96,97,98].each { |n| + Danbooru.resize_image("png", "test.png", "test-out-#{n}.jpg", 2490, 3500, + 0, 0, 0, 0, n) +} + diff --git a/lib/diff.rb b/lib/diff.rb new file mode 100644 index 00000000..3596f197 --- /dev/null +++ b/lib/diff.rb @@ -0,0 +1,61 @@ +module Danbooru + TAG_DEL = '' + TAG_INS = '' + TAG_DEL_CLOSE = '' + TAG_INS_CLOSE = '' + TAG_NEWLINE = "↲\n" + TAG_BREAK = "
    \n" + + # Produce a formatted page that shows the difference between two versions of a page. + def diff(old, new) + pattern = Regexp.new('(?:<.+?>)|(?:[0-9_A-Za-z\x80-\xff]+[\x09\x20]?)|(?:[ \t]+)|(?:\r?\n)|(?:.+?)') + + thisarr = old.scan(pattern) + otharr = new.scan(pattern) + + cbo = Diff::LCS::ContextDiffCallbacks.new + diffs = thisarr.diff(otharr, cbo) + + escape_html = lambda {|str| str.gsub(/&/,'&').gsub(//,'>')} + + output = thisarr; + output.each { |q| q.replace(escape_html[q]) } + + diffs.reverse_each do |hunk| + newchange = hunk.max{|a,b| a.old_position <=> b.old_position} + newstart = newchange.old_position + oldstart = hunk.min{|a,b| a.old_position <=> b.old_position}.old_position + + if newchange.action == '+' + output.insert(newstart, TAG_INS_CLOSE) + end + + hunk.reverse_each do |chg| + case chg.action + when '-' + oldstart = chg.old_position + output[chg.old_position] = TAG_NEWLINE if chg.old_element.match(/^\r?\n$/) + when '+' + if chg.new_element.match(/^\r?\n$/) + output.insert(chg.old_position, TAG_NEWLINE) + else + output.insert(chg.old_position, "#{escape_html[chg.new_element]}") + end + end + end + + if newchange.action == '+' + output.insert(newstart, TAG_INS) + end + + if hunk[0].action == '-' + output.insert((newstart == oldstart || newchange.action != '+') ? newstart+1 : newstart, TAG_DEL_CLOSE) + output.insert(oldstart, TAG_DEL) + end + end + + output.join.gsub(/\r?\n/, TAG_BREAK) + end + + module_function :diff +end diff --git a/lib/download.rb b/lib/download.rb new file mode 100644 index 00000000..5aa66d3b --- /dev/null +++ b/lib/download.rb @@ -0,0 +1,61 @@ +module Danbooru + # Download the given URL, following redirects; once we have the result, yield the request. + def http_get_streaming(source, options = {}, &block) + max_size = options[:max_size] || CONFIG["max_image_size"] + max_size = nil if max_size == 0 # unlimited + + limit = 4 + + while true + url = URI.parse(source) + + unless url.is_a?(URI::HTTP) + raise SocketError, "URL must be HTTP" + end + + Net::HTTP.start(url.host, url.port) do |http| + http.read_timeout = 10 + + headers = { + "User-Agent" => "#{CONFIG["app_name"]}/#{CONFIG["version"]}", + "Referer" => source + } + + if source =~ /pixiv\.net/ + headers["Referer"] = "http://www.pixiv.net" + + # Don't download the small version + if source =~ %r!(/img/.+?/.+?)_m.+$! + match = $1 + source.sub!(match + "_m", match) + end + end + + http.request_get(url.request_uri, headers) do |res| + case res + when Net::HTTPSuccess then + if max_size + len = res["Content-Length"] + raise SocketError, "File is too large (#{len} bytes)" if len && len.to_i > max_size + end + + return yield(res) + + when Net::HTTPRedirection then + if limit == 0 then + raise SocketError, "Too many redirects" + end + source = res["location"] + limit -= 1 + + else + raise SocketError, "HTTP error code: #{res.code} #{res.message}" + end + end + end + end + end + + module_function :http_get_streaming +end + diff --git a/lib/dtext.rb b/lib/dtext.rb new file mode 100644 index 00000000..ab4c8ad9 --- /dev/null +++ b/lib/dtext.rb @@ -0,0 +1,124 @@ +#!/usr/bin/env ruby + +require 'cgi' + +module DText + def parse_inline(str) + str = CGI.escapeHTML(str) + str.gsub!(/\[\[.+?\]\]/m) do |tag| + tag = tag[2..-3] + if tag =~ /^(.+?)\|(.+)$/ + tag = $1 + name = $2 + '' + name + '' + else + '' + tag + '' + end + end + str.gsub!(/\{\{.+?\}\}/m) do |tag| + tag = tag[2..-3] + '' + tag + '' + end + str.gsub!(/[Pp]ost #(\d+)/, 'post #\1') + str.gsub!(/[Ff]orum #(\d+)/, 'forum #\1') + str.gsub!(/[Cc]omment #(\d+)/, 'comment #\1') + str.gsub!(/[Pp]ool #(\d+)/, 'pool #\1') + str.gsub!(/\n/m, "
    ") + str.gsub!(/\[b\](.+?)\[\/b\]/, '\1') + str.gsub!(/\[i\](.+?)\[\/i\]/, '\1') + str.gsub!(/\[spoilers?\](.+?)\[\/spoilers?\]/m, 'spoiler') + str.gsub!(/\[spoilers?(=(.+))\](.+?)\[\/spoilers?\]/m, '\2') + + # Ruby regexes are in the localization dark ages, so we need to match UTF-8 characters + # manually: + utf8_char = '[\xC0-\xFF][\x80-\xBF]+' + + url = "(h?ttps?:\\/\\/(?:[a-zA-Z0-9_\\-#~%.,:;\\(\\)\\[\\]$@!&=+?\\/#]|#{utf8_char})+)" + str.gsub!(/#{url}|<<#{url}(?:\|(.+?))?>>/m) do |link| # url or <> + if $1 then + link = $1 + url = link.gsub(/[.;,:'"]+$/, "") + if url =~ /^ttp/ then url = "h" + url end + '' + link + '' + else + link = $2 + text = $3 + '' + text + '' + end + end + str + end + + def parse_list(str) + html = "" + layout = [] + nest = 0 + + str.split(/\n/).each do |line| + if line =~ /^\s*(\*+) (.+)/ + nest = $1.size + content = parse_inline($2) + else + content = parse_inline(line) + end + + if nest > layout.size + html += "
      " + layout << "ul" + end + + while nest < layout.size + elist = layout.pop + if elist + html += "" + end + end + + html += "
    • #{content}
    • " + end + + while layout.any? + elist = layout.pop + html += "" + end + + html + end + + def parse(str) + # Make sure quote tags are surrounded by newlines + str.gsub!(/\s*\[quote\]\s*/m, "\n\n[quote]\n\n") + str.gsub!(/\s*\[\/quote\]\s*/m, "\n\n[/quote]\n\n") + str.gsub!(/(?:\r?\n){3,}/, "\n\n") + str.strip! + blocks = str.split(/(?:\r?\n){2}/) + + html = blocks.map do |block| + case block + when /^(h[1-6])\.\s*(.+)$/ + tag = $1 + content = $2 + "<#{tag}>" + parse_inline(content) + "" + + when /^\s*\*+ / + parse_list(block) + + when "[quote]" + '
      ' + + when "[/quote]" + '
      ' + + else + '

      ' + parse_inline(block) + "

      " + end + end + + html.join("") + end + + module_function :parse_inline + module_function :parse_list + module_function :parse +end + diff --git a/lib/error_logging.rb b/lib/error_logging.rb new file mode 100644 index 00000000..1e44c633 --- /dev/null +++ b/lib/error_logging.rb @@ -0,0 +1,39 @@ +module ActionController #:nodoc: + module Rescue + protected + alias_method :orig_log_error, :log_error + def log_error(exception) #:doc: + case exception + when + ActiveRecord::RecordNotFound, + ActionController::UnknownController, + ActionController::UnknownAction, + ActionController::RoutingError + return + end + + ActiveSupport::Deprecation.silence do + if ActionView::TemplateError === exception + logger.fatal(exception.to_s) + else + text = "\n\n" + text << "#{exception.class} (#{exception.message}) #{self.controller_name}/#{self.action_name}\n" + text << "Host: #{request.env["REMOTE_ADDR"]}\n" + text << "U-A: #{request.env["HTTP_USER_AGENT"]}\n" + + + + text << "Parameters: #{request.parameters.inspect}\n" if not request.parameters.empty? + text << "Cookies: #{request.cookies.inspect}\n" if not request.cookies.empty? + text << " " + text << clean_backtrace(exception).join("\n ") + text << "\n\n" + logger.fatal(text) + end + end + +# orig_log_error exception + end + end +end + diff --git a/lib/external_post.rb b/lib/external_post.rb new file mode 100644 index 00000000..352a585c --- /dev/null +++ b/lib/external_post.rb @@ -0,0 +1,35 @@ +class ExternalPost + # These mimic the equivalent attributes in Post directly. + attr_accessor :md5, :url, :preview_url, :service, :width, :height, :tags, :rating, :id + + class << self + def get_service_icon(service) + if service == CONFIG["local_image_service"] then + "/favicon.ico" + elsif service == "gelbooru.com" then # hack + "/favicon-" + service + ".png" + else + "/favicon-" + service + ".ico" + end + end + end + + def service_icon + ExternalPost.get_service_icon(service) + end + def ext + true + end + def cached_tags + tags + end + + def to_xml(options = {}) + {:md5 => md5, :url => url, :preview_url => preview_url, :service => service}.to_xml(options.merge(:root => "external-post")) + end + + def preview_dimensions + dim = Danbooru.reduce_to({:width => width, :height => height}, {:width => 150, :height => 150}) + return [dim[:width], dim[:height]] + end +end diff --git a/lib/fix_form_tag.rb b/lib/fix_form_tag.rb new file mode 100644 index 00000000..801e7049 --- /dev/null +++ b/lib/fix_form_tag.rb @@ -0,0 +1,20 @@ +require "action_view/helpers/tag_helper.rb" + +# submit_tag "Search" generates a submit tag that adds "commit=Search" to the URL, +# which is ugly and unnecessary. Override TagHelper#tag and remove this globally. +module ActionView + module Helpers + module TagHelper + alias_method :orig_tag, :tag + def tag(name, options = nil, open = false, escape = true) + + if name == :input && options["type"] == "submit" && options["name"] == "commit" && options["value"] == "Search" + options.delete("name") + end + + orig_tag name, options, open, escape + end + end + end +end + diff --git a/lib/html_4_tags.rb b/lib/html_4_tags.rb new file mode 100644 index 00000000..507a5dbb --- /dev/null +++ b/lib/html_4_tags.rb @@ -0,0 +1,29 @@ +# Override default tag helper to output HTMl 4 code +module ActionView + module Helpers #:nodoc: + module TagHelper + # Disable open; validates better... + def tag(name, options = nil, open = true, escape = true) + # workaround: PicLens is rendered as HTML, instead of XML, so don't force open tags + # based on MIME type instead of template_format + if headers["Content-Type"] != "application/rss+xml" + open = true + end + + "<#{name}#{tag_options(options, escape) if options}" + (open ? ">" : " />") + end + end + + module AssetTagHelper + def stylesheet_tag(source, options) + tag("link", { "rel" => "stylesheet", "type" => Mime::CSS, "media" => "screen", "href" => html_escape(path_to_stylesheet(source)) }.merge(options), false, false) + end + end + + class InstanceTag + def tag(name, options = nil, open = true, escape = true) + "<#{name}#{tag_options(options, escape) if options}" + (open ? ">" : " />") + end + end + end +end diff --git a/lib/memcache_util_store.rb b/lib/memcache_util_store.rb new file mode 100644 index 00000000..2c57ec9a --- /dev/null +++ b/lib/memcache_util_store.rb @@ -0,0 +1,100 @@ +## +# A copy of the MemCacheStore that uses memcache-client instead of ruby-memcache. +# +# Mod by Geoffrey Grosenbach http://topfunky.com + +begin + require 'cgi/session' + require 'memcache_util' + + class CGI + class Session + # MemCache-based session storage class. + # + # This builds upon the top-level MemCache class provided by the + # library file memcache.rb. Session data is marshalled and stored + # in a memcached cache. + class MemcacheUtilStore + def check_id(id) #:nodoc:# + /[^0-9a-zA-Z]+/ =~ id.to_s ? false : true + end + + # Create a new CGI::Session::MemCache instance + # + # This constructor is used internally by CGI::Session. The + # user does not generally need to call it directly. + # + # +session+ is the session for which this instance is being + # created. The session id must only contain alphanumeric + # characters; automatically generated session ids observe + # this requirement. + # + # +options+ is a hash of options for the initializer. The + # following options are recognized: + # + # cache:: an instance of a MemCache client to use as the + # session cache. + # + # expires:: an expiry time value to use for session entries in + # the session cache. +expires+ is interpreted in seconds + # relative to the current time if it is less than 60*60*24*30 + # (30 days), or as an absolute Unix time (e.g., Time#to_i) if + # greater. If +expires+ is +0+, or not passed on +options+, + # the entry will never expire. + # + # This session's memcache entry will be created if it does + # not exist, or retrieved if it does. + def initialize(session, options = {}) + id = session.session_id + unless check_id(id) + raise ArgumentError, "session_id '%s' is invalid" % id + end + @expires = options['expires'] || 0 + @session_key = "session:#{id}" + @session_data = {} + end + + # Restore session state from the session's memcache entry. + # + # Returns the session state as a hash. + def restore + begin + @session_data = Cache.get(@session_key) || {} + rescue + @session_data = {} + end + end + + # Save session state to the session's memcache entry. + def update + begin + Cache.put(@session_key, @session_data, @expires) + rescue + # Ignore session update failures. + end + end + + # Update and close the session's memcache entry. + def close + update + end + + # Delete the session's memcache entry. + def delete + begin + Cache.delete(@session_key) + rescue + # Ignore session delete failures. + end + @session_data = {} + end + + def data + @session_data + end + end + end + end +rescue LoadError + # MemCache wasn't available so neither can the store be +end diff --git a/lib/mirror.rb b/lib/mirror.rb new file mode 100644 index 00000000..09fa1bfd --- /dev/null +++ b/lib/mirror.rb @@ -0,0 +1,158 @@ +module Mirrors + class MirrorError < Exception; end + + def ssh_open_pipe(mirror, command, timeout=30) + remote_user_host = "#{mirror[:user]}@#{mirror[:host]}" + ret = nil + IO.popen("/usr/bin/ssh -o Compression=no -o BatchMode=yes -o ConnectTimeout=#{timeout} #{remote_user_host} '#{command}'") do |f| + ret = yield(f) + end + if ($? & 0xFF) != 0 then + raise MirrorError, "Command \"%s\" to %s exited with signal %i" % [command, mirror[:host], $? & 0xFF] + end + if ($? >> 8) != 0 then + raise MirrorError, "Command \"%s\" to %s exited with status %i" % [command, mirror[:host], $? >> 8] + end + return ret + end + module_function :ssh_open_pipe + + # Copy a file to all mirrors. file is an absolute path which must be + # located in public/data; the files will land in the equivalent public/data + # on each mirror. + # + # Because we have no mechanism for indicating that a file is only available on + # certain mirrors, if any mirror fails to upload, MirrorError will be thrown + # and the file should be treated as completely unwarehoused. + def copy_file_to_mirrors(file, options={}) + # CONFIG[:data_dir] is equivalent to our local_base. + local_base = "#{RAILS_ROOT}/public/data/" + options = { :timeout => 30 }.merge(options) + + if file[0,local_base.length] != local_base then + raise "Invalid filename to mirror: \"%s" % file + end + + expected_md5 = File.open(file, 'rb') {|fp| Digest::MD5.hexdigest(fp.read)} + + CONFIG["mirrors"].each { |mirror| + remote_user_host = "#{mirror[:user]}@#{mirror[:host]}" + remote_filename = "#{mirror[:data_dir]}/#{file[local_base.length, file.length]}" + + # Tolerate a few errors in case of communication problems. + retry_count = 0 + + begin + # Check if the file is already mirrored before we spend time uploading it. + # Linux needs md5sum; FreeBSD needs md5 -q. + actual_md5 = Mirrors.ssh_open_pipe(mirror, + "if [ -f #{remote_filename} ]; then (which md5sum >/dev/null) && md5sum #{remote_filename} || md5 -q #{remote_filename}; fi", + timeout=options[:timeout]) do |f| f.gets end + if actual_md5 =~ /^[0-9a-f]{32}/ + actual_md5 = actual_md5.slice(0, 32) + if expected_md5 == actual_md5 + next + end + end + + if not system("/usr/bin/scp", "-pq", "-o", "Compression no", "-o", "BatchMode=yes", + "-o", "ConnectTimeout=%i" % timeout, + file, "#{remote_user_host}:#{remote_filename}") then + raise MirrorError, "Error copying #{file} to #{remote_user_host}:#{remote_filename}" + end + + # Don't trust scp; verify the files. + actual_md5 = Mirrors.ssh_open_pipe(mirror, "if [ -f #{remote_filename} ]; then (which md5sum >/dev/null) && md5sum #{remote_filename} || md5 -q #{remote_filename}; fi") do |f| f.gets end + if actual_md5 !~ /^[0-9a-f]{32}/ + raise MirrorError, "Error verifying #{remote_user_host}:#{remote_filename}: #{actual_md5}" + end + + actual_md5 = actual_md5.slice(0, 32) + + if expected_md5 != actual_md5 + raise MirrorError, "Verifying #{remote_user_host}:#{remote_filename} failed: got #{actual_md5}, expected #{expected_md5}" + end + rescue MirrorError => e + retry_count += 1 + raise if retry_count == 3 + + retry + end + } + end + module_function :copy_file_to_mirrors + + # Return a URL prefix for a file. If not warehoused, always returns the main + # server. If a seed is specified, seeds the server selection; otherwise, each + # IP will always use the same server. + # + # If :zipfile is set, ignore mirrors with the :nozipfile flag. + if CONFIG["image_store"] == :remote_hierarchy + def select_main_image_server + return CONFIG["url_base"] if !CONFIG["image_servers"] || CONFIG["image_servers"].empty? + raise 'CONFIG["url_base"] is set incorrectly; please see config/default_config.rb' if CONFIG["image_servers"][0].class == String + + return CONFIG["image_servers"][0][:server] + end + + def select_image_server(is_warehoused, seed = 0, options = {}) + return CONFIG["url_base"] if !CONFIG["image_servers"] || CONFIG["image_servers"].empty? + raise 'CONFIG["url_base"] is set incorrectly; please see config/default_config.rb' if CONFIG["image_servers"][0].class == String + + if not is_warehoused + # return CONFIG["url_base"] + return CONFIG["image_servers"][0][:server] + end + + mirrors = CONFIG["image_servers"] + if(options[:preview]) then + mirrors = mirrors.select { |mirror| + mirror[:nopreview] != true + } + end + + if not options[:preview] then + mirrors = mirrors.select { |mirror| + mirror[:previews_only] != true + } + end + + if(options[:zipfile]) then + mirrors = mirrors.select { |mirror| + mirror[:nozipfile] != true + } + end + + raise "No usable mirrors" if mirrors.empty? + + total_weights = 0 + mirrors.each { |s| total_weights += s[:traffic] } + + seed += Thread.current["danbooru-ip_addr_seed"] || 0 + seed %= total_weights + + server = nil + mirrors.each { |s| + w = s[:traffic] + if seed < w + server = s + break + end + + seed -= w + } + server ||= mirrors[0] + + return server[:server] + end + else + def select_main_image_server + return CONFIG["url_base"] + end + def select_image_server(is_warehoused, seed = 0, options = {}) + return CONFIG["url_base"] + end + end + module_function :select_main_image_server + module_function :select_image_server +end diff --git a/lib/multipart.rb b/lib/multipart.rb new file mode 100644 index 00000000..e61ccda9 --- /dev/null +++ b/lib/multipart.rb @@ -0,0 +1,30 @@ +require 'net/http' +require 'mime/types' + +class Net::HTTP::Post + def multipart=(params=[]) + boundary_token = "--multipart-boundary" + self.content_type = "multipart/form-data; boundary=#{boundary_token}" + + self.body = "" + params.each { |p| + self.body += "--#{boundary_token}\r\n" + self.body += "Content-Disposition: form-data; name=#{p[:name]}" + self.body += "; filename=#{p[:filename]}" if p[:filename] + self.body += "\r\n" + if p[:binary] then + self.body += "Content-Transfer-Encoding: binary\r\n" + + mime_type = "application/octet-stream" + if p[:filename] + mime_types = MIME::Types.of(p[:filename]) + mime_type = mime_types.first.content_type unless mime_types.empty? + end + + self.body += "Content-Type: #{mime_type}\r\n" + end + self.body += "\r\n#{p[:data].to_s}\r\n" + } + self.body += "--#{boundary_token}--\r\n" + end +end diff --git a/lib/nagato.rb b/lib/nagato.rb new file mode 100644 index 00000000..c3b900a9 --- /dev/null +++ b/lib/nagato.rb @@ -0,0 +1,186 @@ +# Nagato is a library that allows you to programatically build SQL queries. +module Nagato + # Represents a single subquery. + class Subquery + # === Parameters + # * :join:: Can be either "and" or "or". All the conditions will be joined using this string. + def initialize(join = "and") + @join = join.upcase + @conditions = [] + @condition_params = [] + end + + # Returns true if the subquery is empty. + def empty? + return @conditions.empty? + end + + # Returns an array of 1 or more elements, the first being a SQL fragment and the rest being placeholder parameters. + def conditions + if @conditions.empty? + return ["TRUE"] + else + return [@conditions.join(" " + @join + " "), *@condition_params] + end + end + + # Creates a subquery (within the current subquery). + # + # === Parameters + # * :join:: Can be either "and" or "or". This will be passed on to the generated subquery. + def subquery(join = "and") + subconditions = self.class.new(join) + yield(subconditions) + c = subconditions.conditions + @conditions << "(#{c[0]})" + @condition_params += c[1..-1] + end + + # Adds a condition to the subquery. If the condition has placeholder parameters, you can pass them in directly in :params:. + # + # === Parameters + # * :sql:: A SQL fragment. + # * :params:: A list of object to be used as the placeholder parameters. + def add(sql, *params) + @conditions << sql + @condition_params += params + end + + # A special case in which there's only one parameter. If the parameter is nil, then don't add the condition. + # + # === Parameters + # * :sql:: A SQL fragment. + # * :param:: A placeholder parameter. + def add_unless_blank(sql, param) + unless param == nil || param == "" + @conditions << sql + @condition_params << param + end + end + end + + class Builder + attr_reader :order, :limit, :offset + + # Constructs a new Builder object. You must use it in block form. + # + # Example: + # + # n = Nagato::Builder.new do |builder, cond| + # builder.get("posts.id") + # builder.get("posts.rating") + # builder.rjoin("posts_tags ON posts_tags.post_id = posts.id") + # cond.add_unless_blank "posts.rating = ?", params[:rating] + # cond.subquery do |c1| + # c1.add "posts.user_id is null" + # c1.add "posts.user_id = 1" + # end + # end + # + # Post.find(:all, n.to_hash) + def initialize + @select = [] + @joins = [] + @subquery = Subquery.new("and") + @order = nil + @offset = nil + @limit = nil + + yield(self, @subquery) + end + + # Defines a new join. + # + # Example: + # + # cond.join "posts_tags ON posts_tags.post_id = posts.id" + def join(sql) + @joins << "JOIN " + sql + end + + # Defines a new left join. + # + # Example: + # + # cond.ljoin "posts_tags ON posts_tags.post_id = posts.id" + def ljoin(sql) + @joins << "LEFT JOIN " + sql + end + + # Defines a new right join. + # + # Example: + # + # cond.rjoin "posts_tags ON posts_tags.post_id = posts.id" + def rjoin(sql) + @joins << "RIGHT JOIN " + sql + end + + # Defines the select list. + # + # === Parameters + # * :fields: the fields to select + def get(fields) + if fields.is_a?(String) + @select << fields + elsif fields.is_a?(Array) + @select += fields + else + raise TypeError + end + end + + # Sets the ordering. + # + # === Parameters + # * :sql:: A SQL fragment defining the ordering + def order(sql) + @order = sql + end + + # Sets the limit. + # + # === Parameters + # * :amount:: The amount + def limit(amount) + @limit = amount.to_i + end + + # Sets the offset. + # + # === Parameters + # * :amount:: The amount + def offset(amount) + @offset = amount.to_i + end + + # Return the conditions (as an array suitable for usage with ActiveRecord) + def conditions + return @subquery.conditions + end + + # Returns the joins (as an array suitable for usage with ActiveRecord) + def joins + return @joins.join(" ") + end + + # Converts the SQL fragment as a hash (suitable for usage with ActiveRecord) + def to_hash + hash = {} + hash[:conditions] = conditions + hash[:joins] = joins unless @joins.empty? + hash[:order] = @order if @order + hash[:limit] = @limit if @limit + hash[:offset] = @offset if @offset + hash[:select] = @select if @select.any? + return hash + end + end + + def find(model, &block) + return model.find(:all, Builder.new(&block).to_hash) + end + + module_function :find +end + diff --git a/lib/post_save.rb b/lib/post_save.rb new file mode 100644 index 00000000..a836ad10 --- /dev/null +++ b/lib/post_save.rb @@ -0,0 +1,31 @@ +# post_save is run after the save has completed, all after_save callbacks have +# been run, and the attribute dirty flags have been cleared by save_with_dirty. +# This callback is useful for any post-save behavior that may need to call +# self.save again. We must not save in after_save, because it'll cause +# lib/versioning to save duplicate history records (due to dirty flags not +# being cleared yet). + +module ActiveRecord + module PostSave + def self.included(base) + base.define_callbacks :post_save + base.alias_method_chain :save, :post_callback + base.alias_method_chain :save!, :post_callback + end + + def save_with_post_callback!(*args) + status = save_without_post_callback!(*args) + run_callbacks(:post_save) + status + end + + def save_with_post_callback(*args) + if status = save_without_post_callback(*args) + run_callbacks(:post_save) + end + status + end + end +end + +ActiveRecord::Base.send :include, ActiveRecord::PostSave diff --git a/lib/query_parser.rb b/lib/query_parser.rb new file mode 100644 index 00000000..0395cc3f --- /dev/null +++ b/lib/query_parser.rb @@ -0,0 +1,30 @@ +module QueryParser + # Extracts the tokens from a query string + # + # === Parameters + # * :query_string: the query to parse + def parse(query_string) + return query_string.to_s.downcase.scan(/\S+/) + end + + # Extracts the metatokens (tokens matching \S+:\S+). Returns a two element array: the first element contains plain tokens, and the second element contains metatokens. + # + # === Parameters + # * :parsed_query: a list of tokens + def parse_meta(parsed_query) + hoge = [[], {}] + + parsed_query.each do |token| + if token =~ /^(.+?):(.+)$/ + hoge[1][$1] = $2 + else + hoge[0] << token + end + end + + return hoge + end + + module_function :parse + module_function :parse_meta +end diff --git a/lib/report.rb b/lib/report.rb new file mode 100644 index 00000000..36a81643 --- /dev/null +++ b/lib/report.rb @@ -0,0 +1,29 @@ +module Report + def usage_by_user(table_name, start, stop, conds = [], params = [], column = "created_at") + conds << ["%s BETWEEN ? AND ?" % column] + params << start + params << stop + + users = ActiveRecord::Base.connection.select_all(ActiveRecord::Base.sanitize_sql(["SELECT user_id, COUNT(*) as change_count FROM #{table_name} WHERE " + conds.join(" AND ") + " GROUP BY user_id ORDER BY change_count DESC LIMIT 9", *params])) + + conds << "user_id NOT IN (?)" + params << users.map {|x| x["user_id"]} + + other_count = ActiveRecord::Base.connection.select_value(ActiveRecord::Base.sanitize_sql(["SELECT COUNT(*) FROM #{table_name} WHERE " + conds.join(" AND "), *params])) + + users << {"user_id" => nil, "change_count" => other_count} + + users.each do |user| + if user["user_id"] + user["user"] = User.find(user["user_id"]) + user["name"] = user["user"].name + else + user["name"] = "Other" + end + end + + return users + end + + module_function :usage_by_user +end diff --git a/lib/similar_images.rb b/lib/similar_images.rb new file mode 100644 index 00000000..13854a81 --- /dev/null +++ b/lib/similar_images.rb @@ -0,0 +1,282 @@ +require 'multipart' +require 'external_post' + +module SimilarImages + def get_services(services) + services = services + services ||= "local" + if services == "all" + services = CONFIG["image_service_list"].map do |a, b| a end + else + services = services.split(/,/) + end + + services.each_index { |i| if services[i] == "local" then services[i] = CONFIG["local_image_service"] end } + return services + end + + def similar_images(options={}) + errors = {}; + + local_service = CONFIG["local_image_service"] + + services = options[:services] + + services_by_server = {} + services.each { |service| + server = CONFIG["image_service_list"][service] + if !server + errors[""] = { :services=>[service], :message=>"%s is an unknown service" % service } + next + end + services_by_server[server] = [] unless services_by_server[server] + services_by_server[server] += [service] + } + + # If the source is a local post, read the preview and send it with the request. + if options[:type] == :post then + source_file = File.open(options[:source].preview_path, 'rb') do |file| file.read end + source_filename = options[:source].preview_path + elsif options[:type] == :file then + source_file = options[:source].read + source_filename = options[:source_filename] + end + + server_threads = [] + server_responses = {} + services_by_server.map do |server, services_list| + server_threads.push Thread.new { + if options[:type] == :url + search_url = options[:source] + end + if options[:type] == :post && CONFIG["image_service_local_searches_use_urls"] + search_url = options[:source].preview_url + end + + params = [] + if search_url + params += [{ + :name=>"url", + :data=>search_url, + }] + else + params += [{ + :name=>"file", + :binary=>true, + :data=>source_file, + :filename=>File.basename(source_filename), + }] + end + + services_list.each { |s| + params += [{:name=>"service[]", :data=>s}] + } + params += [{:name=>"forcegray", :data=>"on"}] if options[:forcegray] == "1" + + begin + Timeout::timeout(10) { + url = URI.parse(server) + Net::HTTP.start(url.host, url.port) do |http| + http.read_timeout = 10 + + request = Net::HTTP::Post.new(server) + request.multipart = params + response = http.request(request) + server_responses[server] = response.body + end + } + rescue SocketError, SystemCallError => e + errors[server] = { :message=>e } + rescue Timeout::Error => e + errors[server] = { :message=>"Timed out" } + end + } + end + server_threads.each { |t| t.join } + + posts = [] + posts_external = [] + similarity = {} + preview_url = "" + next_id = 1 + server_responses.map do |server, xml| + doc = begin + REXML::Document.new xml + rescue Exception => e + errors[server] = { :message=>"parse error" } + next + end + + if doc.root.name=="error" + errors[server] = { :message=>doc.root.attributes["message"] } + next + end + + threshold = options[:threshold] || doc.root.attributes["threshold"] + threshold = threshold.to_f + + doc.elements.each("matches/match") { |element| + if element.attributes["sim"].to_f >= threshold + service = element.attributes["service"] + if service == "e-shuushuu.net" then # hack + image = element.get_elements(".//image")[0] + else + image = element.get_elements(".//post")[0] + end + + id = image.attributes["id"] + md5 = element.attributes["md5"] + + if service == local_service + post = Post.find(:first, :conditions => ["id = ?", id]) + unless post.nil? || post == options[:source] + posts += [post] + similarity[post] = element.attributes["sim"].to_f + end + elsif service + post = ExternalPost.new() + post.id = "#{next_id}" + next_id = next_id + 1 + post.md5 = md5 + post.preview_url = element.attributes["preview"] + if service == "gelbooru.com" then # hack + post.url = "http://" + service + "/index.php?page=post&s=view&id=" + id + elsif service == "e-shuushuu.net" then # hack + post.url = "http://" + service + "/image/" + id + "/" + else + post.url = "http://" + service + "/post/show/" + id + end + post.service = service + post.width = element.attributes["width"].to_i + post.height = element.attributes["height"].to_i + post.tags = image.attributes["tags"] || "" + post.rating = image.attributes["rating"] || "s" + posts_external += [post] + + similarity[post] = element.attributes["sim"].to_f + end + end + } + end + + posts = posts.sort { |a, b| similarity[b] <=> similarity[a] } + posts_external = posts_external.sort { |a, b| similarity[b] <=> similarity[a] } + + errors.map { |server,error| + if not error[:services] + error[:services] = services_by_server[server] rescue server + end + } + ret = { :posts => posts, :posts_external => posts_external, :similarity => similarity, :services => services, :errors => errors } + if options[:type] == :post + ret[:source] = options[:source] + ret[:similarity][options[:source]] = "Original" + ret[:search_id] = ret[:source].id + else + post = ExternalPost.new() + # post.md5 = md5 + post.preview_url = options[:source_thumb] + post.url = options[:full_url] || options[:url] || options[:source_thumb] + post.id = "source" + post.service = "" + post.tags = "" + post.rating = "q" + ret[:search_id] = "source" + + imgsize = ImageSize.new(source_file) + source_width = imgsize.get_width + source_height = imgsize.get_height + + # Since we lose access to the original image when we redirect to a saved search, + # the original dimensions can be passed as parameters so we can still display + # the original size. This can also be used by user scripts to include the + # size of the real image when a thumbnail is passed. + post.width = options[:width] || source_width + post.height = options[:height] || source_height + + ret[:external_source] = post + ret[:similarity][post] = "Original" + end + + return ret + end + + SEARCH_CACHE_DIR = "#{RAILS_ROOT}/public/data/search" + # Save a file locally to be searched for. Returns the path to the saved file, and + # the search ID which can be passed to find_saved_search. + def save_search + begin + FileUtils.mkdir_p(SEARCH_CACHE_DIR, :mode => 0775) + + tempfile_path = "#{SEARCH_CACHE_DIR}/#{$PROCESS_ID}.upload" + File.open(tempfile_path, 'wb') { |f| yield f } + + # Use the resizer to validate the file and convert it to a thumbnail-size JPEG. + imgsize = ImageSize.new(File.open(tempfile_path, 'rb')) + if imgsize.get_width.nil? + raise Danbooru::ResizeError, "Unrecognized image format" + end + + ret = {} + ret[:original_width] = imgsize.get_width + ret[:original_height] = imgsize.get_height + size = Danbooru.reduce_to({:width => ret[:original_width], :height => ret[:original_height]}, {:width => 150, :height => 150}) + ext = imgsize.get_type.gsub(/JPEG/, "JPG").downcase + + tempfile_path_resize = "#{tempfile_path}.2" + Danbooru.resize(ext, tempfile_path, tempfile_path_resize, size, 95) + FileUtils.mv(tempfile_path_resize, tempfile_path) + + md5 = File.open(tempfile_path, 'rb') {|fp| Digest::MD5.hexdigest(fp.read)} + id = "#{md5}.#{ext}" + file_path = "#{SEARCH_CACHE_DIR}/#{id}" + + FileUtils.mv(tempfile_path, file_path) + FileUtils.chmod(0664, file_path) + rescue + FileUtils.rm_f(file_path) if file_path + raise + ensure + FileUtils.rm_f(tempfile_path) if tempfile_path + FileUtils.rm_f(tempfile_path_resize) if tempfile_path_resize + end + + ret[:file_path] = file_path + ret[:search_id] = id + return ret + end + + def valid_saved_search(id) + id =~ /\A[a-zA-Z0-9]{32}\.[a-z]+\Z/ + end + + # Find a saved file. + def find_saved_search(id) + if not valid_saved_search(id) then return nil end + + file_path = "#{SEARCH_CACHE_DIR}/#{id}" + if not File.exists?(file_path) + return nil + end + + # Touch the file to delay its deletion. + File.open(file_path, 'a') + return file_path + end + + # Delete old searches. + def cull_old_searches + Dir.foreach(SEARCH_CACHE_DIR) { |path| + next if not valid_saved_search(path) + + file = "#{SEARCH_CACHE_DIR}/#{path}" + mtime = File.mtime(file) + age = Time.now-mtime + if age > 60*60*24 then + FileUtils.rm_f(file) + end + } + end + + module_function :similar_images, :get_services, :find_saved_search, :cull_old_searches, :save_search, :valid_saved_search +end diff --git a/lib/tasks/create_user_blacklisted_tags.rake b/lib/tasks/create_user_blacklisted_tags.rake new file mode 100644 index 00000000..c7671adf --- /dev/null +++ b/lib/tasks/create_user_blacklisted_tags.rake @@ -0,0 +1,60 @@ +require 'activerecord.rb' + +class User < ActiveRecord::Base +end + +class UserBlacklistedTags < ActiveRecord::Base +end + +class CreateUserBlacklistedTags < ActiveRecord::Migration + def self.up + create_table :user_blacklisted_tags do |t| + t.column :user_id, :integer, :null => false + t.column :tags, :text, :null => false + end + + add_index :user_blacklisted_tags, :user_id + + add_foreign_key :user_blacklisted_tags, :user_id, :users, :id, :on_delete => :cascade + UserBlacklistedTags.reset_column_information + + User.find(:all, :order => "id").each do |user| + unless user[:blacklisted_tags].blank? + tags = user[:blacklisted_tags].scan(/\S+/).each do |tag| + UserBlacklistedTags.create(:user_id => user.id, :tags => tag) + end + end + end + + remove_column :users, :blacklisted_tags + end + + def self.down + drop_table :user_blacklisted_tags + add_column :users, :blacklisted_tags, :text, :null => false, :default => "" + end +end + +namespace :user_blacklisted_tags do + def SetDefaultBlacklistedTags + User.transaction do + User.find(:all, :order => "id").each do |user| + CONFIG["default_blacklists"].each do |b| + UserBlacklistedTags.create(:user_id => user.id, :tags => b) + end + end + end + end + + desc 'CreateUserBlacklistedTags' + + task :up => :environment do + CreateUserBlacklistedTags.migrate(:up) + end + task :down => :environment do + CreateUserBlacklistedTags.migrate(:down) + end + task :add_defaults => :environment do + SetDefaultBlacklistedTags() + end +end diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake new file mode 100644 index 00000000..192b3a31 --- /dev/null +++ b/lib/tasks/db.rake @@ -0,0 +1,9 @@ +require 'activerecord.rb' + +namespace :db do + desc "Import histories" + task :import_histories => :environment do ActiveRecord::Base.import_post_tag_history end + + desc "Update histories" + task :update_histories => :environment do ActiveRecord::Base.update_all_versioned_tables end +end diff --git a/lib/tasks/job_restart.rake b/lib/tasks/job_restart.rake new file mode 100644 index 00000000..684ab450 --- /dev/null +++ b/lib/tasks/job_restart.rake @@ -0,0 +1,6 @@ +namespace :job do + desc 'Retart the job task processor' + task :restart => :environment do + `ruby #{RAILS_ROOT}/app/daemons/job_task_processor_ctl.rb restart` + end +end diff --git a/lib/tasks/job_start.rake b/lib/tasks/job_start.rake new file mode 100644 index 00000000..9da3afed --- /dev/null +++ b/lib/tasks/job_start.rake @@ -0,0 +1,6 @@ +namespace :job do + desc 'Start the job task processor' + task :start => :environment do + `ruby #{RAILS_ROOT}/app/daemons/job_task_processor_ctl.rb start` + end +end diff --git a/lib/tasks/job_stop.rake b/lib/tasks/job_stop.rake new file mode 100644 index 00000000..484c22a9 --- /dev/null +++ b/lib/tasks/job_stop.rake @@ -0,0 +1,6 @@ +namespace :job do + desc 'Stop the job task processor' + task :stop => :environment do + `ruby #{RAILS_ROOT}/app/daemons/job_task_processor_ctl.rb stop` + end +end diff --git a/lib/tasks/maint.rake b/lib/tasks/maint.rake new file mode 100644 index 00000000..3a252425 --- /dev/null +++ b/lib/tasks/maint.rake @@ -0,0 +1,27 @@ +namespace :maint do + desc 'fix_tags' + task :fix_tags => :environment do + # Fix post counts + Tag.recalculate_post_count + + # Fix cached tags + Post.recalculate_cached_tags + + Post.recalculate_has_children + end + + desc 'Recalculate post counts' + task :recalculate_row_count => :environment do + Post.recalculate_row_count + end + + desc 'Recalculate fav_count cache' + task :recalc_fav => :environment do + # Post.recalc_fav_counts + end + + desc 'Purge unused tags' + task :purge_tags => :environment do + Tag.purge_tags + end +end diff --git a/lib/tasks/make_sample_images.rake b/lib/tasks/make_sample_images.rake new file mode 100644 index 00000000..b15723c5 --- /dev/null +++ b/lib/tasks/make_sample_images.rake @@ -0,0 +1,33 @@ +namespace :sample_images do + def regen(post) + unless post.regenerate_images(:sample) + unless post.errors.empty? + error = post.errors.full_messages.join(", ") + puts "Error generating sample: post ##{post.id}: #{error}" + end + + return false + end + + unless post.regenerate_images(:jpeg) + unless post.errors.empty? + error = post.errors.full_messages.join(", ") + puts "Error generating JPEG: post ##{post.id}: #{error}" + end + + return false + end + + puts "post ##{post.id}" + post.save! + return true + end + + desc 'Create missing sample images' + task :create_missing => :environment do + Post.find_by_sql("SELECT p.* FROM posts p ORDER BY p.id DESC").each do |post| + regen(post) + end + end +end + diff --git a/lib/tasks/posts.rake b/lib/tasks/posts.rake new file mode 100644 index 00000000..f2f7bec7 --- /dev/null +++ b/lib/tasks/posts.rake @@ -0,0 +1,95 @@ +def get_metatags(tags) + metatags, tags = tags.scan(/\S+/).partition {|x| x=~ /^(?:rating):/} + ret = {} + metatags.each do |metatag| + case metatag + when /^rating:([qse])/ + ret[:rating] ||= $1 + end + end + + return ret +end + +namespace :posts do + desc 'Recalculate all post votes' + task :recalc_votes => :environment do + Post.recalculate_score + end + + desc 'Set missing CRC32s' + task :set_crc32 => :environment do + Post.find(:all, :order => "ID ASC", :conditions => "crc32 IS NULL OR (sample_width IS NOT NULL AND sample_crc32 IS NULL)").each do |post| + p "Post #{post.id}..." + old_md5 = post.md5 + + # Older deleted posts will have been deleted from disk. Tolerate the error from this; + # just leave the CRCs null. + begin + post.regenerate_hash + post.generate_sample_hash + rescue SocketError, URI::Error, Timeout::Error, SystemCallError => x + next + end + + if old_md5 != post.md5 + # Changing the MD5 would break the file path, and we only care about populating + # CRC32. + p "warning: post #{post.id} MD5 is incorrect; got #{post.md5}, expected #{old_md5} (corrupted file?)" + old_md5 = post.md5 + end + post.save! + end + end + + desc 'Add missing tag history data' + task :add_post_history => :environment do + # Add missing metatags to post_tag_history, using the nearest data (nearby tag + # history or the post itself). We won't break if this is missing, but this data + # will be added on the next change for every post, which will make it look like + # people are making changes that they're not. + PostTagHistory.transaction do + PostTagHistory.find(:all, :order => "id ASC").each do |change| + #:all, :order => "id ASC").each + post = Post.find(change.post_id) + next_change = change.next + if next_change + next_change = next_change.tags + else + next_change = "" + end + + prev_change = change.previous + if prev_change + prev_change = prev_change.tags + else + prev_change = "" + end + + sources = [prev_change, next_change, post.cached_tags_versioned].map { |x| get_metatags(x) } + current_metatags = get_metatags(change.tags) + + metatags_to_add = [] + [:rating].each do |metatag| + next if current_metatags[metatag] + val = nil + sources.each { |source| val ||= source[metatag] } + + metatags_to_add += [metatag.to_s + ":" + val] + end + + next if metatags_to_add.empty? + change.tags = (metatags_to_add + [change.tags]).join(" ") + change.save! + end + end + end + + desc 'Upload posts to mirrors' + task :mirror => :environment do + Post.find(:all, :conditions => ["NOT is_warehoused AND status <> 'deleted'"], :order => "id DESC").each { |post| + p "Mirroring ##{post.id}..." + post.upload_to_mirrors + } + end +end diff --git a/lib/versioning.rb b/lib/versioning.rb new file mode 100644 index 00000000..496ba79f --- /dev/null +++ b/lib/versioning.rb @@ -0,0 +1,403 @@ +require 'history_change' +require 'history' + +module ActiveRecord + module Versioning + def self.included(base) + base.extend ClassMethods + base.define_callbacks :after_undo + end + + def remember_new + @object_is_new = true + end + + def get_group_by_id + self[self.class.get_group_by_foreign_key] + end + + # Get the current History object. This is reused as long as we're operating on + # the same group_by_obj (that is, they'll be displayed together in the history view). + # Only call this when you're actually going to create HistoryChanges, or this will + # leave behind empty Histories. + private + def get_current_history + #p "get_current_history %s #%i" % [self.class.table_name, id] + history = Thread.current[:versioning_history] + if history + #p "reuse? %s != %s, %i != %i" % [history.group_by_table, self.class.get_group_by_table_name, history.group_by_id, self.get_group_by_id] + if history.group_by_table != self.class.get_group_by_table_name or + history.group_by_id != self.get_group_by_id then + #p "don't reuse" + Thread.current[:versioning_history] = nil + history = nil + else + #p "reuse" + end + end + + if not history then + options = { + :group_by_table => self.class.get_group_by_table_name, + :group_by_id => self.get_group_by_id, + :user_id => Thread.current["danbooru-user_id"] + } + history = History.new(options) + history.save! + + Thread.current[:versioning_history] = history + end + + return history + end + + public + def save_versioned_attributes + transaction do + self.class.get_versioned_attributes.each { |att, options| + # Always save all properties on creation. + # + # Don't use _changed?; it'll be true if a field was changed and then changed back, + # in which case we must not create a change entry. + old = self.__send__("%s_was" % att.to_s) + new = self.__send__(att.to_s) + +# p "%s: %s -> %s" % [att.to_s, old, new] + next if old == new && !@object_is_new + + history = get_current_history + h = HistoryChange.new(:table_name => self.class.table_name, + :remote_id => self.id, + :field => att.to_s, + :value => new, + :history_id => history.id) + h.save! + } + end + + # The object has been saved, so don't treat it as new if it's saved again. + @object_is_new = false + + return true + end + + def versioned_master_object + parent = self.class.get_versioned_parent + return nil if !parent + type = Object.const_get(parent[:class].to_s.classify) + foreign_key = parent[:foreign_key] + id = self[foreign_key] + type.find(id) + end + + module ClassMethods + # :default => If a default value is specified, initial changes (created with the + # object) that set the value to the default will not be displayed in the UI. + # This is also used by :allow_reverting_to_default. This value can be set + # to nil, which will match NULL. Be sure at least one property has no default, + # or initial changes will show up as a blank line in the UI. + # + # :allow_reverting_to_default => By default, initial changes. Fields with + # :allow_reverting_to_default => true can be undone; the default value will + # be treated as the previous value. + def versioned(att, *options) + if not @versioned_attributes + @versioned_attributes = {} + self.after_save :save_versioned_attributes + self.after_create :remember_new + + self.versioning_display if not @versioning_display + end + + @versioned_attributes[att] = *options || {} + end + + # Configure the history display. + # + # :class => Group displayed changes with another class. + # :foreign_key => Key within :class to display. + # :controller, :action => Route for displaying the grouped class. + # + # versioning_display :class => :pool + # versioning_display :class => :pool, :foreign_key => :pool_id, :action => "show" + # versioning_display :class => :pool, :foreign_key => :pool_id, :controller => Post + def versioning_display(options = {}) + opt = { + :class => self.to_s.to_sym, + :controller => self.to_s, + :action => "show", + }.merge(options) + + if not opt[:foreign_key] + if opt[:class] == self.to_s.to_sym + opt[:foreign_key] = :id + else + reflection = self.reflections[opt[:class]] + opt[:foreign_key] = reflection.klass.base_class.to_s.foreign_key.to_sym + end + end + + @versioning_display = opt + end + + def get_versioning_group_by + @versioning_display + end + + def get_group_by_class + cl = @versioning_display[:class].to_s.classify + Object.const_get(cl) + end + + def get_group_by_table_name + get_group_by_class.table_name + end + + def get_group_by_foreign_key + @versioning_display[:foreign_key] + end + + # Specify a parent table. After a change is undone in this table, the + # parent class will also receive an after_undo message. If multiple + # changes are undone together, changes to parent tables will always + # be undone after changes to child tables. + def versioned_parent(c, options = {}) + foreign_key = options[:foreign_key] + foreign_key ||= self.reflections[c].klass.base_class.to_s.foreign_key + foreign_key = foreign_key.to_sym + @versioned_parent = { + :class => c, + :foreign_key => foreign_key + } + end + + def get_versioned_parent + @versioned_parent + end + + def get_versioned_attributes + @versioned_attributes || {} + end + + def get_versioned_attribute_options(field) + get_versioned_attributes[field.to_sym] + end + + # Called at the start of each request to reset the history object, so we don't reuse an + # object between requests on the same thread. + def init_history + Thread.current[:versioning_history] = nil + end + + # Add default histories for any new versioned properties. Group the new fields + # with existing histories for the same object, if any, so new properties don't + # fill up the history as if they were new properties. + # + # options: + # + # :attrs => [:column_name, :column_name2] + # If set, specifies the attributes to import. Otherwise, all versioned attributes + # with no records are imported. + # + # :allow_missing => true + # If set, don't throw an error if we're trying to import a property that doesn't exist + # in the database. This is used for initial import, where the versioned properties + # in the codebase correspond to columns that will be added and imported in a later + # migration. This is not used for explicit imports done later, because we want to catch + # errors (versioned properties that don't match up with column names). + def update_versioned_tables(c, options = {}) + table_name = c.table_name + p "Updating %s ..." % [table_name] + + # Our schema doesn't allow us to apply single ON DELETE constraints, so use + # a rule to do it. This is Postgresql-specific. + connection.execute <<-EOS + CREATE OR REPLACE RULE delete_histories AS ON DELETE TO #{table_name} + DO ( + DELETE FROM history_changes WHERE remote_id = OLD.id AND table_name = '#{table_name}'; + DELETE FROM histories WHERE group_by_id = OLD.id AND group_by_table = '#{table_name}'; + ); + EOS + + attributes_to_update = [] + + if options[:attrs] + attrs = options[:attrs] + + # Verify that the attributes we were told to update are actually versioned. + missing_attributes = attrs - c.get_versioned_attributes.keys + p c.get_versioned_attributes + if not missing_attributes.empty? + raise "Tried to add versioned propertes for table \"%s\" that aren't versioned: %s" % [table_name, missing_attributes.join(" ")] + end + else + attrs = c.get_versioned_attributes + end + + attrs.each { |att, opts| + # If any histories already exist for this attribute, assume that it's already been updated. + next if HistoryChange.find(:first, :conditions => ["table_name = ? AND field = ?", table_name, att.to_s]) + attributes_to_update << att + } + return if attributes_to_update.empty? + + attributes_to_update = attributes_to_update.select { |att| + column_exists = select_value_sql "SELECT 1 FROM information_schema.columns WHERE table_name = ? AND column_name = ?", table_name, att.to_s + if column_exists + true + else + if not options[:allow_missing] + raise "Expected to add versioned property \"%s\" for table \"%s\", but that column doesn't exist in the database" % [att, table_name] + end + + false + end + } + return if attributes_to_update.empty? + + transaction do + current = 1 + count = c.count(:all) + c.find(:all, :order => :id).each { |item| + p "%i/%i" % [current, count] + current += 1 + + group_by_table = item.class.get_group_by_table_name + group_by_id = item.get_group_by_id + #p "group %s by %s" % [item.to_s, item.class.get_group_by_table_name.to_s] + history = History.find(:first, :order => "id ASC", + :conditions => ["group_by_table = ? AND group_by_id = ?", group_by_table, group_by_id]) + + if not history + #p "new history" + options = { + :group_by_table=> group_by_table, + :group_by_id => group_by_id + } + options[:user_id] = item.user_id if item.respond_to?("user_id") + options[:user_id] ||= 1 + history = History.new(options) + history.save! + end + + to_create = [] + attributes_to_update.each { |att| + value = item.__send__(att.to_s) + options = { + :field => att.to_s, + :value => value, + :table_name => table_name, + :remote_id => item.id, + :history_id => history.id + } + + escaped_options = {} + options.each { |key,value| + if value == nil + escaped_options[key] = "NULL" + else + column = HistoryChange.columns_hash[key] + quoted_value = Base.connection.quote(value, column) + escaped_options[key] = quoted_value + end + } + + to_create += [escaped_options] + } + + columns = to_create.first.map { |key,value| key.to_s } + + values = [] + to_create.each { |row| + outrow = [] + columns.each { |col| + val = row[col.to_sym] + outrow += [val] + } + values += ["(#{outrow.join(",")})"] + } + sql = <<-EOS + INSERT INTO history_changes (#{columns.join(", ")}) VALUES #{values.join(",")} + EOS + Base.connection.execute sql + } + end + end + + def import_post_tag_history + count = PostTagHistory.count(:all) + current = 1 + PostTagHistory.find(:all, :order => "id ASC").each { |tag_history| + p "%i/%i" % [current, count] + current += 1 + + prev = tag_history.previous + + tags = tag_history.tags.scan(/\S+/) + metatags, tags = tags.partition {|x| x=~ /^(?:rating):/} + tags = tags.sort.join(" ") + + rating = "" + prev_rating = "" + metatags.each do |metatag| + case metatag + when /^rating:([qse])/ + rating = $1 + end + end + + if prev + prev_tags = prev.tags.scan(/\S+/) + prev_metatags, prev_tags = prev_tags.partition {|x| x=~ /^(?:-pool|pool|rating|parent):/} + prev_tags = prev_tags.sort.join(" ") + + prev_metatags.each do |metatag| + case metatag + when /^rating:([qse])/ + prev_rating = $1 + end + end + end + + changed = false + if tags != prev_tags or rating != prev_rating then + h = History.new(:group_by_table => "posts", + :group_by_id => tag_history.post_id, + :user_id => tag_history.user_id || tag_history.post.user_id, + :created_at => tag_history.created_at) + h.save! + end + if tags != prev_tags then + c = h.history_changes.new(:table_name => "posts", + :remote_id => tag_history.post_id, + :field => "cached_tags", + :value => tags) + c.save! + end + + if rating != prev_rating then + c = h.history_changes.new(:table_name => "posts", + :remote_id => tag_history.post_id, + :field => "rating", + :value => rating) + c.save! + end + } + end + + # Add base history values for newly-added properties. + # + # This is only used for importing initial histories. When adding new versioned properties, + # call update_versioned_tables directly with the table and attributes to update. + def update_all_versioned_tables + update_versioned_tables Pool, :allow_missing => true + update_versioned_tables PoolPost, :allow_missing => true + update_versioned_tables Post, :allow_missing => true + update_versioned_tables Tag, :allow_missing => true + end + end + end +end + +ActiveRecord::Base.send :include, ActiveRecord::Versioning + diff --git a/lib/versioning.rb.mine b/lib/versioning.rb.mine new file mode 100644 index 00000000..6e9569fb --- /dev/null +++ b/lib/versioning.rb.mine @@ -0,0 +1,352 @@ +require 'history_change' +require 'history' + +module ActiveRecord + module Versioning + def self.included(base) + base.extend ClassMethods + base.define_callbacks :after_undo + end + + def remember_new + @object_is_new = true + end + + def get_group_by_id + self[self.class.get_group_by_foreign_key] + end + + # Get the current History object. This is reused as long as we're operating on + # the same group_by_obj (that is, they'll be displayed together in the history view). + # Only call this when you're actually going to create HistoryChanges, or this will + # leave behind empty Histories. + private + def get_current_history + #p "get_current_history %s #%i" % [self.class.table_name, id] + history = Thread.current[:versioning_history] + if history + #p "reuse? %s != %s, %i != %i" % [history.group_by_table, self.class.get_group_by_table_name, history.group_by_id, self.get_group_by_id] + if history.group_by_table != self.class.get_group_by_table_name or + history.group_by_id != self.get_group_by_id then + #p "don't reuse" + Thread.current[:versioning_history] = nil + history = nil + else + #p "reuse" + end + end + + if not history then + options = { + :group_by_table => self.class.get_group_by_table_name, + :group_by_id => self.get_group_by_id, + :user_id => Thread.current["danbooru-user_id"] + } + history = History.new(options) + history.save! + + Thread.current[:versioning_history] = history + end + + return history + end + + public + def save_versioned_attributes + transaction do + self.class.get_versioned_attributes.each { |att, options| + # Always save all properties on creation. + # + # Don't use _changed?; it'll be true if a field was changed and then changed back, + # in which case we must not create a change entry. + old = self.__send__("%s_was" % att.to_s) + new = self.__send__(att.to_s) + +# p "%s: %s -> %s" % [att.to_s, old, new] + next if old == new && !@object_is_new + + history = get_current_history + h = HistoryChange.new(:table_name => self.class.table_name, + :remote_id => self.id, + :field => att.to_s, + :value => new, + :history_id => history.id) + h.save! + } + end + end + + def versioned_master_object + parent = self.class.get_versioned_parent + return nil if !parent + type = Object.const_get(parent[:class].to_s.classify) + foreign_key = parent[:foreign_key] + id = self[foreign_key] + type.find(id) + end + + module ClassMethods + # :default => If a default value is specified, initial changes (created with the + # object) that set the value to the default will not be displayed in the UI. + # This is also used by :allow_reverting_to_default. This value can be set + # to nil, which will match NULL. Be sure at least one property has no default, + # or initial changes will show up as a blank line in the UI. + # + # :allow_reverting_to_default => By default, initial changes. Fields with + # :allow_reverting_to_default => true can be undone; the default value will + # be treated as the previous value. + def versioned(att, *options) + if not @versioned_attributes + @versioned_attributes = {} + self.after_save :save_versioned_attributes + self.after_create :remember_new + self.after_destroy :delete_history_records + + self.versioning_display if not @versioning_display + end + + @versioned_attributes[att] = *options || {} + end + + # Configure the history display. + # + # :class => Group displayed changes with another class. + # :foreign_key => Key within :class to display. + # :controller, :action => Route for displaying the grouped class. + # + # versioning_display :class => :pool + # versioning_display :class => :pool, :foreign_key => :pool_id, :action => "show" + # versioning_display :class => :pool, :foreign_key => :pool_id, :controller => Post + def versioning_display(options = {}) + opt = { + :class => self.to_s.to_sym, + :controller => self.to_s, + :action => "show", + }.merge(options) + + if not opt[:foreign_key] + if opt[:class] == self.to_s.to_sym + opt[:foreign_key] = :id + else + reflection = self.reflections[opt[:class]] + opt[:foreign_key] = reflection.klass.base_class.to_s.foreign_key.to_sym + end + end + + @versioning_display = opt + end + + def get_versioning_group_by + @versioning_display + end + + def get_group_by_class + cl = @versioning_display[:class].to_s.classify + Object.const_get(cl) + end + + def get_group_by_table_name + get_group_by_class.table_name + end + + def get_group_by_foreign_key + @versioning_display[:foreign_key] + end + + # Specify a parent table. After a change is undone in this table, the + # parent class will also receive an after_undo message. If multiple + # changes are undone together, changes to parent tables will always + # be undone after changes to child tables. + def versioned_parent(c, options = {}) + foreign_key = options[:foreign_key] + foreign_key ||= self.reflections[c].klass.base_class.to_s.foreign_key + foreign_key = foreign_key.to_sym + @versioned_parent = { + :class => c, + :foreign_key => foreign_key + } + end + + def get_versioned_parent + @versioned_parent + end + + def get_versioned_attributes + @versioned_attributes || {} + end + + def get_versioned_attribute_options(field) + get_versioned_attributes[field.to_sym] + end + + # Add default histories for any new versioned properties. Group the new fields + # with existing histories for the same object, if any, so new properties don't + # fill up the history as if they were new properties. + def update_versioned_tables(c) + table_name = c.table_name + p "Updating %s ..." % [table_name] + + # Our schema doesn't allow us to apply single ON DELETE constraints, so use + # a rule to do it. This is Postgresql-specific. + connection.execute <<-EOS + CREATE OR REPLACE RULE delete_histories AS ON DELETE TO #{table_name} + DO ( + DELETE FROM history_changes WHERE remote_id = OLD.id AND table_name = '#{table_name}'; + DELETE FROM histories WHERE group_by_id = OLD.id AND group_by_table = '#{table_name}'; + ); + EOS + + attributes_to_update = [] + + c.get_versioned_attributes.each { |att, options| + # If any histories already exist for this attribute, assume that it's already been updated. + next if HistoryChange.find(:first, :conditions => ["table_name = ? AND field = ?", table_name, att.to_s]) + attributes_to_update << att + } +p c.instance_methods(false) +p c.get_versioned_attributes +p attributes_to_update + return if attributes_to_update.empty? + + transaction do + current = 1 + count = c.count(:all) + c.find(:all, :order => :id).each { |item| + p "%i/%i" % [current, count] + current += 1 + + group_by_table = item.class.get_group_by_table_name + group_by_id = item.get_group_by_id + #p "group %s by %s" % [item.to_s, item.class.get_group_by_table_name.to_s] + history = History.find(:first, :order => "id ASC", + :conditions => ["group_by_table = ? AND group_by_id = ?", group_by_table, group_by_id]) + + if not history + #p "new history" + options = { + :group_by_table=> group_by_table, + :group_by_id => group_by_id + } + options[:user_id] = item.user_id if item.respond_to?("user_id") + options[:user_id] ||= 1 + history = History.new(options) + history.save! + end + + to_create = [] + attributes_to_update.each { |att| + value = item.__send__(att.to_s) + options = { + :field => att.to_s, + :value => value, + :table_name => table_name, + :remote_id => item.id, + :history_id => history.id + } + + escaped_options = {} + options.each { |key,value| + if value == nil + escaped_options[key] = "NULL" + else + column = HistoryChange.columns_hash[key] + quoted_value = Base.connection.quote(value, column) + escaped_options[key] = quoted_value + end + } + + to_create += [escaped_options] + } + + columns = to_create.first.map { |key,value| key.to_s } + + values = [] + to_create.each { |row| + outrow = [] + columns.each { |col| + val = row[col.to_sym] + outrow += [val] + } + values += ["(#{outrow.join(",")})"] + } + sql = <<-EOS + INSERT INTO history_changes (#{columns.join(", ")}) VALUES #{values.join(",")} + EOS + Base.connection.execute sql + } + end + end + + def import_post_tag_history + count = PostTagHistory.count(:all) + current = 1 + PostTagHistory.find(:all, :order => "id ASC").each { |tag_history| + p "%i/%i" % [current, count] + current += 1 + + prev = tag_history.previous + + tags = tag_history.tags.scan(/\S+/) + metatags, tags = tags.partition {|x| x=~ /^(?:rating):/} + tags = tags.sort.join(" ") + + rating = "" + prev_rating = "" + metatags.each do |metatag| + case metatag + when /^rating:([qse])/ + rating = $1 + end + end + + if prev + prev_tags = prev.tags.scan(/\S+/) + prev_metatags, prev_tags = prev_tags.partition {|x| x=~ /^(?:-pool|pool|rating|parent):/} + prev_tags = prev_tags.sort.join(" ") + + prev_metatags.each do |metatag| + case metatag + when /^rating:([qse])/ + prev_rating = $1 + end + end + end + + changed = false + if tags != prev_tags or rating != prev_rating then + h = History.new(:group_by_table => "posts", + :group_by_id => tag_history.post_id, + :user_id => tag_history.user_id || tag_history.post.user_id || 1, + :created_at => tag_history.created_at) + h.save! + end + if tags != prev_tags then + c = h.history_changes.new(:table_name => "posts", + :remote_id => tag_history.post_id, + :field => "cached_tags", + :value => tags) + c.save! + end + + if rating != prev_rating then + c = h.history_changes.new(:table_name => "posts", + :remote_id => tag_history.post_id, + :field => "rating", + :value => rating) + c.save! + end + } + end + + def update_all_versioned_tables + update_versioned_tables Pool + update_versioned_tables PoolPost + update_versioned_tables Post + update_versioned_tables Tag + end + end + end +end + +ActiveRecord::Base.send :include, ActiveRecord::Versioning + diff --git a/lib/versioning.rb.r646 b/lib/versioning.rb.r646 new file mode 100644 index 00000000..93ec18b2 --- /dev/null +++ b/lib/versioning.rb.r646 @@ -0,0 +1,349 @@ +require 'history_change' +require 'history' + +module ActiveRecord + module Versioning + def self.included(base) + base.extend ClassMethods + base.define_callbacks :after_undo + end + + def remember_new + @object_is_new = true + end + + def get_group_by_id + self[self.class.get_group_by_foreign_key] + end + + # Get the current History object. This is reused as long as we're operating on + # the same group_by_obj (that is, they'll be displayed together in the history view). + # Only call this when you're actually going to create HistoryChanges, or this will + # leave behind empty Histories. + private + def get_current_history + #p "get_current_history %s #%i" % [self.class.table_name, id] + history = Thread.current[:versioning_history] + if history + #p "reuse? %s != %s, %i != %i" % [history.group_by_table, self.class.get_group_by_table_name, history.group_by_id, self.get_group_by_id] + if history.group_by_table != self.class.get_group_by_table_name or + history.group_by_id != self.get_group_by_id then + #p "don't reuse" + Thread.current[:versioning_history] = nil + history = nil + else + #p "reuse" + end + end + + if not history then + options = { + :group_by_table => self.class.get_group_by_table_name, + :group_by_id => self.get_group_by_id, + :user_id => Thread.current["danbooru-user_id"] + } + history = History.new(options) + history.save! + + Thread.current[:versioning_history] = history + end + + return history + end + + public + def save_versioned_attributes + transaction do + self.class.get_versioned_attributes.each { |att, options| + # Always save all properties on creation. + # + # Don't use _changed?; it'll be true if a field was changed and then changed back, + # in which case we must not create a change entry. + old = self.__send__("%s_was" % att.to_s) + new = self.__send__(att.to_s) + +# p "%s: %s -> %s" % [att.to_s, old, new] + next if old == new && !@object_is_new + + history = get_current_history + h = HistoryChange.new(:table_name => self.class.table_name, + :remote_id => self.id, + :field => att.to_s, + :value => new, + :history_id => history.id) + h.save! + } + end + end + + def versioned_master_object + parent = self.class.get_versioned_parent + return nil if !parent + type = Object.const_get(parent[:class].to_s.classify) + foreign_key = parent[:foreign_key] + id = self[foreign_key] + type.find(id) + end + + module ClassMethods + # :default => If a default value is specified, initial changes (created with the + # object) that set the value to the default will not be displayed in the UI. + # This is also used by :allow_reverting_to_default. This value can be set + # to nil, which will match NULL. Be sure at least one property has no default, + # or initial changes will show up as a blank line in the UI. + # + # :allow_reverting_to_default => By default, initial changes. Fields with + # :allow_reverting_to_default => true can be undone; the default value will + # be treated as the previous value. + def versioned(att, *options) + if not @versioned_attributes + @versioned_attributes = {} + self.after_save :save_versioned_attributes + self.after_create :remember_new + self.after_destroy :delete_history_records + + self.versioning_display if not @versioning_display + end + + @versioned_attributes[att] = *options || {} + end + + # Configure the history display. + # + # :class => Group displayed changes with another class. + # :foreign_key => Key within :class to display. + # :controller, :action => Route for displaying the grouped class. + # + # versioning_display :class => :pool + # versioning_display :class => :pool, :foreign_key => :pool_id, :action => "show" + # versioning_display :class => :pool, :foreign_key => :pool_id, :controller => Post + def versioning_display(options = {}) + opt = { + :class => self.to_s.to_sym, + :controller => self.to_s, + :action => "show", + }.merge(options) + + if not opt[:foreign_key] + if opt[:class] == self.to_s.to_sym + opt[:foreign_key] = :id + else + reflection = self.reflections[opt[:class]] + opt[:foreign_key] = reflection.klass.base_class.to_s.foreign_key.to_sym + end + end + + @versioning_display = opt + end + + def get_versioning_group_by + @versioning_display + end + + def get_group_by_class + cl = @versioning_display[:class].to_s.classify + Object.const_get(cl) + end + + def get_group_by_table_name + get_group_by_class.table_name + end + + def get_group_by_foreign_key + @versioning_display[:foreign_key] + end + + # Specify a parent table. After a change is undone in this table, the + # parent class will also receive an after_undo message. If multiple + # changes are undone together, changes to parent tables will always + # be undone after changes to child tables. + def versioned_parent(c, options = {}) + foreign_key = options[:foreign_key] + foreign_key ||= self.reflections[c].klass.base_class.to_s.foreign_key + foreign_key = foreign_key.to_sym + @versioned_parent = { + :class => c, + :foreign_key => foreign_key + } + end + + def get_versioned_parent + @versioned_parent + end + + def get_versioned_attributes + @versioned_attributes || {} + end + + def get_versioned_attribute_options(field) + get_versioned_attributes[field.to_sym] + end + + # Add default histories for any new versioned properties. Group the new fields + # with existing histories for the same object, if any, so new properties don't + # fill up the history as if they were new properties. + def update_versioned_tables(c) + table_name = c.table_name + p "Updating %s ..." % [table_name] + + # Our schema doesn't allow us to apply single ON DELETE constraints, so use + # a rule to do it. This is Postgresql-specific. + connection.execute <<-EOS + CREATE OR REPLACE RULE delete_histories AS ON DELETE TO #{table_name} + DO ( + DELETE FROM history_changes WHERE remote_id = OLD.id AND table_name = '#{table_name}'; + DELETE FROM histories WHERE group_by_id = OLD.id AND group_by_table = '#{table_name}'; + ); + EOS + + attributes_to_update = [] + + c.get_versioned_attributes.each { |att, options| + # If any histories already exist for this attribute, assume that it's already been updated. + next if HistoryChange.find(:first, :conditions => ["table_name = ? AND field = ?", table_name, att.to_s]) + attributes_to_update << att + } + return if attributes_to_update.empty? + + transaction do + current = 1 + count = c.count(:all) + c.find(:all, :order => :id).each { |item| + p "%i/%i" % [current, count] + current += 1 + + group_by_table = item.class.get_group_by_table_name + group_by_id = item.get_group_by_id + #p "group %s by %s" % [item.to_s, item.class.get_group_by_table_name.to_s] + history = History.find(:first, :order => "id ASC", + :conditions => ["group_by_table = ? AND group_by_id = ?", group_by_table, group_by_id]) + + if not history + #p "new history" + options = { + :group_by_table=> group_by_table, + :group_by_id => group_by_id + } + options[:user_id] = item.user_id if item.respond_to?("user_id") + options[:user_id] ||= 1 + history = History.new(options) + history.save! + end + + to_create = [] + attributes_to_update.each { |att| + value = item.__send__(att.to_s) + options = { + :field => att.to_s, + :value => value, + :table_name => table_name, + :remote_id => item.id, + :history_id => history.id + } + + escaped_options = {} + options.each { |key,value| + if value == nil + escaped_options[key] = "NULL" + else + column = HistoryChange.columns_hash[key] + quoted_value = Base.connection.quote(value, column) + escaped_options[key] = quoted_value + end + } + + to_create += [escaped_options] + } + + columns = to_create.first.map { |key,value| key.to_s } + + values = [] + to_create.each { |row| + outrow = [] + columns.each { |col| + val = row[col.to_sym] + outrow += [val] + } + values += ["(#{outrow.join(",")})"] + } + sql = <<-EOS + INSERT INTO history_changes (#{columns.join(", ")}) VALUES #{values.join(",")} + EOS + Base.connection.execute sql + } + end + end + + def import_post_tag_history + count = PostTagHistory.count(:all) + current = 1 + PostTagHistory.find(:all, :order => "id ASC").each { |tag_history| + p "%i/%i" % [current, count] + current += 1 + + prev = tag_history.previous + + tags = tag_history.tags.scan(/\S+/) + metatags, tags = tags.partition {|x| x=~ /^(?:rating):/} + tags = tags.sort.join(" ") + + rating = "" + prev_rating = "" + metatags.each do |metatag| + case metatag + when /^rating:([qse])/ + rating = $1 + end + end + + if prev + prev_tags = prev.tags.scan(/\S+/) + prev_metatags, prev_tags = prev_tags.partition {|x| x=~ /^(?:-pool|pool|rating|parent):/} + prev_tags = prev_tags.sort.join(" ") + + prev_metatags.each do |metatag| + case metatag + when /^rating:([qse])/ + prev_rating = $1 + end + end + end + + changed = false + if tags != prev_tags or rating != prev_rating then + h = History.new(:group_by_table => "posts", + :group_by_id => tag_history.post_id, + :user_id => tag_history.user_id, + :created_at => tag_history.created_at) + h.save! + end + if tags != prev_tags then + c = h.history_changes.new(:table_name => "posts", + :remote_id => tag_history.post_id, + :field => "cached_tags", + :value => tags) + c.save! + end + + if rating != prev_rating then + c = h.history_changes.new(:table_name => "posts", + :remote_id => tag_history.post_id, + :field => "rating", + :value => rating) + c.save! + end + } + end + + def update_all_versioned_tables + update_versioned_tables Pool + update_versioned_tables PoolPost + update_versioned_tables Post + update_versioned_tables Tag + end + end + end +end + +ActiveRecord::Base.send :include, ActiveRecord::Versioning + diff --git a/lib/versioning.rb.r652 b/lib/versioning.rb.r652 new file mode 100644 index 00000000..4ac1727a --- /dev/null +++ b/lib/versioning.rb.r652 @@ -0,0 +1,355 @@ +require 'history_change' +require 'history' + +module ActiveRecord + module Versioning + def self.included(base) + base.extend ClassMethods + base.define_callbacks :after_undo + end + + def remember_new + @object_is_new = true + end + + def get_group_by_id + self[self.class.get_group_by_foreign_key] + end + + # Get the current History object. This is reused as long as we're operating on + # the same group_by_obj (that is, they'll be displayed together in the history view). + # Only call this when you're actually going to create HistoryChanges, or this will + # leave behind empty Histories. + private + def get_current_history + #p "get_current_history %s #%i" % [self.class.table_name, id] + history = Thread.current[:versioning_history] + if history + #p "reuse? %s != %s, %i != %i" % [history.group_by_table, self.class.get_group_by_table_name, history.group_by_id, self.get_group_by_id] + if history.group_by_table != self.class.get_group_by_table_name or + history.group_by_id != self.get_group_by_id then + #p "don't reuse" + Thread.current[:versioning_history] = nil + history = nil + else + #p "reuse" + end + end + + if not history then + options = { + :group_by_table => self.class.get_group_by_table_name, + :group_by_id => self.get_group_by_id, + :user_id => Thread.current["danbooru-user_id"] + } + history = History.new(options) + history.save! + + Thread.current[:versioning_history] = history + end + + return history + end + + public + def save_versioned_attributes + transaction do + self.class.get_versioned_attributes.each { |att, options| + # Always save all properties on creation. + # + # Don't use _changed?; it'll be true if a field was changed and then changed back, + # in which case we must not create a change entry. + old = self.__send__("%s_was" % att.to_s) + new = self.__send__(att.to_s) + +# p "%s: %s -> %s" % [att.to_s, old, new] + next if old == new && !@object_is_new + + history = get_current_history + h = HistoryChange.new(:table_name => self.class.table_name, + :remote_id => self.id, + :field => att.to_s, + :value => new, + :history_id => history.id) + h.save! + } + end + end + + def versioned_master_object + parent = self.class.get_versioned_parent + return nil if !parent + type = Object.const_get(parent[:class].to_s.classify) + foreign_key = parent[:foreign_key] + id = self[foreign_key] + type.find(id) + end + + module ClassMethods + # :default => If a default value is specified, initial changes (created with the + # object) that set the value to the default will not be displayed in the UI. + # This is also used by :allow_reverting_to_default. This value can be set + # to nil, which will match NULL. Be sure at least one property has no default, + # or initial changes will show up as a blank line in the UI. + # + # :allow_reverting_to_default => By default, initial changes. Fields with + # :allow_reverting_to_default => true can be undone; the default value will + # be treated as the previous value. + def versioned(att, *options) + if not @versioned_attributes + @versioned_attributes = {} + self.after_save :save_versioned_attributes + self.after_create :remember_new + self.after_destroy :delete_history_records + + self.versioning_display if not @versioning_display + end + + @versioned_attributes[att] = *options || {} + end + + # Configure the history display. + # + # :class => Group displayed changes with another class. + # :foreign_key => Key within :class to display. + # :controller, :action => Route for displaying the grouped class. + # + # versioning_display :class => :pool + # versioning_display :class => :pool, :foreign_key => :pool_id, :action => "show" + # versioning_display :class => :pool, :foreign_key => :pool_id, :controller => Post + def versioning_display(options = {}) + opt = { + :class => self.to_s.to_sym, + :controller => self.to_s, + :action => "show", + }.merge(options) + + if not opt[:foreign_key] + if opt[:class] == self.to_s.to_sym + opt[:foreign_key] = :id + else + reflection = self.reflections[opt[:class]] + opt[:foreign_key] = reflection.klass.base_class.to_s.foreign_key.to_sym + end + end + + @versioning_display = opt + end + + def get_versioning_group_by + @versioning_display + end + + def get_group_by_class + cl = @versioning_display[:class].to_s.classify + Object.const_get(cl) + end + + def get_group_by_table_name + get_group_by_class.table_name + end + + def get_group_by_foreign_key + @versioning_display[:foreign_key] + end + + # Specify a parent table. After a change is undone in this table, the + # parent class will also receive an after_undo message. If multiple + # changes are undone together, changes to parent tables will always + # be undone after changes to child tables. + def versioned_parent(c, options = {}) + foreign_key = options[:foreign_key] + foreign_key ||= self.reflections[c].klass.base_class.to_s.foreign_key + foreign_key = foreign_key.to_sym + @versioned_parent = { + :class => c, + :foreign_key => foreign_key + } + end + + def get_versioned_parent + @versioned_parent + end + + def get_versioned_attributes + @versioned_attributes || {} + end + + def get_versioned_attribute_options(field) + get_versioned_attributes[field.to_sym] + end + + # Called at the start of each request to reset the history object, so we don't reuse an + # object between requests on the same thread. + def init_history + Thread.current[:versioning_history] = nil + end + + # Add default histories for any new versioned properties. Group the new fields + # with existing histories for the same object, if any, so new properties don't + # fill up the history as if they were new properties. + def update_versioned_tables(c) + table_name = c.table_name + p "Updating %s ..." % [table_name] + + # Our schema doesn't allow us to apply single ON DELETE constraints, so use + # a rule to do it. This is Postgresql-specific. + connection.execute <<-EOS + CREATE OR REPLACE RULE delete_histories AS ON DELETE TO #{table_name} + DO ( + DELETE FROM history_changes WHERE remote_id = OLD.id AND table_name = '#{table_name}'; + DELETE FROM histories WHERE group_by_id = OLD.id AND group_by_table = '#{table_name}'; + ); + EOS + + attributes_to_update = [] + + c.get_versioned_attributes.each { |att, options| + # If any histories already exist for this attribute, assume that it's already been updated. + next if HistoryChange.find(:first, :conditions => ["table_name = ? AND field = ?", table_name, att.to_s]) + attributes_to_update << att + } + return if attributes_to_update.empty? + + transaction do + current = 1 + count = c.count(:all) + c.find(:all, :order => :id).each { |item| + p "%i/%i" % [current, count] + current += 1 + + group_by_table = item.class.get_group_by_table_name + group_by_id = item.get_group_by_id + #p "group %s by %s" % [item.to_s, item.class.get_group_by_table_name.to_s] + history = History.find(:first, :order => "id ASC", + :conditions => ["group_by_table = ? AND group_by_id = ?", group_by_table, group_by_id]) + + if not history + #p "new history" + options = { + :group_by_table=> group_by_table, + :group_by_id => group_by_id + } + options[:user_id] = item.user_id if item.respond_to?("user_id") + options[:user_id] ||= 1 + history = History.new(options) + history.save! + end + + to_create = [] + attributes_to_update.each { |att| + value = item.__send__(att.to_s) + options = { + :field => att.to_s, + :value => value, + :table_name => table_name, + :remote_id => item.id, + :history_id => history.id + } + + escaped_options = {} + options.each { |key,value| + if value == nil + escaped_options[key] = "NULL" + else + column = HistoryChange.columns_hash[key] + quoted_value = Base.connection.quote(value, column) + escaped_options[key] = quoted_value + end + } + + to_create += [escaped_options] + } + + columns = to_create.first.map { |key,value| key.to_s } + + values = [] + to_create.each { |row| + outrow = [] + columns.each { |col| + val = row[col.to_sym] + outrow += [val] + } + values += ["(#{outrow.join(",")})"] + } + sql = <<-EOS + INSERT INTO history_changes (#{columns.join(", ")}) VALUES #{values.join(",")} + EOS + Base.connection.execute sql + } + end + end + + def import_post_tag_history + count = PostTagHistory.count(:all) + current = 1 + PostTagHistory.find(:all, :order => "id ASC").each { |tag_history| + p "%i/%i" % [current, count] + current += 1 + + prev = tag_history.previous + + tags = tag_history.tags.scan(/\S+/) + metatags, tags = tags.partition {|x| x=~ /^(?:rating):/} + tags = tags.sort.join(" ") + + rating = "" + prev_rating = "" + metatags.each do |metatag| + case metatag + when /^rating:([qse])/ + rating = $1 + end + end + + if prev + prev_tags = prev.tags.scan(/\S+/) + prev_metatags, prev_tags = prev_tags.partition {|x| x=~ /^(?:-pool|pool|rating|parent):/} + prev_tags = prev_tags.sort.join(" ") + + prev_metatags.each do |metatag| + case metatag + when /^rating:([qse])/ + prev_rating = $1 + end + end + end + + changed = false + if tags != prev_tags or rating != prev_rating then + h = History.new(:group_by_table => "posts", + :group_by_id => tag_history.post_id, + :user_id => tag_history.user_id || tag_history.post.user_id || 1, + created_at => tag_history.created_at) + h.save! + end + if tags != prev_tags then + c = h.history_changes.new(:table_name => "posts", + :remote_id => tag_history.post_id, + :field => "cached_tags", + :value => tags) + c.save! + end + + if rating != prev_rating then + c = h.history_changes.new(:table_name => "posts", + :remote_id => tag_history.post_id, + :field => "rating", + :value => rating) + c.save! + end + } + end + + def update_all_versioned_tables + update_versioned_tables Pool + update_versioned_tables PoolPost + update_versioned_tables Post + update_versioned_tables Tag + end + end + end +end + +ActiveRecord::Base.send :include, ActiveRecord::Versioning + diff --git a/lighttpd-moe-dev.conf b/lighttpd-moe-dev.conf new file mode 100644 index 00000000..76c4d232 --- /dev/null +++ b/lighttpd-moe-dev.conf @@ -0,0 +1,422 @@ +# lighttpd configuration file +# +# use it as a base for lighttpd 1.0.0 and above +# +# $Id: lighttpd.conf,v 1.7 2004/11/03 22:26:05 weigon Exp $ + +############ Options you really have to take care of #################### + +## modules to load +# at least mod_access and mod_accesslog should be loaded +# all other module should only be loaded if really neccesary +# - saves some time +# - saves memory +server.modules = ( + "mod_rewrite", + "mod_evasive", + "mod_throttle", + "mod_redirect", +# "mod_alias", + "mod_access", +# "mod_cml", +# "mod_trigger_b4_dl", +# "mod_auth", +# "mod_status", + "mod_setenv", + "mod_fastcgi", +# "mod_proxy", + "mod_simple_vhost", +# "mod_evhost", +# "mod_userdir", + "mod_cgi", + "mod_compress", +# "mod_ssi", +# "mod_usertrack", + "mod_expire", +# "mod_secdownload", +# "mod_rrdtool", + "mod_accesslog", +# "mod_useronline" +) + +#useronline.enable = 1 +#useronline.online-age = 600 +#useronline.active-age = 600 +#useronline.max-ips = 4096 + +#normal version +$HTTP["referer"] !~ "^($|http://moe.imouto.org)" { + url.redirect = ( "^/data/[0-9a-f]{2}/[0-9a-f]{2}/([0-9a-f]+)\..*$" => "http://moe.imouto.org/post/show?md5=$1" ) + url.redirect-http-status = 307 +} +url.redirect = ( + "^/(data/.*)$" => "http://sheryl.imouto.org/$1", + "^/(image/.*)" => "http://sheryl.imouto.org/$1", + "^/(sample/.*)" => "http://sheryl.imouto.org/$1" + #"^/(data/.*)$" => "http://67.159.46.69/$1", + #"^/(image/.*)" => "http://67.159.46.69/$1", + #"^/(sample/.*)" => "http://67.159.46.69/$1" +) + +#secdownload version +#$HTTP["referer"] !~ "moe.imouto.org" { +# url.redirect = ( "^/data/(.*)/(.*)/(.*)/(.*)/(.*)\.(jpg|gif|png)" => "http://moe.imouto.org/post/show?md5=$5" ) +#} + + +evasive.max-conns-per-ip = 20 +server.max-write-idle = 600 + + +#secdownload.secret = "uguuruuwasuremono" +#secdownload.document-root = "/home/moe/danbooru/data" +#secdownload.uri-prefix = "/data/" +#secdownload.timeout = 2000 + + +# No files in /data are Rails requests, and there are a constant stream of bogus requests +# in /data from broken robots. Only redir to /dispatch.cgi for paths not in /data. +$HTTP["url"] !~ "^/data.*" { + server.error-handler-404 = "/dispatch.fcgi" +} +server.document-root = "/mnt/moe/moe-live" + "/public/" + +url.rewrite = ( + "^/$" => "index.html", + "^([^.]+)$" => "$1.html", + + # /image/d9cca721a735dac4efe709e0f3518373/anything.jpg -> /data/d9/cc/d9cca721a735dac4efe709e0f3518373.jpg + # This is a simple way of setting the filename on downloaded files. + #"^/image/([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{28})(/.*)?(\.[a-z]*)" => "/data/$1/$2/$1$2$3$5", + #"^/sample/([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{28})(/.*)?(\.[a-z]*)" => "/data/sample/$1/$2/$1$2$3$5" +) + +#$HTTP["orig-url"] =~ "^/image/.*" { +# setenv.add-response-header = ( "Content-Disposition" => "attachment" ) +#} + +compress.filetype = ( "text/plain", "text/html", "text/css", "text/javascript" ) +compress.cache-dir = "/mnt/moe/moe-live" + "/tmp/cache" + +expire.url = ( "/favicon.ico" => "access 3 days", + "/images/" => "access 3 days", + "/stylesheets/" => "access 3 days", + "/javascripts/" => "access 3 days" ) + +#server.network-backend = "writev" + +# Change *-procs to 2 if you need to use Upload Progress or other tasks that +# *need* to execute a second request while the first is still pending. +fastcgi.server = ( ".fcgi" => ( "danbooru" => ( + "min-procs" => 1, + "max-procs" => 1, + "socket" => "/mnt/moe/moe-live" + "/tmp/sockets/fcgi-dev.socket", + "bin-path" => "/mnt/moe/moe-live" + "/public/dispatch.fcgi", + "bin-environment" => ( "RAILS_ENV" => "development" ) +) ) ) + +# Making sure file uploads above 64k always work when using IE or Safari +# For more information, see http://trac.lighttpd.net/trac/ticket/360 +$HTTP["useragent"] =~ "^(.*MSIE.*)|(.*AppleWebKit.*)$" { + server.max-keep-alive-requests = 0 +} + + +#$HTTP["referer"] !~ "^http://moe.imouto.org/.*" { +#url.access-deny = ( ".jpg", ".jpeg", ".png", ".avg", ".mpeg" ) +#} + + +# files to check for if .../ is requested +index-file.names = ( "index.php", "index.html", + "index.htm", "default.htm" ) + +## set the event-handler (read the performance section in the manual) +server.event-handler = "freebsd-kqueue" # needed on OS X + +# mimetype mapping +mimetype.assign = ( + ".pdf" => "application/pdf", + ".sig" => "application/pgp-signature", + ".spl" => "application/futuresplash", + ".class" => "application/octet-stream", + ".ps" => "application/postscript", + ".torrent" => "application/x-bittorrent", + ".dvi" => "application/x-dvi", + ".gz" => "application/x-gzip", + ".pac" => "application/x-ns-proxy-autoconfig", + ".swf" => "application/x-shockwave-flash", + ".tar.gz" => "application/x-tgz", + ".tgz" => "application/x-tgz", + ".tar" => "application/x-tar", + ".zip" => "application/zip", + ".mp3" => "audio/mpeg", + ".m3u" => "audio/x-mpegurl", + ".wma" => "audio/x-ms-wma", + ".wax" => "audio/x-ms-wax", + ".ogg" => "application/ogg", + ".wav" => "audio/x-wav", + ".gif" => "image/gif", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".xbm" => "image/x-xbitmap", + ".xpm" => "image/x-xpixmap", + ".xwd" => "image/x-xwindowdump", + ".css" => "text/css", + ".xhtml" => "text/html", + ".html" => "text/html", + ".htm" => "text/html", + ".js" => "text/javascript", + ".asc" => "text/plain", + ".c" => "text/plain", + ".cpp" => "text/plain", + ".log" => "text/plain", + ".conf" => "text/plain", + ".text" => "text/plain", + ".txt" => "text/plain", + ".dtd" => "text/xml", + ".xml" => "text/xml", + ".xsl" => "text/xml", + ".mpeg" => "video/mpeg", + ".mpg" => "video/mpeg", + ".mov" => "video/quicktime", + ".qt" => "video/quicktime", + ".avi" => "video/x-msvideo", + ".asf" => "video/x-ms-asf", + ".asx" => "video/x-ms-asf", + ".wmv" => "video/x-ms-wmv", + ".bz2" => "application/x-bzip", + ".tbz" => "application/x-bzip-compressed-tar", + ".tar.bz2" => "application/x-bzip-compressed-tar" + ) + +# Use the "Content-Type" extended attribute to obtain mime type if possible +#mimetype.use-xattr = "enable" + + +## send a different Server: header +## be nice and keep it at lighttpd +# server.tag = "lighttpd" + +#### accesslog module +accesslog.filename = "/var/log/lighttpd/access-moe.log" + +## deny access the file-extensions +# +# ~ is for backupfiles from vi, emacs, joe, ... +# .inc is often used for code includes which should in general not be part +# of the document-root +url.access-deny = ( "~", ".inc" ) + +$HTTP["url"] =~ "\.pdf$" { + server.range-requests = "disable" +} + +## +# which extensions should not be handle via static-file transfer +# +# .php, .pl, .fcgi are most often handled by mod_fastcgi or mod_cgi +static-file.exclude-extensions = ( ".php", ".pl", ".fcgi" ) + +######### Options that are good to be but not neccesary to be changed ####### + +## bind to port (default: 80) +#server.port = 81 +server.port = 10002 + +#$SERVER["socket"] == "67.159.5.233:8080" { +#} + +## bind to localhost (default: all interfaces) +#server.bind = "67.159.46.66" + +## error-handler for status 404 +#server.error-handler-404 = "/error-handler.html" +#server.error-handler-404 = "/error-handler.php" + +## to help the rc.scripts +server.pid-file = "/var/run/lighttpd-moe.pid" + + +###### virtual hosts +## +## If you want name-based virtual hosting add the next three settings and load +## mod_simple_vhost +## +## document-root = +## virtual-server-root + virtual-server-default-host + virtual-server-docroot +## or +## virtual-server-root + http-host + virtual-server-docroot +## + +## +## Format: .html +## -> ..../status-404.html for 'File not found' +#server.errorfile-prefix = "/home/weigon/projects/lighttpd/doc/status-" + +## virtual directory listings +dir-listing.activate = "disable" + +## enable debugging +#debug.log-request-header = "enable" +#debug.log-response-header = "enable" +#debug.log-request-handling = "enable" +#debug.log-file-not-found = "enable" +#fastcgi.debug = 3 + +### only root can use these options +# +# chroot() to directory (default: no chroot() ) +#server.chroot = "/" + +## change uid to (default: don't care) +server.username = "www" + +## change uid to (default: don't care) +server.groupname = "www" + +#### compress module +#compress.cache-dir = "/tmp/lighttpd/cache/compress/" +#compress.filetype = ("text/plain", "text/html") + +#### proxy module +## read proxy.txt for more info +#proxy.server = ( ".php" => +# ( "localhost" => +# ( +# "host" => "192.168.0.101", +# "port" => 80 +# ) +# ) +# ) + +#### SSL engine +#ssl.engine = "enable" +#ssl.pemfile = "server.pem" + +#### status module +#status.status-url = "/server-status" +#status.config-url = "/server-config" + +#### auth module +## read authentication.txt for more info +#auth.backend = "plain" +#auth.backend.plain.userfile = "lighttpd.user" +#auth.backend.plain.groupfile = "lighttpd.group" + +#auth.backend.ldap.hostname = "localhost" +#auth.backend.ldap.base-dn = "dc=my-domain,dc=com" +#auth.backend.ldap.filter = "(uid=$)" + +#auth.require = ( "/server-status" => +# ( +# "method" => "digest", +# "realm" => "download archiv", +# "require" => "user=jan" +# ), +# "/server-config" => +# ( +# "method" => "digest", +# "realm" => "download archiv", +# "require" => "valid-user" +# ) +# ) + +#### url handling modules (rewrite, redirect, access) +#url.rewrite = ( "^/$" => "/server-status" ) +#url.redirect = ( "^/wishlist/(.+)" => "http://www.123.org/$1" ) +#### both rewrite/redirect support back reference to regex conditional using %n +#$HTTP["host"] =~ "^www\.(.*)" { +# url.redirect = ( "^/(.*)" => "http://%1/$1" ) +#} + +# +# define a pattern for the host url finding +# %% => % sign +# %0 => domain name + tld +# %1 => tld +# %2 => domain name without tld +# %3 => subdomain 1 name +# %4 => subdomain 2 name +# +#evhost.path-pattern = "/home/storage/dev/www/%3/htdocs/" + +#### expire module +#expire.url = ( "/buggy/" => "access 2 hours", "/asdhas/" => "access plus 1 seconds 2 minutes") + +#### ssi +#ssi.extension = ( ".shtml" ) + +#### rrdtool +#rrdtool.binary = "/usr/bin/rrdtool" +#rrdtool.db-name = "/var/www/lighttpd.rrd" + +#### setenv +#setenv.add-request-header = ( "TRAV_ENV" => "mysql://user@host/db" ) +#setenv.add-response-header = ( "X-Secret-Message" => "42" ) + +## for mod_trigger_b4_dl +# trigger-before-download.gdbm-filename = "/home/weigon/testbase/trigger.db" +# trigger-before-download.memcache-hosts = ( "127.0.0.1:11211" ) +# trigger-before-download.trigger-url = "^/trigger/" +# trigger-before-download.download-url = "^/download/" +# trigger-before-download.deny-url = "http://127.0.0.1/index.html" +# trigger-before-download.trigger-timeout = 10 + +## for mod_cml +## don't forget to add index.cml to server.indexfiles +# cml.extension = ".cml" +# cml.memcache-hosts = ( "127.0.0.1:11211" ) + +#### variable usage: +## variable name without "." is auto prefixed by "var." and becomes "var.bar" +#bar = 1 +#var.mystring = "foo" + +## integer add +#bar += 1 +## string concat, with integer cast as string, result: "www.foo1.com" +#server.name = "www." + mystring + var.bar + ".com" +## array merge +#index-file.names = (foo + ".php") + index-file.names +#index-file.names += (foo + ".php") + +#### include +#include /etc/lighttpd/lighttpd-inc.conf +## same as above if you run: "lighttpd -f /etc/lighttpd/lighttpd.conf" +#include "lighttpd-inc.conf" + +#### include_shell +#include_shell "echo var.a=1" +## the above is same as: +#var.a=1 + + + +server.errorlog = "/var/log/lighttpd/error-moe.log" + +# Don't allow more than 10 active concurrent connections from the same host. +throttle.max-concurrent-connections = 10 +# Don't allow more than three Rails requests at once. +$HTTP["url"] =~ "/dispatch.fcgi" { + throttle.max-concurrent-connections = 3 +} +# Don't allow more than one post/similar request at once. +$HTTP["orig-url"] =~ "/post/similar.*" { + throttle.max-concurrent-connections = 1 +} +# Limit connections for non-preview/thumb images. +$HTTP["url"] =~ "^/data/[0-9a-f]{2}/" { + throttle.max-concurrent-connections = 3 +} +# If lots of requests are being made to pages disallowed in robots.txt, it's probably a misbehaving bot. HACK: prevent lighttpd from merging config sections +$HTTP["url"] =~ "/dispatch.fcgi|^ba29b12f" { + $HTTP["orig-url"] =~ "(/artist/edit|/artist/update|/comment/show|/comment/create|/favorite|/pool/add_post|/pool/remove_post|/post/atom|/post/upload|/post/create|/post/destroy|/post/tag_history|/post/update|/post/similar|/note/history|/tag/edit|/tag/update|/tag/mass_edit|/wiki/edit|/wiki/update|/wiki/revert|/wiki/history|/wiki/rename|/user).*" { + # Allow 1 connection/sec over 5 minutes + throttle.bucket-size = 300 + throttle.tokens-per-second = 1 + throttle.ban-when-empty = 1 + throttle.ban-duration = 3600 + } +} diff --git a/lighttpd-moe-test.conf b/lighttpd-moe-test.conf new file mode 100644 index 00000000..02f898a9 --- /dev/null +++ b/lighttpd-moe-test.conf @@ -0,0 +1,422 @@ +# lighttpd configuration file +# +# use it as a base for lighttpd 1.0.0 and above +# +# $Id: lighttpd.conf,v 1.7 2004/11/03 22:26:05 weigon Exp $ + +############ Options you really have to take care of #################### + +## modules to load +# at least mod_access and mod_accesslog should be loaded +# all other module should only be loaded if really neccesary +# - saves some time +# - saves memory +server.modules = ( + "mod_rewrite", + "mod_evasive", + "mod_throttle", + "mod_redirect", +# "mod_alias", + "mod_access", +# "mod_cml", +# "mod_trigger_b4_dl", +# "mod_auth", +# "mod_status", + "mod_setenv", + "mod_fastcgi", +# "mod_proxy", + "mod_simple_vhost", +# "mod_evhost", +# "mod_userdir", + "mod_cgi", + "mod_compress", + "mod_deflate", +# "mod_ssi", +# "mod_usertrack", + "mod_expire", +# "mod_secdownload", +# "mod_rrdtool", + "mod_accesslog", +# "mod_useronline" +) + +#useronline.enable = 1 +#useronline.online-age = 600 +#useronline.active-age = 600 +#useronline.max-ips = 4096 +deflate.enabled = "enable" + + +#normal version +$HTTP["referer"] !~ "^($|http://moe.imouto.org)" { + url.redirect = ( "^/data/image/[0-9a-f]{2}/[0-9a-f]{2}/([0-9a-f]+)\..*$" => "http://moe.imouto.org/post/show?md5=$1" ) + url.redirect-http-status = 307 +} +url.redirect = ( + "^/(data/image/[0-9a-f][0-9a-f]\/.*)$" => "http://sheryl.imouto.org/$1", + "^/(image/.*)" => "http://sheryl.imouto.org/$1", + "^/(sample/.*)" => "http://sheryl.imouto.org/$1" +) + +#secdownload version +#$HTTP["referer"] !~ "moe.imouto.org" { +# url.redirect = ( "^/data/(.*)/(.*)/(.*)/(.*)/(.*)\.(jpg|gif|png)" => "http://moe.imouto.org/post/show?md5=$5" ) +#} + + +evasive.max-conns-per-ip = 20 +server.max-write-idle = 600 + + +#secdownload.secret = "uguuruuwasuremono" +#secdownload.document-root = "/home/moe/danbooru/data" +#secdownload.uri-prefix = "/data/" +#secdownload.timeout = 2000 + + +# No files in /data are Rails requests, and there are a constant stream of bogus requests +# in /data from broken robots. Only redir to /dispatch.cgi for paths not in /data. +$HTTP["url"] !~ "^/data.*" { + server.error-handler-404 = "/dispatch.fcgi" +} +server.document-root = "/home/moe/moe-live" + "/public/" + +url.rewrite = ( + "^/$" => "index.html", + "^([^.]+)$" => "$1.html", + + # /image/d9cca721a735dac4efe709e0f3518373/anything.jpg -> /data/d9/cc/d9cca721a735dac4efe709e0f3518373.jpg + # This is a simple way of setting the filename on downloaded files. + "^/image/([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{28})(/.*)?(\.[a-z]*)" => "/data/image/$1/$2/$1$2$3$5", + "^/sample/([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{28})(/.*)?(\.[a-z]*)" => "/data/sample/$1/$2/$1$2$3$5" +) + +#$HTTP["orig-url"] =~ "^/image/.*" { +# setenv.add-response-header = ( "Content-Disposition" => "attachment" ) +#} + +compress.filetype = ( "text/plain", "text/html", "text/css", "text/javascript" ) +compress.cache-dir = "/home/moe/moe-live" + "/tmp/cache" + +expire.url = ( "/favicon.ico" => "access 3 days", + "/images/" => "access 3 days", + "/stylesheets/" => "access 3 days", + "/javascripts/" => "access 3 days" ) + +#server.network-backend = "writev" + +# Change *-procs to 2 if you need to use Upload Progress or other tasks that +# *need* to execute a second request while the first is still pending. +fastcgi.server = ( ".fcgi" => ( "danbooru" => ( + "min-procs" => 2, + "max-procs" => 2, + "socket" => "/home/moe/moe-live" + "/tmp/sockets/fcgi-test.socket", + "bin-path" => "/home/moe/moe-live" + "/public/dispatch.fcgi", + "bin-environment" => ( "RAILS_ENV" => "production_with_logging" ) +) ) ) + +# Making sure file uploads above 64k always work when using IE or Safari +# For more information, see http://trac.lighttpd.net/trac/ticket/360 +$HTTP["useragent"] =~ "^(.*MSIE.*)|(.*AppleWebKit.*)$" { + server.max-keep-alive-requests = 0 +} + + +#$HTTP["referer"] !~ "^http://moe.imouto.org/.*" { +#url.access-deny = ( ".jpg", ".jpeg", ".png", ".avg", ".mpeg" ) +#} + + +# files to check for if .../ is requested +index-file.names = ( "index.php", "index.html", + "index.htm", "default.htm" ) + +## set the event-handler (read the performance section in the manual) +server.event-handler = "freebsd-kqueue" # needed on OS X + +# mimetype mapping +mimetype.assign = ( + ".pdf" => "application/pdf", + ".sig" => "application/pgp-signature", + ".spl" => "application/futuresplash", + ".class" => "application/octet-stream", + ".ps" => "application/postscript", + ".torrent" => "application/x-bittorrent", + ".dvi" => "application/x-dvi", + ".gz" => "application/x-gzip", + ".pac" => "application/x-ns-proxy-autoconfig", + ".swf" => "application/x-shockwave-flash", + ".tar.gz" => "application/x-tgz", + ".tgz" => "application/x-tgz", + ".tar" => "application/x-tar", + ".zip" => "application/zip", + ".mp3" => "audio/mpeg", + ".m3u" => "audio/x-mpegurl", + ".wma" => "audio/x-ms-wma", + ".wax" => "audio/x-ms-wax", + ".ogg" => "application/ogg", + ".wav" => "audio/x-wav", + ".gif" => "image/gif", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".xbm" => "image/x-xbitmap", + ".xpm" => "image/x-xpixmap", + ".xwd" => "image/x-xwindowdump", + ".css" => "text/css", + ".xhtml" => "text/html", + ".html" => "text/html", + ".htm" => "text/html", + ".js" => "text/javascript", + ".asc" => "text/plain", + ".c" => "text/plain", + ".cpp" => "text/plain", + ".log" => "text/plain", + ".conf" => "text/plain", + ".text" => "text/plain", + ".txt" => "text/plain", + ".dtd" => "text/xml", + ".xml" => "text/xml", + ".xsl" => "text/xml", + ".mpeg" => "video/mpeg", + ".mpg" => "video/mpeg", + ".mov" => "video/quicktime", + ".qt" => "video/quicktime", + ".avi" => "video/x-msvideo", + ".asf" => "video/x-ms-asf", + ".asx" => "video/x-ms-asf", + ".wmv" => "video/x-ms-wmv", + ".bz2" => "application/x-bzip", + ".tbz" => "application/x-bzip-compressed-tar", + ".tar.bz2" => "application/x-bzip-compressed-tar" + ) + +# Use the "Content-Type" extended attribute to obtain mime type if possible +#mimetype.use-xattr = "enable" + + +## send a different Server: header +## be nice and keep it at lighttpd +# server.tag = "lighttpd" + +#### accesslog module +accesslog.filename = "/var/log/lighttpd/access-moe-test.log" + +## deny access the file-extensions +# +# ~ is for backupfiles from vi, emacs, joe, ... +# .inc is often used for code includes which should in general not be part +# of the document-root +url.access-deny = ( "~", ".inc" ) + +$HTTP["url"] =~ "\.pdf$" { + server.range-requests = "disable" +} + +## +# which extensions should not be handle via static-file transfer +# +# .php, .pl, .fcgi are most often handled by mod_fastcgi or mod_cgi +static-file.exclude-extensions = ( ".php", ".pl", ".fcgi" ) + +######### Options that are good to be but not neccesary to be changed ####### + +## bind to port (default: 80) +#server.port = 81 +server.port = 85 + +#$SERVER["socket"] == "67.159.5.233:8080" { +#} + +## bind to localhost (default: all interfaces) +server.bind = "76.73.1.90" + +## error-handler for status 404 +#server.error-handler-404 = "/error-handler.html" +#server.error-handler-404 = "/error-handler.php" + +## to help the rc.scripts +server.pid-file = "/var/run/lighttpd-moe-test.pid" + + +###### virtual hosts +## +## If you want name-based virtual hosting add the next three settings and load +## mod_simple_vhost +## +## document-root = +## virtual-server-root + virtual-server-default-host + virtual-server-docroot +## or +## virtual-server-root + http-host + virtual-server-docroot +## + +## +## Format: .html +## -> ..../status-404.html for 'File not found' +#server.errorfile-prefix = "/home/weigon/projects/lighttpd/doc/status-" + +## virtual directory listings +dir-listing.activate = "disable" + +## enable debugging +#debug.log-request-header = "enable" +#debug.log-response-header = "enable" +#debug.log-request-handling = "enable" +#debug.log-file-not-found = "enable" +#fastcgi.debug = 3 + +### only root can use these options +# +# chroot() to directory (default: no chroot() ) +#server.chroot = "/" + +## change uid to (default: don't care) +server.username = "moe" + +## change uid to (default: don't care) +server.groupname = "www" + +#### compress module +#compress.cache-dir = "/tmp/lighttpd/cache/compress/" +#compress.filetype = ("text/plain", "text/html") + +#### proxy module +## read proxy.txt for more info +#proxy.server = ( ".php" => +# ( "localhost" => +# ( +# "host" => "192.168.0.101", +# "port" => 80 +# ) +# ) +# ) + +#### SSL engine +#ssl.engine = "enable" +#ssl.pemfile = "server.pem" + +#### status module +#status.status-url = "/server-status" +#status.config-url = "/server-config" + +#### auth module +## read authentication.txt for more info +#auth.backend = "plain" +#auth.backend.plain.userfile = "lighttpd.user" +#auth.backend.plain.groupfile = "lighttpd.group" + +#auth.backend.ldap.hostname = "localhost" +#auth.backend.ldap.base-dn = "dc=my-domain,dc=com" +#auth.backend.ldap.filter = "(uid=$)" + +#auth.require = ( "/server-status" => +# ( +# "method" => "digest", +# "realm" => "download archiv", +# "require" => "user=jan" +# ), +# "/server-config" => +# ( +# "method" => "digest", +# "realm" => "download archiv", +# "require" => "valid-user" +# ) +# ) + +#### url handling modules (rewrite, redirect, access) +#url.rewrite = ( "^/$" => "/server-status" ) +#url.redirect = ( "^/wishlist/(.+)" => "http://www.123.org/$1" ) +#### both rewrite/redirect support back reference to regex conditional using %n +#$HTTP["host"] =~ "^www\.(.*)" { +# url.redirect = ( "^/(.*)" => "http://%1/$1" ) +#} + +# +# define a pattern for the host url finding +# %% => % sign +# %0 => domain name + tld +# %1 => tld +# %2 => domain name without tld +# %3 => subdomain 1 name +# %4 => subdomain 2 name +# +#evhost.path-pattern = "/home/storage/dev/www/%3/htdocs/" + +#### expire module +#expire.url = ( "/buggy/" => "access 2 hours", "/asdhas/" => "access plus 1 seconds 2 minutes") + +#### ssi +#ssi.extension = ( ".shtml" ) + +#### rrdtool +#rrdtool.binary = "/usr/bin/rrdtool" +#rrdtool.db-name = "/var/www/lighttpd.rrd" + +#### setenv +#setenv.add-request-header = ( "TRAV_ENV" => "mysql://user@host/db" ) +#setenv.add-response-header = ( "X-Secret-Message" => "42" ) + +## for mod_trigger_b4_dl +# trigger-before-download.gdbm-filename = "/home/weigon/testbase/trigger.db" +# trigger-before-download.memcache-hosts = ( "127.0.0.1:11211" ) +# trigger-before-download.trigger-url = "^/trigger/" +# trigger-before-download.download-url = "^/download/" +# trigger-before-download.deny-url = "http://127.0.0.1/index.html" +# trigger-before-download.trigger-timeout = 10 + +## for mod_cml +## don't forget to add index.cml to server.indexfiles +# cml.extension = ".cml" +# cml.memcache-hosts = ( "127.0.0.1:11211" ) + +#### variable usage: +## variable name without "." is auto prefixed by "var." and becomes "var.bar" +#bar = 1 +#var.mystring = "foo" + +## integer add +#bar += 1 +## string concat, with integer cast as string, result: "www.foo1.com" +#server.name = "www." + mystring + var.bar + ".com" +## array merge +#index-file.names = (foo + ".php") + index-file.names +#index-file.names += (foo + ".php") + +#### include +#include /etc/lighttpd/lighttpd-inc.conf +## same as above if you run: "lighttpd -f /etc/lighttpd/lighttpd.conf" +#include "lighttpd-inc.conf" + +#### include_shell +#include_shell "echo var.a=1" +## the above is same as: +#var.a=1 + + + +server.errorlog = "/var/log/lighttpd/error-moe-test.log" + +# Don't allow more than 10 active concurrent connections from the same host. +throttle.max-concurrent-connections = 10 +# Don't allow more than three Rails requests at once. +$HTTP["url"] =~ "/dispatch.fcgi" { + throttle.max-concurrent-connections = 3 +} +# Don't allow more than one post/similar request at once. +$HTTP["orig-url"] =~ "/post/similar.*" { + throttle.max-concurrent-connections = 1 +} +# Limit connections for non-preview/thumb images. +$HTTP["url"] =~ "^/data/[0-9a-f]{2}/" { + throttle.max-concurrent-connections = 3 +} +# If lots of requests are being made to pages disallowed in robots.txt, it's probably a misbehaving bot. HACK: prevent lighttpd from merging config sections +$HTTP["url"] =~ "/dispatch.fcgi|^ba29b12f" { + $HTTP["orig-url"] =~ "(/artist/edit|/artist/update|/comment/show|/comment/create|/favorite|/pool/add_post|/pool/remove_post|/post/atom|/post/upload|/post/create|/post/destroy|/post/tag_history|/post/update|/post/similar|/note/history|/tag/edit|/tag/update|/tag/mass_edit|/wiki/edit|/wiki/update|/wiki/revert|/wiki/history|/wiki/rename|/user).*" { + # Allow 1 connection/sec over 5 minutes + throttle.bucket-size = 300 + throttle.tokens-per-second = 1 + throttle.ban-when-empty = 1 + throttle.ban-duration = 3600 + } +} diff --git a/public/404.html b/public/404.html new file mode 100644 index 00000000..76c2fa4a --- /dev/null +++ b/public/404.html @@ -0,0 +1,16 @@ + + + + + Page not found + + + + +
      + Page not found +

      That page does not exist.

      +

      Return to index

      +
      + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 00000000..d6c4d877 --- /dev/null +++ b/public/500.html @@ -0,0 +1,14 @@ + + + + + Error + + + + +
      +

      An error has occurred. Yes the site is broken for some people, lurk in the irc channel (#moe-imouto @ irc.rizon.net) for now

      +
      + + diff --git a/public/503.html b/public/503.html new file mode 100644 index 00000000..a6d55db7 --- /dev/null +++ b/public/503.html @@ -0,0 +1,17 @@ + + + + + + i can't take it easy ;_; + + + + +
      + take it easy +

      The server is currently overloaded. Try one of these Danbooru alternatives.

      +

      This page will automatically refresh in one minute.

      +
      + + diff --git a/public/IE8.js b/public/IE8.js new file mode 100644 index 00000000..b880cf26 --- /dev/null +++ b/public/IE8.js @@ -0,0 +1,2602 @@ +// timestamp: Sun, 03 Feb 2008 19:26:22 +/* + IE7/IE8.js - copyright 2004-2008, Dean Edwards + http://dean.edwards.name/IE7/ + http://www.opensource.org/licenses/mit-license.php +*/ + +/* W3C compliance for Microsoft Internet Explorer */ + +/* credits/thanks: + Shaggy, Martijn Wargers, Jimmy Cerra, Mark D Anderson, + Lars Dieckow, Erik Arvidsson, Gellrt Gyuris, James Denny, + Unknown W Brackets, Benjamin Westfarer, Rob Eberhardt, + Bill Edney, Kevin Newman, James Crompton, Matthew Mastracci, + Doug Wright, Richard York, Kenneth Kolano, MegaZone, + Thomas Verelst, Mark 'Tarquin' Wilton-Jones, Rainer hlfors, + David Zulaica, Ken Kolano, Kevin Newman +*/ + +// ======================================================================= +// TO DO +// ======================================================================= + +// PNG - unclickable content + +// ======================================================================= +// TEST/BUGGY +// ======================================================================= + +// hr{margin:1em auto} (doesn't look right in IE5) + +(function() { + +IE7 = { + toString: function(){return "IE7 version 2.0 (beta3)"} +}; +var appVersion = IE7.appVersion = navigator.appVersion.match(/MSIE (\d\.\d)/)[1]; + +if (/ie7_off/.test(top.location.search) || appVersion < 5) return; + +var Undefined = K(); +var quirksMode = document.compatMode != "CSS1Compat"; +var documentElement = document.documentElement, body, viewport; +var ANON = "!"; +var HEADER = ":link{ie7-link:link}:visited{ie7-link:visited}"; + +// ----------------------------------------------------------------------- +// external +// ----------------------------------------------------------------------- + +var RELATIVE = /^[\w\.]+[^:]*$/; +function makePath(href, path) { + if (RELATIVE.test(href)) href = (path || "") + href; + return href; +}; + +function getPath(href, path) { + href = makePath(href, path); + return href.slice(0, href.lastIndexOf("/") + 1); +}; + +// get the path to this script +var script = document.scripts[document.scripts.length - 1]; +var path = getPath(script.src); + +// we'll use microsoft's http request object to load external files +try { + var httpRequest = new ActiveXObject("Microsoft.XMLHTTP"); +} catch (e) { + // ActiveX disabled +} + +var fileCache = {}; +function loadFile(href, path) { +try { + href = makePath(href, path); + if (!fileCache[href]) { + // easy to load a file huh? + httpRequest.open("GET", href, false); + httpRequest.send(); + if (httpRequest.status == 0 || httpRequest.status == 200) { + fileCache[href] = httpRequest.responseText; + } + } +} catch (e) { + // ignore errors +} finally { + return fileCache[href] || ""; +}}; + +// ----------------------------------------------------------------------- +// IE5.0 compatibility +// ----------------------------------------------------------------------- + + +if (appVersion < 5.5) { + undefined = Undefined(); + + ANON = "HTML:!"; // for anonymous content + + // Fix String.replace (Safari1.x/IE5.0). + var GLOBAL = /(g|gi)$/; + var _String_replace = String.prototype.replace; + String.prototype.replace = function(expression, replacement) { + if (typeof replacement == "function") { // Safari doesn't like functions + if (expression && expression.constructor == RegExp) { + var regexp = expression; + var global = regexp.global; + if (global == null) global = GLOBAL.test(regexp); + // we have to convert global RexpExps for exec() to work consistently + if (global) regexp = new RegExp(regexp.source); // non-global + } else { + regexp = new RegExp(rescape(expression)); + } + var match, string = this, result = ""; + while (string && (match = regexp.exec(string))) { + result += string.slice(0, match.index) + replacement.apply(this, match); + string = string.slice(match.index + match[0].length); + if (!global) break; + } + return result + string; + } + return _String_replace.apply(this, arguments); + }; + + Array.prototype.pop = function() { + if (this.length) { + var i = this[this.length - 1]; + this.length--; + return i; + } + return undefined; + }; + + Array.prototype.push = function() { + for (var i = 0; i < arguments.length; i++) { + this[this.length] = arguments[i]; + } + return this.length; + }; + + var ns = this; + Function.prototype.apply = function(o, a) { + if (o === undefined) o = ns; + else if (o == null) o = window; + else if (typeof o == "string") o = new String(o); + else if (typeof o == "number") o = new Number(o); + else if (typeof o == "boolean") o = new Boolean(o); + if (arguments.length == 1) a = []; + else if (a[0] && a[0].writeln) a[0] = a[0].documentElement.document || a[0]; + var $ = "#ie7_apply", r; + o[$] = this; + switch (a.length) { // unroll for speed + case 0: r = o[$](); break; + case 1: r = o[$](a[0]); break; + case 2: r = o[$](a[0],a[1]); break; + case 3: r = o[$](a[0],a[1],a[2]); break; + case 4: r = o[$](a[0],a[1],a[2],a[3]); break; + case 5: r = o[$](a[0],a[1],a[2],a[3],a[4]); break; + default: + var b = [], i = a.length - 1; + do b[i] = "a[" + i + "]"; while (i--); + eval("r=o[$](" + b + ")"); + } + if (typeof o.valueOf == "function") { // not a COM object + delete o[$]; + } else { + o[$] = undefined; + if (r && r.writeln) r = r.documentElement.document || r; + } + return r; + }; + + Function.prototype.call = function(o) { + return this.apply(o, _slice.apply(arguments, [1])); + }; + + // block elements are "inline" according to IE5.0 so we'll fix it + HEADER += "address,blockquote,body,dd,div,dt,fieldset,form,"+ + "frame,frameset,h1,h2,h3,h4,h5,h6,iframe,noframes,object,p,"+ + "hr,applet,center,dir,menu,pre,dl,li,ol,ul{display:block}"; +} + +// ----------------------------------------------------------------------- +// OO support +// ----------------------------------------------------------------------- + + +// This is a cut-down version of base2 (http://code.google.com/p/base2/) + +var _slice = Array.prototype.slice; + +// private +var _FORMAT = /%([1-9])/g; +var _LTRIM = /^\s\s*/; +var _RTRIM = /\s\s*$/; +var _RESCAPE = /([\/()[\]{}|*+-.,^$?\\])/g; // safe regular expressions +var _BASE = /\bbase\b/; +var _HIDDEN = ["constructor", "toString"]; // only override these when prototyping + +var prototyping; + +function Base(){}; +Base.extend = function(_instance, _static) { + // Build the prototype. + prototyping = true; + var _prototype = new this; + extend(_prototype, _instance); + prototyping = false; + + // Create the wrapper for the constructor function. + var _constructor = _prototype.constructor; + function klass() { + // Don't call the constructor function when prototyping. + if (!prototyping) _constructor.apply(this, arguments); + }; + _prototype.constructor = klass; + + // Build the static interface. + klass.extend = arguments.callee; + extend(klass, _static); + klass.prototype = _prototype; + return klass; +}; +Base.prototype.extend = function(source) { + return extend(this, source); +}; + +// A collection of regular expressions and their associated replacement values. +// A Base class for creating parsers. + +var _HASH = "#"; +var _KEYS = "~"; + +var _RG_ESCAPE_CHARS = /\\./g; +var _RG_ESCAPE_BRACKETS = /\(\?[:=!]|\[[^\]]+\]/g; +var _RG_BRACKETS = /\(/g; + +var RegGrp = Base.extend({ + constructor: function(values) { + this[_KEYS] = []; + this.merge(values); + }, + + exec: function(string) { + var items = this, keys = this[_KEYS]; + return String(string).replace(new RegExp(this, this.ignoreCase ? "gi" : "g"), function() { + var item, offset = 1, i = 0; + // Loop through the RegGrp items. + while ((item = items[_HASH + keys[i++]])) { + var next = offset + item.length + 1; + if (arguments[offset]) { // do we have a result? + var replacement = item.replacement; + switch (typeof replacement) { + case "function": + return replacement.apply(items, _slice.call(arguments, offset, next)); + case "number": + return arguments[offset + replacement]; + default: + return replacement; + } + } + offset = next; + } + }); + }, + + add: function(expression, replacement) { + if (expression instanceof RegExp) { + expression = expression.source; + } + if (!this[_HASH + expression]) this[_KEYS].push(String(expression)); + this[_HASH + expression] = new RegGrp.Item(expression, replacement); + }, + + merge: function(values) { + for (var i in values) this.add(i, values[i]); + }, + + toString: function() { + // back references not supported in simple RegGrp + return "(" + this[_KEYS].join(")|(") + ")"; + } +}, { + IGNORE: "$0", + + Item: Base.extend({ + constructor: function(expression, replacement) { + expression = expression instanceof RegExp ? expression.source : String(expression); + + if (typeof replacement == "number") replacement = String(replacement); + else if (replacement == null) replacement = ""; + + // does the pattern use sub-expressions? + if (typeof replacement == "string" && /\$(\d+)/.test(replacement)) { + // a simple lookup? (e.g. "$2") + if (/^\$\d+$/.test(replacement)) { + // store the index (used for fast retrieval of matched strings) + replacement = parseInt(replacement.slice(1)); + } else { // a complicated lookup (e.g. "Hello $2 $1") + // build a function to do the lookup + var Q = /'/.test(replacement.replace(/\\./g, "")) ? '"' : "'"; + replacement = replacement.replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\$(\d+)/g, Q + + "+(arguments[$1]||" + Q+Q + ")+" + Q); + replacement = new Function("return " + Q + replacement.replace(/(['"])\1\+(.*)\+\1\1$/, "$1") + Q); + } + } + + this.length = RegGrp.count(expression); + this.replacement = replacement; + this.toString = K(expression); + } + }), + + count: function(expression) { + // Count the number of sub-expressions in a RegExp/RegGrp.Item. + expression = String(expression).replace(_RG_ESCAPE_CHARS, "").replace(_RG_ESCAPE_BRACKETS, ""); + return match(expression, _RG_BRACKETS).length; + } +}); + +// ========================================================================= +// lang/extend.js +// ========================================================================= + +function extend(object, source) { // or extend(object, key, value) + if (object && source) { + var proto = (typeof source == "function" ? Function : Object).prototype; + // Add constructor, toString etc + var i = _HIDDEN.length, key; + if (prototyping) while (key = _HIDDEN[--i]) { + var value = source[key]; + if (value != proto[key]) { + if (_BASE.test(value)) { + _override(object, key, value) + } else { + object[key] = value; + } + } + } + // Copy each of the source object's properties to the target object. + for (key in source) if (proto[key] === undefined) { + var value = source[key]; + // Check for method overriding. + if (object[key] && typeof value == "function" && _BASE.test(value)) { + _override(object, key, value); + } else { + object[key] = value; + } + } + } + return object; +}; + +function _override(object, name, method) { + // Override an existing method. + var ancestor = object[name]; + object[name] = function() { + var previous = this.base; + this.base = ancestor; + var returnValue = method.apply(this, arguments); + this.base = previous; + return returnValue; + }; +}; + +function combine(keys, values) { + // Combine two arrays to make a hash. + if (!values) values = keys; + var hash = {}; + for (var i in keys) hash[i] = values[i]; + return hash; +}; + +function format(string) { + // Replace %n with arguments[n]. + // e.g. format("%1 %2%3 %2a %1%3", "she", "se", "lls"); + // ==> "she sells sea shells" + // Only %1 - %9 supported. + var args = arguments; + var _FORMAT = new RegExp("%([1-" + arguments.length + "])", "g"); + return String(string).replace(_FORMAT, function(match, index) { + return index < args.length ? args[index] : match; + }); +}; + +function match(string, expression) { + // Same as String.match() except that this function will return an empty + // array if there is no match. + return String(string).match(expression) || []; +}; + +function rescape(string) { + // Make a string safe for creating a RegExp. + return String(string).replace(_RESCAPE, "\\$1"); +}; + +// http://blog.stevenlevithan.com/archives/faster-trim-javascript +function trim(string) { + return String(string).replace(_LTRIM, "").replace(_RTRIM, ""); +}; + +function K(k) { + return function() { + return k; + }; +}; + +// ----------------------------------------------------------------------- +// parsing +// ----------------------------------------------------------------------- + +var Parser = RegGrp.extend({ignoreCase: true}); + +var ENCODED = /\x01(\d+)/g, + QUOTES = /'/g, + STRING = /^\x01/, + UNICODE = /\\([\da-fA-F]{1,4})/g; + +var _strings = []; + +var encoder = new Parser({ + // comments + "": "", + "\\/\\*[^*]*\\*+([^\\/][^*]*\\*+)*\\/": "", + // get rid + "@(namespace|import)[^;\\n]+[;\\n]": "", + // strings + "'(\\\\.|[^'\\\\])*'": encodeString, + '"(\\\\.|[^"\\\\])*"': encodeString, + // white space + "\\s+": " " +}); + +function encode(cssText) { + return encoder.exec(cssText); +}; + +function decode(cssText) { + return cssText.replace(ENCODED, function(match, index) { + return _strings[index - 1]; + }); +}; + +function encodeString(string) { + return "\x01" + _strings.push(string.replace(UNICODE, function(match, chr) { + return eval("'\\u" + "0000".slice(chr.length) + chr + "'"); + }).slice(1, -1).replace(QUOTES, "\\'")); +}; + +function getString(value) { + return STRING.test(value) ? _strings[value.slice(1) - 1] : value; +}; + +// clone a "width" function to create a "height" function +var rotater = new RegGrp({ + Width: "Height", + width: "height", + Left: "Top", + left: "top", + Right: "Bottom", + right: "bottom", + onX: "onY" +}); + +function rotate(fn) { + return rotater.exec(fn); +}; + +// ----------------------------------------------------------------------- +// event handling +// ----------------------------------------------------------------------- + +var eventHandlers = []; + +function addResize(handler) { + addRecalc(handler); + addEventHandler(window, "onresize", handler); +}; + +// add an event handler (function) to an element +function addEventHandler(element, type, handler) { + element.attachEvent(type, handler); + // store the handler so it can be detached later + eventHandlers.push(arguments); +}; + +// remove an event handler assigned to an element by IE7 +function removeEventHandler(element, type, handler) { +try { + element.detachEvent(type, handler); +} catch (ignore) { + // write a letter of complaint to microsoft.. +}}; + +// remove event handlers (they eat memory) +addEventHandler(window, "onunload", function() { + var handler; + while (handler = eventHandlers.pop()) { + removeEventHandler(handler[0], handler[1], handler[2]); + } +}); + +function register(handler, element, condition) { // -@DRE + //var set = handler[element.uniqueID]; + if (!handler.elements) handler.elements = {}; + if (condition) handler.elements[element.uniqueID] = element; + else delete handler.elements[element.uniqueID]; + //return !set && condition; + return condition; +}; + +addEventHandler(window, "onbeforeprint", function() { + if (!IE7.CSS.print) new StyleSheet("print"); + IE7.CSS.print.recalc(); +}); + +// ----------------------------------------------------------------------- +// pixel conversion +// ----------------------------------------------------------------------- + +// this is handy because it means that web developers can mix and match +// measurement units in their style sheets. it is not uncommon to +// express something like padding in "em" units whilst border thickness +// is most often expressed in pixels. + +var PIXEL = /^\d+(px)?$/i; +var PERCENT = /^\d+%$/; +var getPixelValue = function(element, value) { + if (PIXEL.test(value)) return parseInt(value); + var style = element.style.left; + var runtimeStyle = element.runtimeStyle.left; + element.runtimeStyle.left = element.currentStyle.left; + element.style.left = value || 0; + value = element.style.pixelLeft; + element.style.left = style; + element.runtimeStyle.left = runtimeStyle; + return value; +}; + +// ----------------------------------------------------------------------- +// generic +// ----------------------------------------------------------------------- + +var $IE7 = "ie7-"; + +var Fix = Base.extend({ + constructor: function() { + this.fixes = []; + this.recalcs = []; + }, + init: Undefined +}); + +// a store for functions that will be called when refreshing IE7 +var recalcs = []; +function addRecalc(recalc) { + recalcs.push(recalc); +}; + +IE7.recalc = function() { + IE7.HTML.recalc(); + // re-apply style sheet rules (re-calculate ie7 classes) + IE7.CSS.recalc(); + // apply global fixes to the document + for (var i = 0; i < recalcs.length; i++) recalcs[i](); +}; + +function isFixed(element) { + return element.currentStyle["ie7-position"] == "fixed"; +}; + +// original style +function getDefinedStyle(element, propertyName) { + return element.currentStyle[$IE7 + propertyName] || element.currentStyle[propertyName]; +}; + +function setOverrideStyle(element, propertyName, value) { + if (element.currentStyle[$IE7 + propertyName] == null) { + element.runtimeStyle[$IE7 + propertyName] = element.currentStyle[propertyName]; + } + element.runtimeStyle[propertyName] = value; +}; + +// create a temporary element which is used to inherit styles +// from the target element. the temporary element can be resized +// to determine pixel widths/heights +function createTempElement(tagName) { + var element = document.createElement(tagName || "object"); + element.style.cssText = "position:absolute;padding:0;display:block;border:none;clip:rect(0 0 0 0);left:-9999"; + element.ie7_anon = true; + return element; +}; + + +// ========================================================================= +// ie7-cssQuery.js +// ========================================================================= + +function cssQuery(selector, context, single) { + if (!_cache[selector]) { + reg = []; // store for RegExp objects + var fn = ""; + var selectors = cssParser.escape(selector).split(","); + for (var i = 0; i < selectors.length; i++) { + _wild = _index = _list = 0; // reset + _duplicate = selectors.length > 1 ? 2 : 0; // reset + var block = cssParser.exec(selectors[i]) || "if(0){"; + if (_wild) { // IE's pesky comment nodes + block += format("if(e%1.nodeName!='!'){", _index); + } + // check for duplicates before storing results + var store = _duplicate > 1 ? _TEST : ""; + block += format(store + _STORE, _index); + // add closing braces + block += Array(match(block, /\{/g).length + 1).join("}"); + fn += block; + } + eval(format(_FN, reg) + cssParser.unescape(fn) + "return s?null:r}"); + _cache[selector] = _selectorFunction; + } + return _cache[selector](context || document, single); +}; + +var _MSIE5 = appVersion < 6; + +var _EVALUATED = /^(href|src)$/; +var _ATTRIBUTES = { + "class": "className", + "for": "htmlFor" +}; + +IE7._indexed = 1; + +IE7._byId = function(document, id) { + var result = document.all[id] || null; + // returns a single element or a collection + if (!result || result.id == id) return result; + // document.all has returned a collection of elements with name/id + for (var i = 0; i < result.length; i++) { + if (result[i].id == id) return result[i]; + } + return null; +}; + +IE7._getAttribute = function(element, name) { + if (name == "src" && element.pngSrc) return element.pngSrc; + + var attribute = _MSIE5 ? (element.attributes[name] || element.attributes[_ATTRIBUTES[name.toLowerCase()]]) : element.getAttributeNode(name); + if (attribute && (attribute.specified || name == "value")) { + if (_EVALUATED.test(name)) { + return element.getAttribute(name, 2); + } else if (name == "class") { + return element.className.replace(/\sie7_\w+/g, ""); + } else if (name == "style") { + return element.style.cssText; + } else { + return attribute.nodeValue; + } + } + return null; +}; + +var names = "colSpan,rowSpan,vAlign,dateTime,accessKey,tabIndex,encType,maxLength,readOnly,longDesc"; +// Convert the list of strings to a hash, mapping the lowercase name to the camelCase name. +extend(_ATTRIBUTES, combine(names.toLowerCase().split(","), names.split(","))); + +IE7._getNextElementSibling = function(node) { + // return the next element to the supplied element + // nextSibling is not good enough as it might return a text or comment node + while (node && (node = node.nextSibling) && (node.nodeType != 1 || node.nodeName == "!")) continue; + return node; +}; + +IE7._getPreviousElementSibling = function(node) { + // return the previous element to the supplied element + while (node && (node = node.previousSibling) && (node.nodeType != 1 || node.nodeName == "!")) continue; + return node; +}; + +// ========================================================================= +// CSSParser +// ========================================================================= + +var IMPLIED_ASTERISK = /([\s>+~,]|[^(]\+|^)([#.:\[])/g, + IMPLIED_SPACE = /(^|,)([^\s>+~])/g, + WHITESPACE = /\s*([\s>+~(),]|^|$)\s*/g, + WILD_CARD = /\s\*\s/g;; + +var CSSParser = RegGrp.extend({ + constructor: function(items) { + this.base(items); + this.sorter = new RegGrp; + this.sorter.add(/:not\([^)]*\)/, RegGrp.IGNORE); + this.sorter.add(/([ >](\*|[\w-]+))([^: >+~]*)(:\w+-child(\([^)]+\))?)([^: >+~]*)/, "$1$3$6$4"); + }, + + ignoreCase: true, + + escape: function(selector) { + return this.optimise(this.format(selector)); + }, + + format: function(selector) { + return selector + .replace(WHITESPACE, "$1") + .replace(IMPLIED_SPACE, "$1 $2") + .replace(IMPLIED_ASTERISK, "$1*$2"); + }, + + optimise: function(selector) { + // optimise wild card descendant selectors + return this.sorter.exec(selector.replace(WILD_CARD, ">* ")); + }, + + unescape: function(selector) { + return decode(selector); + } +}); + +// some constants +var _OPERATORS = { + "": "%1!=null", + "=": "%1=='%2'", + "~=": /(^| )%1( |$)/, + "|=": /^%1(-|$)/, + "^=": /^%1/, + "$=": /%1$/, + "*=": /%1/ +}; + +var _PSEUDO_CLASSES = { + "first-child": "!IE7._getPreviousElementSibling(e%1)", + "link": "e%1.currentStyle['ie7-link']=='link'", + "visited": "e%1.currentStyle['ie7-link']=='visited'" +}; + +var _VAR = "var p%2=0,i%2,e%2,n%2=e%1."; +var _ID = "e%1.sourceIndex"; +var _TEST = "var g=" + _ID + ";if(!p[g]){p[g]=1;"; +var _STORE = "r[r.length]=e%1;if(s)return e%1;"; +var _FN = "var _selectorFunction=function(e0,s){IE7._indexed++;var r=[],p={},reg=[%1],d=document;"; +var reg; // a store for RexExp objects +var _index; +var _wild; // need to flag certain _wild card selectors as MSIE includes comment nodes +var _list; // are we processing a node _list? +var _duplicate; // possible duplicates? +var _cache = {}; // store parsed selectors + +// a hideous parser +var cssParser = new CSSParser({ + " (\\*|[\\w-]+)#([\\w-]+)": function(match, tagName, id) { // descendant selector followed by ID + _wild = false; + var replacement = "var e%2=IE7._byId(d,'%4');if(e%2&&"; + if (tagName != "*") replacement += "e%2.nodeName=='%3'&&"; + replacement += "(e%1==d||e%1.contains(e%2))){"; + if (_list) replacement += format("i%1=n%1.length;", _list); + return format(replacement, _index++, _index, tagName.toUpperCase(), id); + }, + + " (\\*|[\\w-]+)": function(match, tagName) { // descendant selector + _duplicate++; // this selector may produce duplicates + _wild = tagName == "*"; + var replacement = _VAR; + // IE5.x does not support getElementsByTagName("*"); + replacement += (_wild && _MSIE5) ? "all" : "getElementsByTagName('%3')"; + replacement += ";for(i%2=0;(e%2=n%2[i%2]);i%2++){"; + return format(replacement, _index++, _list = _index, tagName.toUpperCase()); + }, + + ">(\\*|[\\w-]+)": function(match, tagName) { // child selector + var children = _list; + _wild = tagName == "*"; + var replacement = _VAR; + // use the children property for MSIE as it does not contain text nodes + // (but the children collection still includes comments). + // the document object does not have a children collection + replacement += children ? "children": "childNodes"; + if (!_wild && children) replacement += ".tags('%3')"; + replacement += ";for(i%2=0;(e%2=n%2[i%2]);i%2++){"; + if (_wild) { + replacement += "if(e%2.nodeType==1){"; + _wild = _MSIE5; + } else { + if (!children) replacement += "if(e%2.nodeName=='%3'){"; + } + return format(replacement, _index++, _list = _index, tagName.toUpperCase()); + }, + + "\\+(\\*|[\\w-]+)": function(match, tagName) { // direct adjacent selector + var replacement = ""; + if (_wild) replacement += "if(e%1.nodeName!='!'){"; + _wild = false; + replacement += "e%1=IE7._getNextElementSibling(e%1);if(e%1"; + if (tagName != "*") replacement += "&&e%1.nodeName=='%2'"; + replacement += "){"; + return format(replacement, _index, tagName.toUpperCase()); + }, + + "~(\\*|[\\w-]+)": function(match, tagName) { // indirect adjacent selector + var replacement = ""; + if (_wild) replacement += "if(e%1.nodeName!='!'){"; + _wild = false; + _duplicate = 2; // this selector may produce duplicates + replacement += "while(e%1=e%1.nextSibling){if(e%1.ie7_adjacent==IE7._indexed)break;if("; + if (tagName == "*") { + replacement += "e%1.nodeType==1"; + if (_MSIE5) replacement += "&&e%1.nodeName!='!'"; + } else replacement += "e%1.nodeName=='%2'"; + replacement += "){e%1.ie7_adjacent=IE7._indexed;"; + return format(replacement, _index, tagName.toUpperCase()); + }, + + "#([\\w-]+)": function(match, id) { // ID selector + _wild = false; + var replacement = "if(e%1.id=='%2'){"; + if (_list) replacement += format("i%1=n%1.length;", _list); + return format(replacement, _index, id); + }, + + "\\.([\\w-]+)": function(match, className) { // class selector + _wild = false; + // store RegExp objects - slightly faster on IE + reg.push(new RegExp("(^|\\s)" + rescape(className) + "(\\s|$)")); + return format("if(e%1.className&®[%2].test(e%1.className)){", _index, reg.length - 1); + }, + + "\\[([\\w-]+)\\s*([^=]?=)?\\s*([^\\]]*)\\]": function(match, attr, operator, value) { // attribute selectors + var alias = _ATTRIBUTES[attr] || attr; + if (operator) { + var getAttribute = "e%1.getAttribute('%2',2)"; + if (!_EVALUATED.test(attr)) { + getAttribute = "e%1.%3||" + getAttribute; + } + attr = format("(" + getAttribute + ")", _index, attr, alias); + } else { + attr = format("IE7._getAttribute(e%1,'%2')", _index, attr); + } + var replacement = _OPERATORS[operator || ""] || "0"; + if (replacement && replacement.source) { + reg.push(new RegExp(format(replacement.source, rescape(cssParser.unescape(value))))); + replacement = "reg[%2].test(%1)"; + value = reg.length - 1; + } + return "if(" + format(replacement, attr, value) + "){"; + }, + + ":+([\\w-]+)(\\(([^)]+)\\))?": function(match, pseudoClass, $2, args) { // pseudo class selectors + pseudoClass = _PSEUDO_CLASSES[pseudoClass]; + return "if(" + (pseudoClass ? format(pseudoClass, _index, args || "") : "0") + "){"; + } +}); + +// ========================================================================= +// ie7-css.js +// ========================================================================= + +var HYPERLINK = /a(#[\w-]+)?(\.[\w-]+)?:(hover|active)/i; +var BRACE1 = /\s*\{\s*/, BRACE2 = /\s*\}\s*/, COMMA = /\s*\,\s*/; +var FIRST_LINE_LETTER = /(.*)(:first-(line|letter))/; + +//var UNKNOWN = /UNKNOWN|([:.])\w+\1/i; + +var styleSheets = document.styleSheets; + +IE7.CSS = new (Fix.extend({ // single instance + parser: new Parser, + screen: "", + print: "", + styles: [], + rules: [], + pseudoClasses: appVersion < 7 ? "first\\-child" : "", + dynamicPseudoClasses: { + toString: function() { + var strings = []; + for (var pseudoClass in this) strings.push(pseudoClass); + return strings.join("|"); + } + }, + + init: function() { + var NONE = "^\x01$"; + var CLASS = "\\[class=?[^\\]]*\\]"; + var pseudoClasses = []; + if (this.pseudoClasses) pseudoClasses.push(this.pseudoClasses); + var dynamicPseudoClasses = this.dynamicPseudoClasses.toString(); + if (dynamicPseudoClasses) pseudoClasses.push(dynamicPseudoClasses); + pseudoClasses = pseudoClasses.join("|"); + var unknown = appVersion < 7 ? ["[>+~[(]|([:.])\\w+\\1"] : [CLASS]; + if (pseudoClasses) unknown.push(":(" + pseudoClasses + ")"); + this.UNKNOWN = new RegExp(unknown.join("|") || NONE, "i"); + var complex = appVersion < 7 ? ["\\[[^\\]]+\\]|[^\\s(\\[]+\\s*[+~]"] : [CLASS]; + var complexRule = complex.concat(); + if (pseudoClasses) complexRule.push(":(" + pseudoClasses + ")"); + Rule.COMPLEX = new RegExp(complexRule.join("|") || NONE, "ig"); + if (this.pseudoClasses) complex.push(":(" + this.pseudoClasses + ")"); + DynamicRule.COMPLEX = new RegExp(complex.join("|") || NONE, "i"); + DynamicRule.MATCH = new RegExp(dynamicPseudoClasses ? "(.*):(" + dynamicPseudoClasses + ")(.*)" : NONE, "i"); + + this.createStyleSheet(); + this.refresh(); + }, + + addEventHandler: function() { + addEventHandler.apply(null, arguments); + }, + + addFix: function(expression, replacement) { + this.parser.add(expression, replacement); + }, + + addRecalc: function(propertyName, test, handler, replacement) { + // recalcs occur whenever the document is refreshed using document.recalc() + test = new RegExp("([{;\\s])" + propertyName + "\\s*:\\s*" + test + "[^;}]*"); + var id = this.recalcs.length; + if (replacement) replacement = propertyName + ":" + replacement; + this.addFix(test, function(match, $1) { + return (replacement ? $1 + replacement : match) + ";ie7-" + match.slice(1) + ";ie7_recalc" + id + ":1"; + }); + this.recalcs.push(arguments); + return id; + }, + + apply: function() { + this.getInlineStyles(); + new StyleSheet("screen"); + this.trash(); + }, + + createStyleSheet: function() { + // create the IE7 style sheet + this.styleSheet = document.createStyleSheet(); + // flag it so we can ignore it during parsing + this.styleSheet.ie7 = true; + this.styleSheet.owningElement.ie7 = true; + this.styleSheet.cssText = HEADER; + }, + + getInlineStyles: function() { + // load inline styles + var styleSheets = document.getElementsByTagName("style"), styleSheet; + for (var i = styleSheets.length - 1; (styleSheet = styleSheets[i]); i--) { + if (!styleSheet.disabled && !styleSheet.ie7) { + this.styles.push(styleSheet.innerHTML); + } + } + }, + + getText: function(styleSheet, path) { + // explorer will trash unknown selectors (it converts them to "UNKNOWN"). + // so we must reload external style sheets (internal style sheets can have their text + // extracted through the innerHTML property). + // load the style sheet text from an external file + try { + var cssText = styleSheet.cssText; + } catch (e) { + cssText = ""; + } + if (httpRequest) cssText = loadFile(styleSheet.href, path) || cssText; + return cssText; + }, + + recalc: function() { + this.screen.recalc(); + // we're going to read through all style rules. + // certain rules have had ie7 properties added to them. + // e.g. p{top:0; ie7_recalc2:1; left:0} + // this flags a property in the rule as needing a fix. + // the selector text is then used to query the document. + // we can then loop through the results of the query + // and fix the elements. + // we ignore the IE7 rules - so count them in the header + var RECALCS = /ie7_recalc\d+/g; + var start = HEADER.match(/[{,]/g).length; + // only calculate screen fixes. print fixes don't show up anyway + var stop = start + (this.screen.cssText.match(/\{/g)||"").length; + var rules = this.styleSheet.rules, rule; + var calcs, calc, elements, element, i, j, k, id; + // loop through all rules + for (i = start; i < stop; i++) { + rule = rules[i]; + var cssText = rule.style.cssText; + // search for the "ie7_recalc" flag (there may be more than one) + if (rule && (calcs = cssText.match(RECALCS))) { + // use the selector text to query the document + elements = cssQuery(rule.selectorText); + // if there are matching elements then loop + // through the recalc functions and apply them + // to each element + if (elements.length) for (j = 0; j < calcs.length; j++) { + // get the matching flag (e.g. ie7_recalc3) + id = calcs[j]; + // extract the numeric id from the end of the flag + // and use it to index the collection of recalc + // functions + calc = IE7.CSS.recalcs[id.slice(10)][2]; + for (k = 0; (element = elements[k]); k++) { + // apply the fix + if (element.currentStyle[id]) calc(element, cssText); + } + } + } + } + }, + + refresh: function() { + this.styleSheet.cssText = HEADER + this.screen + this.print; + }, + + trash: function() { + // trash the old style sheets + for (var i = 0; i < styleSheets.length; i++) { + if (!styleSheets[i].ie7) { + try { + var cssText = styleSheets[i].cssText; + } catch (e) { + cssText = ""; + } + if (cssText) styleSheets[i].cssText = ""; + } + } + } +})); + +// ----------------------------------------------------------------------- +// IE7 StyleSheet class +// ----------------------------------------------------------------------- + +var StyleSheet = Base.extend({ + constructor: function(media) { + this.media = media; + this.load(); + IE7.CSS[media] = this; + IE7.CSS.refresh(); + }, + + createRule: function(selector, cssText) { + if (IE7.CSS.UNKNOWN.test(selector)) { + var match; + if (PseudoElement && (match = selector.match(PseudoElement.MATCH))) { + return new PseudoElement(match[1], match[2], cssText); + } else if (match = selector.match(DynamicRule.MATCH)) { + if (!HYPERLINK.test(match[0]) || DynamicRule.COMPLEX.test(match[0])) { + return new DynamicRule(selector, match[1], match[2], match[3], cssText); + } + } else return new Rule(selector, cssText); + } + return selector + " {" + cssText + "}"; + }, + + getText: function() { + // store for style sheet text + var _inlineStyles = [].concat(IE7.CSS.styles); + // parse media decalarations + var MEDIA = /@media\s+([^{]*)\{([^@]+\})\s*\}/gi; + var ALL = /\ball\b|^$/i, SCREEN = /\bscreen\b/i, PRINT = /\bprint\b/i; + function _parseMedia(cssText, media) { + _filterMedia.value = media; + return cssText.replace(MEDIA, _filterMedia); + }; + function _filterMedia(match, media, cssText) { + media = _simpleMedia(media); + switch (media) { + case "screen": + case "print": + if (media != _filterMedia.value) return ""; + case "all": + return cssText; + } + return ""; + }; + function _simpleMedia(media) { + if (ALL.test(media)) return "all"; + else if (SCREEN.test(media)) return (PRINT.test(media)) ? "all" : "screen"; + else if (PRINT.test(media)) return "print"; + }; + var self = this; + function _getCSSText(styleSheet, path, media, level) { + var cssText = ""; + if (!level) { + media = _simpleMedia(styleSheet.media); + level = 0; + } + if (media == "all" || media == self.media) { + // IE only allows importing style sheets three levels deep. + // it will crash if you try to access a level below this + if (level < 3) { + // loop through imported style sheets + for (var i = 0; i < styleSheet.imports.length; i++) { + // call this function recursively to get all imported style sheets + cssText += _getCSSText(styleSheet.imports[i], getPath(styleSheet.href, path), media, level + 1); + } + } + // retrieve inline style or load an external style sheet + cssText += encode(styleSheet.href ? _loadStyleSheet(styleSheet, path) : _inlineStyles.pop() || ""); + cssText = _parseMedia(cssText, self.media); + } + return cssText; + }; + // store loaded cssText URLs + var fileCache = {}; + // load an external style sheet + function _loadStyleSheet(styleSheet, path) { + var url = makePath(styleSheet.href, path); + // if the style sheet has already loaded then don't duplicate it + if (fileCache[url]) return ""; + // load from source + fileCache[url] = (styleSheet.disabled) ? "" : + _fixUrls(IE7.CSS.getText(styleSheet, path), getPath(styleSheet.href, path)); + return fileCache[url]; + }; + // fix css paths + // we're lumping all css text into one big style sheet so relative + // paths have to be fixed. this is necessary anyway because of other + // explorer bugs. + var URL = /(url\s*\(\s*['"]?)([\w\.]+[^:\)]*['"]?\))/gi; + function _fixUrls(cssText, pathname) { + // hack & slash + return cssText.replace(URL, "$1" + pathname.slice(0, pathname.lastIndexOf("/") + 1) + "$2"); + }; + + // load all style sheets in the document + for (var i = 0; i < styleSheets.length; i++) { + if (!styleSheets[i].disabled && !styleSheets[i].ie7) { + this.cssText += _getCSSText(styleSheets[i]); + } + } + }, + + load: function() { + this.cssText = ""; + this.getText(); + this.parse(); + this.cssText = decode(this.cssText); + fileCache = {}; + }, + + parse: function() { + this.cssText = IE7.CSS.parser.exec(this.cssText); + + // parse the style sheet + var offset = IE7.CSS.rules.length; + var rules = this.cssText.split(BRACE2), rule; + var selectors, cssText, i, j; + for (i = 0; i < rules.length; i++) { + rule = rules[i].split(BRACE1); + selectors = rule[0].split(COMMA); + cssText = rule[1]; + for (j = 0; j < selectors.length; j++) { + selectors[j] = cssText ? this.createRule(selectors[j], cssText) : ""; + } + rules[i] = selectors.join("\n"); + } + this.cssText = rules.join("\n"); + this.rules = IE7.CSS.rules.slice(offset); + }, + + recalc: function() { + var rule, i; + for (i = 0; (rule = this.rules[i]); i++) rule.recalc(); + }, + + toString: function() { + return "@media " + this.media + "{" + this.cssText + "}"; + } +}); + +var PseudoElement; + +// ----------------------------------------------------------------------- +// IE7 style rules +// ----------------------------------------------------------------------- + +var Rule = IE7.Rule = Base.extend({ + // properties + constructor: function(selector, cssText) { + this.id = IE7.CSS.rules.length; + this.className = Rule.PREFIX + this.id; + selector = selector.match(FIRST_LINE_LETTER) || selector || "*"; + this.selector = selector[1] || selector; + this.selectorText = this.parse(this.selector) + (selector[2] || ""); + this.cssText = cssText; + this.MATCH = new RegExp("\\s" + this.className + "(\\s|$)", "g"); + IE7.CSS.rules.push(this); + this.init(); + }, + + init: Undefined, + + add: function(element) { + // allocate this class + element.className += " " + this.className; + }, + + recalc: function() { + // execute the underlying css query for this class + var match = cssQuery(this.selector); + // add the class name for all matching elements + for (var i = 0; i < match.length; i++) this.add(match[i]); + }, + + parse: function(selector) { + // attempt to preserve specificity for "loose" parsing by + // removing unknown tokens from a css selector but keep as + // much as we can.. + var simple = selector.replace(Rule.CHILD, " ").replace(Rule.COMPLEX, ""); + if (appVersion < 7) simple = simple.replace(Rule.MULTI, ""); + var tags = match(simple, Rule.TAGS).length - match(selector, Rule.TAGS).length; + var classes = match(simple, Rule.CLASSES).length - match(selector, Rule.CLASSES).length + 1; + while (classes > 0 && Rule.CLASS.test(simple)) { + simple = simple.replace(Rule.CLASS, ""); + classes--; + } + while (tags > 0 && Rule.TAG.test(simple)) { + simple = simple.replace(Rule.TAG, "$1*"); + tags--; + } + simple += "." + this.className; + classes = Math.min(classes, 2); + tags = Math.min(tags, 2); + var score = -10 * classes - tags; + if (score > 0) { + simple = simple + "," + Rule.MAP[score] + " " + simple; + } + return simple; + }, + + remove: function(element) { + // deallocate this class + element.className = element.className.replace(this.MATCH, "$1"); + }, + + toString: function() { + return format("%1 {%2}", this.selectorText, this.cssText); + } +}, { + CHILD: />/g, + CLASS: /\.[\w-]+/, + CLASSES: /[.:\[]/g, + MULTI: /(\.[\w-]+)+/g, + PREFIX: "ie7_class", + TAG: /^\w+|([\s>+~])\w+/, + TAGS: /^\w|[\s>+~]\w/g, + MAP: { + 1: "html", + 2: "html body", + 10: ".ie7_html", + 11: "html.ie7_html", + 12: "html.ie7_html body", + 20: ".ie7_html .ie7_body", + 21: "html.ie7_html .ie7_body", + 22: "html.ie7_html body.ie7_body" + } +}); + +// ----------------------------------------------------------------------- +// IE7 dynamic style +// ----------------------------------------------------------------------- + +// object properties: +// attach: the element that an event handler will be attached to +// target: the element that will have the IE7 class applied + +var DynamicRule = Rule.extend({ + // properties + constructor: function(selector, attach, dynamicPseudoClass, target, cssText) { + // initialise object properties + this.attach = attach || "*"; + this.dynamicPseudoClass = IE7.CSS.dynamicPseudoClasses[dynamicPseudoClass]; + this.target = target; + this.base(selector, cssText); + }, + + recalc: function() { + // execute the underlying css query for this class + var attaches = cssQuery(this.attach), attach; + // process results + for (var i = 0; attach = attaches[i]; i++) { + // retrieve the event handler's target element(s) + var target = this.target ? cssQuery(this.target, attach) : [attach]; + // attach event handlers for dynamic pseudo-classes + if (target.length) this.dynamicPseudoClass.apply(attach, target, this); + } + } +}); + +// ----------------------------------------------------------------------- +// IE7 dynamic pseudo-classes +// ----------------------------------------------------------------------- + +var DynamicPseudoClass = Base.extend({ + constructor: function(name, apply) { + this.name = name; + this.apply = apply; + this.instances = {}; + IE7.CSS.dynamicPseudoClasses[name] = this; + }, + + register: function(instance) { + // an "instance" is actually an Arguments object + var _class = instance[2]; + instance.id = _class.id + instance[0].uniqueID; + if (!this.instances[instance.id]) { + var target = instance[1], j; + for (j = 0; j < target.length; j++) _class.add(target[j]); + this.instances[instance.id] = instance; + } + }, + + unregister: function(instance) { + if (this.instances[instance.id]) { + var _class = instance[2]; + var target = instance[1], j; + for (j = 0; j < target.length; j++) _class.remove(target[j]); + delete this.instances[instance.id]; + } + } +}); + +// ----------------------------------------------------------------------- +// dynamic pseudo-classes +// ----------------------------------------------------------------------- + +if (appVersion < 7) { + var Hover = new DynamicPseudoClass("hover", function(element) { + var instance = arguments; + IE7.CSS.addEventHandler(element, appVersion < 5.5 ? "onmouseover" : "onmouseenter", function() { + Hover.register(instance); + }); + IE7.CSS.addEventHandler(element, appVersion < 5.5 ? "onmouseout" : "onmouseleave", function() { + Hover.unregister(instance); + }); + }); + + // globally trap the mouseup event (thanks Martijn!) + addEventHandler(document, "onmouseup", function() { + var instances = Hover.instances; + for (var i in instances) + if (!instances[i][0].contains(event.srcElement)) + Hover.unregister(instances[i]); + }); +} + +// ----------------------------------------------------------------------- +// propertyName: inherit; +// ----------------------------------------------------------------------- + +IE7.CSS.addRecalc("[\\w-]+", "inherit", function(element, cssText) { + var inherited = cssText.match(/[\w-]+\s*:\s*inherit/g); + for (var i = 0; i < inherited.length; i++) { + var propertyName = inherited[i].replace(/ie7\-|\s*:\s*inherit/g, "").replace(/\-([a-z])/g, function(match, chr) { + return chr.toUpperCase() + }); + element.runtimeStyle[propertyName] = element.parentElement.currentStyle[propertyName]; + } +}); + +// ========================================================================= +// ie7-html.js +// ========================================================================= + +// default font-sizes +//HEADER += "h1{font-size:2em}h2{font-size:1.5em;}h3{font-size:1.17em;}h4{font-size:1em}h5{font-size:.83em}h6{font-size:.67em}"; + +IE7.HTML = new (Fix.extend({ // single instance + fixed: {}, + + init: Undefined, + + addFix: function() { + // fixes are a one-off, they are applied when the document is loaded + this.fixes.push(arguments); + }, + + apply: function() { + for (var i = 0; i < this.fixes.length; i++) { + var match = cssQuery(this.fixes[i][0]); + var fix = this.fixes[i][1]; + for (var j = 0; j < match.length; j++) fix(match[j]); + } + }, + + addRecalc: function() { + // recalcs occur whenever the document is refreshed using document.recalc() + this.recalcs.push(arguments); + }, + + recalc: function() { + // loop through the fixes + for (var i = 0; i < this.recalcs.length; i++) { + var match = cssQuery(this.recalcs[i][0]); + var recalc = this.recalcs[i][1], element; + var key = Math.pow(2, i); + for (var j = 0; (element = match[j]); j++) { + var uniqueID = element.uniqueID; + if ((this.fixed[uniqueID] & key) == 0) { + element = recalc(element) || element; + this.fixed[uniqueID] |= key; + } + } + } + } +})); + +if (appVersion < 7) { + // provide support for the tag. + // this is a proper fix, it preserves the DOM structure and + // elements report the correct tagName & namespace prefix + document.createElement("abbr"); + + // bind to the first child control + IE7.HTML.addRecalc("label", function(label) { + if (!label.htmlFor) { + var firstChildControl = cssQuery("input,textarea", label, true); + if (firstChildControl) { + addEventHandler(label, "onclick", function() { + firstChildControl.click(); + }); + } + } + }); +} + +// ========================================================================= +// ie7-layout.js +// ========================================================================= + +var NUMERIC = "[.\\d]"; + +new function(_) { +var layout = IE7.Layout = this; + + // big, ugly box-model hack + min/max stuff + + // #tantek > #erik > #dean { voice-family: hacker; } + + // ----------------------------------------------------------------------- + // "layout" + // ----------------------------------------------------------------------- + + HEADER += "*{boxSizing:content-box}"; + + // does an element have "layout" ? + IE7.hasLayout = appVersion < 5.5 ? function(element) { + // element.currentStyle.hasLayout doesn't work for IE5.0 + return element.clientWidth; + } : function(element) { + return element.currentStyle.hasLayout; + }; + + // give an element "layout" + layout.boxSizing = function(element) { + if (!IE7.hasLayout(element)) { + //# element.runtimeStyle.fixedHeight = + element.style.height = "0cm"; + if (element.currentStyle.verticalAlign == "auto") + element.runtimeStyle.verticalAlign = "top"; + // when an element acquires "layout", margins no longer collapse correctly + collapseMargins(element); + } + }; + + // ----------------------------------------------------------------------- + // Margin Collapse + // ----------------------------------------------------------------------- + + function collapseMargins(element) { + if (element != viewport && element.currentStyle.position != "absolute") { + collapseMargin(element, "marginTop"); + collapseMargin(element, "marginBottom"); + } + }; + + function collapseMargin(element, type) { + if (!element.runtimeStyle[type]) { + var parentElement = element.parentElement; + if (parentElement && IE7.hasLayout(parentElement) && !IE7[type == "marginTop" ? "_getPreviousElementSibling" : "_getNextElementSibling"](element)) return; + var child = cssQuery(">*:" + (type == "marginTop" ? "first" : "last") + "-child", element, true); + if (child && child.currentStyle.styleFloat == "none" && IE7.hasLayout(child)) { + collapseMargin(child, type); + margin = _getMargin(element, element.currentStyle[type]); + childMargin = _getMargin(child, child.currentStyle[type]); + if (margin < 0 || childMargin < 0) { + element.runtimeStyle[type] = margin + childMargin; + } else { + element.runtimeStyle[type] = Math.max(childMargin, margin); + } + child.runtimeStyle[type] = "0px"; + } + } + }; + + function _getMargin(element, value) { + return value == "auto" ? 0 : getPixelValue(element, value); + }; + + // ----------------------------------------------------------------------- + // box-model + // ----------------------------------------------------------------------- + + // constants + var UNIT = /^[.\d][\w%]*$/, AUTO = /^(auto|0cm)$/; + + var applyWidth, applyHeight; + IE7.Layout.borderBox = function(element){ + applyWidth(element); + applyHeight(element); + }; + + var fixWidth = function(HEIGHT) { + applyWidth = function(element) { + if (!PERCENT.test(element.currentStyle.width)) fixWidth(element); + collapseMargins(element); + }; + + function fixWidth(element, value) { + if (!element.runtimeStyle.fixedWidth) { + if (!value) value = element.currentStyle.width; + element.runtimeStyle.fixedWidth = (UNIT.test(value)) ? Math.max(0, getFixedWidth(element, value)) : value; + setOverrideStyle(element, "width", element.runtimeStyle.fixedWidth); + } + }; + + function layoutWidth(element) { + if (!isFixed(element)) { + var layoutParent = element.offsetParent; + while (layoutParent && !IE7.hasLayout(layoutParent)) layoutParent = layoutParent.offsetParent; + } + return (layoutParent || viewport).clientWidth; + }; + + function getPixelWidth(element, value) { + if (PERCENT.test(value)) return parseInt(parseFloat(value) / 100 * layoutWidth(element)); + return getPixelValue(element, value); + }; + + var getFixedWidth = function(element, value) { + var borderBox = element.currentStyle["box-sizing"] == "border-box"; + var adjustment = 0; + if (quirksMode && !borderBox) + adjustment += getBorderWidth(element) + getWidth(element, "padding"); + else if (!quirksMode && borderBox) + adjustment -= getBorderWidth(element) + getWidth(element, "padding"); + return getPixelWidth(element, value) + adjustment; + }; + + // easy way to get border thickness for elements with "layout" + function getBorderWidth(element) { + return element.offsetWidth - element.clientWidth; + }; + + // have to do some pixel conversion to get padding/margin thickness :-( + function getWidth(element, type) { + return getPixelWidth(element, element.currentStyle[type + "Left"]) + getPixelWidth(element, element.currentStyle[type + "Right"]); + }; + + // ----------------------------------------------------------------------- + // min/max + // ----------------------------------------------------------------------- + + HEADER += "*{minWidth:none;maxWidth:none;min-width:none;max-width:none}"; + + // handle min-width property + layout.minWidth = function(element) { + // IE6 supports min-height so we frig it here + //#if (element.currentStyle.minHeight == "auto") element.runtimeStyle.minHeight = 0; + if (element.currentStyle["min-width"] != null) { + element.style.minWidth = element.currentStyle["min-width"]; + } + if (register(arguments.callee, element, element.currentStyle.minWidth != "none")) { + layout.boxSizing(element); + fixWidth(element); + resizeWidth(element); + } + }; + + // clone the minWidth function to make a maxWidth function + eval("IE7.Layout.maxWidth=" + String(layout.minWidth).replace(/min/g, "max")); + + // apply min/max restrictions + function resizeWidth(element) { + // check boundaries + var rect = element.getBoundingClientRect(); + var width = rect.right - rect.left; + + if (element.currentStyle.minWidth != "none" && width <= getFixedWidth(element, element.currentStyle.minWidth)) { + element.runtimeStyle.width = element.currentStyle.minWidth; + } else if (element.currentStyle.maxWidth != "none" && width >= getFixedWidth(element, element.currentStyle.maxWidth)) { + element.runtimeStyle.width = element.currentStyle.maxWidth; + } else { + element.runtimeStyle.width = element.runtimeStyle.fixedWidth; // || "auto"; + } + }; + + // ----------------------------------------------------------------------- + // right/bottom + // ----------------------------------------------------------------------- + + function fixRight(element) { + if (register(fixRight, element, /^(fixed|absolute)$/.test(element.currentStyle.position) && + getDefinedStyle(element, "left") != "auto" && + getDefinedStyle(element, "right") != "auto" && + AUTO.test(getDefinedStyle(element, "width")))) { + resizeRight(element); + IE7.Layout.boxSizing(element); + } + }; + IE7.Layout.fixRight = fixRight; + + function resizeRight(element) { + var left = getPixelWidth(element, element.runtimeStyle._left || element.currentStyle.left); + var width = layoutWidth(element) - getPixelWidth(element, element.currentStyle.right) - left - getWidth(element, "margin"); + if (parseInt(element.runtimeStyle.width) == width) return; + element.runtimeStyle.width = ""; + if (isFixed(element) || HEIGHT || element.offsetWidth < width) { + if (!quirksMode) width -= getBorderWidth(element) + getWidth(element, "padding"); + if (width < 0) width = 0; + element.runtimeStyle.fixedWidth = width; + setOverrideStyle(element, "width", width); + } + }; + + // ----------------------------------------------------------------------- + // window.onresize + // ----------------------------------------------------------------------- + + // handle window resize + var clientWidth = 0; + addResize(function() { + if (!viewport) return; + var i, wider = (clientWidth < viewport.clientWidth); + clientWidth = viewport.clientWidth; + // resize elements with "min-width" set + var elements = layout.minWidth.elements; + for (i in elements) { + var element = elements[i]; + var fixedWidth = (parseInt(element.runtimeStyle.width) == getFixedWidth(element, element.currentStyle.minWidth)); + if (wider && fixedWidth) element.runtimeStyle.width = ""; + if (wider == fixedWidth) resizeWidth(element); + } + // resize elements with "max-width" set + var elements = layout.maxWidth.elements; + for (i in elements) { + var element = elements[i]; + var fixedWidth = (parseInt(element.runtimeStyle.width) == getFixedWidth(element, element.currentStyle.maxWidth)); + if (!wider && fixedWidth) element.runtimeStyle.width = ""; + if (wider != fixedWidth) resizeWidth(element); + } + // resize elements with "right" set + for (i in fixRight.elements) resizeRight(fixRight.elements[i]); + }); + + // ----------------------------------------------------------------------- + // fix CSS + // ----------------------------------------------------------------------- + if (quirksMode) { + IE7.CSS.addRecalc("width", NUMERIC, applyWidth); + } + if (appVersion < 7) { + IE7.CSS.addRecalc("min-width", NUMERIC, layout.minWidth); + IE7.CSS.addRecalc("max-width", NUMERIC, layout.maxWidth); + IE7.CSS.addRecalc("right", NUMERIC, fixRight); + } + }; + + eval("var fixHeight=" + rotate(fixWidth)); + + // apply box-model + min/max fixes + fixWidth(); + fixHeight(true); +}; + +// ========================================================================= +// ie7-graphics.js +// ========================================================================= + +// a small transparent image used as a placeholder +var BLANK_GIF = makePath("blank.gif", path); + +var ALPHA_IMAGE_LOADER = "DXImageTransform.Microsoft.AlphaImageLoader"; +var PNG_FILTER = "progid:" + ALPHA_IMAGE_LOADER + "(src='%1',sizingMethod='%2')"; + +// regular expression version of the above +var PNG; + +var filtered = []; + +function fixImage(element) { + if (PNG.test(element.src)) { + // we have to preserve width and height + var image = new Image(element.width, element.height); + image.onload = function() { + element.width = image.width; + element.height = image.height; + image = null; + }; + image.src = element.src; + // store the original url (we'll put it back when it's printed) + element.pngSrc = element.src; + // add the AlphaImageLoader thingy + addFilter(element); + } +}; + +if (appVersion >= 5.5 && appVersion < 7) { + // ** IE7 VARIABLE + // e.g. only apply the hack to files ending in ".png" + // IE7_PNG_SUFFIX = ".png"; + + // replace background(-image): url(..) .. with background(-image): .. ;filter: ..; + IE7.CSS.addFix(/background(-image)?\s*:\s*([^};]*)?url\(([^\)]+)\)([^;}]*)?/, function(match, $1, $2, url, $4) { + url = getString(url); + return PNG.test(url) ? "filter:" + format(PNG_FILTER, url, "crop") + + ";zoom:1;background" + ($1||"") + ":" + ($2||"") + "none" + ($4||"") : match; + }); + + // ----------------------------------------------------------------------- + // fix PNG transparency (HTML images) + // ----------------------------------------------------------------------- + + IE7.HTML.addRecalc("img,input", function(element) { + if (element.tagName == "INPUT" && element.type != "image") return; + fixImage(element); + addEventHandler(element, "onpropertychange", function() { + if (!printing && event.propertyName == "src" && + element.src.indexOf(BLANK_GIF) == -1) fixImage(element); + }); + }); + + // assume that background images should not be printed + // (if they are not transparent then they'll just obscure content) + // but we'll put foreground images back... + var printing = false; + addEventHandler(window, "onbeforeprint", function() { + printing = true; + for (var i = 0; i < filtered.length; i++) removeFilter(filtered[i]); + }); + addEventHandler(window, "onafterprint", function() { + for (var i = 0; i < filtered.length; i++) addFilter(filtered[i]); + printing = false; + }); +} + +// apply a filter +function addFilter(element, sizingMethod) { + var filter = element.filters[ALPHA_IMAGE_LOADER]; + if (filter) { + filter.src = element.src; + filter.enabled = true; + } else { + element.runtimeStyle.filter = format(PNG_FILTER, element.src, sizingMethod || "scale"); + filtered.push(element); + } + // remove the real image + element.src = BLANK_GIF; +}; + +function removeFilter(element) { + element.src = element.pngSrc; + element.filters[ALPHA_IMAGE_LOADER].enabled = false; +}; + +// ========================================================================= +// ie7-fixed.js +// ========================================================================= + +new function(_) { + if (appVersion >= 7) return; + + // some things to consider for this hack. + // the document body requires a fixed background. even if + // it is just a blank image. + // you have to use setExpression instead of onscroll, this + // together with a fixed body background helps avoid the + // annoying screen flicker of other solutions. + + IE7.CSS.addRecalc("position", "fixed", _positionFixed, "absolute"); + IE7.CSS.addRecalc("background(-attachment)?", "[^};]*fixed", _backgroundFixed); + + // scrolling is relative to the documentElement (HTML tag) when in + // standards mode, otherwise it's relative to the document body + var $viewport = quirksMode ? "body" : "documentElement"; + + function _fixBackground() { + // this is required by both position:fixed and background-attachment:fixed. + // it is necessary for the document to also have a fixed background image. + // we can fake this with a blank image if necessary + if (body.currentStyle.backgroundAttachment != "fixed") { + if (body.currentStyle.backgroundImage == "none") { + body.runtimeStyle.backgroundRepeat = "no-repeat"; + body.runtimeStyle.backgroundImage = "url(" + BLANK_GIF + ")"; // dummy + } + body.runtimeStyle.backgroundAttachment = "fixed"; + } + _fixBackground = Undefined; + }; + + var _tmp = createTempElement("img"); + + function _isFixed(element) { + return element ? isFixed(element) || _isFixed(element.parentElement) : false; + }; + + function _setExpression(element, propertyName, expression) { + setTimeout("document.all." + element.uniqueID + ".runtimeStyle.setExpression('" + propertyName + "','" + expression + "')", 0); + }; + + // ----------------------------------------------------------------------- + // backgroundAttachment: fixed + // ----------------------------------------------------------------------- + + function _backgroundFixed(element) { + if (register(_backgroundFixed, element, element.currentStyle.backgroundAttachment == "fixed" && !element.contains(body))) { + _fixBackground(); + bgLeft(element); + bgTop(element); + _backgroundPosition(element); + } + }; + + function _backgroundPosition(element) { + _tmp.src = element.currentStyle.backgroundImage.slice(5, -2); + var parentElement = element.canHaveChildren ? element : element.parentElement; + parentElement.appendChild(_tmp); + setOffsetLeft(element); + setOffsetTop(element); + parentElement.removeChild(_tmp); + }; + + function bgLeft(element) { + element.style.backgroundPositionX = element.currentStyle.backgroundPositionX; + if (!_isFixed(element)) { + _setExpression(element, "backgroundPositionX", "(parseInt(runtimeStyle.offsetLeft)+document." + $viewport + ".scrollLeft)||0"); + } + }; + eval(rotate(bgLeft)); + + function setOffsetLeft(element) { + var propertyName = _isFixed(element) ? "backgroundPositionX" : "offsetLeft"; + element.runtimeStyle[propertyName] = + getOffsetLeft(element, element.style.backgroundPositionX) - + element.getBoundingClientRect().left - element.clientLeft + 2; + }; + eval(rotate(setOffsetLeft)); + + function getOffsetLeft(element, position) { + switch (position) { + case "left": + case "top": + return 0; + case "right": + case "bottom": + return viewport.clientWidth - _tmp.offsetWidth; + case "center": + return (viewport.clientWidth - _tmp.offsetWidth) / 2; + default: + if (PERCENT.test(position)) { + return parseInt((viewport.clientWidth - _tmp.offsetWidth) * parseFloat(position) / 100); + } + _tmp.style.left = position; + return _tmp.offsetLeft; + } + }; + eval(rotate(getOffsetLeft)); + + // ----------------------------------------------------------------------- + // position: fixed + // ----------------------------------------------------------------------- + + function _positionFixed(element) { + if (register(_positionFixed, element, isFixed(element))) { + setOverrideStyle(element, "position", "absolute"); + setOverrideStyle(element, "left", element.currentStyle.left); + setOverrideStyle(element, "top", element.currentStyle.top); + _fixBackground(); + IE7.Layout.fixRight(element); + _foregroundPosition(element); + } + }; + + function _foregroundPosition(element, recalc) { + positionTop(element, recalc); + positionLeft(element, recalc, true); + if (!element.runtimeStyle.autoLeft && element.currentStyle.marginLeft == "auto" && + element.currentStyle.right != "auto") { + var left = viewport.clientWidth - getPixelWidth(element, element.currentStyle.right) - + getPixelWidth(element, element.runtimeStyle._left) - element.clientWidth; + if (element.currentStyle.marginRight == "auto") left = parseInt(left / 2); + if (_isFixed(element.offsetParent)) element.runtimeStyle.pixelLeft += left; + else element.runtimeStyle.shiftLeft = left; + } + clipWidth(element); + clipHeight(element); + }; + + function clipWidth(element) { + var fixWidth = element.runtimeStyle.fixWidth; + element.runtimeStyle.borderRightWidth = ""; + element.runtimeStyle.width = fixWidth ? getPixelWidth(element, fixWidth) : ""; + if (element.currentStyle.width != "auto") { + var rect = element.getBoundingClientRect(); + var width = element.offsetWidth - viewport.clientWidth + rect.left - 2; + if (width >= 0) { + element.runtimeStyle.borderRightWidth = "0px"; + width = Math.max(getPixelValue(element, element.currentStyle.width) - width, 0); + setOverrideStyle(element, "width", width); + return width; + } + } + }; + eval(rotate(clipWidth)); + + function positionLeft(element, recalc) { + // if the element's width is in % units then it must be recalculated + // with respect to the viewport + if (!recalc && PERCENT.test(element.currentStyle.width)) { + element.runtimeStyle.fixWidth = element.currentStyle.width; + } + if (element.runtimeStyle.fixWidth) { + element.runtimeStyle.width = getPixelWidth(element, element.runtimeStyle.fixWidth); + } + //if (recalc) { + // // if the element is fixed on the right then no need to recalculate + // if (!element.runtimeStyle.autoLeft) return; + //} else { + element.runtimeStyle.shiftLeft = 0; + element.runtimeStyle._left = element.currentStyle.left; + // is the element fixed on the right? + element.runtimeStyle.autoLeft = element.currentStyle.right != "auto" && + element.currentStyle.left == "auto"; + //} + // reset the element's "left" value and get it's natural position + element.runtimeStyle.left = ""; + element.runtimeStyle.screenLeft = getScreenLeft(element); + element.runtimeStyle.pixelLeft = element.runtimeStyle.screenLeft; + // if the element is contained by another fixed element then there is no need to + // continually recalculate it's left position + if (!recalc && !_isFixed(element.offsetParent)) { + // onsrcoll produces jerky movement, so we use an expression + _setExpression(element, "pixelLeft", "runtimeStyle.screenLeft+runtimeStyle.shiftLeft+document." + $viewport + ".scrollLeft"); + } + }; + // clone this function so we can do "top" + eval(rotate(positionLeft)); + + // I've forgotten how this works... + function getScreenLeft(element) { // thanks to kevin newman (captainn) + var screenLeft = element.offsetLeft, nested = 1; + if (element.runtimeStyle.autoLeft) { + screenLeft = viewport.clientWidth - element.offsetWidth - getPixelWidth(element, element.currentStyle.right); + } + // accommodate margins + if (element.currentStyle.marginLeft != "auto") { + screenLeft -= getPixelWidth(element, element.currentStyle.marginLeft); + } + while (element = element.offsetParent) { + if (element.currentStyle.position != "static") nested = -1; + screenLeft += element.offsetLeft * nested; + } + return screenLeft; + }; + eval(rotate(getScreenLeft)); + + function getPixelWidth(element, value) { + return PERCENT.test(value) ? parseInt(parseFloat(value) / 100 * viewport.clientWidth) : getPixelValue(element, value); + }; + eval(rotate(getPixelWidth)); + + // ----------------------------------------------------------------------- + // capture window resize + // ----------------------------------------------------------------------- + + function _resize() { + // if the window has been resized then some positions need to be + // recalculated (especially those aligned to "right" or "top" + var elements = _backgroundFixed.elements; + for (var i in elements) _backgroundPosition(elements[i]); + elements = _positionFixed.elements; + for (i in elements) { + _foregroundPosition(elements[i], true); + // do this twice to be sure - hackish, I know :-) + _foregroundPosition(elements[i], true); + } + _timer = 0; + }; + + // use a timer for some reason. + // (sometimes this is a good way to prevent resize loops) + var _timer; + addResize(function() { + if (!_timer) _timer = setTimeout(_resize, 0); + }); +}; + +// ========================================================================= +// ie7-oveflow.js +// ========================================================================= + +/* --------------------------------------------------------------------- + + This module alters the structure of the document. + It may adversely affect other CSS rules. Be warned. + +--------------------------------------------------------------------- */ +/* +var WRAPPER_STYLE = { + backgroundColor: "transparent", + backgroundImage: "none", + backgroundPositionX: null, + backgroundPositionY: null, + backgroundRepeat: null, + borderTopWidth: 0, + borderRightWidth: 0, + borderBottomWidth: 0, + borderLeftStyle: "none", + borderTopStyle: "none", + borderRightStyle: "none", + borderBottomStyle: "none", + borderLeftWidth: 0, + height: null, + marginTop: 0, + marginBottom: 0, + marginRight: 0, + marginLeft: 0, + width: "100%" +}; + +IE7.CSS.addRecalc("overflow", "visible", function(element) { + // don't do this again + if (element.parentNode.ie7_wrapped) return; + + // if max-height is applied, makes sure it gets applied first + if (IE7.Layout && element.currentStyle["max-height"] != "auto") { + IE7.Layout.maxHeight(element); + } + + if (element.currentStyle.marginLeft == "auto") element.style.marginLeft = 0; + if (element.currentStyle.marginRight == "auto") element.style.marginRight = 0; + + var wrapper = document.createElement(ANON); + wrapper.ie7_wrapped = element; + for (var propertyName in WRAPPER_STYLE) { + wrapper.style[propertyName] = element.currentStyle[propertyName]; + if (WRAPPER_STYLE[propertyName] != null) { + element.runtimeStyle[propertyName] = WRAPPER_STYLE[propertyName]; + } + } + wrapper.style.display = "block"; + wrapper.style.position = "relative"; + element.runtimeStyle.position = "absolute"; + element.parentNode.insertBefore(wrapper, element); + wrapper.appendChild(element); +}); +*/ +// ========================================================================= +// ie7-quirks.js +// ========================================================================= + +function ie7Quirks() { + var FONT_SIZES = "xx-small,x-small,small,medium,large,x-large,xx-large".split(","); + for (var i = 0; i < FONT_SIZES.length; i++) { + FONT_SIZES[FONT_SIZES[i]] = FONT_SIZES[i - 1] || "0.67em"; + } + + IE7.CSS.addFix(/(font(-size)?\s*:\s*)([\w.-]+)/, function(match, label, size, value) { + return label + (FONT_SIZES[value] || value); + }); + + if (appVersion < 6) { + var NEGATIVE = /^\-/, LENGTH = /(em|ex)$/i; + var EM = /em$/i, EX = /ex$/i; + + getPixelValue = function(element, value) { + if (PIXEL.test(value)) return parseInt(value)||0; + var scale = NEGATIVE.test(value)? -1 : 1; + if (LENGTH.test(value)) scale *= getFontScale(element); + temp.style.width = (scale < 0) ? value.slice(1) : value; + body.appendChild(temp); + // retrieve pixel width + value = scale * temp.offsetWidth; + // remove the temporary element + temp.removeNode(); + return parseInt(value); + }; + + var temp = createTempElement(); + function getFontScale(element) { + var scale = 1; + temp.style.fontFamily = element.currentStyle.fontFamily; + temp.style.lineHeight = element.currentStyle.lineHeight; + //temp.style.fontSize = ""; + while (element != body) { + var fontSize = element.currentStyle["ie7-font-size"]; + if (fontSize) { + if (EM.test(fontSize)) scale *= parseFloat(fontSize); + else if (PERCENT.test(fontSize)) scale *= (parseFloat(fontSize) / 100); + else if (EX.test(fontSize)) scale *= (parseFloat(fontSize) / 2); + else { + temp.style.fontSize = fontSize; + return 1; + } + } + element = element.parentElement; + } + return scale; + }; + + // cursor:pointer (IE5.x) + IE7.CSS.addFix(/cursor\s*:\s*pointer/, "cursor:hand"); + // display:list-item (IE5.x) + IE7.CSS.addFix(/display\s*:\s*list-item/, "display:block"); + } + + // ----------------------------------------------------------------------- + // margin:auto + // ----------------------------------------------------------------------- + + function fixMargin(element) { + if (appVersion < 5.5) IE7.Layout.boxSizing(element.parentElement); + var parent = element.parentElement; + var margin = parent.offsetWidth - element.offsetWidth - getPaddingWidth(parent); + var autoRight = (element.currentStyle["ie7-margin"] && element.currentStyle.marginRight == "auto") || + element.currentStyle["ie7-margin-right"] == "auto"; + switch (parent.currentStyle.textAlign) { + case "right": + margin = autoRight ? parseInt(margin / 2) : 0; + element.runtimeStyle.marginRight = margin + "px"; + break; + case "center": + if (autoRight) margin = 0; + default: + if (autoRight) margin /= 2; + element.runtimeStyle.marginLeft = parseInt(margin) + "px"; + } + }; + + function getPaddingWidth(element) { + return getPixelValue(element, element.currentStyle.paddingLeft) + + getPixelValue(element, element.currentStyle.paddingRight); + }; + + IE7.CSS.addRecalc("margin(-left|-right)?", "[^};]*auto", function(element) { + if (register(fixMargin, element, + element.parentElement && + element.currentStyle.display == "block" && + element.currentStyle.marginLeft == "auto" && + element.currentStyle.position != "absolute")) { + fixMargin(element); + } + }); + + addResize(function() { + for (var i in fixMargin.elements) { + var element = fixMargin.elements[i]; + element.runtimeStyle.marginLeft = + element.runtimeStyle.marginRight = ""; + fixMargin(element); + } + }); +}; + + +// ========================================================================= +// ie8-cssQuery.js +// ========================================================================= + +IE7._isEmpty = function(element) { + element = element.firstChild; + while (element) { + if (element.nodeType == 3 || (element.nodeType == 1 && element.nodeName != "!")) return false; + element = element.nextSibling; + } + return true; +}; + +IE7._isLang = function(element, code) { + while (element && !element.getAttribute("lang")) element = element.parentNode; + return element && new RegExp("^" + rescape(code), "i").test(element.getAttribute("lang")); +}; + +function _nthChild(match, args, position, last) { + // ugly but it works... + last = /last/i.test(match) ? last + "+1-" : ""; + if (!isNaN(args)) args = "0n+" + args; + else if (args == "even") args = "2n"; + else if (args == "odd") args = "2n+1"; + args = args.split("n"); + var a = args[0] ? (args[0] == "-") ? -1 : parseInt(args[0]) : 1; + var b = parseInt(args[1]) || 0; + var negate = a < 0; + if (negate) { + a = -a; + if (a == 1) b++; + } + var query = format(a == 0 ? "%3%7" + (last + b) : "(%4%3-%2)%6%1%70%5%4%3>=%2", a, b, position, last, "&&", "%", "=="); + if (negate) query = "!(" + query + ")"; + return query; +}; + +_PSEUDO_CLASSES = { + "link": "e%1.currentStyle['ie7-link']=='link'", + "visited": "e%1.currentStyle['ie7-link']=='visited'", + "checked": "e%1.checked", + "contains": "e%1.innerText.indexOf('%2')!=-1", + "disabled": "e%1.isDisabled", + "empty": "IE7._isEmpty(e%1)", + "enabled": "e%1.disabled===false", + "first-child": "!IE7._getPreviousElementSibling(e%1)", + "lang": "IE7._isLang(e%1,'%2')", + "last-child": "!IE7._getNextElementSibling(e%1)", + "only-child": "!IE7._getPreviousElementSibling(e%1)&&!IE7._getNextElementSibling(e%1)", + "target": "e%1.id==location.hash.slice(1)", + "indeterminate": "e%1.indeterminate" +}; + + +// register a node and index its children +IE7._register = function(element) { + if (element.rows) { + element.ie7_length = element.rows.length; + element.ie7_lookup = "rowIndex"; + } else if (element.cells) { + element.ie7_length = element.cells.length; + element.ie7_lookup = "cellIndex"; + } else if (element.ie7_indexed != IE7._indexed) { + var index = 0; + var child = element.firstChild; + while (child) { + if (child.nodeType == 1 && child.nodeName != "!") { + child.ie7_index = ++index; + } + child = child.nextSibling; + } + element.ie7_length = index; + element.ie7_lookup = "ie7_index"; + } + element.ie7_indexed = IE7._indexed; + return element; +}; + +var keys = cssParser[_KEYS]; +var pseudoClass = keys[keys.length - 1]; +keys.length--; + +cssParser.merge({ + ":not\\((\\*|[\\w-]+)?([^)]*)\\)": function(match, tagName, filters) { // :not pseudo class + var replacement = (tagName && tagName != "*") ? format("if(e%1.nodeName=='%2'){", _index, tagName.toUpperCase()) : ""; + replacement += cssParser.exec(filters); + return "if(!" + replacement.slice(2, -1).replace(/\)\{if\(/g, "&&") + "){"; + }, + + ":nth(-last)?-child\\(([^)]+)\\)": function(match, last, args) { // :nth-child pseudo classes + _wild = false; + last = format("e%1.parentNode.ie7_length", _index); + var replacement = "if(p%1!==e%1.parentNode)p%1=IE7._register(e%1.parentNode);"; + replacement += "var i=e%1[p%1.ie7_lookup];if(p%1.ie7_lookup!='ie7_index')i++;if("; + return format(replacement, _index) + _nthChild(match, args, "i", last) + "){"; + } +}); + +keys.push(pseudoClass); + +// ========================================================================= +// ie8-css.js +// ========================================================================= + +var BRACKETS = "\\([^)]*\\)"; + +if (IE7.CSS.pseudoClasses) IE7.CSS.pseudoClasses += "|"; +IE7.CSS.pseudoClasses += "before|after|last\\-child|only\\-child|empty|root|" + + "not|nth\\-child|nth\\-last\\-child|contains|lang".split("|").join(BRACKETS + "|") + BRACKETS; + +// pseudo-elements can be declared with a double colon +encoder.add(/::/, ":"); + +// ----------------------------------------------------------------------- +// dynamic pseudo-classes +// ----------------------------------------------------------------------- + +var Focus = new DynamicPseudoClass("focus", function(element) { + var instance = arguments; + + IE7.CSS.addEventHandler(element, "onfocus", function() { + Focus.unregister(instance); // in case it starts with focus + Focus.register(instance); + }); + + IE7.CSS.addEventHandler(element, "onblur", function() { + Focus.unregister(instance); + }); + + // check the active element for initial state + if (element == document.activeElement) { + Focus.register(instance) + } +}); + +var Active = new DynamicPseudoClass("active", function(element) { + var instance = arguments; + IE7.CSS.addEventHandler(element, "onmousedown", function() { + Active.register(instance); + }); +}); + +// globally trap the mouseup event (thanks Martijn!) +addEventHandler(document, "onmouseup", function() { + var instances = Active.instances; + for (var i in instances) Active.unregister(instances[i]); +}); + +// :checked +var Checked = new DynamicPseudoClass("checked", function(element) { + if (typeof element.checked != "boolean") return; + var instance = arguments; + IE7.CSS.addEventHandler(element, "onpropertychange", function() { + if (event.propertyName == "checked") { + if (element.checked) Checked.register(instance); + else Checked.unregister(instance); + } + }); + // check current checked state + if (element.checked) Checked.register(instance); +}); + +// :enabled +var Enabled = new DynamicPseudoClass("enabled", function(element) { + if (typeof element.disabled != "boolean") return; + var instance = arguments; + IE7.CSS.addEventHandler(element, "onpropertychange", function() { + if (event.propertyName == "disabled") { + if (!element.isDisabled) Enabled.register(instance); + else Enabled.unregister(instance); + } + }); + // check current disabled state + if (!element.isDisabled) Enabled.register(instance); +}); + +// :disabled +var Disabled = new DynamicPseudoClass("disabled", function(element) { + if (typeof element.disabled != "boolean") return; + var instance = arguments; + IE7.CSS.addEventHandler(element, "onpropertychange", function() { + if (event.propertyName == "disabled") { + if (element.isDisabled) Disabled.register(instance); + else Disabled.unregister(instance); + } + }); + // check current disabled state + if (element.isDisabled) Disabled.register(instance); +}); + +// :indeterminate (Kevin Newman) +var Indeterminate = new DynamicPseudoClass("indeterminate", function(element) { + if (typeof element.indeterminate != "boolean") return; + var instance = arguments; + IE7.CSS.addEventHandler(element, "onpropertychange", function() { + if (event.propertyName == "indeterminate") { + if (element.indeterminate) Indeterminate.register(instance); + else Indeterminate.unregister(instance); + } + }); + IE7.CSS.addEventHandler(element, "onclick", function() { + Indeterminate.unregister(instance); + }); + // clever Kev says no need to check this up front +}); + +// :target +var Target = new DynamicPseudoClass("target", function(element) { + var instance = arguments; + // if an element has a tabIndex then it can become "active". + // The default is zero anyway but it works... + if (!element.tabIndex) element.tabIndex = 0; + // this doesn't detect the back button. I don't know how to do that :-( + IE7.CSS.addEventHandler(document, "onpropertychange", function() { + if (event.propertyName == "activeElement") { + if (element.id && element.id == location.hash.slice(1)) Target.register(instance); + else Target.unregister(instance); + } + }); + // check the current location + if (element.id && element.id == location.hash.slice(1)) Target.register(instance); +}); + +// ----------------------------------------------------------------------- +// IE7 pseudo elements +// ----------------------------------------------------------------------- + +// constants +var ATTR = /^attr/; +var URL = /^url\s*\(\s*([^)]*)\)$/; +var POSITION_MAP = { + before0: "beforeBegin", + before1: "afterBegin", + after0: "afterEnd", + after1: "beforeEnd" +}; + +var PseudoElement = IE7.PseudoElement = Rule.extend({ + constructor: function(selector, position, cssText) { + // initialise object properties + this.position = position; + var content = cssText.match(PseudoElement.CONTENT), match, entity; + if (content) { + content = content[1]; + match = content.split(/\s+/); + for (var i = 0; (entity = match[i]); i++) { + match[i] = ATTR.test(entity) ? {attr: entity.slice(5, -1)} : + (entity.charAt(0) == "'") ? getString(entity) : decode(entity); + } + content = match; + } + this.content = content; + // CSS text needs to be decoded immediately + this.base(selector, decode(cssText)); + }, + + init: function() { + // execute the underlying css query for this class + this.match = cssQuery(this.selector); + for (var i = 0; i < this.match.length; i++) { + var runtimeStyle = this.match[i].runtimeStyle; + if (!runtimeStyle[this.position]) runtimeStyle[this.position] = {cssText:""}; + runtimeStyle[this.position].cssText += ";" + this.cssText; + if (this.content != null) runtimeStyle[this.position].content = this.content; + } + }, + + create: function(target) { + var generated = target.runtimeStyle[this.position]; + if (generated) { + // copy the array of values + var content = [].concat(generated.content || ""); + for (var j = 0; j < content.length; j++) { + if (typeof content[j] == "object") { + content[j] = target.getAttribute(content[j].attr); + } + } + content = content.join(""); + var url = content.match(URL); + var cssText = "overflow:hidden;" + generated.cssText.replace(/'/g, '"'); + if (target.currentStyle.styleFloat != "none") { + //cssText = cssText.replace(/display\s*:\s*block/, "display:inline-block"); + } + var position = POSITION_MAP[this.position + Number(target.canHaveChildren)]; + var id = 'ie7_pseudo' + PseudoElement.count++; + target.insertAdjacentHTML(position, format(PseudoElement.ANON, this.className, id, cssText, url ? "" : content)); + if (url) { + var pseudoElement = document.getElementById(id); + pseudoElement.src = getString(url[1]); + addFilter(pseudoElement, "crop"); + } + target.runtimeStyle[this.position] = null; + } + }, + + recalc: function() { + if (this.content == null) return; + for (var i = 0; i < this.match.length; i++) { + this.create(this.match[i]); + } + }, + + toString: function() { + return "." + this.className + "{display:inline}"; + } +}, { + CONTENT: /content\s*:\s*([^;]*)(;|$)/, + ANON: "%4", + MATCH: /(.*):(before|after).*/, + + count: 0 +}); + +// ========================================================================= +// ie8-html.js +// ========================================================================= + +var UNSUCCESSFUL = /^(submit|reset|button)$/; + +// ----------------------------------------------------------------------- +//