yeshoua_crm 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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/Rakefile +11 -0
  3. data/lib/yeshoua_crm.rb +87 -0
  4. data/lib/yeshoua_crm/acts_as_draftable/draft.rb +40 -0
  5. data/lib/yeshoua_crm/acts_as_draftable/rcrm_acts_as_draftable.rb +154 -0
  6. data/lib/yeshoua_crm/acts_as_list/list.rb +282 -0
  7. data/lib/yeshoua_crm/acts_as_taggable/rcrm_acts_as_taggable.rb +350 -0
  8. data/lib/yeshoua_crm/acts_as_taggable/tag.rb +81 -0
  9. data/lib/yeshoua_crm/acts_as_taggable/tag_list.rb +111 -0
  10. data/lib/yeshoua_crm/acts_as_taggable/tagging.rb +16 -0
  11. data/lib/yeshoua_crm/acts_as_viewed/rcrm_acts_as_viewed.rb +274 -0
  12. data/lib/yeshoua_crm/acts_as_votable/rcrm_acts_as_votable.rb +80 -0
  13. data/lib/yeshoua_crm/acts_as_votable/rcrm_acts_as_voter.rb +20 -0
  14. data/lib/yeshoua_crm/acts_as_votable/votable.rb +323 -0
  15. data/lib/yeshoua_crm/acts_as_votable/vote.rb +28 -0
  16. data/lib/yeshoua_crm/acts_as_votable/voter.rb +131 -0
  17. data/lib/yeshoua_crm/assets_manager.rb +43 -0
  18. data/lib/yeshoua_crm/currency.rb +439 -0
  19. data/lib/yeshoua_crm/currency/formatting.rb +224 -0
  20. data/lib/yeshoua_crm/currency/heuristics.rb +151 -0
  21. data/lib/yeshoua_crm/currency/loader.rb +24 -0
  22. data/lib/yeshoua_crm/helpers/external_assets_helper.rb +17 -0
  23. data/lib/yeshoua_crm/helpers/form_tag_helper.rb +123 -0
  24. data/lib/yeshoua_crm/helpers/tags_helper.rb +13 -0
  25. data/lib/yeshoua_crm/helpers/vote_helper.rb +35 -0
  26. data/lib/yeshoua_crm/liquid/drops/cells_drop.rb +86 -0
  27. data/lib/yeshoua_crm/liquid/drops/issues_drop.rb +66 -0
  28. data/lib/yeshoua_crm/liquid/drops/news_drop.rb +54 -0
  29. data/lib/yeshoua_crm/liquid/drops/users_drop.rb +72 -0
  30. data/lib/yeshoua_crm/liquid/filters/arrays.rb +177 -0
  31. data/lib/yeshoua_crm/liquid/filters/base.rb +208 -0
  32. data/lib/yeshoua_crm/money_helper.rb +65 -0
  33. data/lib/yeshoua_crm/version.rb +3 -0
  34. data/test/acts_as_draftable/draft_test.rb +29 -0
  35. data/test/acts_as_draftable/rcrm_acts_as_draftable_test.rb +185 -0
  36. data/test/acts_as_taggable/rcrm_acts_as_taggable_test.rb +345 -0
  37. data/test/acts_as_taggable/tag_list_test.rb +34 -0
  38. data/test/acts_as_taggable/tag_test.rb +72 -0
  39. data/test/acts_as_taggable/tagging_test.rb +15 -0
  40. data/test/acts_as_viewed/rcrm_acts_as_viewed_test.rb +47 -0
  41. data/test/acts_as_votable/rcrm_acts_as_votable_test.rb +19 -0
  42. data/test/acts_as_votable/rcrm_acts_as_voter_test.rb +14 -0
  43. data/test/acts_as_votable/votable_test.rb +507 -0
  44. data/test/acts_as_votable/voter_test.rb +296 -0
  45. data/test/currency_test.rb +292 -0
  46. data/test/liquid/drops/issues_drop_test.rb +34 -0
  47. data/test/liquid/drops/news_drop_test.rb +38 -0
  48. data/test/liquid/drops/projects_drop_test.rb +44 -0
  49. data/test/liquid/drops/uses_drop_test.rb +36 -0
  50. data/test/liquid/filters/arrays_filter_test.rb +24 -0
  51. data/test/liquid/filters/base_filter_test.rb +63 -0
  52. data/test/liquid/liquid_helper.rb +32 -0
  53. data/test/models/issue.rb +14 -0
  54. data/test/models/news.rb +3 -0
  55. data/test/models/project.rb +8 -0
  56. data/test/models/user.rb +11 -0
  57. data/test/models/vote_classes.rb +33 -0
  58. data/test/money_helper_test.rb +12 -0
  59. data/test/schema.rb +121 -0
  60. data/test/tags_helper_test.rb +29 -0
  61. data/test/test_helper.rb +66 -0
  62. data/test/vote_helper_test.rb +28 -0
  63. data/yeshoua_crm.gemspec +28 -0
  64. metadata +206 -0
@@ -0,0 +1,350 @@
1
+ require 'active_record'
2
+
3
+ module YeshouaCrm
4
+ module ActsAsTaggable #:nodoc:
5
+ module Taggable #:nodoc:
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def taggable?
12
+ false
13
+ end
14
+
15
+ def rcrm_acts_as_taggable
16
+ has_many :taggings, :as => :taggable, :dependent => :destroy, :class_name => '::YeshouaCrm::ActsAsTaggable::Tagging'
17
+ has_many :tags, :through => :taggings, :class_name => '::YeshouaCrm::ActsAsTaggable::Tag'
18
+
19
+ before_save :save_cached_tag_list
20
+
21
+ after_create :save_tags
22
+ after_update :save_tags
23
+
24
+ include YeshouaCrm::ActsAsTaggable::Taggable::InstanceMethods
25
+ extend YeshouaCrm::ActsAsTaggable::Taggable::SingletonMethods
26
+
27
+ alias_method :reload_without_tag_list, :reload
28
+ alias_method :reload, :reload_with_tag_list
29
+
30
+ class_eval do
31
+ def self.taggable?
32
+ true
33
+ end
34
+ end
35
+ end
36
+
37
+ def cached_tag_list_column_name
38
+ 'cached_tag_list'
39
+ end
40
+
41
+ def set_cached_tag_list_column_name(value = nil, &block)
42
+ define_attr_method :cached_tag_list_column_name, value, &block
43
+ end
44
+
45
+ # Create the taggable tables
46
+ # === Options hash:
47
+ # * <tt>:table_name</tt> - use a table name other than viewings
48
+ # To be used during migration, but can also be used in other places
49
+ def create_taggable_table(options = {})
50
+ tag_name_table = options[:tags] || :tags
51
+
52
+ if !self.connection.table_exists?(tag_name_table)
53
+ self.connection.create_table(tag_name_table) do |t|
54
+ t.column :name, :string
55
+ end
56
+ end
57
+
58
+ taggings_name_table = options[:taggings] || :taggings
59
+ if !self.connection.table_exists?(taggings_name_table)
60
+ self.connection.create_table(taggings_name_table) do |t|
61
+ t.column :tag_id, :integer
62
+ t.column :taggable_id, :integer
63
+
64
+ # You should make sure that the column created is
65
+ # long enough to store the required class names.
66
+ t.column :taggable_type, :string
67
+
68
+ t.column :created_at, :datetime
69
+ end
70
+
71
+ self.connection.add_index :taggings, :tag_id
72
+ self.connection.add_index :taggings, [:taggable_id, :taggable_type]
73
+ end
74
+ end
75
+
76
+ def drop_taggable_table(options = {})
77
+ tag_name_table = options[:tags] || :tags
78
+ if self.connection.table_exists?(tag_name_table)
79
+ self.connection.drop_table tag_name_table
80
+ end
81
+
82
+ taggings_name_table = options[:taggings] || :taggings
83
+ if self.connection.table_exists?(taggings_name_table)
84
+ self.connection.drop_table taggings_name_table
85
+ end
86
+ end
87
+ end
88
+
89
+ module SingletonMethods
90
+ #Return all avalible tags for a cell or global
91
+ #Example: Question.available_tags(:cell => @cell_id )
92
+ def available_tags(options = {})
93
+ cell = options[:cell]
94
+ limit = options[:limit].to_i.zero? ? 30 : options[:limit].to_i
95
+ scope = Tag.where({})
96
+ class_name = quote_string_value(base_class.name)
97
+ join = []
98
+ join << "JOIN #{Tagging.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id "
99
+ join << "JOIN #{table_name} ON #{table_name}.id = #{Tagging.table_name}.taggable_id
100
+ AND #{Tagging.table_name}.taggable_type = #{class_name} "
101
+ if attribute_names.include?('cell_id') && cell
102
+ join << "JOIN #{Cell.table_name} ON #{Cell.table_name}.id = #{table_name}.cell_id"
103
+ scope = scope.where("#{table_name}.cell_id = ?", cell.id)
104
+ end
105
+
106
+ if options[:name_like]
107
+ scope = scope.where("LOWER(#{Tag.table_name}.name) LIKE LOWER(?)", "%#{options[:name_like]}%")
108
+ end
109
+
110
+ group_fields = ''
111
+ group_fields << ", #{Tag.table_name}.created_at" if Tag.respond_to?(:created_at)
112
+ group_fields << ", #{Tag.table_name}.updated_at" if Tag.respond_to?(:updated_at)
113
+
114
+ scope = scope.joins(join.join(' '))
115
+ scope = scope.select("#{Tag.table_name}.*, COUNT(DISTINCT #{Tagging.table_name}.taggable_id) AS count")
116
+ scope = scope.group("#{Tag.table_name}.id, #{Tag.table_name}.name #{group_fields}")
117
+ scope = scope.having('COUNT(*) > 0')
118
+ scope = scope.order("#{Tag.table_name}.name")
119
+ scope = scope.limit(limit)
120
+ scope
121
+ end
122
+ # Returns an array of related tags.
123
+ # Related tags are all the other tags that are found on the models tagged with the provided tags.
124
+ #
125
+ # Pass either a tag, string, or an array of strings or tags.
126
+ #
127
+ # Options:
128
+ # :order - SQL Order how to order the tags. Defaults to "count DESC, tags.name".
129
+ def find_related_tags(tags, options = {})
130
+ tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
131
+
132
+ related_models = find_tagged_with(tags)
133
+
134
+ return [] if related_models.blank?
135
+
136
+ related_ids = related_models.map{|c| c.id }.join(",")
137
+ Tag.select(
138
+ "#{Tag.table_name}.*, COUNT(#{Tag.table_name}.id) AS count").joins(
139
+ "JOIN #{Tagging.table_name} ON #{Tagging.table_name}.taggable_type = '#{base_class.name}'
140
+ AND #{Tagging.table_name}.taggable_id IN (#{related_ids})
141
+ AND #{Tagging.table_name}.tag_id = #{Tag.table_name}.id").order(
142
+ options[:order] || "count DESC, #{Tag.table_name}.name").group(
143
+ "#{Tag.table_name}.id, #{Tag.table_name}.name HAVING #{Tag.table_name}.name NOT IN (#{tags.map { |n| quote_string_value(n) }.join(",")})")
144
+ end
145
+
146
+ # Pass either a tag, string, or an array of strings or tags.
147
+ #
148
+ # Options:
149
+ # :exclude - Find models that are not tagged with the given tags
150
+ # :match_all - Find models that match all of the given tags, not just one
151
+ # :conditions - A piece of SQL conditions to add to the query
152
+ def find_tagged_with(*args)
153
+ options = find_options_for_find_tagged_with(*args)
154
+ options.blank? ? [] : select(options[:select]).where(options[:conditions]).joins(options[:joins]).order(options[:order]).to_a
155
+ end
156
+
157
+ alias_method :tagged_with, :find_tagged_with
158
+
159
+ def find_options_for_find_tagged_with(tags, options = {})
160
+ tags = tags.is_a?(Array) ? TagList.new(tags.map(&:to_s)) : TagList.from(tags)
161
+ options = options.dup
162
+
163
+ return {} if tags.empty?
164
+
165
+ conditions = []
166
+ conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
167
+
168
+ taggings_alias, tags_alias = "#{table_name}_taggings", "#{table_name}_tags"
169
+
170
+ joins = [
171
+ "INNER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND #{taggings_alias}.taggable_type = #{quote_string_value(base_class.name)}",
172
+ "INNER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id"
173
+ ]
174
+
175
+ if options.delete(:exclude)
176
+ conditions << <<-END
177
+ #{table_name}.id NOT IN
178
+ (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name}
179
+ INNER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id
180
+ WHERE #{tags_condition(tags)} AND #{Tagging.table_name}.taggable_type = #{quote_string_value(base_class.name)})
181
+ END
182
+ else
183
+ if options.delete(:match_all)
184
+ joins << joins_for_match_all_tags(tags)
185
+ else
186
+ conditions << tags_condition(tags, tags_alias)
187
+ end
188
+ end
189
+
190
+ { :select => "DISTINCT #{table_name}.* ",
191
+ :joins => joins.join(" "),
192
+ :conditions => conditions.join(" AND ")
193
+ }.reverse_merge!(options)
194
+ end
195
+
196
+ def joins_for_match_all_tags(tags)
197
+ joins = []
198
+
199
+ tags.each_with_index do |tag, index|
200
+ taggings_alias, tags_alias = "taggings_#{index}", "tags_#{index}"
201
+
202
+ join = <<-END
203
+ INNER JOIN #{Tagging.table_name} #{taggings_alias} ON
204
+ #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND
205
+ #{taggings_alias}.taggable_type = #{quote_string_value(base_class.name)}
206
+
207
+ INNER JOIN #{Tag.table_name} #{tags_alias} ON
208
+ #{taggings_alias}.tag_id = #{tags_alias}.id AND
209
+ #{tags_alias}.name = ?
210
+ END
211
+
212
+ joins << sanitize_sql([join, tag])
213
+ end
214
+
215
+ joins.join(' ')
216
+ end
217
+
218
+ # Calculate the tag counts for all tags.
219
+ #
220
+ # See Tag.counts for available options.
221
+ def tag_counts(options = {})
222
+ # Tag.find(:all, find_options_for_tag_counts(options))
223
+ opt = find_options_for_tag_counts(options)
224
+ Tag.select(opt[:select]).where(opt[:conditions]).joins(opt[:joins]).group(opt[:group]).having(opt[:having]).order(opt[:order]).limit(options[:limit])
225
+ end
226
+ alias_method :all_tag_counts, :tag_counts
227
+
228
+ def find_options_for_tag_counts(options = {})
229
+ options = options.dup
230
+ scope = scope_attributes
231
+ # scope(:find)
232
+
233
+ conditions = []
234
+ conditions << send(:sanitize_sql_for_assignment, options.delete(:conditions)) if options[:conditions]
235
+ conditions << send(:sanitize_sql_for_assignment, scope) if scope
236
+ conditions << "#{Tagging.table_name}.taggable_type = #{quote_string_value(base_class.name)}"
237
+ conditions << type_condition unless descends_from_active_record?
238
+ conditions.delete('')
239
+ conditions.compact!
240
+ conditions = conditions.join(" AND ")
241
+
242
+ joins = ["INNER JOIN #{table_name} ON #{table_name}.#{primary_key} = #{Tagging.table_name}.taggable_id"]
243
+ joins << options.delete(:joins) if options[:joins].present?
244
+ # joins << scope[:joins] if scope && scope[:joins].present?
245
+ joins = joins.join(" ")
246
+
247
+ options = { :conditions => conditions, :joins => joins }.update(options)
248
+
249
+ Tag.options_for_counts(options)
250
+ end
251
+
252
+ def caching_tag_list?
253
+ column_names.include?(cached_tag_list_column_name)
254
+ end
255
+
256
+ private
257
+
258
+ def quote_string_value(object)
259
+ connection.quote(object)
260
+ end
261
+
262
+ def tags_condition(tags, table_name = Tag.table_name)
263
+ condition = tags.map { |t| sanitize_sql(["#{table_name}.name LIKE ?", t]) }.join(" OR ")
264
+ "(" + condition + ")" unless condition.blank?
265
+ end
266
+
267
+ def merge_conditions(*conditions)
268
+ segments = []
269
+
270
+ conditions.each do |condition|
271
+ unless condition.blank?
272
+ sql = sanitize_sql(condition)
273
+ segments << sql unless sql.blank?
274
+ end
275
+ end
276
+
277
+ "(#{segments.join(') AND (')})" unless segments.empty?
278
+ end
279
+ end
280
+
281
+ module InstanceMethods
282
+ def tag_list
283
+ return @tag_list if @tag_list ||= nil
284
+
285
+ if self.class.caching_tag_list? && !(cached_value = send(self.class.cached_tag_list_column_name)).nil?
286
+ @tag_list = TagList.from(cached_value)
287
+ else
288
+ @tag_list = TagList.new(*tags.map(&:name))
289
+ end
290
+ end
291
+
292
+ def tag_list=(value)
293
+ @tag_list = TagList.from(value)
294
+ end
295
+
296
+ def save_cached_tag_list
297
+ if self.class.caching_tag_list?
298
+ self[self.class.cached_tag_list_column_name] = tag_list.to_s
299
+ end
300
+ end
301
+
302
+ #build list from related tags
303
+ def all_tags_list
304
+ tags.pluck(:name)
305
+ end
306
+
307
+ def save_tags
308
+ return unless @tag_list
309
+
310
+ new_tag_names = @tag_list - tags.map(&:name)
311
+ old_tags = tags.reject { |tag| @tag_list.include?(tag.name) }
312
+
313
+ self.class.transaction do
314
+ if old_tags.any?
315
+ taggings.where("tag_id IN (?)", old_tags.map(&:id)).each(&:destroy)
316
+ taggings.reset
317
+ end
318
+ new_tag_names.each do |new_tag_name|
319
+ tags << Tag.find_or_create_with_like_by_name(new_tag_name)
320
+ end
321
+ end
322
+
323
+ true
324
+ end
325
+
326
+ # Calculate the tag counts for the tags used by this model.
327
+ #
328
+ # The possible options are the same as the tag_counts class method.
329
+ def tag_counts(options = {})
330
+ return [] if tag_list.blank?
331
+
332
+ options[:conditions] = self.class.send(:merge_conditions, options[:conditions], self.class.send(:tags_condition, tag_list))
333
+ self.class.tag_counts(options)
334
+ end
335
+
336
+ def reload_with_tag_list(*args) #:nodoc:
337
+ @tag_list = nil
338
+ reload_without_tag_list(*args)
339
+ end
340
+ end
341
+ end
342
+ end
343
+ end
344
+
345
+ ActiveRecord::Base.send(:include, YeshouaCrm::ActsAsTaggable::Taggable)
346
+
347
+ # Class aliases
348
+ YeshouaCrm::Tag = YeshouaCrm::ActsAsTaggable::Tag
349
+ YeshouaCrm::TagList = YeshouaCrm::ActsAsTaggable::TagList
350
+ YeshouaCrm::Tagging = YeshouaCrm::ActsAsTaggable::Tagging
@@ -0,0 +1,81 @@
1
+ module YeshouaCrm
2
+ module ActsAsTaggable #:nodoc:
3
+ class Tag < ActiveRecord::Base #:nodoc:
4
+ has_many :taggings, :dependent => :destroy
5
+
6
+ validates_presence_of :name
7
+ validates :name, :uniqueness => { :message => " not uniq tag" }
8
+ validates :name, :presence => true
9
+ cattr_accessor :destroy_unused
10
+ self.destroy_unused = false
11
+
12
+ attr_accessible :name if defined?(ActiveModel::MassAssignmentSecurity)
13
+
14
+ # LIKE is used for cross-database case-insensitivity
15
+ def self.find_or_create_with_like_by_name(name)
16
+ # find(:first, :conditions => ["name LIKE ?", name]) || create(:name => name)
17
+ where("LOWER(name) LIKE LOWER(?)", name).first || create(:name => name)
18
+ end
19
+
20
+ def ==(object)
21
+ super || (object.is_a?(Tag) && name == object.name)
22
+ end
23
+
24
+ def to_s
25
+ name
26
+ end
27
+
28
+ def count
29
+ read_attribute(:count).to_i
30
+ end
31
+
32
+ class << self
33
+ # Calculate the tag counts for all tags.
34
+ # :start_at - Restrict the tags to those created after a certain time
35
+ # :end_at - Restrict the tags to those created before a certain time
36
+ # :conditions - A piece of SQL conditions to add to the query
37
+ # :limit - The maximum number of tags to return
38
+ # :order - A piece of SQL to order by. Eg 'count desc' or 'taggings.created_at desc'
39
+ # :at_least - Exclude tags with a frequency less than the given value
40
+ # :at_most - Exclude tags with a frequency greater than the given value
41
+ def counts(options = {})
42
+ opt = options_for_counts(options)
43
+ select(opt[:select]).where(opt[:conditions]).joins(opt[:joins]).group(opt[:group])
44
+ end
45
+
46
+ def options_for_counts(options = {})
47
+ options.assert_valid_keys :start_at, :end_at, :conditions, :at_least, :at_most, :order, :limit, :joins
48
+ options = options.dup
49
+
50
+ start_at = sanitize_sql(["#{Tagging.table_name}.created_at >= ?", options.delete(:start_at)]) if options[:start_at]
51
+ end_at = sanitize_sql(["#{Tagging.table_name}.created_at <= ?", options.delete(:end_at)]) if options[:end_at]
52
+
53
+ conditions = [
54
+ (sanitize_sql(options.delete(:conditions)) if options[:conditions]),
55
+ start_at,
56
+ end_at
57
+ ].compact
58
+
59
+ conditions = conditions.join(' AND ') if conditions.any?
60
+
61
+ joins = ["INNER JOIN #{Tagging.table_name} ON #{Tag.table_name}.id = #{Tagging.table_name}.tag_id"]
62
+ joins << options.delete(:joins) if options[:joins]
63
+
64
+ at_least = sanitize_sql(['COUNT(*) >= ?', options.delete(:at_least)]) if options[:at_least]
65
+ at_most = sanitize_sql(['COUNT(*) <= ?', options.delete(:at_most)]) if options[:at_most]
66
+ having = 'COUNT(*) > 0'
67
+ having = [having, at_least, at_most].compact.join(' AND ')
68
+ group_by = "#{Tag.table_name}.id, #{Tag.table_name}.name"
69
+ # group_by << " AND #{having}" unless having.blank?
70
+
71
+ { :select => "#{Tag.table_name}.id, #{Tag.table_name}.name, COUNT(*) AS count",
72
+ :joins => joins.join(" "),
73
+ :conditions => conditions,
74
+ :group => group_by,
75
+ :having => having
76
+ }.update(options)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,111 @@
1
+ module YeshouaCrm
2
+ module ActsAsTaggable #:nodoc:
3
+ class TagList < Array #:nodoc:
4
+ cattr_accessor :delimiter
5
+ self.delimiter = ','
6
+
7
+ def initialize(*args)
8
+ add(*args)
9
+ end
10
+
11
+ # Add tags to the tag_list. Duplicate or blank tags will be ignored.
12
+ #
13
+ # tag_list.add("Fun", "Happy")
14
+ #
15
+ # Use the <tt>:parse</tt> option to add an unparsed tag string.
16
+ #
17
+ # tag_list.add("Fun, Happy", :parse => true)
18
+ def add(*names)
19
+ extract_and_apply_options!(names)
20
+ concat(names)
21
+ clean!
22
+ self
23
+ end
24
+
25
+ # Remove specific tags from the tag_list.
26
+ #
27
+ # tag_list.remove("Sad", "Lonely")
28
+ #
29
+ # Like #add, the <tt>:parse</tt> option can be used to remove multiple tags in a string.
30
+ #
31
+ # tag_list.remove("Sad, Lonely", :parse => true)
32
+ def remove(*names)
33
+ extract_and_apply_options!(names)
34
+ delete_if { |name| names.include?(name) }
35
+ self
36
+ end
37
+
38
+ # Toggle the presence of the given tags.
39
+ # If a tag is already in the list it is removed, otherwise it is added.
40
+ def toggle(*names)
41
+ extract_and_apply_options!(names)
42
+
43
+ names.each do |name|
44
+ include?(name) ? delete(name) : push(name)
45
+ end
46
+
47
+ clean!
48
+ self
49
+ end
50
+
51
+ # Transform the tag_list into a tag string suitable for edting in a form.
52
+ # The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
53
+ #
54
+ # tag_list = TagList.new("Round", "Square,Cube")
55
+ # tag_list.to_s # 'Round, "Square,Cube"'
56
+ def to_s
57
+ clean!
58
+
59
+ map do |name|
60
+ name.include?(delimiter) ? "\"#{name}\"" : name
61
+ end.join(delimiter.ends_with?(" ") ? delimiter : "#{delimiter} ")
62
+ end
63
+
64
+ private
65
+ # Remove whitespace, duplicates, and blanks.
66
+ def clean!
67
+ reject!(&:blank?)
68
+ map!(&:strip)
69
+ uniq!
70
+ end
71
+
72
+ def extract_and_apply_options!(args)
73
+ options = args.last.is_a?(Hash) ? args.pop : {}
74
+ options.assert_valid_keys :parse
75
+
76
+ args.map! { |a| self.class.from(a) } if options[:parse]
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
+ tag_list = new
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
+
106
+ tag_list
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end