nateabbott-friendly-id 2.2.1

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/.gitignore ADDED
@@ -0,0 +1,21 @@
1
+ ## MAC OS
2
+ .DS_Store
3
+
4
+ ## TEXTMATE
5
+ *.tmproj
6
+ tmtags
7
+
8
+ ## EMACS
9
+ *~
10
+ \#*
11
+ .\#*
12
+
13
+ ## VIM
14
+ *.swp
15
+
16
+ ## PROJECT::GENERAL
17
+ coverage
18
+ rdoc
19
+ pkg
20
+
21
+ ## PROJECT::SPECIFIC
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Nate Abbott
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,18 @@
1
+ = nateabbott-friendly-id
2
+
3
+ Description goes here.
4
+
5
+ == Note on Patches/Pull Requests
6
+
7
+ * Fork the project.
8
+ * Make your feature addition or bug fix.
9
+ * Add tests for it. This is important so I don't break it in a
10
+ future version unintentionally.
11
+ * Commit, do not mess with rakefile, version, or history.
12
+ (if you want to have your own version, that is fine but
13
+ bump version in a commit by itself I can ignore when I pull)
14
+ * Send me a pull request. Bonus points for topic branches.
15
+
16
+ == Copyright
17
+
18
+ Copyright (c) 2009 Nate Abbott. See LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,52 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "nateabbott-friendly-id"
8
+ gem.summary = "A comprehensive slugging and pretty-URL plugin for ActiveRecord."
9
+ gem.description = 'A comprehensive slugging and pretty-URL plugin for ActiveRecord.'
10
+ gem.email = ['norman@njclarke.com', 'adrian@mugnolo.com', 'miloops@gmail.com']
11
+ gem.homepage = "http://github.com/nateabbott/nateabbott-friendly-id"
12
+ gem.authors = ['Norman Clarke', 'Adrian Mugnolo', 'Emilio Tagua']
13
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
14
+ end
15
+ Jeweler::GemcutterTasks.new
16
+ rescue LoadError
17
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
18
+ end
19
+
20
+ require 'rake/testtask'
21
+ Rake::TestTask.new(:test) do |test|
22
+ test.libs << 'lib' << 'test'
23
+ test.pattern = 'test/**/test_*.rb'
24
+ test.verbose = true
25
+ end
26
+
27
+ begin
28
+ require 'rcov/rcovtask'
29
+ Rcov::RcovTask.new do |test|
30
+ test.libs << 'test'
31
+ test.pattern = 'test/**/test_*.rb'
32
+ test.verbose = true
33
+ end
34
+ rescue LoadError
35
+ task :rcov do
36
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
37
+ end
38
+ end
39
+
40
+ task :test => :check_dependencies
41
+
42
+ task :default => :test
43
+
44
+ require 'rake/rdoctask'
45
+ Rake::RDocTask.new do |rdoc|
46
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
47
+
48
+ rdoc.rdoc_dir = 'rdoc'
49
+ rdoc.title = "nateabbott-friendly-id #{version}"
50
+ rdoc.rdoc_files.include('README*')
51
+ rdoc.rdoc_files.include('lib/**/*.rb')
52
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 2.2.1
@@ -0,0 +1,15 @@
1
+ # encoding: utf-8
2
+
3
+ module FriendlyId
4
+
5
+ module Helpers
6
+ # Calculate expected result size for find_some_with_friendly (taken from
7
+ # active_record/base.rb)
8
+ def expected_size(ids_and_names, options) #:nodoc:#
9
+ size = options[:offset] ? ids_and_names.size - options[:offset] : ids_and_names.size
10
+ size = options[:limit] if options[:limit] && size > options[:limit]
11
+ size
12
+ end
13
+ end
14
+
15
+ end
@@ -0,0 +1,35 @@
1
+ # encoding: utf-8
2
+
3
+ module FriendlyId::NonSluggableClassMethods
4
+
5
+ include FriendlyId::Helpers
6
+
7
+ protected
8
+
9
+ def find_one(id, options) #:nodoc:#
10
+ if id.is_a?(String) && result = send("find_by_#{ friendly_id_options[:column] }", id, options)
11
+ result.send(:found_using_friendly_id=, true)
12
+ else
13
+ result = super id, options
14
+ end
15
+ result
16
+ end
17
+
18
+ def find_some(ids_and_names, options) #:nodoc:#
19
+
20
+ results = with_scope :find => options do
21
+ find :all, :conditions => ["#{quoted_table_name}.#{primary_key} IN (?) OR #{friendly_id_options[:column].to_s} IN (?)",
22
+ ids_and_names, ids_and_names]
23
+ end
24
+
25
+ expected = expected_size(ids_and_names, options)
26
+ if results.size != expected
27
+ 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 })"
28
+ end
29
+
30
+ results.each {|r| r.send(:found_using_friendly_id=, true) if ids_and_names.include?(r.friendly_id)}
31
+
32
+ results
33
+
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # encoding: utf-8
2
+
3
+ module FriendlyId::NonSluggableInstanceMethods
4
+
5
+ attr :found_using_friendly_id
6
+
7
+ # Was the record found using one of its friendly ids?
8
+ def found_using_friendly_id?
9
+ @found_using_friendly_id
10
+ end
11
+
12
+ # Was the record found using its numeric id?
13
+ def found_using_numeric_id?
14
+ !@found_using_friendly_id
15
+ end
16
+ alias has_better_id? found_using_numeric_id?
17
+
18
+ # Returns the friendly_id.
19
+ def friendly_id
20
+ send friendly_id_options[:column]
21
+ end
22
+ alias best_id friendly_id
23
+
24
+ # Returns the friendly id, or if none is available, the numeric id.
25
+ def to_param
26
+ (friendly_id || id).to_s
27
+ end
28
+
29
+ private
30
+
31
+ def validate_friendly_id
32
+ if self.class.friendly_id_options[:reserved].include? friendly_id
33
+ self.errors.add(self.class.friendly_id_options[:column],
34
+ self.class.friendly_id_options[:reserved_message] % friendly_id)
35
+ return false
36
+ end
37
+ end
38
+
39
+ def found_using_friendly_id=(value) #:nodoc#
40
+ @found_using_friendly_id = value
41
+ end
42
+
43
+ end
@@ -0,0 +1,100 @@
1
+ #encoding: utf-8
2
+
3
+ # A Slug is a unique, human-friendly identifier for an ActiveRecord.
4
+ class Slug < ActiveRecord::Base
5
+
6
+ belongs_to :sluggable, :polymorphic => true
7
+ before_save :check_for_blank_name, :set_sequence
8
+
9
+
10
+ ASCII_APPROXIMATIONS = {
11
+ 198 => "AE",
12
+ 208 => "D",
13
+ 216 => "O",
14
+ 222 => "Th",
15
+ 223 => "ss",
16
+ 230 => "ae",
17
+ 240 => "d",
18
+ 248 => "o",
19
+ 254 => "th"
20
+ }.freeze
21
+
22
+ class << self
23
+
24
+ # Sanitizes and dasherizes string to make it safe for URL's.
25
+ #
26
+ # Example:
27
+ #
28
+ # slug.normalize('This... is an example!') # => "this-is-an-example"
29
+ #
30
+ # Note that the Unicode handling in ActiveSupport may fail to process some
31
+ # characters from Polish, Icelandic and other languages.
32
+ def normalize(slug_text)
33
+ return "" if slug_text.nil? || slug_text == ""
34
+ ActiveSupport::Multibyte.proxy_class.new(slug_text.to_s).normalize(:kc).
35
+ gsub(/[\W]/u, ' ').
36
+ strip.
37
+ gsub(/\s+/u, '-').
38
+ gsub(/-\z/u, '').
39
+ downcase.
40
+ to_s
41
+ end
42
+
43
+ def parse(friendly_id)
44
+ name, sequence = friendly_id.split('--')
45
+ sequence ||= "1"
46
+ return name, sequence
47
+ end
48
+
49
+ # Remove diacritics (accents, umlauts, etc.) from the string. Borrowed
50
+ # from "The Ruby Way."
51
+ def strip_diacritics(string)
52
+ a = ActiveSupport::Multibyte.proxy_class.new(string).normalize(:kd) || ""
53
+ a.unpack('U*').inject([]) { |a, u|
54
+ if ASCII_APPROXIMATIONS[u]
55
+ a += ASCII_APPROXIMATIONS[u].unpack('U*')
56
+ elsif (u < 0x300 || u > 0x036F)
57
+ a << u
58
+ end
59
+ a
60
+ }.pack('U*')
61
+ end
62
+
63
+
64
+
65
+ # Remove non-ascii characters from the string.
66
+ def strip_non_ascii(string)
67
+ strip_diacritics(string).gsub(/[^a-z0-9]+/i, ' ')
68
+ end
69
+
70
+ private
71
+
72
+ end
73
+
74
+ # Whether or not this slug is the most recent of its owner's slugs.
75
+ def is_most_recent?
76
+ sluggable.slug == self
77
+ end
78
+
79
+ def to_friendly_id
80
+ sequence > 1 ? "#{name}--#{sequence}" : name
81
+ end
82
+
83
+ protected
84
+
85
+ # Raise a FriendlyId::SlugGenerationError if the slug name is blank.
86
+ def check_for_blank_name #:nodoc:#
87
+ if name.blank?
88
+ raise FriendlyId::SlugGenerationError.new("The slug text is blank.")
89
+ end
90
+ end
91
+
92
+ def set_sequence
93
+ return unless new_record?
94
+ last = Slug.find(:first, :conditions => { :name => name, :scope => scope,
95
+ :sluggable_type => sluggable_type}, :order => "sequence DESC",
96
+ :select => 'sequence')
97
+ self.sequence = last.sequence + 1 if last
98
+ end
99
+
100
+ end
@@ -0,0 +1,109 @@
1
+ # encoding: utf-8
2
+
3
+ module FriendlyId::SluggableClassMethods
4
+
5
+ include FriendlyId::Helpers
6
+
7
+ # Finds a single record using the friendly id, or the record's id.
8
+ def find_one(id_or_name, options) #:nodoc:#
9
+
10
+ scope = options.delete(:scope)
11
+ return super(id_or_name, options) if id_or_name.is_a?(Integer)
12
+
13
+ find_options = {:select => "#{self.table_name}.*"}
14
+ find_options[:joins] = :slugs unless options[:include] && [*options[:include]].flatten.include?(:slugs)
15
+
16
+ name, sequence = Slug.parse(id_or_name)
17
+
18
+ find_options[:conditions] = {
19
+ "#{Slug.table_name}.name" => name,
20
+ "#{Slug.table_name}.scope" => scope,
21
+ "#{Slug.table_name}.sequence" => sequence
22
+ }
23
+
24
+ result = with_scope(:find => find_options) { find_initial(options) }
25
+
26
+ if result
27
+ result.finder_slug_name = id_or_name
28
+ elsif id_or_name.to_i.to_s != id_or_name
29
+ raise ActiveRecord::RecordNotFound
30
+ else
31
+ result = super id_or_name, options
32
+ end
33
+
34
+ result
35
+
36
+ rescue ActiveRecord::RecordNotFound => e
37
+
38
+ if friendly_id_options[:scope]
39
+ if !scope
40
+ raise ActiveRecord::RecordNotFound.new("%s; expected scope but got none" % e.message)
41
+ else
42
+ raise ActiveRecord::RecordNotFound.new("%s and scope=#{scope}" % e.message)
43
+ end
44
+ end
45
+
46
+ raise e
47
+
48
+ end
49
+
50
+ # Finds multiple records using the friendly ids, or the records' ids.
51
+ def find_some(ids_and_names, options) #:nodoc:#
52
+
53
+ slugs, ids = get_slugs_and_ids(ids_and_names, options)
54
+ results = []
55
+
56
+ find_options = {:select => "#{self.table_name}.*"}
57
+ find_options[:joins] = :slugs unless options[:include] && [*options[:include]].flatten.include?(:slugs)
58
+ find_options[:conditions] = "#{quoted_table_name}.#{primary_key} IN (#{ids.empty? ? 'NULL' : ids.join(',')}) "
59
+ find_options[:conditions] << "OR slugs.id IN (#{slugs.to_s(:db)})"
60
+
61
+ results = with_scope(:find => find_options) { find_every(options) }.uniq
62
+
63
+ expected = expected_size(ids_and_names, options)
64
+ if results.size != expected
65
+ 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 })"
66
+ end
67
+
68
+ assign_finder_slugs(slugs, results)
69
+
70
+ results
71
+ end
72
+
73
+ def validate_find_options(options) #:nodoc:#
74
+ options.assert_valid_keys([:conditions, :include, :joins, :limit, :offset,
75
+ :order, :select, :readonly, :group, :from, :lock, :having, :scope])
76
+ end
77
+
78
+ private
79
+
80
+ # Assign finder slugs for the results found in find_some_with_friendly
81
+ def assign_finder_slugs(slugs, results) #:nodoc:#
82
+ slugs.each do |slug|
83
+ results.select { |r| r.id == slug.sluggable_id }.each do |result|
84
+ result.send(:finder_slug=, slug)
85
+ end
86
+ end
87
+ end
88
+
89
+ # Build arrays of slugs and ids, for the find_some_with_friendly method.
90
+ def get_slugs_and_ids(ids_and_names, options) #:nodoc:#
91
+ scope = options.delete(:scope)
92
+ slugs = []
93
+ ids = []
94
+ ids_and_names.each do |id_or_name|
95
+ name, sequence = Slug.parse id_or_name.to_s
96
+ slug = Slug.find(:first, :conditions => {
97
+ :name => name,
98
+ :scope => scope,
99
+ :sequence => sequence,
100
+ :sluggable_type => base_class.name
101
+ })
102
+ # If the slug was found, add it to the array for later use. If not, and
103
+ # the id_or_name is a number, assume that it is a regular record id.
104
+ slug ? slugs << slug : (ids << id_or_name if id_or_name.to_s =~ /\A\d*\z/)
105
+ end
106
+ return slugs, ids
107
+ end
108
+
109
+ end
@@ -0,0 +1,132 @@
1
+ # encoding: utf-8
2
+
3
+ module FriendlyId::SluggableInstanceMethods
4
+
5
+ NUM_CHARS_RESERVED_FOR_FRIENDLY_ID_EXTENSION = 2
6
+
7
+ attr :finder_slug
8
+ attr_accessor :finder_slug_name
9
+
10
+ def finder_slug
11
+ @finder_slug ||= init_finder_slug or nil
12
+ end
13
+
14
+ # Was the record found using one of its friendly ids?
15
+ def found_using_friendly_id?
16
+ !!@finder_slug_name
17
+ end
18
+
19
+ # Was the record found using its numeric id?
20
+ def found_using_numeric_id?
21
+ !found_using_friendly_id?
22
+ end
23
+
24
+ # Was the record found using an old friendly id?
25
+ def found_using_outdated_friendly_id?
26
+ if cache = friendly_id_options[:cache_column]
27
+ return false if send(cache) == @finder_slug_name
28
+ end
29
+ finder_slug.id != slug.id
30
+ end
31
+
32
+ # Was the record found using an old friendly id, or its numeric id?
33
+ def has_better_id?
34
+ has_a_slug? and found_using_numeric_id? || found_using_outdated_friendly_id?
35
+ end
36
+
37
+ # Does the record have (at least) one slug?
38
+ def has_a_slug?
39
+ @finder_slug_name || slug
40
+ end
41
+
42
+ # Returns the friendly id.
43
+ def friendly_id
44
+ slug(true).to_friendly_id
45
+ end
46
+ alias best_id friendly_id
47
+
48
+ # Has the basis of our friendly id changed, requiring the generation of a
49
+ # new slug?
50
+ def new_slug_needed?
51
+ !slug || slug_text != slug.name
52
+ end
53
+
54
+ # Returns the most recent slug, which is used to determine the friendly
55
+ # id.
56
+ def slug(reload = false)
57
+ @most_recent_slug = nil if reload
58
+ @most_recent_slug ||= slugs.first(:order => "id DESC")
59
+ end
60
+
61
+ # Returns the friendly id, or if none is available, the numeric id.
62
+ def to_param
63
+ if cache = friendly_id_options[:cache_column]
64
+ return read_attribute(cache) || id.to_s
65
+ end
66
+ slug ? slug.to_friendly_id : id.to_s
67
+ end
68
+
69
+ # Get the processed string used as the basis of the friendly id.
70
+ def slug_text
71
+ base = send friendly_id_options[:column]
72
+ if self.slug_normalizer_block
73
+ base = self.slug_normalizer_block.call(base)
74
+ else
75
+ if self.friendly_id_options[:strip_diacritics]
76
+ base = Slug::strip_diacritics(base)
77
+ end
78
+ if self.friendly_id_options[:strip_non_ascii]
79
+ base = Slug::strip_non_ascii(base)
80
+ end
81
+ base = Slug::normalize(base)
82
+ end
83
+
84
+ if base.mb_chars.length > friendly_id_options[:max_length]
85
+ base = base.mb_chars[0...friendly_id_options[:max_length]]
86
+ end
87
+ if friendly_id_options[:reserved].include?(base)
88
+ raise FriendlyId::SlugGenerationError.new("The slug text is a reserved value")
89
+ end
90
+ return base
91
+ end
92
+
93
+ private
94
+
95
+ def finder_slug=(finder_slug)
96
+ @finder_slug_name = finder_slug.name
97
+ slug = finder_slug
98
+ slug.sluggable = self
99
+ slug
100
+ end
101
+
102
+ def init_finder_slug
103
+ return false if !@finder_slug_name
104
+ name, sequence = Slug.parse(@finder_slug_name)
105
+ slug = Slug.find(:first, :conditions => {:sluggable_id => id, :name => name, :sequence => sequence, :sluggable_type => self.class.base_class.name })
106
+ finder_slug = slug
107
+ end
108
+
109
+ # Set the slug using the generated friendly id.
110
+ def set_slug
111
+ if self.class.friendly_id_options[:use_slug] && new_slug_needed?
112
+ @most_recent_slug = nil
113
+ slug_attributes = {:name => slug_text}
114
+ if friendly_id_options[:scope]
115
+ scope = send(friendly_id_options[:scope])
116
+ slug_attributes[:scope] = scope.respond_to?(:to_param) ? scope.to_param : scope.to_s
117
+ end
118
+ # If we're renaming back to a previously used friendly_id, delete the
119
+ # slug so that we can recycle the name without having to use a sequence.
120
+ slugs.find(:all, :conditions => {:name => slug_text, :scope => scope}).each { |s| s.destroy }
121
+ slugs.build slug_attributes
122
+ end
123
+ end
124
+
125
+ def set_slug_cache
126
+ if friendly_id_options[:cache_column] && send(friendly_id_options[:cache_column]) != slug.name
127
+ send "#{friendly_id_options[:cache_column]}=", slug.name
128
+ send :update_without_callbacks
129
+ end
130
+ end
131
+
132
+ end