taglish 0.1.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/.gitignore +10 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +107 -0
- data/MIT-LICENSE +20 -0
- data/README.md +11 -0
- data/Rakefile +17 -0
- data/lib/generators/taglish/migration/migration_generator.rb +39 -0
- data/lib/generators/taglish/migration/templates/active_record/migration.rb +31 -0
- data/lib/taglish.rb +33 -0
- data/lib/taglish/core.rb +157 -0
- data/lib/taglish/tag.rb +31 -0
- data/lib/taglish/tag_list.rb +115 -0
- data/lib/taglish/tag_type.rb +65 -0
- data/lib/taglish/taggable.rb +144 -0
- data/lib/taglish/tagging.rb +50 -0
- data/lib/taglish/util.rb +4 -0
- data/lib/taglish/version.rb +3 -0
- data/rails/init.rb +1 -0
- data/spec/database.yml.sample +19 -0
- data/spec/models.rb +64 -0
- data/spec/schema.rb +72 -0
- data/spec/spec_helper.rb +82 -0
- data/spec/taglish/taggable_spec.rb +695 -0
- data/taglish.gemspec +25 -0
- metadata +154 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require 'active_support/core_ext/module/delegation'
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# Contains a list of strings.
|
|
5
|
+
# Works like an array.
|
|
6
|
+
class Taglish::TagList < Array
|
|
7
|
+
attr_accessor :tag_type
|
|
8
|
+
attr_accessor :taggable
|
|
9
|
+
|
|
10
|
+
def initialize(tag_type, *args)
|
|
11
|
+
self.tag_type = tag_type or raise "tag_type is required"
|
|
12
|
+
add(*args)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# Add tags to the tag_list. Duplicate or blank tags will be ignored.
|
|
17
|
+
#
|
|
18
|
+
# Example:
|
|
19
|
+
# tag_list.add("Fun", "Happy")
|
|
20
|
+
# tag_list.add("Fun, Happy", :parse => true)
|
|
21
|
+
def add(*names)
|
|
22
|
+
extract_and_apply_options!(names)
|
|
23
|
+
concat(names)
|
|
24
|
+
clean!
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
##
|
|
29
|
+
# Returns a new TagList using the given tag string.
|
|
30
|
+
#
|
|
31
|
+
# Example:
|
|
32
|
+
# tag_list = TagList.from("One , Two, Three")
|
|
33
|
+
# tag_list # ["One", "Two", "Three"]
|
|
34
|
+
def self.from(tag_type, string)
|
|
35
|
+
string = string.join(tag_type.glue) if string.respond_to?(:join)
|
|
36
|
+
|
|
37
|
+
new(tag_type).tap do |tag_list|
|
|
38
|
+
string = string.to_s.dup
|
|
39
|
+
|
|
40
|
+
# Parse the quoted tags
|
|
41
|
+
d = tag_type.delimiter
|
|
42
|
+
d = d.join("|") if d.kind_of?(Array)
|
|
43
|
+
string.gsub!(/(\A|#{d})\s*"(.*?)"\s*(#{d}\s*|\z)/) { tag_list << $2; $3 }
|
|
44
|
+
string.gsub!(/(\A|#{d})\s*'(.*?)'\s*(#{d}\s*|\z)/) { tag_list << $2; $3 }
|
|
45
|
+
|
|
46
|
+
tag_list.add(string.split(Regexp.new d))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# Remove specific tags from the tag_list.
|
|
52
|
+
# Use the <tt>:parse</tt> option to add an unparsed tag string.
|
|
53
|
+
#
|
|
54
|
+
# Example:
|
|
55
|
+
# tag_list.remove("Sad", "Lonely")
|
|
56
|
+
# tag_list.remove("Sad, Lonely", :parse => true)
|
|
57
|
+
def remove(*names)
|
|
58
|
+
extract_and_apply_options!(names)
|
|
59
|
+
if tag_type.scored
|
|
60
|
+
delete_if { |name| names.include?(name.sub(Taglish::Taggable::SCORED_TAG_REGEX, '\1')) }
|
|
61
|
+
else
|
|
62
|
+
delete_if { |name| names.include?(name) }
|
|
63
|
+
end
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# Transform the tag_list into a tag string suitable for edting in a form.
|
|
69
|
+
# The tags are joined with <tt>TagList.delimiter</tt> and quoted if necessary.
|
|
70
|
+
#
|
|
71
|
+
# Example:
|
|
72
|
+
# tag_list = TagList.new("Round", "Square,Cube")
|
|
73
|
+
# tag_list.to_s # 'Round, "Square,Cube"'
|
|
74
|
+
def to_s
|
|
75
|
+
tags = frozen? ? self.dup : self
|
|
76
|
+
tags.send(:clean!)
|
|
77
|
+
|
|
78
|
+
tags.map do |name|
|
|
79
|
+
d = tag_type.delimiter
|
|
80
|
+
d = Regexp.new d.join("|") if d.kind_of? Array
|
|
81
|
+
name.index(d) ? "\"#{name}\"" : name
|
|
82
|
+
end.join(tag_type.glue)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def to_tagging_array
|
|
86
|
+
map { |name|
|
|
87
|
+
ar = tag_type.name_and_score(name)
|
|
88
|
+
Taglish::Tagging.new(:name => ar[0], :score => ar[1])
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Remove whitespace, duplicates, and blanks.
|
|
95
|
+
def clean!
|
|
96
|
+
# Do this in self.from instead, or wherever we parse from strings:
|
|
97
|
+
reject!(&:blank?)
|
|
98
|
+
map!(&:strip)
|
|
99
|
+
map!(&:downcase) if tag_type.force_lowercase
|
|
100
|
+
map!(&:parameterize) if tag_type.force_parameterize
|
|
101
|
+
|
|
102
|
+
uniq!
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def extract_and_apply_options!(args)
|
|
106
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
|
107
|
+
options.assert_valid_keys :parse
|
|
108
|
+
|
|
109
|
+
if options[:parse]
|
|
110
|
+
args.map! { |a| self.class.from(a) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
args.flatten!
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
class Taglish::TagType
|
|
2
|
+
attr_accessor :name, :scored, :ordered, :delimiter, :score_delimiter,
|
|
3
|
+
:force_parameterize, :force_lowercase
|
|
4
|
+
|
|
5
|
+
def initialize(name, opts={})
|
|
6
|
+
opts = {
|
|
7
|
+
:scored => false,
|
|
8
|
+
:ordered => false,
|
|
9
|
+
:delimiter => Taglish::DEFAULT_DELIMITER,
|
|
10
|
+
:score_delimiter => Taglish::DEFAULT_SCORE_DELIMITER,
|
|
11
|
+
:force_parameterize => false,
|
|
12
|
+
:force_lowercase => false
|
|
13
|
+
}.merge(opts)
|
|
14
|
+
|
|
15
|
+
self.name = name
|
|
16
|
+
self.scored = opts[:scored]
|
|
17
|
+
self.ordered = opts[:ordered]
|
|
18
|
+
self.delimiter = opts[:delimiter]
|
|
19
|
+
self.score_delimiter = opts[:score_delimiter]
|
|
20
|
+
self.force_parameterize = opts[:force_parameterize]
|
|
21
|
+
self.force_lowercase = opts[:force_lowercase]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def scored?
|
|
25
|
+
scored
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ordered?
|
|
29
|
+
ordered
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def name_and_score(tag_str)
|
|
33
|
+
if scored
|
|
34
|
+
tag_str =~ Taglish::Taggable::SCORED_TAG_REGEX or raise "Scored tag has no score: #{tag_str}"
|
|
35
|
+
[$1, $2.to_i]
|
|
36
|
+
else
|
|
37
|
+
[tag_str, nil]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find_or_create_tags(*tag_list)
|
|
42
|
+
return [] if tag_list.empty?
|
|
43
|
+
|
|
44
|
+
list = scored ? tag_list.map{|t| name_and_score(t)[0]} : tag_list
|
|
45
|
+
|
|
46
|
+
existing_tags = Tag.named_any(list).all
|
|
47
|
+
new_tag_names = list.reject do |name|
|
|
48
|
+
name = comparable_name(name)
|
|
49
|
+
existing_tags.any? {|tag| comparable_name(tag.name) == name}
|
|
50
|
+
end
|
|
51
|
+
created_tags = new_tag_names.map {|name| Tag.create(:name => name) }
|
|
52
|
+
|
|
53
|
+
existing_tags + created_tags
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Returns the proper string used to join tags:
|
|
58
|
+
# basically the first choice of delimiters,
|
|
59
|
+
# with a space after each delimiter.
|
|
60
|
+
def glue
|
|
61
|
+
d = delimiter.kind_of?(Array) ? delimiter[0] : delimiter
|
|
62
|
+
d.ends_with?(" ") ? d : "#{d} "
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
module Taglish::Taggable
|
|
2
|
+
|
|
3
|
+
SCORED_TAG_REGEX = /^(.+):(-?\d+)$/
|
|
4
|
+
|
|
5
|
+
def taggable?
|
|
6
|
+
false
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def taglish
|
|
10
|
+
taglish_on(:tags)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def ordered_taglish
|
|
14
|
+
ordered_taglish_on(:tags)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def scored_taglish
|
|
18
|
+
scored_taglish_on(:tags)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
##
|
|
22
|
+
# Make a model taggable on specified contexts.
|
|
23
|
+
#
|
|
24
|
+
# @param [Array] tag_types An array of taggable contexts
|
|
25
|
+
#
|
|
26
|
+
# Example:
|
|
27
|
+
# class User < ActiveRecord::Base
|
|
28
|
+
# scored_taglish_on :languages, :skills
|
|
29
|
+
# end
|
|
30
|
+
def scored_taglish_on(*tag_types)
|
|
31
|
+
taggable_on(false, true, tag_types)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# Make a model taggable on specified contexts.
|
|
36
|
+
#
|
|
37
|
+
# @param [Array] tag_types An array of taggable contexts
|
|
38
|
+
#
|
|
39
|
+
# Example:
|
|
40
|
+
# class User < ActiveRecord::Base
|
|
41
|
+
# taglish_on :languages, :skills
|
|
42
|
+
# end
|
|
43
|
+
def taglish_on(*tag_types)
|
|
44
|
+
taggable_on(false, false, tag_types)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
##
|
|
48
|
+
# Make a model taggable on specified contexts.
|
|
49
|
+
#
|
|
50
|
+
# @param [Array] tag_types An array of taggable contexts
|
|
51
|
+
#
|
|
52
|
+
# Example:
|
|
53
|
+
# class User < ActiveRecord::Base
|
|
54
|
+
# scored_taglish_on :languages, :skills
|
|
55
|
+
# end
|
|
56
|
+
def ordered_taglish_on(*tag_types)
|
|
57
|
+
taggable_on(true, false, tag_types)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def taggable_on(ordered, scored, *new_tag_types)
|
|
63
|
+
# Assume new_tag_types has plural forms, like `skills`:
|
|
64
|
+
new_tag_types = new_tag_types.to_a.flatten.compact.map(&:to_sym)
|
|
65
|
+
unless taggable?
|
|
66
|
+
class_eval do
|
|
67
|
+
class_attribute :tag_types
|
|
68
|
+
self.tag_types = HashWithIndifferentAccess.new
|
|
69
|
+
|
|
70
|
+
has_many :taggings, :as => :taggable, :dependent => :destroy,
|
|
71
|
+
:include => :tag, :class_name => "Taglish::Tagging"
|
|
72
|
+
has_many :all_tags, :through => :taggings, :source => :tag,
|
|
73
|
+
:class_name => "Taglish::Tag"
|
|
74
|
+
|
|
75
|
+
def self.taggable?
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
include Taglish::Util
|
|
80
|
+
include Taglish::Core
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
# THEN: Copy/paste the save method, and think about how to implement
|
|
84
|
+
# set_tag_list_on.
|
|
85
|
+
# THEN: Implement tag_list_on.
|
|
86
|
+
# LATER: Implement TagList (extending Array) to save back tags
|
|
87
|
+
# if the user removes from/adds to the array.
|
|
88
|
+
new_tag_types.each do |ptt| # ptt is the plural form
|
|
89
|
+
stt = ptt.to_s.singularize
|
|
90
|
+
|
|
91
|
+
tag_type = Taglish::TagType.new(ptt, :scored => scored, :ordered => ordered)
|
|
92
|
+
self.tag_types[ptt] = tag_type
|
|
93
|
+
|
|
94
|
+
taggings_scope_name = ptt.to_sym
|
|
95
|
+
taggings_order = tag_type.ordered ? "#{Taglish::Tagging.table_name}.id" : nil
|
|
96
|
+
|
|
97
|
+
class_eval do
|
|
98
|
+
has_many taggings_scope_name, :as => :taggable,
|
|
99
|
+
:dependent => :destroy,
|
|
100
|
+
:include => :tag,
|
|
101
|
+
:class_name => 'Taglish::Tagging',
|
|
102
|
+
:conditions => ["#{Taglish::Tagging.table_name}.context = ?", ptt],
|
|
103
|
+
:order => taggings_order
|
|
104
|
+
|
|
105
|
+
has_many "#{stt}_tags".to_sym, :through => taggings_scope_name,
|
|
106
|
+
:source => :tag,
|
|
107
|
+
:class_name => "Taglish:Tag",
|
|
108
|
+
:order => taggings_order
|
|
109
|
+
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class_eval %(
|
|
113
|
+
def #{stt}_list
|
|
114
|
+
tag_list_on(tag_types['#{ptt}'])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def #{stt}_list=(new_tags)
|
|
118
|
+
set_tag_list_on(tag_types['#{ptt}'], new_tags)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def all_#{ptt}
|
|
122
|
+
all_tags_list_on('#{ptt}')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def add_#{stt}(tag)
|
|
126
|
+
add_tag_on('#{ptt}', tag)
|
|
127
|
+
end
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if scored
|
|
131
|
+
class_eval %(
|
|
132
|
+
def score_for_#{stt}(tag)
|
|
133
|
+
raise "TODO"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def set_score_for_#{stt}(tag, score)
|
|
137
|
+
set_score_for_tag_on('#{ptt}', tag, score)
|
|
138
|
+
end
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
class Taglish::Tagging < ActiveRecord::Base
|
|
2
|
+
include Taglish::Util
|
|
3
|
+
|
|
4
|
+
attr_accessible :tag, :tag_id, :context, :taggable, :taggable_type, :taggable_id,
|
|
5
|
+
:tagger, :tagger_type, :tagger_id, :score, :name
|
|
6
|
+
|
|
7
|
+
belongs_to :tag, :class_name => 'Taglish::Tag'
|
|
8
|
+
belongs_to :taggable, :polymorphic => true
|
|
9
|
+
belongs_to :tagger, :polymorphic => true
|
|
10
|
+
|
|
11
|
+
validates_presence_of :context
|
|
12
|
+
validates_presence_of :tag_id
|
|
13
|
+
validates_uniqueness_of :tag_id, :scope => [ :taggable_type, :taggable_id, :context, :tagger_id, :tagger_type ]
|
|
14
|
+
|
|
15
|
+
# after_destroy :remove_unused_tags
|
|
16
|
+
|
|
17
|
+
def tag_type
|
|
18
|
+
@tag_type ||= taggable.tag_types[context]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def name
|
|
22
|
+
@name ||= tag.name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def name=(v)
|
|
26
|
+
raise "Can't change the name of a tag/tagging" if @name or (tag and tag.name)
|
|
27
|
+
@name = v
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ==(object)
|
|
31
|
+
super || (
|
|
32
|
+
object.is_a?(Tagging) &&
|
|
33
|
+
context == object.context &&
|
|
34
|
+
name == object.name &&
|
|
35
|
+
score == object.score)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
score ? "#{name}:#{score}" : name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def remove_unused_tags
|
|
45
|
+
if taggable.remove_unused_tags_for?(context)
|
|
46
|
+
tag.destroy if tag.taggings.count.zero?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
end
|
data/lib/taglish/util.rb
ADDED
data/rails/init.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require 'taglish'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
sqlite3:
|
|
2
|
+
adapter: sqlite3
|
|
3
|
+
database: acts_as_taggable_on.sqlite3
|
|
4
|
+
|
|
5
|
+
mysql:
|
|
6
|
+
adapter: mysql2
|
|
7
|
+
hostname: localhost
|
|
8
|
+
username: root
|
|
9
|
+
password:
|
|
10
|
+
database: acts_as_taggable_on
|
|
11
|
+
charset: utf8
|
|
12
|
+
|
|
13
|
+
postgresql:
|
|
14
|
+
adapter: postgresql
|
|
15
|
+
hostname: localhost
|
|
16
|
+
username: postgres
|
|
17
|
+
password:
|
|
18
|
+
database: acts_as_taggable_on
|
|
19
|
+
encoding: utf8
|
data/spec/models.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
class TaggableModel < ActiveRecord::Base
|
|
2
|
+
taglish
|
|
3
|
+
taglish_on :languages
|
|
4
|
+
taglish_on :skills
|
|
5
|
+
taglish_on :needs, :offerings
|
|
6
|
+
has_many :untaggable_models
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class CachedModel < ActiveRecord::Base
|
|
10
|
+
taglish
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class OtherCachedModel < ActiveRecord::Base
|
|
14
|
+
taglish_on :languages, :statuses, :glasses
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class OtherTaggableModel < ActiveRecord::Base
|
|
18
|
+
taglish_on :tags, :languages
|
|
19
|
+
taglish_on :needs, :offerings
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class InheritingTaggableModel < TaggableModel
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class AlteredInheritingTaggableModel < TaggableModel
|
|
26
|
+
taglish_on :parts
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class TaggableUser < ActiveRecord::Base
|
|
30
|
+
# TODO LATER
|
|
31
|
+
# acts_as_tagger
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class InheritingTaggableUser < TaggableUser
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class UntaggableModel < ActiveRecord::Base
|
|
38
|
+
belongs_to :taggable_model
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class NonStandardIdTaggableModel < ActiveRecord::Base
|
|
42
|
+
primary_key = "an_id"
|
|
43
|
+
taglish
|
|
44
|
+
taglish_on :languages
|
|
45
|
+
taglish_on :skills
|
|
46
|
+
taglish_on :needs, :offerings
|
|
47
|
+
has_many :untaggable_models
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class OrderedTaggableModel < ActiveRecord::Base
|
|
51
|
+
ordered_taglish
|
|
52
|
+
ordered_taglish_on :colors
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class MixedTaggableModel < ActiveRecord::Base
|
|
56
|
+
taglish_on :skills
|
|
57
|
+
ordered_taglish_on :colors
|
|
58
|
+
scored_taglish_on :question_counts
|
|
59
|
+
taglish_on :needs, :offerings
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class ScoredTaggableModel < ActiveRecord::Base
|
|
63
|
+
scored_taglish_on :question_counts
|
|
64
|
+
end
|