genki-dm-is-taggable 0.1
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/History.txt +1 -0
- data/LICENSE +20 -0
- data/Manifest.txt +25 -0
- data/README.textile +185 -0
- data/Rakefile +52 -0
- data/TODO +2 -0
- data/lib/dm-is-taggable.rb +31 -0
- data/lib/dm-is-taggable/aggregate_patch.rb +47 -0
- data/lib/dm-is-taggable/is/shared.rb +111 -0
- data/lib/dm-is-taggable/is/tag.rb +189 -0
- data/lib/dm-is-taggable/is/taggable.rb +114 -0
- data/lib/dm-is-taggable/is/tagger.rb +80 -0
- data/lib/dm-is-taggable/is/tagging.rb +30 -0
- data/lib/dm-is-taggable/is/version.rb +8 -0
- data/lib/dm-is-taggable/tag.rb +4 -0
- data/lib/dm-is-taggable/tag_list.rb +113 -0
- data/lib/dm-is-taggable/tagging.rb +4 -0
- data/spec/data/article.rb +5 -0
- data/spec/data/bot.rb +6 -0
- data/spec/data/picture.rb +5 -0
- data/spec/data/user.rb +6 -0
- data/spec/integration/taggable_spec.rb +289 -0
- data/spec/integration/tagger_similarity_spec.rb +40 -0
- data/spec/spec.opts +2 -0
- data/spec/spec_helper.rb +36 -0
- metadata +110 -0
@@ -0,0 +1,189 @@
|
|
1
|
+
module DataMapper
|
2
|
+
module Is
|
3
|
+
module Taggable
|
4
|
+
|
5
|
+
def is_tag(options=nil)
|
6
|
+
extend DataMapper::Is::Taggable::SharedClassMethods
|
7
|
+
include DataMapper::Is::Taggable::SharedInstanceMethods
|
8
|
+
extend DataMapper::Is::Taggable::TagClassMethods
|
9
|
+
include DataMapper::Is::Taggable::TagInstanceMethods
|
10
|
+
property :id, DataMapper::Types::Serial
|
11
|
+
property :name, String, :length => 255, :unique => true, :nullable => false
|
12
|
+
has n, :taggings
|
13
|
+
@tagger_classes ||= []
|
14
|
+
@taggable_classes ||= []
|
15
|
+
end
|
16
|
+
|
17
|
+
module TagClassMethods
|
18
|
+
attr_reader :tagger
|
19
|
+
attr_reader :tagger_classes
|
20
|
+
attr_reader :taggable_classes
|
21
|
+
#TODO: check if there is any concurrency problem here
|
22
|
+
# NOT thread safe right now!!
|
23
|
+
# need to make it safer!!
|
24
|
+
def as(tagger_or_tagger_class, &block)
|
25
|
+
self.tagger = tagger_or_tagger_class
|
26
|
+
raise Exception("A block must be provided!") unless block_given?
|
27
|
+
yield
|
28
|
+
self.tagger=nil
|
29
|
+
end
|
30
|
+
|
31
|
+
def tagged_count(options={})
|
32
|
+
tagger, taggable, tag_list, options = extract_options(options)
|
33
|
+
tagger_class, tagger_obj = extract_tagger_class_object(tagger)
|
34
|
+
taggable_class, taggable_obj = extract_taggable_class_object(taggable)
|
35
|
+
|
36
|
+
association = Tagging
|
37
|
+
association = association.by(tagger) if tagger
|
38
|
+
association = association.on(taggable) if taggable
|
39
|
+
|
40
|
+
query = {:unique => true, :fields => [:taggable_type]}
|
41
|
+
query.merge!(Tagging.tag.name => tag_list.to_a) unless tag_list.empty?
|
42
|
+
query.merge!(options)
|
43
|
+
|
44
|
+
association.aggregate(:taggable_id.count, query).inject(0){|count, i| count + i[1]}
|
45
|
+
end
|
46
|
+
|
47
|
+
def find_taggables(options)
|
48
|
+
tagger, taggable, tag_list, options = extract_options(options)
|
49
|
+
tagger_class, tagger_obj = extract_tagger_class_object(tagger)
|
50
|
+
taggable_class, taggable_obj = extract_taggable_class_object(taggable)
|
51
|
+
|
52
|
+
if taggable_class.nil?
|
53
|
+
rv = Tag.taggable_classes.map{|klass| find_taggables(options.merge(:with => tag_list, :on =>klass, :by => tagger) )}.flatten!
|
54
|
+
rv.uniq!
|
55
|
+
return rv
|
56
|
+
end
|
57
|
+
|
58
|
+
query = { taggable_class.tags.tag.name => tag_list.to_a,
|
59
|
+
Tagging.properties[:taggable_type] => taggable_class.to_s,
|
60
|
+
:unique => true
|
61
|
+
}
|
62
|
+
query.merge!(Tagging.properties[:tagger_type] => tagger_class) if tagger_class
|
63
|
+
query.merge!(Tagging.properties[:tagger_id] => tagger_obj.id) if tagger_obj
|
64
|
+
|
65
|
+
unless options[:match] == :any
|
66
|
+
conditions = "SELECT COUNT(DISTINCT(tag_id)) FROM taggings INNER JOIN tags ON taggings.tag_id = tags.id WHERE "
|
67
|
+
counter_conditions = [
|
68
|
+
"taggings.taggable_type = '#{taggable_class.to_s}'",
|
69
|
+
"taggings.taggable_id = #{taggable_class.storage_name}.id",
|
70
|
+
"tags.name IN ?"
|
71
|
+
]
|
72
|
+
counter_conditions << "taggings.tagger_type = '#{tagger_class.to_s}'" if tagger_class
|
73
|
+
counter_conditions << "taggings.tagger_id = #{tagger_obj.id}" if tagger_obj
|
74
|
+
conditions = "(" << conditions << counter_conditions.join(" AND ") << ") = ?"
|
75
|
+
conditions = [conditions, tag_list, tag_list.size]
|
76
|
+
query.merge!(:conditions => conditions)
|
77
|
+
end
|
78
|
+
taggable_class.all(query)
|
79
|
+
end
|
80
|
+
|
81
|
+
def fetch(name)
|
82
|
+
first(:name => name) || create(:name => name)
|
83
|
+
end
|
84
|
+
|
85
|
+
def get(name)
|
86
|
+
first(:name => name)
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
def tagger=(tagger)
|
91
|
+
unless (tagger.respond_to?(:tagger?) && tagger.tagger?) || tagger.nil?
|
92
|
+
raise Exception.new("#{tagger} is not a tagger datamapper resource object!")
|
93
|
+
end
|
94
|
+
@tagger = tagger
|
95
|
+
end
|
96
|
+
end # ClassMethods
|
97
|
+
|
98
|
+
module TagInstanceMethods
|
99
|
+
def <=>(other)
|
100
|
+
self.name <=>(other.name)
|
101
|
+
end
|
102
|
+
def to_s
|
103
|
+
self.name
|
104
|
+
end
|
105
|
+
|
106
|
+
# Returns an array of related tags.
|
107
|
+
# Related tags are all the other tags that are found tagged with the provided tags.
|
108
|
+
def related
|
109
|
+
sql = "SELECT count(*) AS count, tag_id
|
110
|
+
FROM
|
111
|
+
(SELECT DISTINCT t2.tag_id AS tag_id, t2.tagger_id, t2.tagger_type, t2.taggable_id, t2.taggable_type
|
112
|
+
FROM taggings AS t1
|
113
|
+
INNER JOIN taggings AS t2
|
114
|
+
ON ( t2.taggable_id = t1.taggable_id AND t2.taggable_type = t1.taggable_type)
|
115
|
+
WHERE t1.tag_id = #{self.id} AND t2.tag_id != #{self.id})
|
116
|
+
AS a
|
117
|
+
GROUP BY tag_id
|
118
|
+
HAVING count(*) > 1
|
119
|
+
ORDER BY count(*) DESC;"
|
120
|
+
|
121
|
+
# get a hash with tag_id => tag count
|
122
|
+
tags = repository.adapter.query(sql).inject({}){|h, t| h[t[1].to_i] = t[0].to_i; h}
|
123
|
+
|
124
|
+
# get all the tag resources
|
125
|
+
tag_objects = Tag.all(:id => tags.keys)
|
126
|
+
|
127
|
+
# turn it into an array like this [[count, tag], [count, tag] ...]
|
128
|
+
# sorted by count in descending order
|
129
|
+
tag_objects.collect do |t|
|
130
|
+
[tags[t.id], t]
|
131
|
+
end.sort{|a, b| a[0] <=> b[0]}.reverse
|
132
|
+
end
|
133
|
+
|
134
|
+
def popular_by_tags
|
135
|
+
sql= "SELECT tagger_type, tagger_id, COUNT( taggable_id ) AS counter
|
136
|
+
FROM taggings
|
137
|
+
INNER JOIN tags ON taggings.tag_id = tags.id
|
138
|
+
WHERE tags.name = '#{self.name}'
|
139
|
+
GROUP BY tagger_type, tagger_id
|
140
|
+
ORDER BY counter DESC"
|
141
|
+
tagger_columns = repository.adapter.query(sql)
|
142
|
+
tagger_hash = {}
|
143
|
+
counter_hash = {}
|
144
|
+
# group all keys
|
145
|
+
tagger_columns.each do |t|
|
146
|
+
t = t.to_a
|
147
|
+
tagger_hash[t[0]] ||= []
|
148
|
+
tagger_hash[t[0]] << t[1]
|
149
|
+
count = t.pop
|
150
|
+
counter_hash[t] = count
|
151
|
+
end
|
152
|
+
taggers = []
|
153
|
+
# find all taggers
|
154
|
+
tagger_hash.each_pair do |key, value|
|
155
|
+
taggers = taggers + Extlib::Inflection.constantize(key).all(:id => value)
|
156
|
+
end
|
157
|
+
# sort the taggers by count
|
158
|
+
taggers.sort! do |a, b|
|
159
|
+
counter_hash[[a.class.to_s, a.id]] <=>counter_hash[[b.class.to_s, b.id]]
|
160
|
+
end.reverse!
|
161
|
+
|
162
|
+
taggers
|
163
|
+
end
|
164
|
+
|
165
|
+
def tagged_together_count
|
166
|
+
end
|
167
|
+
|
168
|
+
def tagged_count(conditions={})
|
169
|
+
taggable_class, taggable_obj = extract_taggable_class_object(conditions.delete(:on))
|
170
|
+
tagger_class, tagger_obj = extract_tagger_class_object(conditions.delete(:by))
|
171
|
+
|
172
|
+
association = if taggable_class && taggable_class.is_a?(Class) && taggable_class.taggable?
|
173
|
+
taggable_class.all.taggings
|
174
|
+
else
|
175
|
+
Tagging.all
|
176
|
+
end
|
177
|
+
|
178
|
+
association = association.all(:tagger_type => tagger_class.to_s) if tagger_class
|
179
|
+
association = association.all(:tagger_id => tagger_obj.id) if tagger_obj
|
180
|
+
|
181
|
+
query = {:unique => true, :tag_id => self.id, :fields => [:taggable_type]}
|
182
|
+
query.merge!(conditions)
|
183
|
+
|
184
|
+
association.aggregate(:taggable_id.count, query).inject(0){|count, i| count + i[1]}
|
185
|
+
end
|
186
|
+
end # InstanceMethods
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module DataMapper
|
2
|
+
module Is
|
3
|
+
module Taggable
|
4
|
+
|
5
|
+
def is_taggable(options)
|
6
|
+
extend DataMapper::Is::Taggable::SharedClassMethods
|
7
|
+
include DataMapper::Is::Taggable::SharedInstanceMethods
|
8
|
+
extend DataMapper::Is::Taggable::TaggableClassMethods
|
9
|
+
include DataMapper::Is::Taggable::TaggableInstanceMethods
|
10
|
+
|
11
|
+
@tagger_classes = options[:by]
|
12
|
+
Tag.instance_variable_set('@tagger_classes', (Tag.instance_variable_get('@tagger_classes') | @tagger_classes))
|
13
|
+
|
14
|
+
has n, :taggings, :class_name => "Tagging", :child_key => [:taggable_id], :taggable_type => self.to_s
|
15
|
+
# real ugly syntax... wait until dm make better conditional has n :through association better, than update code
|
16
|
+
has n, :tags, :through => :taggings, :class_name => "Tag",
|
17
|
+
:child_key => [:taggable_id],
|
18
|
+
Tagging.properties[:taggable_type] => self.to_s,
|
19
|
+
:unique => true
|
20
|
+
|
21
|
+
tagger_classes.each do |class_name|
|
22
|
+
has n, "taggings_by_#{class_name.snake_case.plural}".intern,
|
23
|
+
:class_name => "Tagging", :child_key => [:taggable_id],
|
24
|
+
:taggable_type => self.to_s, :tagger_type => class_name
|
25
|
+
|
26
|
+
has n, "tags_by_#{class_name.snake_case.plural}".intern, :through => :taggings,
|
27
|
+
:class_name => "Tag", :child_key => [:taggable_id],
|
28
|
+
Tagging.properties[:taggable_type] => self.to_s,
|
29
|
+
Tagging.properties[:tagger_type] => class_name,
|
30
|
+
:remote_name => "tag"
|
31
|
+
|
32
|
+
class_eval <<-TAGS
|
33
|
+
def count_tags_by_#{class_name.snake_case.plural}(conditions={})
|
34
|
+
conditions = {:unique => true}.merge(conditions)
|
35
|
+
tags_by_#{class_name.snake_case.plural}.count(:id, conditions)
|
36
|
+
end
|
37
|
+
TAGS
|
38
|
+
end
|
39
|
+
|
40
|
+
after :save, :tag_now_with_taglist
|
41
|
+
before :destroy, :destroy_all_taggings
|
42
|
+
end
|
43
|
+
|
44
|
+
module TaggableClassMethods
|
45
|
+
def tagger_classes
|
46
|
+
@tagger_classes.map{|klass| klass.to_s.singular.camel_case}
|
47
|
+
end
|
48
|
+
|
49
|
+
def tagger?;false;end
|
50
|
+
def taggable?;true;end
|
51
|
+
def taggable_class;self;end
|
52
|
+
|
53
|
+
def find(options)
|
54
|
+
tagger, taggable, tags, options = extract_options(options)
|
55
|
+
options.merge!(:on => self, :by =>tagger, :with => tags)
|
56
|
+
Tag.find_taggables(options)
|
57
|
+
end
|
58
|
+
end # ClassMethods
|
59
|
+
|
60
|
+
module TaggableInstanceMethods
|
61
|
+
def tagger?;false;end
|
62
|
+
def taggable?;true;end
|
63
|
+
def taggable_class;self.class;end
|
64
|
+
def taggable;self;end
|
65
|
+
|
66
|
+
def taglist
|
67
|
+
@taglist || TagList.new(self.tags.collect{|t| t.name}).to_s
|
68
|
+
end
|
69
|
+
|
70
|
+
def taglist=(ataglist)
|
71
|
+
# if the object is a new record, we will tag the object after save
|
72
|
+
# if the object isn't a new record, we tag the object now
|
73
|
+
@taglist = ataglist
|
74
|
+
tag_now_with_taglist unless new_record?
|
75
|
+
true
|
76
|
+
end
|
77
|
+
|
78
|
+
def can_tag_by?(tagger)
|
79
|
+
if tagger.is_a?(Class)
|
80
|
+
return self.class.tagger_classes.include?(tagger)
|
81
|
+
end
|
82
|
+
return self.class.tagger_classes.include?(tagger.class)
|
83
|
+
end
|
84
|
+
|
85
|
+
def tag(options)
|
86
|
+
tagger, taggable, tags = extract_options(options)
|
87
|
+
taggable = self
|
88
|
+
# TODO: verify tagger and taggable
|
89
|
+
self.class.create_taggings(tagger, taggable, tags)
|
90
|
+
end
|
91
|
+
|
92
|
+
def count_tags(conditions={})
|
93
|
+
conditions = {:unique => true}.merge(conditions)
|
94
|
+
tags.count(:id, conditions)
|
95
|
+
end
|
96
|
+
|
97
|
+
protected
|
98
|
+
def tag_now_with_taglist
|
99
|
+
return unless @taglist
|
100
|
+
# Destroy all the taggings
|
101
|
+
destroy_all_taggings unless self.new_record?
|
102
|
+
|
103
|
+
# Tag with the tag list
|
104
|
+
self.tag(:with => TagList.from(@taglist))
|
105
|
+
@taglist = nil
|
106
|
+
end
|
107
|
+
|
108
|
+
def destroy_all_taggings
|
109
|
+
self.taggings.destroy!
|
110
|
+
end
|
111
|
+
end # InstanceMethods
|
112
|
+
end # Taggable
|
113
|
+
end # Is
|
114
|
+
end # DataMapper
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module DataMapper
|
2
|
+
module Is
|
3
|
+
module Taggable
|
4
|
+
|
5
|
+
def is_tagger(options)
|
6
|
+
extend DataMapper::Is::Taggable::SharedClassMethods
|
7
|
+
include DataMapper::Is::Taggable::SharedInstanceMethods
|
8
|
+
extend DataMapper::Is::Taggable::TaggerClassMethods
|
9
|
+
include DataMapper::Is::Taggable::TaggerInstanceMethods
|
10
|
+
|
11
|
+
@taggable_classes = options[:on]
|
12
|
+
Tag.instance_variable_set('@taggable_classes', (Tag.instance_variable_get('@taggable_classes') | @taggable_classes))
|
13
|
+
|
14
|
+
has n, :taggings, :class_name => "Tagging", :child_key => [:tagger_id], :tagger_type => self.to_s
|
15
|
+
# real ugly syntax... wait until dm make better conditional has n :through association better, than update code
|
16
|
+
has n, :tags, :through => :taggings, :class_name => "Tag", :child_key => [:tagger_id], Tagging.properties[:tagger_type] => self.to_s
|
17
|
+
end
|
18
|
+
|
19
|
+
module TaggerClassMethods
|
20
|
+
def taggable_classes
|
21
|
+
@taggable_classes.map{|klass| Extlib::Inflection.constantize(klass.to_s.singular.camel_case)}
|
22
|
+
end
|
23
|
+
|
24
|
+
def tagger?;true;end
|
25
|
+
def taggable?;false;end
|
26
|
+
def tagger_class;self;end
|
27
|
+
|
28
|
+
end # ClassMethods
|
29
|
+
|
30
|
+
module TaggerInstanceMethods
|
31
|
+
def tagger?;true;end
|
32
|
+
def taggable?;false;end
|
33
|
+
def tagger_class;self.class;end
|
34
|
+
def tagger;self;end
|
35
|
+
|
36
|
+
def can_tag_on?(taggable)
|
37
|
+
if taggable.is_a?(Class)
|
38
|
+
return self.class.taggable_classes.include?(taggable)
|
39
|
+
end
|
40
|
+
return self.class.taggable_classes.include?(taggable.class)
|
41
|
+
end
|
42
|
+
|
43
|
+
def find_taggables(options)
|
44
|
+
tagger, taggable, tag_list, options = extract_options(options)
|
45
|
+
tagger = self
|
46
|
+
Tag.find_taggables(options.merge(:with => tag_list, :on =>taggable, :by => tagger))
|
47
|
+
end
|
48
|
+
|
49
|
+
def tag(options)
|
50
|
+
tagger, taggable, tags = extract_options(options)
|
51
|
+
tagger = self
|
52
|
+
self.class.create_taggings(tagger, taggable, tags)
|
53
|
+
end
|
54
|
+
|
55
|
+
def all_similar_by_tags
|
56
|
+
# this magic query is basically looking at the taggings table,
|
57
|
+
# searching for taggings having a corresponding tagging from the current taggable object with the same tag_id
|
58
|
+
# grouping by tagger_id and counting how many are they
|
59
|
+
sql = "SELECT count(*), tagger_id
|
60
|
+
FROM (
|
61
|
+
SELECT DISTINCT t1.tag_id, t1.tagger_id AS tagger_id
|
62
|
+
FROM taggings AS t1
|
63
|
+
INNER JOIN taggings AS t2
|
64
|
+
ON ( t2.tag_id = t1.tag_id AND t2.tagger_type = '#{self.class.to_s}' AND t2.tagger_id = #{self.id})
|
65
|
+
WHERE t1.tagger_type = '#{self.class.to_s}' AND t1.tagger_id != #{self.id}
|
66
|
+
)
|
67
|
+
AS a
|
68
|
+
GROUP BY tagger_id;"
|
69
|
+
|
70
|
+
|
71
|
+
taggers = repository.adapter.query(sql)
|
72
|
+
|
73
|
+
# for each other taggers, we have the number of tags in common and the tagger object
|
74
|
+
# we order the result by similarity descending
|
75
|
+
taggers.collect{|tagger| [tagger[0], self.class.get(tagger[1])]}.sort {|x,y| y[0]<=>x[0]}
|
76
|
+
end
|
77
|
+
end # InstanceMethods
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module DataMapper
|
2
|
+
module Is
|
3
|
+
module Taggable
|
4
|
+
|
5
|
+
def is_tagging(options=nil)
|
6
|
+
extend DataMapper::Is::Taggable::SharedClassMethods
|
7
|
+
include DataMapper::Is::Taggable::SharedInstanceMethods
|
8
|
+
extend DataMapper::Is::Taggable::TaggingClassMethods
|
9
|
+
include DataMapper::Is::Taggable::TaggingInstanceMethods
|
10
|
+
|
11
|
+
property :id, DataMapper::Types::Serial
|
12
|
+
property :tag_id, Integer, :index => :true, :nullable => false
|
13
|
+
|
14
|
+
property :tagger_id, Integer, :index => :tagger, :nullable => true
|
15
|
+
property :tagger_type, String, :index => :tagger, :nullable => true, :size => 255
|
16
|
+
|
17
|
+
property :taggable_id, Integer, :index => :taggable, :nullable => false
|
18
|
+
property :taggable_type, String, :index => :taggable, :nullable => false, :size => 255
|
19
|
+
|
20
|
+
belongs_to :tag
|
21
|
+
end
|
22
|
+
|
23
|
+
module TaggingClassMethods
|
24
|
+
end
|
25
|
+
|
26
|
+
module TaggingInstanceMethods
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# Backporting the ends_with? method from Rails' ActiveSupport
|
2
|
+
class String
|
3
|
+
def ends_with?(suffix)
|
4
|
+
suffix = suffix.to_s
|
5
|
+
self[-suffix.length, suffix.length] == suffix
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
class TagList < Array
|
10
|
+
cattr_accessor :delimiter
|
11
|
+
self.delimiter = ','
|
12
|
+
|
13
|
+
def initialize(*args)
|
14
|
+
add(*args)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Add tags to the tag_list. Duplicate or blank tags will be ignored.
|
18
|
+
#
|
19
|
+
# tag_list.add("Fun", "Happy")
|
20
|
+
#
|
21
|
+
# Use the <tt>:parse</tt> option to add an unparsed tag string.
|
22
|
+
#
|
23
|
+
# tag_list.add("Fun, Happy", :parse => true)
|
24
|
+
def add(*names)
|
25
|
+
extract_and_apply_options!(names)
|
26
|
+
concat(names)
|
27
|
+
clean!
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
# Remove specific tags from the tag_list.
|
32
|
+
#
|
33
|
+
# tag_list.remove("Sad", "Lonely")
|
34
|
+
#
|
35
|
+
# Like #add, the <tt>:parse</tt> option can be used to remove multiple tags in a string.
|
36
|
+
#
|
37
|
+
# tag_list.remove("Sad, Lonely", :parse => true)
|
38
|
+
def remove(*names)
|
39
|
+
extract_and_apply_options!(names)
|
40
|
+
delete_if { |name| names.include?(name) }
|
41
|
+
self
|
42
|
+
end
|
43
|
+
|
44
|
+
# Toggle the presence of the given tags.
|
45
|
+
# If a tag is already in the list it is removed, otherwise it is added.
|
46
|
+
def toggle(*names)
|
47
|
+
extract_and_apply_options!(names)
|
48
|
+
|
49
|
+
names.each do |name|
|
50
|
+
include?(name) ? delete(name) : push(name)
|
51
|
+
end
|
52
|
+
|
53
|
+
clean!
|
54
|
+
self
|
55
|
+
end
|
56
|
+
|
57
|
+
# Transform the tag_list into a tag string suitable for edting in a form.
|
58
|
+
# The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
|
59
|
+
#
|
60
|
+
# tag_list = TagList.new("Round", "Square,Cube")
|
61
|
+
# tag_list.to_s # 'Round, "Square,Cube"'
|
62
|
+
def to_s
|
63
|
+
clean!
|
64
|
+
|
65
|
+
map do |name|
|
66
|
+
name.include?(delimiter) ? "\"#{name}\"" : name
|
67
|
+
end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ")
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
# Remove whitespace, duplicates, and blanks.
|
72
|
+
def clean!
|
73
|
+
reject!{|t| t.blank?}
|
74
|
+
map!{|t| t.strip}
|
75
|
+
uniq!
|
76
|
+
end
|
77
|
+
|
78
|
+
def extract_and_apply_options!(args)
|
79
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
80
|
+
|
81
|
+
if options[:parse]
|
82
|
+
args.map! { |a| self.class.from(a) }
|
83
|
+
end
|
84
|
+
|
85
|
+
args.flatten!
|
86
|
+
end
|
87
|
+
|
88
|
+
class << self
|
89
|
+
# Returns a new TagList using the given tag string.
|
90
|
+
#
|
91
|
+
# tag_list = TagList.from("One , Two, Three")
|
92
|
+
# tag_list # ["One", "Two", "Three"]
|
93
|
+
def from(source)
|
94
|
+
tag_list = TagList.new
|
95
|
+
case source
|
96
|
+
when Array
|
97
|
+
source.each{|a| tag_list.add(TagList.from(a))}
|
98
|
+
else
|
99
|
+
string = source.to_s.dup
|
100
|
+
# Parse the quoted tags
|
101
|
+
[
|
102
|
+
/\s*#{delimiter}\s*(['"])(.*?)\1\s*/,
|
103
|
+
/^\s*(['"])(.*?)\1\s*#{delimiter}?/
|
104
|
+
].each do |re|
|
105
|
+
string.gsub!(re) { tag_list << $2; "" }
|
106
|
+
end
|
107
|
+
tag_list.add(string.split(delimiter))
|
108
|
+
end
|
109
|
+
|
110
|
+
tag_list
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|