# FriendlyId is a Rails plugin which lets you use text-based ids in addition # to numeric ones. module Randomba module FriendlyId def self.included(base) # :nodoc: base.extend(ClassMethods) end module ClassMethods # Set up an ActiveRecord model to use a friendly_id. # # The method argument can be one of your model's columns, or a method # you use to generate the slug. # # Options: # * :use_slug - Defaults to false. Use slugs when you want to use a non-unique text field for friendly ids. # * :max_length - Defaults to 255. The maximum allowed length for a slug. # * :strip_diacritics - Defaults to false. If true, it will remove accents, umlauts, etc. from western characters. You must have the unicode gem installed for this to work. def has_friendly_id(method, options = {}) options.assert_valid_keys(:use_slug, :max_length, :strip_diacritics) options = default_friendly_id_options.merge(options).merge(:method => method) write_inheritable_attribute(:friendly_id_options, options) class_inheritable_reader :friendly_id_options if options[:use_slug] has_many :slugs, :order => "id DESC", :as => :sluggable, :dependent => :destroy before_save :set_slug include SluggableInstanceMethods extend SluggableClassMethods else include NonSluggableInstanceMethods extend NonSluggableClassMethods end end # Gets the default options for friendly_id. def default_friendly_id_options { :method => nil, :use_slug => false, :max_length => 255, :strip_diacritics => false } end end module SingletonMethods # Extends ActiveRecord::Base.find to allow simple finds by friendly id. # # @record = Record.find("record name") def find(*args) find_using_friendly_id(*args) or super(*args) end end module NonSluggableClassMethods # Finds the record using only the friendly id. If it can't be found # using the friendly id, then it returns false. If you pass in any # argument other than an instance of String or Array, then it also # returns false. # def find_using_friendly_id() # return false unless slug_text.kind_of?(String) # finder = "find_by_#{self.friendly_id_options[:method].to_s}".to_sym # record = send(finder, slug_text) # record.send(:found_using_friendly_id=, true) if record # return record # end def find_using_friendly_id(slug_text, options = {}) case slug_text when String finder = "find_by_#{self.friendly_id_options[:method].to_s}".to_sym when Array finder = "find_all_by_#{self.friendly_id_options[:method].to_s}".to_sym else return false end records = send(finder, slug_text, options) [*records].each { |record| record.send(:found_using_friendly_id=, true) } unless records.blank? return records end end module NonSluggableInstanceMethods def self.included(base) base.extend SingletonMethods end attr :found_using_friendly_id # Was the record found using one of its friendly ids? def found_using_friendly_id? @found_using_friendly_id end # Was the record found using its numeric id? def found_using_numeric_id? ! @found_using_friendly_id end alias has_better_id? found_using_numeric_id? # Returns the friendly_id. def friendly_id send(friendly_id_options[:method].to_sym) end alias best_id friendly_id # Returns the friendly id, or if none is available, the numeric id. def to_param friendly_id ? friendly_id : id end private def found_using_friendly_id=(value) @found_using_friendly_id = value end end module SluggableClassMethods # Finds the record using only the friendly id. If it can't be found # using the friendly id, then it returns false. If you pass in any # argument other than an instance of String or Array, then it also # returns false. When given as an array will try to find any of the # records and return those that can be found. def find_using_friendly_id(*args) case args.first when String slugs = Slug.find_by_name_and_sluggable_type(args.first, self.to_s) when Array slugs = Slug.find_all_by_name_and_sluggable_type(args.first, self.to_s) else return false end return false if slugs.blank? || ![*slugs].all?(&:sluggable) [*slugs].each { |slug| slug.sluggable.send(:finder_slug=, slug) } (slugs.kind_of?(Array)) ? slugs.collect(&:sluggable) : slugs.sluggable end end module SluggableInstanceMethods def self.included(base) base.extend SingletonMethods end attr :finder_slug # Was the record found using one of its friendly ids? def found_using_friendly_id? !!@finder_slug end # Was the record found using its numeric id? def found_using_numeric_id? !found_using_friendly_id? end # Was the record found using an old friendly id? def found_using_outdated_friendly_id? @finder_slug.id != slug.id end # Was the record found using an old friendly id, or its numeric id? def has_better_id? !slug.nil? && (found_using_numeric_id? || found_using_outdated_friendly_id?) end # Returns the friendly id. def friendly_id slug.name end alias best_id friendly_id # Returns the most recent slug, which is used to determine the friendly # id. def slug(reload = false) @most_recent_slug = nil if reload @most_recent_slug ||= slugs.first end # Returns the friendly id, or if none is available, the numeric id. def to_param slug ? slug.name : id end # Generate the text for the friendly id, ensuring no duplication. def generate_friendly_id slug_text = truncated_friendly_id_base count = Slug.count_matches(slug_text, self.class.to_s, :all, :conditions => "sluggable_id <> #{self.id or 0}") if count == 0 return slug_text else generate_friendly_id_with_extension(slug_text, count) end end # Set the slug using the generated friendly id. def set_slug return unless self.class.friendly_id_options[:use_slug] @most_recent_slug = nil slug_text = generate_friendly_id if slugs.empty? || slugs.first.name != slug_text previous_slug = slugs.find_by_name(slug_text) previous_slug.destroy if previous_slug slugs.build(:name => slug_text) end end # Remove diacritics from the string. def strip_diacritics(string) require 'iconv' require 'unicode' Iconv.new("ascii//ignore//translit", "utf-8").iconv(Unicode.normalize_KD(string)) end # Get the string used as the basis of the friendly id. If you set the # option to remove diacritics from the friendly id's then they will be # removed. def friendly_id_base base = self.send(friendly_id_options[:method].to_sym) if base.blank? raise SlugGenerationError.new("The method or column used as the base of friendly_id's slug text returned a blank value") end if self.friendly_id_options[:strip_diacritics] Slug::normalize(strip_diacritics(base)) else Slug::normalize(base) end end protected # Sets the slug that was used to find the record. This can be used to # determine whether the record was found using the most recent friendly # id. def finder_slug=(val) @finder_slug = val end private def truncated_friendly_id_base max_length = friendly_id_options[:max_length] slug_text = friendly_id_base[0, max_length - NUM_CHARS_RESERVED_FOR_EXTENSION] end # Reserve a few spaces at the end of the slug for the counter extension. # This is to avoid generating slugs longer than the maxlength when an # extension is added. NUM_CHARS_RESERVED_FOR_EXTENSION = 2 def generate_friendly_id_with_extension(slug_text, count) extension = "-" + (count + 1).to_s if extension.length > NUM_CHARS_RESERVED_FOR_EXTENSION raise FriendlyId::SlugGenerationError.new("slug text #{slug_text} " + "goes over limit for similarly named slugs") end slug_text = truncated_friendly_id_base + extension count = Slug.count_matches(slug_text, self.class.to_s, :all, :conditions => "sluggable_id <> #{self.id or 0}") if count != 0 slug_text = truncated_friendly_id_base + "-" + (count + 1).to_s else return slug_text end end end # This error is raised when it's not possible to generate a unique slug. class SlugGenerationError < StandardError ; end end end