tagtical 1.0.6
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 +25 -0
- data/Gemfile +20 -0
- data/Gemfile.lock +25 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +306 -0
- data/Rakefile +59 -0
- data/VERSION +1 -0
- data/generators/tagtical_migration/tagtical_migration_generator.rb +7 -0
- data/generators/tagtical_migration/templates/migration.rb +34 -0
- data/lib/generators/tagtical/migration/migration_generator.rb +32 -0
- data/lib/generators/tagtical/migration/templates/active_record/migration.rb +35 -0
- data/lib/tagtical/acts_as_tagger.rb +69 -0
- data/lib/tagtical/compatibility/Gemfile +8 -0
- data/lib/tagtical/compatibility/active_record_backports.rb +21 -0
- data/lib/tagtical/tag.rb +314 -0
- data/lib/tagtical/tag_list.rb +133 -0
- data/lib/tagtical/taggable/cache.rb +53 -0
- data/lib/tagtical/taggable/collection.rb +141 -0
- data/lib/tagtical/taggable/core.rb +317 -0
- data/lib/tagtical/taggable/ownership.rb +110 -0
- data/lib/tagtical/taggable/related.rb +60 -0
- data/lib/tagtical/taggable.rb +51 -0
- data/lib/tagtical/tagging.rb +42 -0
- data/lib/tagtical/tags_helper.rb +17 -0
- data/lib/tagtical.rb +47 -0
- data/rails/init.rb +1 -0
- data/spec/bm.rb +53 -0
- data/spec/database.yml +17 -0
- data/spec/database.yml.sample +17 -0
- data/spec/models.rb +60 -0
- data/spec/schema.rb +46 -0
- data/spec/spec_helper.rb +159 -0
- data/spec/tagtical/acts_as_tagger_spec.rb +94 -0
- data/spec/tagtical/tag_list_spec.rb +102 -0
- data/spec/tagtical/tag_spec.rb +301 -0
- data/spec/tagtical/taggable_spec.rb +460 -0
- data/spec/tagtical/tagger_spec.rb +76 -0
- data/spec/tagtical/tagging_spec.rb +52 -0
- data/spec/tagtical/tags_helper_spec.rb +28 -0
- data/spec/tagtical/tagtical_spec.rb +340 -0
- metadata +132 -0
@@ -0,0 +1,317 @@
|
|
1
|
+
module Tagtical::Taggable
|
2
|
+
module Core
|
3
|
+
def self.included(base)
|
4
|
+
base.class_eval do
|
5
|
+
include Tagtical::Taggable::Core::InstanceMethods
|
6
|
+
extend Tagtical::Taggable::Core::ClassMethods
|
7
|
+
|
8
|
+
after_save :save_tags
|
9
|
+
|
10
|
+
initialize_tagtical_core
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module ClassMethods
|
15
|
+
def initialize_tagtical_core
|
16
|
+
has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag, :class_name => "Tagtical::Tagging"
|
17
|
+
has_many :tags, :through => :taggings, :source => :tag, :class_name => "Tagtical::Tag",
|
18
|
+
:select => "#{Tagtical::Tag.table_name}.*, #{Tagtical::Tagging.table_name}.relevance" # include the relevance on the tags
|
19
|
+
|
20
|
+
tag_types.each do |tag_type| # has_many :tags gets created here
|
21
|
+
|
22
|
+
# Aryk: Instead of defined multiple associations for the different types of tags, I decided
|
23
|
+
# to define the main associations (tags and taggings) and use AR scope's to build off of them.
|
24
|
+
# This keeps your reflections cleaner.
|
25
|
+
|
26
|
+
# In the case of the base tag type, it will just use the :tags association defined above.
|
27
|
+
Tagtical::Tag.scope(tag_type.scope_name, Proc.new { |options| tag_type.scoping(options || {}) }) unless Tagtical::Tag.respond_to?(tag_type.scope_name)
|
28
|
+
|
29
|
+
# If the tag_type is base? (type=="tag"), then we add additional functionality to the AR
|
30
|
+
# has_many :tags.
|
31
|
+
#
|
32
|
+
# taggable_model.tags(:only => :children)
|
33
|
+
# taggable_model.tags <-- still works like normal has_many
|
34
|
+
# taggable_model.tags(true, :only => :current) <-- reloads the tags association and appends scope for only current type.
|
35
|
+
if tag_type.has_many_name==:tags
|
36
|
+
define_method("tags_with_finder_type_options") do |*args|
|
37
|
+
options = args.pop if args.last.is_a?(Hash)
|
38
|
+
scope = tags_without_finder_type_options(*args)
|
39
|
+
options ? scope.tags(options) : scope
|
40
|
+
end
|
41
|
+
alias_method_chain :tags, :finder_type_options
|
42
|
+
else
|
43
|
+
delegate tag_type.has_many_name, :to => :tags
|
44
|
+
end
|
45
|
+
|
46
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
47
|
+
def self.with_#{tag_type.pluralize}(*tags)
|
48
|
+
options = tags.extract_options!
|
49
|
+
tagged_with(tags.flatten, options.merge(:on => :#{tag_type}))
|
50
|
+
end
|
51
|
+
|
52
|
+
def #{tag_type}_list
|
53
|
+
tag_list_on('#{tag_type}')
|
54
|
+
end
|
55
|
+
|
56
|
+
def #{tag_type}_list=(new_tags)
|
57
|
+
set_tag_list_on('#{tag_type}', new_tags)
|
58
|
+
end
|
59
|
+
|
60
|
+
def all_#{tag_type.pluralize}_list
|
61
|
+
all_tags_list_on('#{tag_type}')
|
62
|
+
end
|
63
|
+
RUBY
|
64
|
+
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def acts_as_taggable(*args)
|
69
|
+
super(*args)
|
70
|
+
initialize_tagtical_core
|
71
|
+
end
|
72
|
+
|
73
|
+
# all column names are necessary for PostgreSQL group clause
|
74
|
+
def grouped_column_names_for(object)
|
75
|
+
object.column_names.map { |column| "#{object.table_name}.#{column}" }.join(", ")
|
76
|
+
end
|
77
|
+
|
78
|
+
##
|
79
|
+
# Return a scope of objects that are tagged with the specified tags.
|
80
|
+
#
|
81
|
+
# @param tags The tags that we want to query for
|
82
|
+
# @param [Hash] options A hash of options to alter you query:
|
83
|
+
# * <tt>:exclude</tt> - if set to true, return objects that are *NOT* tagged with the specified tags
|
84
|
+
# * <tt>:any</tt> - if set to true, return objects that are tagged with *ANY* of the specified tags
|
85
|
+
# * <tt>:match_all</tt> - if set to true, return objects that are *ONLY* tagged with the specified tags
|
86
|
+
#
|
87
|
+
# Example:
|
88
|
+
# User.tagged_with("awesome", "cool") # Users that are tagged with awesome and cool
|
89
|
+
# User.tagged_with("awesome", "cool", :exclude => true) # Users that are not tagged with awesome or cool
|
90
|
+
# User.tagged_with("awesome", "cool", :any => true) # Users that are tagged with awesome or cool
|
91
|
+
# User.tagged_with("awesome", "cool", :match_all => true) # Users that are tagged with just awesome and cool
|
92
|
+
# User.tagged_with("awesome", "cool", :on => :skills) # Users that are tagged with just awesome and cool on skills
|
93
|
+
def tagged_with(tags, options = {})
|
94
|
+
tag_list = Tagtical::TagList.from(tags)
|
95
|
+
return scoped(:conditions => "1 = 0") if tag_list.empty? && !options[:exclude]
|
96
|
+
|
97
|
+
joins = []
|
98
|
+
conditions = []
|
99
|
+
|
100
|
+
options[:on] ||= Tagtical::Tag::Type::BASE
|
101
|
+
tag_type = Tagtical::Tag::Type.find(options.delete(:on))
|
102
|
+
finder_type_condition_options = options.extract!(:only)
|
103
|
+
|
104
|
+
tag_table, tagging_table = Tagtical::Tag.table_name, Tagtical::Tagging.table_name
|
105
|
+
|
106
|
+
if options.delete(:exclude)
|
107
|
+
conditions << "#{table_name}.#{primary_key} NOT IN (" +
|
108
|
+
"SELECT #{tagging_table}.taggable_id " +
|
109
|
+
"FROM #{tagging_table} " +
|
110
|
+
"JOIN #{tag_table} ON #{tagging_table}.tag_id = #{tag_table}.id AND #{tag_list.to_sql_conditions(:operator => "LIKE")} " +
|
111
|
+
"WHERE #{tagging_table}.taggable_type = #{quote_value(base_class.name)})"
|
112
|
+
|
113
|
+
elsif options.delete(:any)
|
114
|
+
conditions << tag_list.to_sql_conditions(:operator => "LIKE")
|
115
|
+
|
116
|
+
tagging_join = " JOIN #{tagging_table}" +
|
117
|
+
" ON #{tagging_table}.taggable_id = #{table_name}.#{primary_key}" +
|
118
|
+
" AND #{tagging_table}.taggable_type = #{quote_value(base_class.name)}" +
|
119
|
+
" JOIN #{tag_table}" +
|
120
|
+
" ON #{tagging_table}.tag_id = #{tag_table}.id"
|
121
|
+
|
122
|
+
|
123
|
+
if (finder_condition = tag_type.finder_type_condition(finder_type_condition_options.merge(:sql => true))).present?
|
124
|
+
conditions << finder_condition
|
125
|
+
end
|
126
|
+
|
127
|
+
select_clause = "DISTINCT #{table_name}.*" unless !tag_type.base? and tag_types.one?
|
128
|
+
|
129
|
+
joins << tagging_join
|
130
|
+
|
131
|
+
else
|
132
|
+
tags_by_value = tag_type.scoping(finder_type_condition_options).where_any_like(tag_list).group_by(&:value)
|
133
|
+
return scoped(:conditions => "1 = 0") unless tags_by_value.length == tag_list.length # allow for chaining
|
134
|
+
|
135
|
+
# Create only one join per tag value.
|
136
|
+
tags_by_value.each do |value, tags|
|
137
|
+
tags.each do |tag|
|
138
|
+
safe_tag = value.gsub(/[^a-zA-Z0-9]/, '')
|
139
|
+
prefix = "#{safe_tag}_#{rand(1024)}"
|
140
|
+
|
141
|
+
taggings_alias = "#{undecorated_table_name}_taggings_#{prefix}"
|
142
|
+
|
143
|
+
tagging_join = "JOIN #{tagging_table} #{taggings_alias}" +
|
144
|
+
" ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
|
145
|
+
" AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}" +
|
146
|
+
" AND #{sanitize_sql("#{taggings_alias}.tag_id" => tags.map(&:id))}"
|
147
|
+
|
148
|
+
joins << tagging_join
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
taggings_alias, tags_alias = "#{undecorated_table_name}_taggings_group", "#{undecorated_table_name}_tags_group"
|
154
|
+
|
155
|
+
if options.delete(:match_all)
|
156
|
+
joins << "LEFT OUTER JOIN #{tagging_table} #{taggings_alias}" +
|
157
|
+
" ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key}" +
|
158
|
+
" AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)}"
|
159
|
+
|
160
|
+
|
161
|
+
group_columns = Tagtical::Tag.using_postgresql? ? grouped_column_names_for(self) : "#{table_name}.#{primary_key}"
|
162
|
+
group = "#{group_columns} HAVING COUNT(#{taggings_alias}.taggable_id) = #{tag_list.size}"
|
163
|
+
end
|
164
|
+
|
165
|
+
scoped(:select => select_clause,
|
166
|
+
:joins => joins.join(" "),
|
167
|
+
:group => group,
|
168
|
+
:conditions => conditions.join(" AND "),
|
169
|
+
:order => options[:order],
|
170
|
+
:readonly => false)
|
171
|
+
end
|
172
|
+
|
173
|
+
def is_taggable?
|
174
|
+
true
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
module InstanceMethods
|
179
|
+
# all column names are necessary for PostgreSQL group clause
|
180
|
+
def grouped_column_names_for(object)
|
181
|
+
self.class.grouped_column_names_for(object)
|
182
|
+
end
|
183
|
+
|
184
|
+
def is_taggable?
|
185
|
+
self.class.is_taggable?
|
186
|
+
end
|
187
|
+
|
188
|
+
def cached_tag_list_on(context)
|
189
|
+
self[tag_type(context).tag_list_name(:cached)]
|
190
|
+
end
|
191
|
+
|
192
|
+
def tag_list_cache_set_on?(context)
|
193
|
+
variable_name = tag_type(context).tag_list_ivar
|
194
|
+
!instance_variable_get(variable_name).nil?
|
195
|
+
end
|
196
|
+
|
197
|
+
def tag_list_cache_on(context)
|
198
|
+
variable_name = tag_type(context).tag_list_ivar
|
199
|
+
instance_variable_get(variable_name) || instance_variable_set(variable_name, Tagtical::TagList.new(tags_on(context).map(&:value)))
|
200
|
+
end
|
201
|
+
|
202
|
+
def tag_list_on(context)
|
203
|
+
tag_list_cache_on(context)
|
204
|
+
end
|
205
|
+
|
206
|
+
def tag_types
|
207
|
+
@tag_types ||= self.class.tag_types.dup
|
208
|
+
end
|
209
|
+
|
210
|
+
def all_tags_list_on(context)
|
211
|
+
variable_name = tag_type(context).tag_list_ivar(:all)
|
212
|
+
return instance_variable_get(variable_name) if instance_variable_get(variable_name)
|
213
|
+
|
214
|
+
instance_variable_set(variable_name, Tagtical::TagList.new(all_tags_on(context).map(&:value)).freeze)
|
215
|
+
end
|
216
|
+
|
217
|
+
##
|
218
|
+
# Returns all tags of a given context
|
219
|
+
def all_tags_on(context, options={})
|
220
|
+
scope = tag_scope(context, options)
|
221
|
+
if Tagtical::Tag.using_postgresql?
|
222
|
+
group_columns = grouped_column_names_for(Tagtical::Tag)
|
223
|
+
scope = scope.order("max(#{Tagtical::Tagging.table_name}.created_at)").group(group_columns)
|
224
|
+
else
|
225
|
+
scope = scope.group("#{Tagtical::Tag.table_name}.#{Tagtical::Tag.primary_key}")
|
226
|
+
end
|
227
|
+
scope.all
|
228
|
+
end
|
229
|
+
|
230
|
+
##
|
231
|
+
# Returns all tags that aren't owned.
|
232
|
+
def tags_on(context, options={})
|
233
|
+
tag_scope(context, options).where("#{Tagtical::Tagging.table_name}.tagger_id IS NULL").all
|
234
|
+
end
|
235
|
+
|
236
|
+
def set_tag_list_on(context, new_list)
|
237
|
+
variable_name = tag_type(context).tag_list_ivar
|
238
|
+
instance_variable_set(variable_name, Tagtical::TagList.from(new_list))
|
239
|
+
end
|
240
|
+
|
241
|
+
def reload(*args)
|
242
|
+
tag_types.each do |tag_type|
|
243
|
+
instance_variable_set(tag_type.tag_list_ivar, nil)
|
244
|
+
instance_variable_set(tag_type.tag_list_ivar(:all), nil)
|
245
|
+
end
|
246
|
+
|
247
|
+
super(*args)
|
248
|
+
end
|
249
|
+
|
250
|
+
def save_tags
|
251
|
+
# Do the classes from top to bottom. We want the list from "tag" to run before "sub_tag" runs.
|
252
|
+
# Otherwise, we will end up removing taggings from "sub_tag" since they aren't on "tag'.
|
253
|
+
tag_types.sort_by(&:active_record_sti_level).each do |tag_type|
|
254
|
+
next unless tag_list_cache_set_on?(tag_type)
|
255
|
+
|
256
|
+
tag_list = tag_list_cache_on(tag_type).uniq
|
257
|
+
|
258
|
+
# Find existing tags or create non-existing tags:
|
259
|
+
tag_value_lookup = tag_type.scoping { find_or_create_tags(tag_list) }
|
260
|
+
tags = tag_value_lookup.keys
|
261
|
+
|
262
|
+
current_tags = tags_on(tag_type, :only => [:current, :parents, :children])
|
263
|
+
old_tags = current_tags - tags
|
264
|
+
new_tags = tags - current_tags
|
265
|
+
|
266
|
+
unowned_taggings = taggings.where(:tagger_id => nil)
|
267
|
+
|
268
|
+
# If relevances are specified on current tags, make sure to update those
|
269
|
+
tags_requiring_relevance_update = tag_value_lookup.map { |tag, value| tag if !value.relevance.nil? }.compact & current_tags
|
270
|
+
if tags_requiring_relevance_update.present? && (update_taggings = unowned_taggings.find_all_by_tag_id(tags_requiring_relevance_update)).present?
|
271
|
+
update_taggings.each { |tagging| tagging.update_attribute(:relevance, tag_value_lookup[tagging.tag].relevance) }
|
272
|
+
end
|
273
|
+
|
274
|
+
# Find and remove old taggings:
|
275
|
+
if old_tags.present? && (old_taggings = unowned_taggings.find_all_by_tag_id(old_tags)).present?
|
276
|
+
old_taggings.reject! do |tagging|
|
277
|
+
if tagging.tag.class > tag_type.klass! # parent of current tag type/class, make sure not to remove these taggings.
|
278
|
+
update_tagging_with_inherited_tag!(tagging, new_tags, tag_value_lookup)
|
279
|
+
true
|
280
|
+
end
|
281
|
+
end
|
282
|
+
Tagtical::Tagging.destroy_all :id => old_taggings.map(&:id) # Destroy old taggings:
|
283
|
+
end
|
284
|
+
|
285
|
+
new_tags.each do |tag|
|
286
|
+
taggings.create!(:tag_id => tag.id, :taggable => self, :relevance => tag_value_lookup[tag].relevance) # Create new taggings:
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
true
|
291
|
+
end
|
292
|
+
|
293
|
+
private
|
294
|
+
|
295
|
+
def tag_scope(input, options={})
|
296
|
+
tags.where(tag_type(input).finder_type_condition(options))
|
297
|
+
end
|
298
|
+
|
299
|
+
# Returns the tag type for the given context and adds any new types tag_types array on this instance.
|
300
|
+
def tag_type(input)
|
301
|
+
(@tag_type ||= {})[input] ||= Tagtical::Tag::Type[input].tap do |tag_type|
|
302
|
+
tag_types << tag_type unless tag_types.include?(tag_type)
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
# Lets say tag class A inherits from B and B has a tag with value "foo". If we tag A with value "foo",
|
307
|
+
# we want B to have only one instance of "foo" and that tag should be an instance of A (a subclass of B).
|
308
|
+
def update_tagging_with_inherited_tag!(tagging, tags, tag_value_lookup)
|
309
|
+
if tags.present? && (tag = Tagtical::Tag.send(:detect_comparable, tags, tagging.tag.value))
|
310
|
+
tagging.update_attributes!(:tag => tag, :relevance => tag_value_lookup[tag].relevance)
|
311
|
+
tags.delete(tag)
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
end
|
316
|
+
end
|
317
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
module Tagtical::Taggable
|
2
|
+
module Ownership
|
3
|
+
def self.included(base)
|
4
|
+
base.send :include, Tagtical::Taggable::Ownership::InstanceMethods
|
5
|
+
base.extend Tagtical::Taggable::Ownership::ClassMethods
|
6
|
+
|
7
|
+
base.after_save :save_owned_tags
|
8
|
+
|
9
|
+
base.initialize_acts_as_taggable_ownership
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
def acts_as_taggable(*args)
|
14
|
+
initialize_acts_as_taggable_ownership
|
15
|
+
super(*args)
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize_acts_as_taggable_ownership
|
19
|
+
tag_types.each do |tag_type|
|
20
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
21
|
+
def #{tag_type.pluralize}_from(owner)
|
22
|
+
owner_tag_list_on(owner, '#{tag_type}')
|
23
|
+
end
|
24
|
+
RUBY
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
module InstanceMethods
|
30
|
+
def owner_tags_on(owner, context, options={})
|
31
|
+
scope = tag_scope(context, options)
|
32
|
+
if owner
|
33
|
+
scope = scope.where([%{#{Tagtical::Tagging.table_name}.tagger_id = ?}, owner.id])
|
34
|
+
scope = scope.where([%{#{Tagtical::Tagging.table_name}.tagger_type = ?}, owner.class.to_s]) if Tagtical.config.polymorphic_tagger?
|
35
|
+
end
|
36
|
+
scope.all
|
37
|
+
end
|
38
|
+
|
39
|
+
def cached_owned_tag_list_on(context)
|
40
|
+
variable_name = tag_type(context).tag_list_ivar(:owned)
|
41
|
+
cache = instance_variable_get(variable_name) || instance_variable_set(variable_name, {})
|
42
|
+
end
|
43
|
+
|
44
|
+
def owner_tag_list_on(owner, context)
|
45
|
+
cache = cached_owned_tag_list_on(context)
|
46
|
+
cache.delete_if { |key, value| key.id == owner.id && key.class == owner.class }
|
47
|
+
|
48
|
+
cache[owner] ||= Tagtical::TagList.new(*owner_tags_on(owner, context).map(&:value))
|
49
|
+
end
|
50
|
+
|
51
|
+
def set_owner_tag_list_on(owner, context, new_list)
|
52
|
+
cache = cached_owned_tag_list_on(context)
|
53
|
+
cache.delete_if { |key, value| key.id == owner.id && key.class == owner.class }
|
54
|
+
|
55
|
+
cache[owner] = Tagtical::TagList.from(new_list)
|
56
|
+
end
|
57
|
+
|
58
|
+
def reload(*args)
|
59
|
+
tag_types.each do |tag_type|
|
60
|
+
instance_variable_set(tag_type.tag_list_ivar(:owned), nil)
|
61
|
+
end
|
62
|
+
|
63
|
+
super(*args)
|
64
|
+
end
|
65
|
+
|
66
|
+
def save_owned_tags
|
67
|
+
# Do the classes from top to bottom. We want the list from "tag" to run before "sub_tag" runs.
|
68
|
+
# Otherwise, we will end up removing taggings from "sub_tag" since they aren't on "tag'.
|
69
|
+
tag_types.sort_by(&:active_record_sti_level).each do |tag_type|
|
70
|
+
cached_owned_tag_list_on(tag_type).each do |owner, tag_list|
|
71
|
+
# Find existing tags or create non-existing tags:
|
72
|
+
tag_value_lookup = tag_type.scoping { find_or_create_tags(tag_list) }
|
73
|
+
tags = tag_value_lookup.keys
|
74
|
+
|
75
|
+
owned_tags = owner_tags_on(owner, tag_type, :only => [:current, :parents, :children])
|
76
|
+
old_tags = owned_tags - tags
|
77
|
+
new_tags = tags - owned_tags
|
78
|
+
|
79
|
+
# Find and remove old taggings:
|
80
|
+
if old_tags.present? && (old_taggings = owner_taggings(owner).find_all_by_tag_id(old_tags)).present?
|
81
|
+
old_taggings.reject! do |tagging|
|
82
|
+
if tagging.tag.class > tag_type.klass! # parent of current tag type/class, make sure not to remove these taggings.
|
83
|
+
update_tagging_with_inherited_tag!(tagging, new_tags, tag_value_lookup)
|
84
|
+
true
|
85
|
+
end
|
86
|
+
end
|
87
|
+
Tagtical::Tagging.destroy_all :id => old_taggings.map(&:id) # Destroy old taggings:
|
88
|
+
end
|
89
|
+
|
90
|
+
# Create new taggings:
|
91
|
+
new_tags.each do |tag|
|
92
|
+
taggings.create!(:tagger => owner, :relevance => tag_value_lookup[tag].relevance, :tag_id => tag.id)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
true
|
98
|
+
end
|
99
|
+
|
100
|
+
# Find all taggings that belong to the taggable (self), are owned by the owner,
|
101
|
+
# have the correct context, and are removed from the list.
|
102
|
+
def owner_taggings(owner)
|
103
|
+
relation = taggings.where(:tagger_id => owner.id)
|
104
|
+
relation = relation.where(:tagger_type => owner.class.to_s) if Tagtical.config.polymorphic_tagger?
|
105
|
+
relation
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module Tagtical::Taggable
|
2
|
+
module Related
|
3
|
+
def self.included(base)
|
4
|
+
base.send :include, Tagtical::Taggable::Related::InstanceMethods
|
5
|
+
base.extend Tagtical::Taggable::Related::ClassMethods
|
6
|
+
base.initialize_tagtical_related
|
7
|
+
end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
def initialize_tagtical_related
|
11
|
+
tag_types.each do |tag_type|
|
12
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
13
|
+
def find_related_#{tag_type.pluralize}(options = {})
|
14
|
+
related_tags_for('#{tag_type}', self.class, options)
|
15
|
+
end
|
16
|
+
alias_method :find_related_on_#{tag_type.pluralize}, :find_related_#{tag_type.pluralize}
|
17
|
+
|
18
|
+
def find_related_#{tag_type.pluralize}_for(klass, options = {})
|
19
|
+
related_tags_for('#{tag_type}', klass, options)
|
20
|
+
end
|
21
|
+
|
22
|
+
def find_matching_contexts(search_context, result_context, options = {})
|
23
|
+
matching_contexts_for(search_context.to_s, result_context.to_s, self.class, options)
|
24
|
+
end
|
25
|
+
|
26
|
+
def find_matching_contexts_for(klass, search_context, result_context, options = {})
|
27
|
+
matching_contexts_for(search_context.to_s, result_context.to_s, klass, options)
|
28
|
+
end
|
29
|
+
RUBY
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def acts_as_taggable(*args)
|
34
|
+
super(*args)
|
35
|
+
initialize_tagtical_related
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
module InstanceMethods
|
40
|
+
def matching_contexts_for(search_context, result_context, klass, options = {})
|
41
|
+
related_tags_for(search_context, klass, options.update(:result_context => result_context))
|
42
|
+
end
|
43
|
+
|
44
|
+
def related_tags_for(context, klass, options = {})
|
45
|
+
tags_to_find = tags_on(context).collect { |t| t.value }
|
46
|
+
exclude_self = "#{klass.table_name}.id != #{id} AND" if self.class == klass
|
47
|
+
group_columns = Tagtical::Tag.using_postgresql? ? grouped_column_names_for(klass) : "#{klass.table_name}.#{klass.primary_key}"
|
48
|
+
|
49
|
+
conditions = ["#{exclude_self} #{klass.table_name}.id = #{Tagtical::Tagging.table_name}.taggable_id AND #{Tagtical::Tagging.table_name}.taggable_type = '#{klass.to_s}' AND #{Tagtical::Tagging.table_name}.tag_id = #{Tagtical::Tag.table_name}.id AND #{Tagtical::Tag.table_name}.value IN (?)", tags_to_find]
|
50
|
+
conditions[0] << tag_type(options.delete(:result_context)).finder_type_condition(:sql => :append).to_s if options[:result_context]
|
51
|
+
|
52
|
+
klass.scoped({ :select => "#{klass.table_name}.*, COUNT(#{Tagtical::Tag.table_name}.id) AS count",
|
53
|
+
:from => "#{klass.table_name}, #{Tagtical::Tag.table_name}, #{Tagtical::Tagging.table_name}",
|
54
|
+
:conditions => conditions,
|
55
|
+
:group => group_columns,
|
56
|
+
:order => "count DESC" }.update(options))
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Tagtical
|
2
|
+
module Taggable
|
3
|
+
def taggable?
|
4
|
+
false
|
5
|
+
end
|
6
|
+
|
7
|
+
##
|
8
|
+
# Make a model taggable on specified contexts.
|
9
|
+
#
|
10
|
+
# @param [Array] tag_types An array of taggable contexts. These must have an associated subclass under Tag.
|
11
|
+
#
|
12
|
+
# Example:
|
13
|
+
# module Tag
|
14
|
+
# class Language < Tagtical::Tag
|
15
|
+
# end
|
16
|
+
# class Skill < Tagtical::Tag
|
17
|
+
# end
|
18
|
+
# end
|
19
|
+
# class User < ActiveRecord::Base
|
20
|
+
# acts_as_taggable :languages, :skills
|
21
|
+
# end
|
22
|
+
def acts_as_taggable(*tag_types)
|
23
|
+
tag_types.flatten!
|
24
|
+
tag_types << Tagtical::Tag::Type::BASE # always include the base type.
|
25
|
+
tag_types = Tagtical::Tag::Type[tag_types.uniq.compact]
|
26
|
+
|
27
|
+
if taggable?
|
28
|
+
write_inheritable_attribute(:tag_types, (self.tag_types + tag_types).uniq)
|
29
|
+
else
|
30
|
+
write_inheritable_attribute(:tag_types, tag_types)
|
31
|
+
class_inheritable_reader(:tag_types)
|
32
|
+
|
33
|
+
class_eval do
|
34
|
+
has_many :taggings, :as => :taggable, :dependent => :destroy, :include => :tag, :class_name => "Tagtical::Tagging"
|
35
|
+
has_many :tags, :through => :taggings, :class_name => "Tagtical::Tag"
|
36
|
+
|
37
|
+
def self.taggable?
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
include Tagtical::Taggable::Core
|
42
|
+
include Tagtical::Taggable::Collection
|
43
|
+
include Tagtical::Taggable::Cache
|
44
|
+
include Tagtical::Taggable::Ownership
|
45
|
+
include Tagtical::Taggable::Related
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Tagtical
|
2
|
+
class Tagging < ::ActiveRecord::Base #:nodoc:
|
3
|
+
include Tagtical::ActiveRecord::Backports if ::ActiveRecord::VERSION::MAJOR < 3
|
4
|
+
|
5
|
+
attr_accessible :tag,
|
6
|
+
:tag_id,
|
7
|
+
:taggable,
|
8
|
+
:taggable_type,
|
9
|
+
:taggable_id,
|
10
|
+
:tagger,
|
11
|
+
:tagger_id,
|
12
|
+
:relevance
|
13
|
+
|
14
|
+
belongs_to :tag, :class_name => 'Tagtical::Tag'
|
15
|
+
belongs_to :taggable, :polymorphic => true
|
16
|
+
|
17
|
+
validates_presence_of :tag_id
|
18
|
+
validates_uniqueness_of :tag_id, :scope => [:taggable_type, :taggable_id, :tagger_id]
|
19
|
+
|
20
|
+
if Tagtical.config.polymorphic_tagger?
|
21
|
+
attr_accessible :tagger_type
|
22
|
+
belongs_to :tagger, :polymorphic => true
|
23
|
+
else
|
24
|
+
belongs_to :tagger, case Tagtical.config.tagger
|
25
|
+
when Hash then Tagtical.config.tagger
|
26
|
+
when true then {:class_name => "User"} # default to using User class.
|
27
|
+
when String then {:class_name => Tagtical.config.tagger}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
before_create { |record| record.relevance ||= default_relevance }
|
33
|
+
|
34
|
+
class_attribute :default_relevance, :instance_writer => false
|
35
|
+
self.default_relevance = 1
|
36
|
+
|
37
|
+
def <=>(tagging)
|
38
|
+
relevance <=> tagging.relevance
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Tagtical
|
2
|
+
module TagsHelper
|
3
|
+
# See the README for an example using tag_cloud.
|
4
|
+
def tag_cloud(tags, classes)
|
5
|
+
tags = tags.all if tags.respond_to?(:all)
|
6
|
+
|
7
|
+
return [] if tags.empty?
|
8
|
+
|
9
|
+
max_count = tags.sort_by(&:count).last.count.to_f
|
10
|
+
|
11
|
+
tags.each do |tag|
|
12
|
+
index = ((tag.count / max_count) * (classes.size - 1)).round
|
13
|
+
yield tag, classes[index]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/tagtical.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
require "active_record"
|
2
|
+
require "action_view"
|
3
|
+
require "active_support/hash_with_indifferent_access"
|
4
|
+
|
5
|
+
module Tagtical
|
6
|
+
|
7
|
+
# Place a tagtical.yml file in the config directory to control settings
|
8
|
+
mattr_accessor :config
|
9
|
+
self.config = ActiveSupport::InheritableOptions.new(ActiveSupport::HashWithIndifferentAccess.new.tap do |hash|
|
10
|
+
require 'yaml'
|
11
|
+
path = Rails.root.join("config", "tagtical.yml") rescue ""
|
12
|
+
hash.update(YAML.load_file(path)) if File.exists?(path)
|
13
|
+
# If tagger association options were not provided, then use the polymorphic_tagger association.
|
14
|
+
hash.reverse_merge!(
|
15
|
+
:polymorphic_tagger? => !hash[:tagger]
|
16
|
+
)
|
17
|
+
end)
|
18
|
+
|
19
|
+
end
|
20
|
+
|
21
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
22
|
+
|
23
|
+
require "tagtical/compatibility/active_record_backports" if ActiveRecord::VERSION::MAJOR < 3
|
24
|
+
|
25
|
+
require "tagtical/taggable"
|
26
|
+
require "tagtical/taggable/core"
|
27
|
+
require "tagtical/taggable/collection"
|
28
|
+
require "tagtical/taggable/cache"
|
29
|
+
require "tagtical/taggable/ownership"
|
30
|
+
require "tagtical/taggable/related"
|
31
|
+
|
32
|
+
require "tagtical/acts_as_tagger"
|
33
|
+
require "tagtical/tag"
|
34
|
+
require "tagtical/tag_list"
|
35
|
+
require "tagtical/tags_helper"
|
36
|
+
require "tagtical/tagging"
|
37
|
+
|
38
|
+
$LOAD_PATH.shift
|
39
|
+
|
40
|
+
if defined?(ActiveRecord::Base)
|
41
|
+
ActiveRecord::Base.extend Tagtical::Taggable
|
42
|
+
ActiveRecord::Base.send :include, Tagtical::Tagger
|
43
|
+
end
|
44
|
+
|
45
|
+
if defined?(ActionView::Base)
|
46
|
+
ActionView::Base.send :include, Tagtical::TagsHelper
|
47
|
+
end
|
data/rails/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'tagtical'
|