public
Description: FriendlyId is the “Swiss Army bulldozer” of slugging and permalink plugins for ActiveRecord. It allows you to create pretty URL’s and work with human-friendly strings as if they were numeric ids for ActiveRecord models.
Homepage: http://friendly-id.rubyforge.org
Clone URL: git://github.com/norman/friendly_id.git
friendly_id / lib / friendly_id.rb
100644 287 lines (239 sloc) 9.467 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# 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:
      # * <tt>:use_slug</tt> - Defaults to false. Use slugs when you want to use a non-unique text field for friendly ids.
      # * <tt>:max_length</tt> - Defaults to 255. The maximum allowed length for a slug.
      # * <tt>:strip_diacritics</tt> - 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.base_class.to_s)
          when Array
            slugs = Slug.find_all_by_name_and_sluggable_type(args.first, self.base_class.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