public
Rubygem
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
Fixed logic error with slug generation. Resolves ticket #7. Thanks to Tim
Kadom for reporting the bug that this commit fixes.
norman (author)
Fri Oct 31 12:38:23 -0700 2008
commit  f2fac151e73c1649fe5c619a6506ab4aa2e95cbd
tree    858beecf4218e0bdad544b30210769ae189380d0
parent  61ab15579986f9240f3d74a6f8affcaa13ebfa77
...
172
173
174
 
175
176
177
...
183
184
185
 
 
186
187
188
...
192
193
194
195
 
196
197
198
...
212
213
214
215
 
216
217
218
...
230
231
232
233
234
235
236
 
 
 
 
 
 
 
 
 
237
238
239
...
241
242
243
244
 
 
 
245
246
 
247
248
249
 
250
251
252
...
267
268
269
270
271
272
273
 
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
 
300
...
172
173
174
175
176
177
178
...
184
185
186
187
188
189
190
191
...
195
196
197
 
198
199
200
201
...
215
216
217
 
218
219
220
221
...
233
234
235
 
 
 
 
236
237
238
239
240
241
242
243
244
245
246
247
...
249
250
251
 
252
253
254
255
 
256
257
 
 
258
259
260
261
...
276
277
278
 
 
279
 
280
281
282
283
284
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
287
 
288
289
0
@@ -172,6 +172,7 @@ module FriendlyId
0
       raise ActiveRecord::RecordNotFound, "Couldn't find all #{ name.pluralize } with IDs (#{ ids_and_names * ', ' }) AND #{ sanitize_sql options[:conditions] } (found #{ results.size } results, but was looking for #{ expected_size })" if results.size != expected_size
0
 
0
       # assign finder slugs
0
+      # FIXME set the actual slugs, not the name to "lazy load" later, because that's not lazy!
0
       slugs.each do |slug|
0
         result = results.find { |r| r.id == slug.sluggable_id } and
0
         result.finder_slug_name = slug.name
0
@@ -183,6 +184,8 @@ module FriendlyId
0
 
0
   module SluggableInstanceMethods
0
 
0
+    NUM_CHARS_RESERVED_FOR_FRIENDLY_ID_EXTENSION = 2
0
+
0
     attr :finder_slug 
0
     attr_accessor :finder_slug_name
0
 
0
@@ -192,7 +195,7 @@ module FriendlyId
0
     
0
     # Was the record found using one of its friendly ids?
0
     def found_using_friendly_id?
0
-      @finder_slug_name
0
+      finder_slug
0
     end
0
 
0
     # Was the record found using its numeric id?
0
@@ -212,7 +215,7 @@ module FriendlyId
0
 
0
     # Returns the friendly id.
0
     def friendly_id
0
-      finder_slug_name or slug.name
0
+      slug(true).name
0
     end
0
     alias best_id friendly_id
0
 
0
@@ -230,10 +233,15 @@ module FriendlyId
0
 
0
     # Generate the text for the friendly id, ensuring no duplication.
0
     def generate_friendly_id
0
-      slug_text = truncated_friendly_id_base
0
-      count = Slug.count_matches slug_text, self.class.name, :all, :conditions => "sluggable_id <> #{ id.to_i }"
0
-      count += 1 if self.class.friendly_id_options[:reserved].include?(slug_text)
0
-      count == 0 ? slug_text : generate_friendly_id_with_extension(slug_text, count)
0
+      base = friendly_id_base
0
+      opts = self.class.friendly_id_options
0
+      if base.length > opts[:max_length]
0
+        base = base[0...opts[:max_length] - NUM_CHARS_RESERVED_FOR_FRIENDLY_ID_EXTENSION]
0
+      end
0
+      if opts[:reserved].include?(base)
0
+        base = "#{base}-2"
0
+      end
0
+      Slug.get_best_name(base, self.class)
0
     end
0
 
0
     # Set the slug using the generated friendly id.
0
@@ -241,12 +249,13 @@ module FriendlyId
0
       if self.class.friendly_id_options[:use_slug]
0
         @most_recent_slug = nil
0
         slug_text = generate_friendly_id
0
-
0
+        # Avoids regenerating slug over and over again.
0
+        # FIXME This could perform pretty badly if a model has tons of similarly-named slugs
0
+        return if slug && slug.succ == slug_text
0
         if slugs.empty? || slugs.first.name != slug_text
0
-          previous_slug = slugs.find_by_name slug_text
0
+          previous_slug = slugs.find_by_name friendly_id_base
0
           previous_slug.destroy if previous_slug
0
-
0
-          slugs.build :name => slug_text
0
+          slugs.build :name => generate_friendly_id
0
         end
0
       end
0
     end
0
@@ -267,33 +276,13 @@ module FriendlyId
0
 
0
     private
0
 
0
-    NUM_CHARS_RESERVED_FOR_FRIENDLY_ID_EXTENSION = 2
0
-
0
     def init_finder_slug
0
-      raise RuntimeError, 'No slug name is set' if !@finder_slug_name
0
+      return false if !@finder_slug_name
0
       slug = Slug.find(:first, :conditions => {:sluggable_id => id, :name => @finder_slug_name})
0
       slug.sluggable = self
0
       return slug
0
     end
0
 
0
-    def truncated_friendly_id_base
0
-      max_length = friendly_id_options[:max_length]
0
-      slug_text = friendly_id_base[0, max_length - NUM_CHARS_RESERVED_FOR_FRIENDLY_ID_EXTENSION]
0
-    end
0
-
0
-    # Reserve a few spaces at the end of the slug for the counter extension.
0
-    # This is to avoid generating slugs longer than the maxlength when an
0
-    # extension is added.
0
-    POSSIBILITIES = 10 ** NUM_CHARS_RESERVED_FOR_FRIENDLY_ID_EXTENSION - 1
0
-    def generate_friendly_id_with_extension(slug_text, count)
0
-      count <= POSSIBILITIES or
0
-      raise FriendlyId::SlugGenerationError.new("slug text #{slug_text} goes over limit for similarly named slugs")
0
-
0
-      slug_text = "#{ truncated_friendly_id_base }-#{ count + 1 }"
0
-
0
-      count = Slug.count_matches slug_text, self.class.name, :all, :conditions => "sluggable_id <> #{ id.to_i }"
0
-      count > 0 ? "#{ truncated_friendly_id_base }-#{ count + 1 }" : slug_text
0
-    end
0
   end
0
 
0
-end
0
+end
0
\ No newline at end of file
...
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
...
68
69
70
71
 
 
 
 
 
 
 
 
 
 
 
 
 
72
73
74
...
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
...
64
65
66
 
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
0
@@ -5,50 +5,46 @@ class Slug < ActiveRecord::Base
0
   validates_uniqueness_of :name, :scope => :sluggable_type
0
 
0
   class << self
0
+    
0
     def with_name(name)
0
       "#{ quoted_table_name }.name = #{ quote_value name, columns_hash['name'] }"
0
     end
0
+    
0
     def with_names(names)
0
       name_column = columns_hash['name']
0
       names = names.map { |n| "#{ quote_value n, name_column }" }.join ','
0
 
0
       "#{ quoted_table_name }.name IN (#{ names })"
0
     end
0
+    
0
     def find_all_by_names_and_sluggable_type(names, type)
0
       names = with_names names
0
       type  = "#{ quoted_table_name }.sluggable_type = #{ quote_value type, columns_hash['sluggable_type'] }"
0
       find :all, :conditions => "#{ names } AND #{ type }"
0
     end
0
 
0
-    # Count exact matches for a slug. Matches include slugs with the same name
0
-    # and an appended numeric suffix, i.e., "an-example-slug" and
0
-    # "an-example-slug-2"
0
-    #
0
-    # The first two arguments are required, after which you may pass in the
0
-    # same arguments as ActiveRecord::Base.find.
0
-    COND = 'name LIKE ? AND sluggable_type = ?'.freeze
0
-    def count_matches(name, type, *args)
0
-      name_esc = Regexp.escape name
0
-
0
-      with_scope(:find => {:conditions => [COND, "#{name}%", type]}) {
0
-        find(*args)
0
-      }.inject(0) do |count, slug|
0
-        slug.name =~ /\A#{name_esc}(-[\d]+)*\Z/ ? count + 1 : count
0
-      end
0
+    # Checks a slug name for collisions
0
+    def get_best_name(name, type)
0
+      slugs = find :all, :conditions => ['name LIKE ? AND sluggable_type = ?', "#{name}%", type.to_s]
0
+      return name if slugs.size == 0
0
+      slugs.map { |x| slugs.delete(x) unless x.base == name }
0
+      slugs.sort! { |x, y| x.extension <=> y.extension }
0
+      slugs.empty? ? name : slugs.last.succ
0
     end
0
 
0
     # Sanitizes and dasherizes string to make it safe for URL's.
0
     #
0
     # Example:
0
+    #
0
     #   slug.normalize('This... is an example!') # => "this-is-an-example"
0
     #
0
-    # Note that Rails 2.2.x offers a parameterize method for stripping
0
-    # diacritics. This is not used here because at the time of writing, it
0
-    # handles several characters incorrectly, for instance replacing
0
-    # Icelandic's "thorn" character with "y" rather than "d." This might be
0
-    # pedantic, but I don't want to piss off the Vikings. The last time anyone
0
-    # pissed them off, they uleashed a wave of terror in Europe unlike
0
-    # anything ever seen before or after. I'm not taking any chances.
0
+    # Note that Rails 2.2.x offers a parameterize method for this. It's not
0
+    # used here because at the time of writing, it handles several characters
0
+    # incorrectly, for instance replacing Icelandic's "thorn" character with
0
+    # "y" rather than "d." This might be pedantic, but I don't want to piss
0
+    # off the Vikings. The last time anyone pissed them off, they uleashed a
0
+    # wave of terror in Europe unlike anything ever seen before or after. I'm
0
+    # not taking any chances.
0
     def normalize(slug_text)
0
       # Use this onces it starts working reliably
0
       # return slug_text.parameterize.to_s if slug_text.respond_to?(:parameterize)
0
@@ -68,7 +64,19 @@ class Slug < ActiveRecord::Base
0
     end
0
 
0
   end
0
-
0
+  
0
+  def succ
0
+    extension == 0 ? "#{base}-2" : name.succ
0
+  end
0
+  
0
+  def base
0
+    name.gsub(/-?\d*\z/, '')
0
+  end
0
+  
0
+  def extension
0
+    /\d*\z/.match(name).to_s.to_i
0
+  end
0
+  
0
   # Whether or not this slug is the most recent of its owner's slugs.
0
   def is_most_recent?
0
     debugger
...
1
2
 
3
...
1
 
2
3
0
@@ -1,3 +1,3 @@
0
 class Post < ActiveRecord::Base
0
-  has_friendly_id :name, :use_slug => true, :reserved => ['new', 'recent']
0
+  has_friendly_id :name, :use_slug => true, :reserved => ['new', 'edit']
0
 end
...
7
8
9
10
 
11
12
13
...
180
181
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
184
185
...
7
8
9
 
10
11
12
13
...
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
0
@@ -7,7 +7,7 @@ class SluggableTest < Test::Unit::TestCase
0
   def setup
0
     Post.friendly_id_options[:max_length] = FriendlyId::ClassMethods::DEFAULT_FRIENDLY_ID_OPTIONS[:max_length]
0
   end
0
-
0
+  
0
   def test_finder_options_are_not_ignored
0
     assert_raises ActiveRecord::RecordNotFound do
0
       Post.find(slugs(:one).name, :conditions => "1 = 2")
0
@@ -180,5 +180,26 @@ class SluggableTest < Test::Unit::TestCase
0
     post = Post.create!(:name => 'new')
0
     assert_equal 'new-2', post.friendly_id
0
   end
0
+  
0
+  def test_slug_sequence_is_based_on_highest_extension_rather_than_slug_count
0
+    assert_equal "test", Slug.get_best_name("test", Post)
0
+    @post = Post.create!(:name => "test", :content => "stuff")
0
+    assert_equal "test", @post.slug.name
0
+    
0
+    assert_equal "test-2", Slug.get_best_name("test", Post)
0
+    @post2 = Post.create!(:name => "test", :content => "stuff") # slug should be "test-2"
0
+    assert_equal "test-2", @post2.slug.name
0
+  
0
+    assert_equal "test-3", Slug.get_best_name("test", Post)
0
+    @post3 = Post.create!(:name => "test", :content => "stuff")
0
+    assert_equal "test-3", @post3.slug.name
0
+    
0
+    assert_equal "test-4", Slug.get_best_name("test", Post)
0
+    @post.destroy # will destroy slug named "test" along with it
0
+    # Make sure the next slug is still test-4 and not test-3
0
+    assert_equal "test-4", Slug.get_best_name("test", Post)    
0
+    @post4 = Post.create!(:name => "test", :content => "stuff")
0
+    assert_equal "test-4", @post4.slug.name
0
+  end
0
 
0
 end
0
\ No newline at end of file

Comments