acts_as_taggable 1.0.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/CHANGELOG +10 -0
- data/README +89 -70
- data/lib/taggable.rb +610 -467
- data/test/acts_as_taggable_test.rb +412 -384
- metadata +26 -39
data/lib/taggable.rb
CHANGED
@@ -1,467 +1,610 @@
|
|
1
|
-
require 'active_support'
|
2
|
-
require 'active_record'
|
3
|
-
|
4
|
-
module ActiveRecord
|
5
|
-
module Acts #:nodoc:
|
6
|
-
module Taggable #:nodoc:
|
7
|
-
|
8
|
-
def self.append_features(base)
|
9
|
-
super
|
10
|
-
base.extend(ClassMethods)
|
11
|
-
end
|
12
|
-
|
13
|
-
def self.split_tag_names(tags, separator)
|
14
|
-
tag_names = []
|
15
|
-
if tags.is_a?(Array)
|
16
|
-
tag_names << tags
|
17
|
-
elsif tags.is_a?(String)
|
18
|
-
tag_names << (separator.is_a?(Proc) ? separator.call(tags) : tags.split(separator))
|
19
|
-
end
|
20
|
-
tag_names = tag_names.flatten.map { |name| name.strip }.uniq.compact #straight 'em up
|
21
|
-
end
|
22
|
-
|
23
|
-
# This mixin provides an easy way for
|
24
|
-
# known as folksnomy) to your active record objects. It allows you to add
|
25
|
-
# tags to your objects as well as search for tagged objects.
|
26
|
-
#
|
27
|
-
# It assumes you are using a fully-normalized tagging database schema. For
|
28
|
-
# that, you need a table (by default, named +tags+) to hold all tags in your
|
29
|
-
# application and this table must have a primary key (normally a +id+ int
|
30
|
-
# autonumber column) and a +name+ varchar column. You must also define a model class
|
31
|
-
# related to this table (by default, named +Tag+).
|
32
|
-
#
|
33
|
-
# All tag names will be stored in this tags table. Taggable objects should reside
|
34
|
-
# in their own tables, like any other object. Tagging objects is
|
35
|
-
# the +acts_as_taggable+ mixin using a +has_and_belong_to_many+ relationship that is
|
36
|
-
# automatically created on the taggable class, and as so, a join table must exist
|
37
|
-
# between the tags table and the taggable object table.
|
38
|
-
#
|
39
|
-
# The name of the join table
|
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
|
-
# Photo.find_tagged_with
|
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
|
-
# Person
|
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
|
-
else
|
175
|
-
|
176
|
-
end
|
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
|
-
# and
|
251
|
-
#
|
252
|
-
#
|
253
|
-
#
|
254
|
-
#
|
255
|
-
#
|
256
|
-
#
|
257
|
-
#
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
sql
|
269
|
-
|
270
|
-
sql << "
|
271
|
-
sql << "
|
272
|
-
sql
|
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
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
#
|
308
|
-
#
|
309
|
-
#
|
310
|
-
#
|
311
|
-
#
|
312
|
-
#
|
313
|
-
# +:
|
314
|
-
#
|
315
|
-
#
|
316
|
-
#
|
317
|
-
#
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
o, o_pk, o_fk, t, t_pk, t_fk, jt = set_locals_for_sql
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
#
|
357
|
-
#
|
358
|
-
#
|
359
|
-
#
|
360
|
-
#
|
361
|
-
#
|
362
|
-
#
|
363
|
-
#
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
#
|
423
|
-
#
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
#
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
end
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
end
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_record'
|
3
|
+
|
4
|
+
module ActiveRecord
|
5
|
+
module Acts #:nodoc:
|
6
|
+
module Taggable #:nodoc:
|
7
|
+
|
8
|
+
def self.append_features(base)
|
9
|
+
super
|
10
|
+
base.extend(ClassMethods)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.split_tag_names(tags, separator,normalizer)
|
14
|
+
tag_names = []
|
15
|
+
if tags.is_a?(Array)
|
16
|
+
tag_names << tags
|
17
|
+
elsif tags.is_a?(String)
|
18
|
+
tag_names << (separator.is_a?(Proc) ? separator.call(tags) : tags.split(separator))
|
19
|
+
end
|
20
|
+
tag_names = tag_names.flatten.map { |name| normalizer.call(name.strip) }.uniq.compact #straight 'em up
|
21
|
+
end
|
22
|
+
|
23
|
+
# This mixin provides an easy way for adding tagging capabilities (also
|
24
|
+
# known as folksnomy) to your active record objects. It allows you to add
|
25
|
+
# tags to your objects as well as search for tagged objects.
|
26
|
+
#
|
27
|
+
# It assumes you are using a fully-normalized tagging database schema. For
|
28
|
+
# that, you need a table (by default, named +tags+) to hold all tags in your
|
29
|
+
# application and this table must have a primary key (normally a +id+ int
|
30
|
+
# autonumber column) and a +name+ varchar column. You must also define a model class
|
31
|
+
# related to this table (by default, named +Tag+).
|
32
|
+
#
|
33
|
+
# All tag names will be stored in this tags table. Taggable objects should reside
|
34
|
+
# in their own tables, like any other object. Tagging objects is performed by
|
35
|
+
# the +acts_as_taggable+ mixin using a +has_and_belong_to_many+ relationship that is
|
36
|
+
# automatically created on the taggable class, and as so, a join table must exist
|
37
|
+
# between the tags table and the taggable object table.
|
38
|
+
#
|
39
|
+
# The name of the join table follows the standards for rails
|
40
|
+
#
|
41
|
+
# Unless the join table is explicitly specified as an option,
|
42
|
+
# it is guessed using the lexical order of the class names.
|
43
|
+
#
|
44
|
+
# The join table must be composed of the foreign keys from the tags table and the
|
45
|
+
# taggable object table, so for instance, if we have a tags table named +tags+ (related
|
46
|
+
# to a +Tag+ model) and a taggable +photos+ table (related to a +Photo+ model),
|
47
|
+
# there should be a join table +tags_photos+ with int FK columns +photo_id+ and +tag_id+.
|
48
|
+
# If you dont use a explicit full model related to the join table (through the
|
49
|
+
# +:join_class_name+ option), you must not add a primary key to the join table.
|
50
|
+
#
|
51
|
+
# The +acts_as_taggable+ adds the instance methods +tag+, +tag_names+,
|
52
|
+
# +tag_names= +, +tag_names<< +, +tagged_with? + for adding tags to the object
|
53
|
+
# and also the class method +find_tagged_with+ method for search tagged objects.
|
54
|
+
#
|
55
|
+
# Examples:
|
56
|
+
#
|
57
|
+
# class Photo < ActiveRecord::Base
|
58
|
+
# # this creates a 'tags' collection, through a has_and_belongs_to_many
|
59
|
+
# # relationship that utilizes the join table 'photos_tags'.
|
60
|
+
# acts_as_taggable :normalizer => Proc.new {|name| name.downcase}
|
61
|
+
# end
|
62
|
+
#
|
63
|
+
# photo = Photo.new
|
64
|
+
#
|
65
|
+
# # splits and adds to the tags collection
|
66
|
+
# photo.tag "wine beer alcohol"
|
67
|
+
#
|
68
|
+
# # don't need to split since it's an array, but replaces the tags collection
|
69
|
+
# # trailing and leading spaces are properly removed
|
70
|
+
# photo.tag [ 'wine ', ' vodka'], :clear => true
|
71
|
+
#
|
72
|
+
# photo.tag_names # => [ 'wine', 'vodka' ]
|
73
|
+
# # You can remove tags one at a time or in a group
|
74
|
+
# photo.tag_remove 'wine'
|
75
|
+
# photo.tag_remove 'wine beer alcohol'
|
76
|
+
#
|
77
|
+
# # appends new tags with a different separator
|
78
|
+
# # the 'wine' tag wont be duplicated
|
79
|
+
# photo.tag_names << 'wine, beer, alcohol', :separator => ','
|
80
|
+
#
|
81
|
+
# # The difference between +tag_names+ and +tags+ is that +tag_names+
|
82
|
+
# # holds an array of String objects, mapped from +tags+, while +tags+
|
83
|
+
# # holds the actual +has_and_belongs_to_many+ collection, and so, is
|
84
|
+
# # composed of +Tag+ objects.
|
85
|
+
# photo.tag_names.size # => 4
|
86
|
+
# photo.tags.size # => 4
|
87
|
+
# # Now you can clear all tags in one call
|
88
|
+
# photo.clear_tags!
|
89
|
+
#
|
90
|
+
# # Find photos with 'wine' OR 'whisky'
|
91
|
+
# Photo.find_tagged_with :any => [ 'wine', 'whisky' ]
|
92
|
+
#
|
93
|
+
# # Finds photos with 'wine' AND 'whisky' using a different separator.
|
94
|
+
# # This is also known as tag combos.
|
95
|
+
# Photo.find_tagged_with(:all => 'wine+whisky', :separator => '+'
|
96
|
+
#
|
97
|
+
# # Gets the top 10 tags for all photos
|
98
|
+
# Photo.tags_count :limit => 10 # => { 'beer' => 68, 'wine' => 37, 'vodka' => '22', ... }
|
99
|
+
#
|
100
|
+
# # Gets the tags count that are greater than 30
|
101
|
+
# Photo.tags_count :count => '> 30' # => { 'beer' => 68, 'wine' => 37 }
|
102
|
+
#
|
103
|
+
# # Replace allows you to find_tagged_with, remove the old tags and add the new ones
|
104
|
+
# Photo.replace_tag("beer whisky","wine vodka")
|
105
|
+
# # Display the photos returned from the tags_count call using 9 different CSS classes
|
106
|
+
# <% Photo.cloud(@photo_tags, %w(cloud1 cloud2 cloud3 cloud4 cloud5 cloud6 cloud7 cloud8 cloud9)) do |tag, cloud_class| %>
|
107
|
+
# <%= link_to(h("<#{tag}>"), tag_photos_url(:name => tag), { :class => cloud_class } ) -%>
|
108
|
+
# <% end %>
|
109
|
+
#
|
110
|
+
# # Display the photos returned from the tags_count call using 5 different font sizes
|
111
|
+
# <% Photo.cloud(@photo_tags, %w(x-small small medium large x-large)) do |tag, font_size| %>
|
112
|
+
# <%= link_to(h("<#{tag}>"), tag_photos_url(:name => tag), { style: => "font-size: #{font_size}" } ) -%>
|
113
|
+
# <% end %>
|
114
|
+
#
|
115
|
+
# You can also use full join models if you want to take advantage of
|
116
|
+
# ActiveRecords callbacks, timestamping, inheritance and other features
|
117
|
+
# on the join records as well. For that, you use the +:join_class_name+ option.
|
118
|
+
# In this case, the join table must have a primary key.
|
119
|
+
#
|
120
|
+
# class Person
|
121
|
+
# # This defines a class +TagPerson+ automagically.
|
122
|
+
# acts_as_taggable :join_class_name => 'TagPerson'
|
123
|
+
# end
|
124
|
+
#
|
125
|
+
# # We can open the +TagPerson+ class and add features to it.
|
126
|
+
# class TagPerson
|
127
|
+
# acts_as_list :scope => :person
|
128
|
+
# belongs_to :created_by, :class_name => 'User', :foreign_key => 'created_by_id'
|
129
|
+
# before_save :do_some_validation
|
130
|
+
# after_save :do_some_stats
|
131
|
+
# end
|
132
|
+
#
|
133
|
+
# # We can do some interesting things with it now
|
134
|
+
# person = Person.new
|
135
|
+
# person.tag "wine beer alcohol", :attributes => { :created_by_id => 1 }
|
136
|
+
# Person.find_tagged_with(:any => 'wine', :condition => "tags_people.created_by_id = 1 AND tags_people.position = 1")
|
137
|
+
module ClassMethods
|
138
|
+
|
139
|
+
# This method defines a +has_and_belongs_to_many+ relationship between
|
140
|
+
# the target class and the tag model class. It also adds several instance methods
|
141
|
+
# for tagging objects of the target class, as well as a class method for searching
|
142
|
+
# objects that contains specific tags.
|
143
|
+
#
|
144
|
+
# The options are:
|
145
|
+
#
|
146
|
+
# The +:collection+ parameter receives a symbol defining
|
147
|
+
# the name of the tag collection method and it defaults to +:tags+.
|
148
|
+
#
|
149
|
+
# The +:tag_class_name+ parameter receives the tag model class name and
|
150
|
+
# it defaults to +'Tag'+.
|
151
|
+
#
|
152
|
+
# The +:tag_class_column_name+ parameter receives the tag model class name attribute and
|
153
|
+
# it defaults to +'name'+.
|
154
|
+
#
|
155
|
+
# The +:normalizer + paramater takes a Procs. This is used to normalize all tags
|
156
|
+
# Simple example
|
157
|
+
# :normalizer => Proc.new {|name| name.capitalize}
|
158
|
+
#
|
159
|
+
# The +:join_class_name+ parameter receives the model class name that joins
|
160
|
+
# the tag model and the taggable model. This automagically defines the join model
|
161
|
+
# class that can be opened and extended.
|
162
|
+
#
|
163
|
+
# The remaining options are passed on to the +has_and_belongs_to_many+ declaration.
|
164
|
+
# The +:join_table+ parameter is defined by default using the standard +has_and_belongs_to_many+ behavior.
|
165
|
+
def acts_as_taggable(options = {})
|
166
|
+
|
167
|
+
options = { :collection => :tags, :tag_class_name => 'Tag', :tag_class_column_name => 'name', :normalizer=> Proc.new {|name| name}}.merge(options)
|
168
|
+
collection_name = options[:collection]
|
169
|
+
tag_model = options[:tag_class_name].constantize
|
170
|
+
tag_model_name = options[:tag_class_column_name]
|
171
|
+
normalizer = options[:normalizer]
|
172
|
+
if tag_model.table_name < self.table_name
|
173
|
+
default_join_table = "#{tag_model.table_name}_#{self.table_name}"
|
174
|
+
else
|
175
|
+
default_join_table = "#{self.table_name}_#{tag_model.table_name}"
|
176
|
+
end
|
177
|
+
options[:join_table] ||= default_join_table
|
178
|
+
options[:foreign_key] ||= self.name.to_s.foreign_key
|
179
|
+
options[:association_foreign_key] ||= tag_model.to_s.foreign_key
|
180
|
+
|
181
|
+
# not using a simple has_and_belongs_to_many but a full model
|
182
|
+
# for joining the tags table and the taggable object table
|
183
|
+
if join_class_name = options[:join_class_name]
|
184
|
+
Object.class_eval "class #{join_class_name} < ActiveRecord::Base; set_table_name '#{options[:join_table]}' end" unless Object.const_defined?(join_class_name)
|
185
|
+
|
186
|
+
join_model = join_class_name.constantize
|
187
|
+
tagged = self
|
188
|
+
join_model.class_eval do
|
189
|
+
belongs_to :tag, :class_name => tag_model.to_s
|
190
|
+
belongs_to :tagged, :class_name => tagged.name.to_s
|
191
|
+
define_method(:normalizer, normalizer)
|
192
|
+
define_method(tag_model_name.to_sym) { self[tag_model_name] ||= normalizer(tag.send(tag_model_name.to_sym)) }
|
193
|
+
end
|
194
|
+
|
195
|
+
|
196
|
+
options[:class_name] ||= join_model.to_s
|
197
|
+
tag_pk, tag_fk = tag_model.primary_key, options[:association_foreign_key]
|
198
|
+
t, tn, jt = tag_model.table_name, tag_model_name, join_model.table_name
|
199
|
+
options[:finder_sql] ||= "SELECT #{jt}.*, #{t}.#{tn} AS #{tn} FROM #{jt}, #{t} WHERE #{jt}.#{tag_fk} = #{t}.#{tag_pk} AND #{jt}.#{options[:foreign_key]} = \#{quoted_id}"
|
200
|
+
else
|
201
|
+
join_model = nil
|
202
|
+
end
|
203
|
+
|
204
|
+
# set some class-wide attributes needed in class and instance methods
|
205
|
+
write_inheritable_attribute(:tag_foreign_key, options[:association_foreign_key])
|
206
|
+
write_inheritable_attribute(:taggable_foreign_key, options[:foreign_key])
|
207
|
+
write_inheritable_attribute(:normalizer, normalizer)
|
208
|
+
write_inheritable_attribute(:tag_collection_name, collection_name)
|
209
|
+
write_inheritable_attribute(:tag_model, tag_model)
|
210
|
+
write_inheritable_attribute(:tag_model_name, tag_model_name)
|
211
|
+
write_inheritable_attribute(:tags_join_model, join_model)
|
212
|
+
write_inheritable_attribute(:tags_join_table, options[:join_table])
|
213
|
+
write_inheritable_attribute(:tag_options, options)
|
214
|
+
|
215
|
+
[ :collection, :tag_class_name, :tag_class_column_name, :join_class_name,:normalizer].each { |key| options.delete(key) } # remove these, we don't need it anymore
|
216
|
+
[ :join_table, :association_foreign_key ].each { |key| options.delete(key) } if join_model # dont need this for has_many
|
217
|
+
|
218
|
+
# now, finally add the proper relationships
|
219
|
+
class_eval do
|
220
|
+
include ActiveRecord::Acts::Taggable::InstanceMethods
|
221
|
+
extend ActiveRecord::Acts::Taggable::SingletonMethods
|
222
|
+
|
223
|
+
class_inheritable_reader :tag_collection_name, :tag_model, :tag_model_name, :tags_join_model,
|
224
|
+
:tags_options, :tags_join_table,
|
225
|
+
:tag_foreign_key, :taggable_foreign_key,:normalizer
|
226
|
+
if join_model
|
227
|
+
has_many collection_name, options
|
228
|
+
else
|
229
|
+
has_and_belongs_to_many collection_name, options
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
module SingletonMethods
|
237
|
+
# This method searches for objects of the taggable class and subclasses that
|
238
|
+
# contains specific tags associated to them. The tags to be searched for can
|
239
|
+
# be passed to the +:any+ or +:all+ options, either as a String or an Array.
|
240
|
+
#
|
241
|
+
# The options are:
|
242
|
+
#
|
243
|
+
# +:any+: searches objects that are related to ANY of the given tags
|
244
|
+
#
|
245
|
+
# +:all+: searcher objects that are related to ALL of the given tags
|
246
|
+
#
|
247
|
+
# +:separator+: a string, regex or Proc object that will be used to split the
|
248
|
+
# tags string passed to +:any+ or +:all+ using a regular +String#split+ method.
|
249
|
+
# If a Proc is passed, the proc should split the string in any way it wants
|
250
|
+
# and return an array of strings.
|
251
|
+
#
|
252
|
+
# +:conditions+: any additional conditions that should be appended to the
|
253
|
+
# WHERE clause of the finder SQL. Just like regular +ActiveRecord::Base#find+ methods.
|
254
|
+
#
|
255
|
+
# +:order+: the same as used in regular +ActiveRecord::Base#find+ methods.
|
256
|
+
#
|
257
|
+
# +:limit+: the same as used in regular +ActiveRecord::Base#find+ methods.
|
258
|
+
def find_tagged_with(options = {})
|
259
|
+
options = { :separator => ' ' }.merge(options)
|
260
|
+
|
261
|
+
tag_names = ActiveRecord::Acts::Taggable.split_tag_names(options[:any] || options[:all], options[:separator], normalizer)
|
262
|
+
raise "No tags were passed to :any or :all options" if tag_names.empty?
|
263
|
+
|
264
|
+
o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
|
265
|
+
sql = "SELECT #{o}.* FROM #{jt}, #{o}, #{t} WHERE #{jt}.#{t_fk} = #{t}.#{t_pk}
|
266
|
+
AND (#{t}.#{tn} = '#{tag_names.join("' OR #{t}.#{tn}='")}')
|
267
|
+
AND #{o}.#{o_pk} = #{jt}.#{o_fk}"
|
268
|
+
sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
|
269
|
+
sql << " GROUP BY #{o}.#{o_pk}"
|
270
|
+
sql << " HAVING COUNT(#{o}.#{o_pk}) = #{tag_names.length}" if options[:all]
|
271
|
+
sql << " ORDER BY #{options[:order]} " if options[:order]
|
272
|
+
add_limit!(sql, options)
|
273
|
+
|
274
|
+
find_by_sql(sql)
|
275
|
+
end
|
276
|
+
#Looks for items with and old_tag and replaces it with all of new_tag
|
277
|
+
# The +old_tag+ ,+new_tag+ parameters can be a +String+, +Array+ or a +Proc+ object.
|
278
|
+
# If it's a +String+, it's split using the +:separator+ specified in
|
279
|
+
# the +options+ hash. If it's an +Array+ it is flattened and compacted.
|
280
|
+
# Duplicate entries will be removed as well. Tag names are also stripped
|
281
|
+
# of trailing and leading whitespace. If a Proc is passed,
|
282
|
+
# the proc should split the string in any way it wants and return an array of strings.
|
283
|
+
#
|
284
|
+
# The +options+ hash has the following parameters:
|
285
|
+
#
|
286
|
+
# +:separator+: a string, regex or Proc object that will be used to split the
|
287
|
+
# tags string passed to +:any+ or +:all+ using a regular +String#split+ method.
|
288
|
+
# If a Proc is passed, the proc should split the string in any way it wants
|
289
|
+
# and return an array of strings.
|
290
|
+
#
|
291
|
+
# +:conditions+: any additional conditions that should be appended to the
|
292
|
+
# WHERE clause of the finder SQL. Just like regular +ActiveRecord::Base#find+ methods.
|
293
|
+
#
|
294
|
+
def replace_tag(old_tag,new_tag,options = {})
|
295
|
+
|
296
|
+
options = { :any => old_tag ,:separator => ' ', :conditions => nil }.merge(options)
|
297
|
+
find_tagged_with(options).each do |item|
|
298
|
+
item.tag_remove(old_tag)
|
299
|
+
item.tag(new_tag, :separator => options[:separator])
|
300
|
+
end
|
301
|
+
end
|
302
|
+
|
303
|
+
# This method counts the number of times the tags have been applied to your objects
|
304
|
+
# and, by default, returns a hash in the form of { 'tag_name' => count, ... }
|
305
|
+
#
|
306
|
+
# The options are:
|
307
|
+
#
|
308
|
+
# +:raw+: If you just want to get the raw output of the SQL statement (Array of Hashes), instead of the regular tags count Hash, set this to +true+.
|
309
|
+
#
|
310
|
+
# +:conditions+: any additional conditions that should be appended to the
|
311
|
+
# WHERE clause of the SQL. Just like in regular +ActiveRecord::Base#find+ methods.
|
312
|
+
#
|
313
|
+
# +:order+: The same as used in +ActiveRecord::Base#find+ methods. By default, this is 'count DESC'.
|
314
|
+
#
|
315
|
+
# +:count+: Adds a HAVING clause to the SQL statement, where you can set conditions for the 'count' column. For example: '> 50'
|
316
|
+
#
|
317
|
+
# +:limit+: the same as used in regular +ActiveRecord::Base#find+ methods.
|
318
|
+
def tags_count(options = {})
|
319
|
+
options = {:order => 'count DESC'}.merge(options)
|
320
|
+
|
321
|
+
o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
|
322
|
+
sql = "SELECT #{t}.#{t_pk} AS id, #{t}.#{tn} AS name, COUNT(*) AS count FROM #{jt}, #{o}, #{t} WHERE #{jt}.#{t_fk} = #{t}.#{t_pk}
|
323
|
+
AND #{jt}.#{o_fk} = #{o}.#{o_pk}"
|
324
|
+
sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
|
325
|
+
sql << " GROUP BY #{t}.#{tn}"
|
326
|
+
sql << " HAVING count #{options[:count]} " if options[:count]
|
327
|
+
sql << " ORDER BY #{options[:order]} " if options[:order]
|
328
|
+
add_limit!(sql, options)
|
329
|
+
result = connection.select_all(sql)
|
330
|
+
count = result.inject({}) { |hsh, row| hsh[row["#{tn}"]] = row['count'].to_i; hsh } unless options[:raw]
|
331
|
+
|
332
|
+
count || result
|
333
|
+
end
|
334
|
+
#This method returns a simple count of the number of distinct objects
|
335
|
+
#Which match the tags provided
|
336
|
+
# by Lon Baker
|
337
|
+
def count_uniq_tagged_with(options = {})
|
338
|
+
options = { :separator => ' ' }.merge(options)
|
339
|
+
|
340
|
+
tag_names = ActiveRecord::Acts::Taggable.split_tag_names(options[:any] || options[:all], options[:separator], normalizer)
|
341
|
+
raise "No tags were passed to :any or :all options" if tag_names.empty?
|
342
|
+
|
343
|
+
o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
|
344
|
+
sql = "SELECT COUNT(DISTINCT #{o}.#{o_pk}) FROM #{jt}, #{o}, #{t} WHERE #{jt}.#{t_fk} = #{t}.#{t_pk}
|
345
|
+
AND (#{t}.#{tn} = '#{tag_names.join("' OR #{t}.#{tn} ='")}')
|
346
|
+
AND #{o}.#{o_pk} = #{jt}.#{o_fk}"
|
347
|
+
sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
|
348
|
+
count_by_sql(sql)
|
349
|
+
end
|
350
|
+
|
351
|
+
# Alias for +tags_count+
|
352
|
+
alias_method :tag_count, :tags_count
|
353
|
+
|
354
|
+
# Finds other records that share the most tags with the record passed
|
355
|
+
# as the +related+ parameter. Useful for constructing 'Related' or
|
356
|
+
# 'See Also' boxes and lists.
|
357
|
+
#
|
358
|
+
# The options are:
|
359
|
+
#
|
360
|
+
# +:limit+: defaults to 5, which means the method will return the top 5 records
|
361
|
+
# that share the greatest number of tags with the passed one.
|
362
|
+
# +:conditions+: any additional conditions that should be appended to the
|
363
|
+
# WHERE clause of the finder SQL. Just like regular +ActiveRecord::Base#find+ methods.
|
364
|
+
def find_related_tagged(related, options = {})
|
365
|
+
related_id = related.is_a?(self) ? related.id : related
|
366
|
+
options = { :limit => 5 }.merge(options)
|
367
|
+
|
368
|
+
o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
|
369
|
+
sql = "SELECT o.*, COUNT(jt2.#{o_fk}) AS count FROM #{o} o, #{jt} jt, #{t} t, #{jt} jt2
|
370
|
+
WHERE jt.#{o_fk}=#{related_id} AND t.#{t_pk} = jt.#{t_fk}
|
371
|
+
AND jt2.#{o_fk} != jt.#{o_fk}
|
372
|
+
AND jt2.#{t_fk}=jt.#{t_fk} AND o.#{o_pk} = jt2.#{o_fk}"
|
373
|
+
sql << " AND #{sanitize_sql(options[:conditions])}" if options[:conditions]
|
374
|
+
sql << " GROUP BY #{o}.#{o_pk}"
|
375
|
+
sql << " ORDER BY count DESC"
|
376
|
+
add_limit!(sql, options)
|
377
|
+
|
378
|
+
find_by_sql(sql)
|
379
|
+
end
|
380
|
+
|
381
|
+
# Finds other tags that are related to the tags passed through the +tags+
|
382
|
+
# parameter, by finding common records that share similar sets of tags.
|
383
|
+
# Useful for constructing 'Related tags' lists.
|
384
|
+
#
|
385
|
+
# The options are:
|
386
|
+
#
|
387
|
+
# +:separator+ => defines the separator (String or Regex) used to split
|
388
|
+
# the tags parameter and defaults to ' ' (space and line breaks).
|
389
|
+
#
|
390
|
+
# +:raw+: If you just want to get the raw output of the SQL statement (Array of Hashes), instead of the regular tags count Hash, set this to +true+.
|
391
|
+
#
|
392
|
+
# +:limit+: the same as used in regular +ActiveRecord::Base#find+ methods.
|
393
|
+
def find_related_tags(tags, options = {})
|
394
|
+
tag_names = ActiveRecord::Acts::Taggable.split_tag_names(tags, options[:separator], normalizer)
|
395
|
+
o, o_pk, o_fk, t, tn, t_pk, t_fk, jt = set_locals_for_sql
|
396
|
+
|
397
|
+
sql = "SELECT jt.#{o_fk} AS o_id FROM #{jt} jt, #{t} t
|
398
|
+
WHERE jt.#{t_fk} = t.#{t_pk}
|
399
|
+
AND (t.#{tn} IN ('#{tag_names.uniq.join("', '")}'))
|
400
|
+
GROUP BY jt.#{o_fk}
|
401
|
+
HAVING COUNT(jt.#{o_fk})=#{tag_names.length}"
|
402
|
+
|
403
|
+
o_ids = connection.select_all(sql).map { |row| row['o_id'] }
|
404
|
+
return options[:raw] ? [] : {} if o_ids.length < 1
|
405
|
+
|
406
|
+
sql = "SELECT t.#{t_pk} AS id, t.#{n} AS #{tn}, COUNT(jt.#{o_fk}) AS count FROM #{jt} jt, #{t} t
|
407
|
+
WHERE jt.#{o_fk} IN (#{o_ids.join(",")})
|
408
|
+
AND t.#{t_pk} = jt.#{t_fk}
|
409
|
+
GROUP BY jt.#{t_fk}
|
410
|
+
ORDER BY count DESC"
|
411
|
+
add_limit!(sql, options)
|
412
|
+
|
413
|
+
result = connection.select_all(sql).delete_if { |row| tag_names.include?(row["#{tn}"]) }
|
414
|
+
count = result.inject({}) { |hsh, row| hsh[row["#{tn}"]] = row['count'].to_i; hsh } unless options[:raw]
|
415
|
+
|
416
|
+
count || result
|
417
|
+
end
|
418
|
+
|
419
|
+
# Takes the result of a tags_count call and an array of categories and
|
420
|
+
# distributes the entries in the tags_count hash evenly across the
|
421
|
+
# categories based on the count value for each tag.
|
422
|
+
#
|
423
|
+
# Typically, this is used to display a 'tag cloud' in your UI.
|
424
|
+
#
|
425
|
+
# The options are:
|
426
|
+
#
|
427
|
+
# +tag_hash+ => The tag hash returned from a tags_count call
|
428
|
+
#
|
429
|
+
# +category_list+ => An array containing the categories to split the tags
|
430
|
+
# into
|
431
|
+
#
|
432
|
+
# +block+ => { |tag, category| }
|
433
|
+
#
|
434
|
+
# The block parameters are:
|
435
|
+
#
|
436
|
+
# +:tag+ => The tag key from the tag_hash
|
437
|
+
#
|
438
|
+
# +:category+ => The category value from the category_list that this tag
|
439
|
+
# is in
|
440
|
+
def cloud(tag_hash, category_list)
|
441
|
+
max, min = 0, 0
|
442
|
+
tag_hash.each_value do |count|
|
443
|
+
max = count if count > max
|
444
|
+
min = count if count < min
|
445
|
+
end
|
446
|
+
|
447
|
+
divisor = ((max - min) / category_list.size) + 1
|
448
|
+
|
449
|
+
tag_hash.each do |tag, count|
|
450
|
+
yield tag, category_list[(count - min) / divisor]
|
451
|
+
end
|
452
|
+
end
|
453
|
+
|
454
|
+
private
|
455
|
+
def set_locals_for_sql
|
456
|
+
[ table_name, primary_key, taggable_foreign_key,
|
457
|
+
tag_model.table_name, tag_model_name, tag_model.primary_key, tag_foreign_key,
|
458
|
+
tags_join_model ? tags_join_model.table_name : tags_join_table ]
|
459
|
+
end
|
460
|
+
|
461
|
+
end
|
462
|
+
|
463
|
+
module InstanceMethods
|
464
|
+
# Handles clearing all associated tags
|
465
|
+
def clear_tags!
|
466
|
+
tag_collection.clear
|
467
|
+
end
|
468
|
+
# This method removes tags from the target object, by parsing the tags parameter
|
469
|
+
# into Tag object instances and removing them from the tag collection of the object if they exist.
|
470
|
+
#
|
471
|
+
# The +tags+ parameter can be a +String+, +Array+ or a +Proc+ object.
|
472
|
+
# If it's a +String+, it's split using the +:separator+ specified in
|
473
|
+
# the +options+ hash. If it's an +Array+ it is flattened and compacted.
|
474
|
+
# Duplicate entries will be removed as well. Tag names are also stripped
|
475
|
+
# of trailing and leading whitespace. If a Proc is passed,
|
476
|
+
# the proc should split the string in any way it wants and return an array of strings.
|
477
|
+
#
|
478
|
+
# The +options+ hash has the following parameters:
|
479
|
+
#
|
480
|
+
# +:separator+ => defines the separator (String or Regex) used to split
|
481
|
+
# the tags parameter and defaults to ' ' (space and line breaks).
|
482
|
+
def tag_remove(tags, options = {})
|
483
|
+
|
484
|
+
options = { :separator => ' '}.merge(options)
|
485
|
+
attributes = options[:attributes] || {}
|
486
|
+
|
487
|
+
# parse the tags parameter
|
488
|
+
tag_names = ActiveRecord::Acts::Taggable.split_tag_names(tags, options[:separator], normalizer)
|
489
|
+
|
490
|
+
# remove the tag names to the collection
|
491
|
+
tag_names.each do |name|
|
492
|
+
tag_record = tag_model.find(:first, :conditions=>["#{tag_model_name} = ?",name]) || tag_model.new(tag_model_name.to_sym => name)
|
493
|
+
if tag_record
|
494
|
+
tag_collection.delete(tag_record)
|
495
|
+
end
|
496
|
+
end
|
497
|
+
end
|
498
|
+
|
499
|
+
# This method applies tags to the target object, by parsing the tags parameter
|
500
|
+
# into Tag object instances and adding them to the tag collection of the object.
|
501
|
+
# If the tag name already exists in the tags table, it just adds a relationship
|
502
|
+
# to the existing tag record. If it doesn't exist, it then creates a new
|
503
|
+
# Tag record for it.
|
504
|
+
#
|
505
|
+
# The +tags+ parameter can be a +String+, +Array+ or a +Proc+ object.
|
506
|
+
# If it's a +String+, it's split using the +:separator+ specified in
|
507
|
+
# the +options+ hash. If it's an +Array+ it is flattened and compacted.
|
508
|
+
# Duplicate entries will be removed as well. Tag names are also stripped
|
509
|
+
# of trailing and leading whitespace. If a Proc is passed,
|
510
|
+
# the proc should split the string in any way it wants and return an array of strings.
|
511
|
+
#
|
512
|
+
# The +options+ hash has the following parameters:
|
513
|
+
#
|
514
|
+
# +:separator+ => defines the separator (String or Regex) used to split
|
515
|
+
# the tags parameter and defaults to ' ' (space and line breaks).
|
516
|
+
#
|
517
|
+
# +:clear+ => defines whether the existing tag collection will be cleared before
|
518
|
+
# applying the new +tags+ passed. Defaults to +false+.
|
519
|
+
def tag(tags, options = {})
|
520
|
+
|
521
|
+
options = { :separator => ' ', :clear => false }.merge(options)
|
522
|
+
attributes = options[:attributes] || {}
|
523
|
+
|
524
|
+
# parse the tags parameter
|
525
|
+
tag_names = ActiveRecord::Acts::Taggable.split_tag_names(tags, options[:separator], normalizer)
|
526
|
+
|
527
|
+
# clear the collection if appropriate
|
528
|
+
self.clear_tags! if options[:clear]
|
529
|
+
|
530
|
+
# append the tag names to the collection
|
531
|
+
tag_names.each do |name|
|
532
|
+
# ensure that tag names don't get duplicated
|
533
|
+
tag_record = tag_model.find(:first, :conditions=>["#{tag_model_name} = ?",name]) || tag_model.new(tag_model_name.to_sym => name)
|
534
|
+
if tags_join_model
|
535
|
+
tag_join_record = tags_join_model.new(attributes)
|
536
|
+
tag_join_record.tag = tag_record
|
537
|
+
tag_join_record.tagged = self
|
538
|
+
tag_collection << tag_join_record unless tagged_with?(name)
|
539
|
+
else
|
540
|
+
tag_collection.push_with_attributes(tag_record, attributes) unless tagged_with?(name)
|
541
|
+
end
|
542
|
+
end
|
543
|
+
|
544
|
+
end
|
545
|
+
|
546
|
+
# Clears the current tags collection and sets the tag names for this object.
|
547
|
+
# Equivalent of calling #tag(..., :clear => true)
|
548
|
+
#
|
549
|
+
# Another way of appending tags to a existing tags collection is by using
|
550
|
+
# the +<<+ or +concat+ method on +tag_names+, which is equivalent of calling
|
551
|
+
# #tag(..., :clear => false).
|
552
|
+
def tag_names=(tags, options = {})
|
553
|
+
tag(tags, options.merge(:clear => true))
|
554
|
+
end
|
555
|
+
|
556
|
+
# Returns an array of strings containing the tags applied to this object.
|
557
|
+
# If +reload+ is +true+, the tags collection is reloaded.
|
558
|
+
def tag_names(reload = false)
|
559
|
+
ary = tag_collection(reload).map { |tag| tag.send(tag_model_name.to_sym)}
|
560
|
+
ary.extend(TagNamesMixin)
|
561
|
+
ary.set_tag_container(self)
|
562
|
+
ary
|
563
|
+
end
|
564
|
+
|
565
|
+
# Checks to see if this object has been tagged with +tag_name+.
|
566
|
+
# If +reload+ is true, reloads the tag collection before doing the check.
|
567
|
+
def tagged_with?(tag_name, reload = false)
|
568
|
+
tag_names(reload).include?(tag_name)
|
569
|
+
end
|
570
|
+
|
571
|
+
# Calls +find_related_tagged+ passing +self+ as the +related+ parameter.
|
572
|
+
def tagged_related(options = {})
|
573
|
+
self.class.find_related_tagged(self.id, options)
|
574
|
+
end
|
575
|
+
|
576
|
+
private
|
577
|
+
def tag_model
|
578
|
+
self.class.tag_model
|
579
|
+
end
|
580
|
+
|
581
|
+
def tag_collection(reload = false)
|
582
|
+
send(self.class.tag_collection_name, reload)
|
583
|
+
end
|
584
|
+
|
585
|
+
def tags_join_model
|
586
|
+
self.class.tags_join_model
|
587
|
+
end
|
588
|
+
|
589
|
+
end
|
590
|
+
|
591
|
+
module TagNamesMixin #:nodoc:
|
592
|
+
|
593
|
+
def set_tag_container(tag_container)
|
594
|
+
@tag_container = tag_container
|
595
|
+
end
|
596
|
+
|
597
|
+
def <<(tags, options = {})
|
598
|
+
@tag_container.tag(tags, options.merge(:clear => false))
|
599
|
+
end
|
600
|
+
|
601
|
+
alias_method :concat, :<<
|
602
|
+
end
|
603
|
+
|
604
|
+
end
|
605
|
+
end
|
606
|
+
end
|
607
|
+
|
608
|
+
ActiveRecord::Base.class_eval do
|
609
|
+
include ActiveRecord::Acts::Taggable
|
610
|
+
end
|