acts_as_20ggable 1.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.
- checksums.yaml +15 -0
- data/.gitignore +1 -0
- data/CHANGELOG +6 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +61 -0
- data/README +76 -0
- data/Rakefile +24 -0
- data/acts_as_20ggable.gemspec +26 -0
- data/generators/acts_as_20ggable_migration/acts_as_20ggable_migration_generator.rb +11 -0
- data/generators/acts_as_20ggable_migration/templates/migration.rb +45 -0
- data/lib/acts_as_20ggable.rb +7 -0
- data/lib/acts_as_taggable.rb +201 -0
- data/lib/tag.rb +146 -0
- data/lib/tag_counts_extension.rb +3 -0
- data/lib/tag_hierarchy_builder.rb +201 -0
- data/lib/tag_list.rb +108 -0
- data/lib/tagging.rb +10 -0
- data/lib/tags_helper.rb +13 -0
- data/test/fixtures/magazine.rb +3 -0
- data/test/fixtures/magazines.yml +7 -0
- data/test/fixtures/photo.rb +8 -0
- data/test/fixtures/photos.yml +24 -0
- data/test/fixtures/post.rb +7 -0
- data/test/fixtures/posts.yml +34 -0
- data/test/fixtures/schema.rb +73 -0
- data/test/fixtures/special_post.rb +2 -0
- data/test/fixtures/subscription.rb +4 -0
- data/test/fixtures/subscriptions.yml +3 -0
- data/test/fixtures/taggings.yml +162 -0
- data/test/fixtures/tags.yml +75 -0
- data/test/fixtures/tags_hierarchy.yml +31 -0
- data/test/fixtures/tags_synonyms.yml +16 -0
- data/test/fixtures/tags_transitive_hierarchy.yml +96 -0
- data/test/fixtures/user.rb +9 -0
- data/test/fixtures/users.yml +7 -0
- data/test/fixtures/video.rb +3 -0
- data/test/fixtures/videos.yml +9 -0
- data/test/lib/acts_as_taggable_test.rb +359 -0
- data/test/lib/tag_hierarchy_builder_test.rb +109 -0
- data/test/lib/tag_list_test.rb +120 -0
- data/test/lib/tag_test.rb +45 -0
- data/test/lib/tagging_test.rb +14 -0
- data/test/lib/tags_helper_test.rb +28 -0
- data/test/lib/tags_hierarchy_test.rb +12 -0
- data/test/support/activerecord_test_connector.rb +129 -0
- data/test/support/custom_asserts.rb +35 -0
- data/test/support/database.yml +10 -0
- data/test/test_helper.rb +20 -0
- metadata +183 -0
data/lib/tag.rb
ADDED
@@ -0,0 +1,146 @@
|
|
1
|
+
class Tag < ActiveRecord::Base
|
2
|
+
class HierarchyCycle < StandardError; end
|
3
|
+
|
4
|
+
SYMBOL = /[^#=\/]/.freeze
|
5
|
+
|
6
|
+
attr_accessor :mark
|
7
|
+
attr_accessible :mark, :name
|
8
|
+
|
9
|
+
has_many :taggings, :dependent => :destroy
|
10
|
+
|
11
|
+
validates_presence_of :name
|
12
|
+
validates_uniqueness_of :name
|
13
|
+
validates_format_of :name, :with => /\A#{SYMBOL}*\Z/
|
14
|
+
validates_format_of :name, :with => /\A\S\Z|\A\S.*\S\Z/
|
15
|
+
|
16
|
+
cattr_accessor :destroy_unused
|
17
|
+
self.destroy_unused = false
|
18
|
+
|
19
|
+
has_and_belongs_to_many :children, :class_name => 'Tag', :foreign_key => 'tag_id',
|
20
|
+
:association_foreign_key => 'child_id',
|
21
|
+
:join_table => 'tags_hierarchy'
|
22
|
+
|
23
|
+
has_and_belongs_to_many :parents, :class_name => 'Tag', :foreign_key => 'child_id',
|
24
|
+
:association_foreign_key => 'tag_id',
|
25
|
+
:join_table => 'tags_hierarchy'
|
26
|
+
|
27
|
+
has_and_belongs_to_many :transitive_children, :class_name => 'Tag', :foreign_key => 'tag_id',
|
28
|
+
:association_foreign_key => 'child_id',
|
29
|
+
:join_table => 'tags_transitive_hierarchy'
|
30
|
+
|
31
|
+
has_and_belongs_to_many :synonyms, :class_name => 'Tag', :foreign_key => 'tag_id',
|
32
|
+
:association_foreign_key => 'synonym_id',
|
33
|
+
:join_table => 'tags_synonyms'
|
34
|
+
|
35
|
+
scope :with_joined_hierarchy_and_synonyms, -> {
|
36
|
+
joins("LEFT OUTER JOIN tags_hierarchy AS tags_hierarchy_parent ON tags_hierarchy_parent.tag_id = #{Tag.table_name}.id "+
|
37
|
+
"LEFT OUTER JOIN tags_hierarchy AS tags_hierarchy_child ON tags_hierarchy_child.child_id = #{Tag.table_name}.id "+
|
38
|
+
"LEFT OUTER JOIN tags_synonyms AS tags_synonyms_left ON tags_synonyms_left.tag_id = #{Tag.table_name}.id " +
|
39
|
+
"LEFT OUTER JOIN tags_synonyms AS tags_synonyms_right ON tags_synonyms_right.synonym_id = #{Tag.table_name}.id ")
|
40
|
+
}
|
41
|
+
|
42
|
+
scope :with_joined_hierarchy, -> {
|
43
|
+
joins("LEFT OUTER JOIN tags_hierarchy AS tags_hierarchy_parent ON tags_hierarchy_parent.tag_id = #{Tag.table_name}.id "+
|
44
|
+
"LEFT OUTER JOIN tags_hierarchy AS tags_hierarchy_child ON tags_hierarchy_child.child_id = #{Tag.table_name}.id")
|
45
|
+
}
|
46
|
+
|
47
|
+
|
48
|
+
# Scopes for "With joined hierarchy"
|
49
|
+
scope :without_children, -> { where('tags_hierarchy_parent.child_id IS NULL') }
|
50
|
+
scope :without_parents, -> { where('tags_hierarchy_child.tag_id IS NULL') }
|
51
|
+
scope :with_parents, -> { where('tags_hierarchy_child.tag_id IS NOT NULL') }
|
52
|
+
scope :without_synonyms, -> { where('tags_synonyms_left.synonym_id IS NULL AND tags_synonyms_right.tag_id IS NULL') }
|
53
|
+
|
54
|
+
|
55
|
+
# LIKE is used for cross-database case-insensitivity
|
56
|
+
def self.find_or_create_with_like_by_name(name)
|
57
|
+
find_with_like_by_name(name) || create(:name => name)
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.find_or_create_with_like_by_name!(name)
|
61
|
+
find_with_like_by_name(name) || create!(:name => name)
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.find_with_like_by_name(name)
|
65
|
+
where("name LIKE ?", name).first
|
66
|
+
end
|
67
|
+
|
68
|
+
def ==(object)
|
69
|
+
super || (object.is_a?(Tag) && name == object.name)
|
70
|
+
end
|
71
|
+
|
72
|
+
def to_s
|
73
|
+
name
|
74
|
+
end
|
75
|
+
|
76
|
+
def to_param
|
77
|
+
name
|
78
|
+
end
|
79
|
+
|
80
|
+
def count
|
81
|
+
read_attribute(:count).to_i
|
82
|
+
end
|
83
|
+
|
84
|
+
def marked?
|
85
|
+
read_attribute(:mark).to_i == 1
|
86
|
+
end
|
87
|
+
|
88
|
+
def check_mark!(condition)
|
89
|
+
self.mark = taggings.find(:first, :conditions => condition) ? 1 : 0
|
90
|
+
end
|
91
|
+
|
92
|
+
class << self
|
93
|
+
# Calculate the tag counts for all tags.
|
94
|
+
# :start_at - Restrict the tags to those created after a certain time
|
95
|
+
# :end_at - Restrict the tags to those created before a certain time
|
96
|
+
# :conditions - A piece of SQL conditions to add to the query
|
97
|
+
# :limit - The maximum number of tags to return
|
98
|
+
# :order - A piece of SQL to order by. Eg 'count desc' or 'taggings.created_at desc'
|
99
|
+
# :at_least - Exclude tags with a frequency less than the given value
|
100
|
+
# :at_most - Exclude tags with a frequency greater than the given value
|
101
|
+
# :mark_condition - Set 'mark' attribute on tags conforms this condition
|
102
|
+
# primarliy used with per-user taggings like ['taggings.create_by_id = ?', user.id]
|
103
|
+
|
104
|
+
def counts(options = {})
|
105
|
+
find(:all, options_for_counts(options))
|
106
|
+
end
|
107
|
+
|
108
|
+
def options_for_counts(options = {})
|
109
|
+
options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :joins, :mark_condition
|
110
|
+
options = options.dup
|
111
|
+
|
112
|
+
start_at = sanitize_sql(["#{Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
|
113
|
+
end_at = sanitize_sql(["#{Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
|
114
|
+
|
115
|
+
conditions = [
|
116
|
+
options.delete(:conditions),
|
117
|
+
start_at,
|
118
|
+
end_at
|
119
|
+
].compact
|
120
|
+
|
121
|
+
conditions = conditions.any? ? conditions.join(' AND ') : nil
|
122
|
+
|
123
|
+
mark_condition = options.delete(:mark_condition)
|
124
|
+
mark_condition = sanitize_sql(mark_condition) if mark_condition
|
125
|
+
mark_select = "GROUP_CONCAT(DISTINCT IF((#{sanitize_sql(mark_condition)}), 1, NULL)) as mark" if mark_condition
|
126
|
+
base_select = "#{Tag.table_name}.id, #{Tag.table_name}.name, COUNT(*) AS count"
|
127
|
+
|
128
|
+
select = [ base_select, mark_select ].compact.join(', ')
|
129
|
+
|
130
|
+
joins = ["INNER JOIN #{Tagging.table_name} ON #{Tag.table_name}.id = #{Tagging.table_name}.tag_id"]
|
131
|
+
joins << options.delete(:joins) if options[:joins]
|
132
|
+
|
133
|
+
at_least = sanitize_sql(['COUNT(*) >= ?', options.delete(:at_least)]) if options[:at_least]
|
134
|
+
at_most = sanitize_sql(['COUNT(*) <= ?', options.delete(:at_most)]) if options[:at_most]
|
135
|
+
having = [at_least, at_most].compact.join(' AND ')
|
136
|
+
group_by = "#{Tag.table_name}.id, #{Tag.table_name}.name HAVING COUNT(*) > 0"
|
137
|
+
group_by << " AND #{having}" unless having.blank?
|
138
|
+
|
139
|
+
{ :select => select,
|
140
|
+
:joins => joins.join(" "),
|
141
|
+
:conditions => conditions,
|
142
|
+
:group => group_by
|
143
|
+
}.update(options)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,201 @@
|
|
1
|
+
class TagHierarchyBuilder
|
2
|
+
class WrongSpecificationSyntax < StandardError; end
|
3
|
+
|
4
|
+
def self.rebuild_transitive_closure
|
5
|
+
Tag.transaction do
|
6
|
+
Tag.connection.execute('DELETE from tags_transitive_hierarchy')
|
7
|
+
|
8
|
+
# (0) — тесты!
|
9
|
+
# OPTIMIZE
|
10
|
+
tags = Tag.includes(:synonyms, :children, :transitive_children)
|
11
|
+
transitive_children = { }
|
12
|
+
|
13
|
+
tags.each do |tag|
|
14
|
+
transitive_children[tag] = []
|
15
|
+
tag.children.each do |tag_child|
|
16
|
+
transitive_children[tag] << tag_child
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
visited_tags = []
|
21
|
+
root = {} ; comp = [] ; stack = []
|
22
|
+
tags.each do |outer_tag|
|
23
|
+
next if visited_tags.include?(outer_tag)
|
24
|
+
reclambda do |this, tag|
|
25
|
+
visited_tags << tag
|
26
|
+
root[tag] = tag; stack.push(tag)
|
27
|
+
tag.children.each do |child|
|
28
|
+
this.call(child) unless visited_tags.include?(child)
|
29
|
+
if !comp.include?(child)
|
30
|
+
root[tag] = visited_tags.index(tag) < visited_tags.index(child) ? tag : child
|
31
|
+
end
|
32
|
+
transitive_children[tag] += transitive_children[child]
|
33
|
+
end
|
34
|
+
if root[tag] == tag
|
35
|
+
loop do
|
36
|
+
w = stack.pop
|
37
|
+
comp << w
|
38
|
+
transitive_children[w] = transitive_children[tag] # Pointer assignment!
|
39
|
+
break if w == tag
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end.call(outer_tag)
|
43
|
+
end
|
44
|
+
|
45
|
+
tags.each do |tag|
|
46
|
+
tag.synonyms.each do |synonym|
|
47
|
+
transitive_children[tag] << synonym unless transitive_children[tag].include?(synonym)
|
48
|
+
transitive_children[synonym] << tag unless transitive_children[synonym].include?(tag)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
tags.each do |tag|
|
53
|
+
tag.transitive_children = transitive_children[tag].uniq
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.rebuild_hierarchy(specification)
|
59
|
+
# TODO save old hierarchy somewhere.
|
60
|
+
|
61
|
+
Tag.transaction do
|
62
|
+
Tag.connection.execute('DELETE from tags_hierarchy')
|
63
|
+
Tag.connection.execute('DELETE from tags_synonyms')
|
64
|
+
|
65
|
+
specification.each do |line|
|
66
|
+
next if line.blank?
|
67
|
+
next if line =~ /^\s*#.*/ # If line is a comment
|
68
|
+
next if line =~ /^\s*#{Tag::SYMBOL}+\s*$/ # If line is a single tag
|
69
|
+
begin
|
70
|
+
if line =~ /^\s*#{Tag::SYMBOL}+\s*(=\s*#{Tag::SYMBOL}+\s*)+$/
|
71
|
+
instantiate_synonyms(line)
|
72
|
+
next
|
73
|
+
end
|
74
|
+
|
75
|
+
if line =~ /^\s*#{Tag::SYMBOL}+\s*(\/\s*#{Tag::SYMBOL}+\s*)+$/
|
76
|
+
instantiate_hierarchy(line)
|
77
|
+
next
|
78
|
+
end
|
79
|
+
|
80
|
+
raise WrongSpecificationSyntax.new("Line #{line}")
|
81
|
+
rescue ActiveRecord::RecordInvalid => _
|
82
|
+
raise WrongSpecificationSyntax.new("Line #{line}")
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
hierarchy_acyclic? or raise Tag::HierarchyCycle
|
87
|
+
rebuild_transitive_closure
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Input should be validated
|
92
|
+
def self.instantiate_synonyms(line)
|
93
|
+
# (2) TODO validate synonyms repetition? Like Cat = Kitty and Kitty = Cat
|
94
|
+
# То есть ни один из «синонимов» не может быть в «стволе»
|
95
|
+
# (3) FIXME а ведь можно намутить цикл при помощи сочетания синонимов и иерархии.
|
96
|
+
# Ни один из синонимов не может участвовать в «иерархии»
|
97
|
+
# То есть нам нужна flat иерархия, flat ствол и flat синонимы
|
98
|
+
# Причём хранить с номерами строк и выдавать ошибки «в такой-то строке»
|
99
|
+
# (4) TODO Мы хотим гламурные сообщения о циклах. Для этого опять-таки нужно можно воспользоваться тем flatten.
|
100
|
+
# Итого тесты. Сообщения об ошибках:
|
101
|
+
# "Левый синтаксис в строке 5"
|
102
|
+
# "Синоним AAA из строки 15 участвует в иерархии в строках …"
|
103
|
+
# "Синоним BBB из строки 15 повторяется в строке …"
|
104
|
+
syns = line.split('=').map(&:strip)
|
105
|
+
|
106
|
+
b = Tag.find_or_create_with_like_by_name!(syns.shift)
|
107
|
+
syns.each do |syn|
|
108
|
+
b.synonyms << Tag.find_or_create_with_like_by_name!(syn)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def self.instantiate_hierarchy(line)
|
113
|
+
line = line.split('/').map(&:strip)
|
114
|
+
|
115
|
+
line.each_cons(2) do |(p, c)|
|
116
|
+
p = Tag.find_or_create_with_like_by_name!(p)
|
117
|
+
c = Tag.find_or_create_with_like_by_name!(c)
|
118
|
+
|
119
|
+
raise Tag::HierarchyCycle.new if c.parents.include?(p) || c == p
|
120
|
+
|
121
|
+
c.parents << p
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def self.hierarchy_acyclic?
|
126
|
+
# OPTIMIZE
|
127
|
+
tags = Tag.includes(:children)
|
128
|
+
|
129
|
+
tags_status = tags.map { |x| [ x, :unvisited ] }.flatten
|
130
|
+
tags_status = Hash[*tags_status]
|
131
|
+
|
132
|
+
tags.each do |tag|
|
133
|
+
next if tags_status[tag] != :unvisited
|
134
|
+
(reclambda do |this, tag|
|
135
|
+
return false if tags_status[tag] == :processing
|
136
|
+
tags_status[tag] = :processing
|
137
|
+
tag.children.any? do |child|
|
138
|
+
this.call(child)
|
139
|
+
end
|
140
|
+
tags_status[tag] = :closed
|
141
|
+
end).call(tag)
|
142
|
+
end
|
143
|
+
return true
|
144
|
+
end
|
145
|
+
|
146
|
+
# ==== DUMPER ======
|
147
|
+
|
148
|
+
def self.dump_tags
|
149
|
+
['# Categories'] +
|
150
|
+
dump_hierarchy.map { |chain| chain * ' / '} +
|
151
|
+
['', '# Synonyms'] +
|
152
|
+
dump_synonyms.map { |chain| chain * ' = '} +
|
153
|
+
['', '# Unlinked tags'] +
|
154
|
+
dump_orphans
|
155
|
+
end
|
156
|
+
|
157
|
+
def self.dump_hierarchy
|
158
|
+
tags_chains = Tag.with_joined_hierarchy.without_children.with_parents.find(:all).map { |x| [x] }
|
159
|
+
|
160
|
+
# OPTIMIZE
|
161
|
+
while chain = tags_chains.detect { |chain| !chain.first.parents.empty? }
|
162
|
+
tags_chains.delete(chain)
|
163
|
+
chain.first.parents.each do |parent|
|
164
|
+
raise Tag::HierarchyCycle.new if chain.include?(parent)
|
165
|
+
tags_chains << ([parent] + chain)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
tags_chains.map { |chain| chain.map { |tag| tag.name } }.sort_by { |chain| chain * ' ' }
|
170
|
+
end
|
171
|
+
|
172
|
+
def self.dump_synonyms
|
173
|
+
# OPTIMIZE N+1
|
174
|
+
tags_with_synonyms = Tag.includes(:synonyms)
|
175
|
+
|
176
|
+
tags_with_synonyms.map { |t|
|
177
|
+
[ t.name, *t.synonyms.pluck(:name).sort ]
|
178
|
+
}.keep_if(&:second)
|
179
|
+
end
|
180
|
+
|
181
|
+
def self.dump_orphans
|
182
|
+
# OPTIMIZE
|
183
|
+
Tag.with_joined_hierarchy_and_synonyms.without_children.without_parents.without_synonyms.
|
184
|
+
pluck(:name)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# FIXME move somewhere, uhm
|
189
|
+
def reclambda
|
190
|
+
lambda do |f|
|
191
|
+
f.call(f)
|
192
|
+
end.call(lambda do |f|
|
193
|
+
lambda do |this|
|
194
|
+
lambda do |*args|
|
195
|
+
yield(this, *args)
|
196
|
+
end
|
197
|
+
end.call(lambda do |x|
|
198
|
+
f.call(f).call(x)
|
199
|
+
end)
|
200
|
+
end)
|
201
|
+
end
|
data/lib/tag_list.rb
ADDED
@@ -0,0 +1,108 @@
|
|
1
|
+
class TagList < Array
|
2
|
+
cattr_accessor :delimiter
|
3
|
+
self.delimiter = ','
|
4
|
+
|
5
|
+
def initialize(*args)
|
6
|
+
add(*args)
|
7
|
+
end
|
8
|
+
|
9
|
+
# Add tags to the tag_list. Duplicate or blank tags will be ignored.
|
10
|
+
#
|
11
|
+
# tag_list.add("Fun", "Happy")
|
12
|
+
#
|
13
|
+
# Use the <tt>:parse</tt> option to add an unparsed tag string.
|
14
|
+
#
|
15
|
+
# tag_list.add("Fun, Happy", :parse => true)
|
16
|
+
def add(*names)
|
17
|
+
extract_and_apply_options!(names)
|
18
|
+
concat(names)
|
19
|
+
clean!
|
20
|
+
self
|
21
|
+
end
|
22
|
+
|
23
|
+
# Remove specific tags from the tag_list.
|
24
|
+
#
|
25
|
+
# tag_list.remove("Sad", "Lonely")
|
26
|
+
#
|
27
|
+
# Like #add, the <tt>:parse</tt> option can be used to remove multiple tags in a string.
|
28
|
+
#
|
29
|
+
# tag_list.remove("Sad, Lonely", :parse => true)
|
30
|
+
def remove(*names)
|
31
|
+
extract_and_apply_options!(names)
|
32
|
+
delete_if { |name| names.include?(name) }
|
33
|
+
self
|
34
|
+
end
|
35
|
+
|
36
|
+
# Toggle the presence of the given tags.
|
37
|
+
# If a tag is already in the list it is removed, otherwise it is added.
|
38
|
+
def toggle(*names)
|
39
|
+
extract_and_apply_options!(names)
|
40
|
+
|
41
|
+
names.each do |name|
|
42
|
+
include?(name) ? delete(name) : push(name)
|
43
|
+
end
|
44
|
+
|
45
|
+
clean!
|
46
|
+
self
|
47
|
+
end
|
48
|
+
|
49
|
+
# Transform the tag_list into a tag string suitable for edting in a form.
|
50
|
+
# The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
|
51
|
+
#
|
52
|
+
# tag_list = TagList.new("Round", "Square,Cube")
|
53
|
+
# tag_list.to_s # 'Round, "Square,Cube"'
|
54
|
+
def to_s
|
55
|
+
clean!
|
56
|
+
|
57
|
+
map do |name|
|
58
|
+
name.include?(delimiter) ? "\"#{name}\"" : name
|
59
|
+
end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ")
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
# Remove whitespace, duplicates, and blanks.
|
64
|
+
def clean!
|
65
|
+
reject!(&:blank?)
|
66
|
+
map!(&:strip)
|
67
|
+
uniq!
|
68
|
+
end
|
69
|
+
|
70
|
+
def extract_and_apply_options!(args)
|
71
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
72
|
+
options.assert_valid_keys :parse
|
73
|
+
|
74
|
+
if options[:parse]
|
75
|
+
args.map! { |a| self.class.from(a) }
|
76
|
+
end
|
77
|
+
|
78
|
+
args.flatten!
|
79
|
+
end
|
80
|
+
|
81
|
+
class << self
|
82
|
+
# Returns a new TagList using the given tag string.
|
83
|
+
#
|
84
|
+
# tag_list = TagList.from("One , Two, Three")
|
85
|
+
# tag_list # ["One", "Two", "Three"]
|
86
|
+
def from(source)
|
87
|
+
new.tap do |tag_list|
|
88
|
+
|
89
|
+
case source
|
90
|
+
when Array
|
91
|
+
tag_list.add(source)
|
92
|
+
else
|
93
|
+
string = source.to_s.dup
|
94
|
+
|
95
|
+
# Parse the quoted tags
|
96
|
+
[
|
97
|
+
/\s*#{delimiter}\s*(['"])(.*?)\1\s*/,
|
98
|
+
/^\s*(['"])(.*?)\1\s*#{delimiter}?/
|
99
|
+
].each do |re|
|
100
|
+
string.gsub!(re) { tag_list << $2; "" }
|
101
|
+
end
|
102
|
+
|
103
|
+
tag_list.add(string.split(delimiter))
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|