has_machine_tags 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG.rdoc ADDED
@@ -0,0 +1,19 @@
1
+ == 0.1.6
2
+ * Release to rubyforge
3
+ * Allow default_predicates to be a proc
4
+
5
+ == 0.1.5
6
+ * Added finder tests
7
+ * Added some helper methods
8
+
9
+ == 0.1.4
10
+ * Added console option and methods
11
+ * Bug fix for init.rb
12
+
13
+ == 0.1.3
14
+ * Added quick mode
15
+ * Added machine tag finders
16
+ * Added reverse_has_many option
17
+
18
+ == 0.1.0
19
+ * Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT LICENSE
2
+
3
+ Copyright (c) 2009 Gabriel Horner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,143 @@
1
+ == Description
2
+
3
+ This plugin implements Flickr's machine tags as explained
4
+ here[http://www.flickr.com/groups/api/discuss/72157594497877875]
5
+ while still maintaining standard tagging behavior.
6
+
7
+ Basically, a machine tag has a namespace, a predicate and a value in the format
8
+ [namespace]:[predicate]=[value]
9
+
10
+ This allows for more precise tagging as tags can have unlimited contexts provided
11
+ by combinations of namespaces and predicates. These unlimited contexts also make
12
+ machine tags ripe for modeling relationships between objects. Read the HasMachineTags::TagMethods class
13
+ documentation for a more thorough explanation.
14
+
15
+ A demo app using this plugin is {here}[http://github.com/cldwalker/tag-tree].
16
+
17
+ == Install
18
+
19
+ Install as a gem
20
+
21
+ bash> gem install has_machine_tags
22
+
23
+ # add in your environment.rb
24
+ config.gem "has_machine_tags"
25
+
26
+ Or as a plugin
27
+
28
+ bash> script/plugin install git://github.com/cldwalker/has_machine_tags.git
29
+
30
+
31
+ Migrate your database from Rails root:
32
+
33
+ bash> script/generate has_machine_tags_migration
34
+ bash> rake db:migrate
35
+
36
+ == Usage
37
+
38
+ Setup a model to use has_machine_tags
39
+
40
+ class Url < ActiveRecord::Base
41
+ has_machine_tags
42
+ end
43
+
44
+ Let's create some urls with machine tags!
45
+
46
+ url = Url.create(:name=>"http://github.com/cldwalker/has_machine_tags",
47
+ :tag_list=>'gem:type=tagging,flickr')
48
+
49
+ url2 = Url.create(:name=>"http://github.com/giraffesoft/is_taggable",
50
+ :tag_list=>'gem:type=tagging, gem:user=giraffesoft')
51
+
52
+ url3 = Url.create(:name=>"http://github.com/datamapper/data_mapper/tree/master",
53
+ :tag_list=>'gem:type=orm')
54
+
55
+ url.tag_list # => ["gem:type=tagging", "flickr"]
56
+
57
+ url.tags # => [<Tag name:"gem:type=tagging">, <Tag name:"flickr">]
58
+
59
+ Let's query them:
60
+
61
+ # Query urls tagged as a gem having type tagging
62
+ Url.tagged_with 'gem:type=tagging' # => [url, url2] from above
63
+
64
+ # Non-machine tags work of course
65
+ Url.tagged_with 'flickr' # => [url] from above
66
+
67
+ # tagged_with() is a named_scope so do your sweet chaining
68
+ Url.tagged_with('flickr').yet_another_finder(:sweet).paginate(:per_page=>30)
69
+
70
+ Nothing interesting so far. We could've done the same with normal tagging.
71
+
72
+ But when we start with wildcard machine tag syntax, machine tags become more valuable:
73
+
74
+ # Query urls tagged as gems (namespace = 'gem')
75
+ Url.tagged_with 'gem:' # => [url, url2, url3] from above
76
+
77
+ # Query urls tagged as having a user, regardless of namespace and value (predicate = 'user')
78
+ Url.tagged_with 'user=' # => [url2] from above
79
+
80
+ # Query urls tagged as gems having a user ( namespace ='gem' AND predicate = 'user')
81
+ Url.tagged_with 'gem:user' # => [url2] from above
82
+
83
+ # Query urls tagged as having a tagging value, regardless of namespace and predicate (value = 'tagging')
84
+ Url.tagged_with '=tagging' # => [url, url2] from above
85
+
86
+ More details on machine tag syntax can be found in the Tag class.
87
+
88
+ === More Usage
89
+
90
+ The wildcard machine tag syntax can also be used to fetch tags:
91
+
92
+ # Tags that are gems
93
+ Tag.machine_tags 'gem:' # => [<Tag name:"gem:type=tagging">, <Tag name:"gem:user=giraffesoft">]
94
+
95
+ # Tags that have a user predicate
96
+ Tag.machine_tags 'user=' # => [<Tag name:"gem:user=giraffesoft">]
97
+
98
+ Of course you can do the standard tag_list manipulation:
99
+
100
+ url.tag_list = "comma, delimited"
101
+ url.save
102
+ url.tag_list # =>['comma', 'delimited']
103
+
104
+ url.tag_list = ['or', 'an', 'array']
105
+ url.save
106
+ url.tag_list # =>['or', 'an' 'array']
107
+
108
+ #Add a tag
109
+ url.tag_list << 'another_tag'
110
+ url.save
111
+ url.tag_list # => ["gem:type=tagging", "flickr", "another_tag']
112
+
113
+ #Delete a tag
114
+ url.tag_list.delete('another_tag')
115
+ url.save
116
+ url.tag_list # => ["gem:type=tagging", "flickr"]
117
+
118
+
119
+ == Caveats
120
+
121
+ This is an experiment in progress so the api is subject to change.
122
+
123
+ Since machine tags require special characters to implement its goodness,
124
+ these characters are off limit unless used in the machine tag context:
125
+
126
+ '.', ':' , '*' , '=' , ','
127
+
128
+ == Todo
129
+
130
+ * Add a match_all option to tagged_with().
131
+ * More helper methods ie for showing relations between namespaces, predicates + values
132
+ * Possible add support for other ORM's ie DataMapper.
133
+ * Play friendly with other tagging plugins as needed.
134
+
135
+ == Issues
136
+ Please report them {on github}[http://github.com/cldwalker/has_machine_tags/issues].
137
+
138
+ == Credits
139
+
140
+ Thanks goes to Flickr for popularizing this tagging model.
141
+ Thanks also goes to the {acts-as-taggable-on plugin}[http://github.com/mbleigh/acts-as-taggable-on/tree/master]
142
+ for their finder code and the {is_taggable plugin}[http://github.com/giraffesoft/is_taggable/tree/master]
143
+ for demonstrating sane testing for a Rails plugin.
data/Rakefile ADDED
@@ -0,0 +1,50 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+ begin
5
+ require 'rcov/rcovtask'
6
+
7
+ Rcov::RcovTask.new do |t|
8
+ t.libs << 'test'
9
+ t.test_files = FileList['test/**/*_test.rb']
10
+ t.rcov_opts = ["-T -x '/Library/Ruby/*'"]
11
+ t.verbose = true
12
+ end
13
+ rescue LoadError
14
+ puts "Rcov not available. Install it for rcov-related tasks with: sudo gem install rcov"
15
+ end
16
+
17
+ begin
18
+ require 'jeweler'
19
+ Jeweler::Tasks.new do |s|
20
+ s.name = "has_machine_tags"
21
+ s.summary = "A rails tagging plugin implementing flickr's machine tags + maybe more (semantic tags)."
22
+ s.description = "This plugin implements Flickr's machine tags while still maintaining standard tagging behavior. This allows for more precise tagging as tags can have unlimited contexts provided by combinations of namespaces and predicates. These unlimited contexts also make machine tags ripe for modeling relationships between objects."
23
+ s.email = "gabriel.horner@gmail.com"
24
+ s.homepage = "http://tagaholic.me/has_machine_tags/"
25
+ s.authors = ["Gabriel Horner"]
26
+ s.rubyforge_project = ['tagaholic']
27
+ s.has_rdoc = true
28
+ s.extra_rdoc_files = ["README.rdoc", "LICENSE.txt"]
29
+ s.files = FileList["CHANGELOG.rdoc", "README.rdoc", "LICENSE.txt", "init.rb", "Rakefile", "VERSION.yml", "{rails,generators,bin,lib,test}/**/*"]
30
+ end
31
+
32
+ rescue LoadError
33
+ puts "Jeweler not available. Install it for jeweler-related tasks with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
34
+ end
35
+
36
+ Rake::TestTask.new do |t|
37
+ t.libs << 'lib'
38
+ t.pattern = 'test/**/*_test.rb'
39
+ t.verbose = false
40
+ end
41
+
42
+ Rake::RDocTask.new do |rdoc|
43
+ rdoc.rdoc_dir = 'rdoc'
44
+ rdoc.title = 'test'
45
+ rdoc.options << '--line-numbers' << '--inline-source'
46
+ rdoc.rdoc_files.include('README*')
47
+ rdoc.rdoc_files.include('lib/**/*.rb')
48
+ end
49
+
50
+ task :default => :test
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :major: 0
3
+ :minor: 1
4
+ :patch: 6
@@ -0,0 +1,7 @@
1
+ class HasMachineTagsMigrationGenerator < Rails::Generator::Base
2
+ def manifest
3
+ record do |m|
4
+ m.migration_template 'migration.rb', 'db/migrate', :migration_file_name => "has_machine_tags_migration"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,26 @@
1
+ class HasMachineTagsMigration < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :tags do |t|
4
+ t.string :name
5
+ t.string :namespace
6
+ t.string :predicate
7
+ t.string :value
8
+ t.datetime :created_at
9
+ end
10
+
11
+ create_table :taggings do |t|
12
+ t.integer :tag_id
13
+ t.integer :taggable_id
14
+ t.string :taggable_type
15
+ t.datetime :created_at
16
+ end
17
+
18
+ add_index :taggings, :tag_id
19
+ add_index :taggings, [:taggable_id, :taggable_type]
20
+ end
21
+
22
+ def self.down
23
+ drop_table :taggings
24
+ drop_table :tags
25
+ end
26
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require File.join(File.dirname(__FILE__), 'rails', 'init.rb')
@@ -0,0 +1,44 @@
1
+ module HasMachineTags
2
+ # Methods for console/irb use.
3
+ module Console
4
+ module InstanceMethods
5
+ # Removes first list, adds second list and saves changes.
6
+ def tag_add_and_remove(remove_list, add_list)
7
+ self.class.transaction do
8
+ tag_add_and_save(add_list)
9
+ tag_remove_and_save(remove_list)
10
+ end
11
+ end
12
+
13
+ # Adds given list and saves.
14
+ def tag_add_and_save(add_list)
15
+ self.tag_list = self.tag_list + current_tag_list(add_list)
16
+ self.save
17
+ self.tag_list
18
+ end
19
+
20
+ # Removes given list and saves.
21
+ def tag_remove_and_save(remove_list)
22
+ self.tag_list = self.tag_list - current_tag_list(remove_list)
23
+ self.save
24
+ self.tag_list
25
+ end
26
+
27
+ # Resets tag_list to given tag_list and saves.
28
+ def tag_and_save(tag_list)
29
+ self.tag_list = tag_list
30
+ self.save
31
+ self.tag_list
32
+ end
33
+ end
34
+
35
+ module ClassMethods
36
+ # Updates items tagged with an old tag to use the given new tag in place of the old tag.
37
+ def find_and_change_tag(old_tag, new_tag)
38
+ results = tagged_with(old_tag)
39
+ results.each {|e| e.tag_add_and_remove(old_tag, new_tag)}
40
+ puts "Changed tag for #{results.length} records"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,91 @@
1
+ module HasMachineTags
2
+ module Finder
3
+ # Takes a string of delimited tags or an array of tags.
4
+ # Note that each tag is interpreted as a possible wildcard machine tag.
5
+ #
6
+ # Options:
7
+ # :conditions - A piece of SQL conditions to add to the query.
8
+ #
9
+ # Example:
10
+ # Url.tagged_with 'something' # => fetches urls tagged with 'something'
11
+ # Url.tagged_with 'gem:' # => fetches urls tagged with tags that have namespace gem
12
+ # Url.tagged_with 'gem, something' # => fetches urls that are tagged with 'something'
13
+ # or 'gem'
14
+ #
15
+ # Note: This method really only needs to be used with Rails < 2.1 .
16
+ # Rails 2.1 and greater should use tagged_with(), which acts the same but with
17
+ # the benefits of named_scope.
18
+ #
19
+ def find_tagged_with(*args)
20
+ options = find_options_for_tagged_with(*args)
21
+ options.blank? ? [] : find(:all,options)
22
+ end
23
+
24
+ # :stopdoc:
25
+ def find_options_for_tagged_with(tags, options = {})
26
+ tags = TagList.new(tags)
27
+ return {} if tags.empty?
28
+ conditions = []
29
+ conditions << sanitize_sql(options.delete(:conditions)) if options[:conditions]
30
+ conditions << condition_from_tags(tags)
31
+ default_find_options_for_tagged_with.update(:conditions=>conditions.join(" AND ")).update(options)
32
+ end
33
+
34
+ def condition_from_tags(tags)
35
+ tag_sql = tags.map {|t|
36
+ if match = Tag.match_wildcard_machine_tag(t)
37
+ string = match.map {|k,v|
38
+ sanitize_sql(["#{tags_alias}.#{k} = ?", v])
39
+ }.join(" AND ")
40
+ "(#{string})"
41
+ else
42
+ sanitize_sql(["#{tags_alias}.name = ?", t])
43
+ end
44
+ }.join(" OR ")
45
+ end
46
+
47
+ def taggings_alias
48
+ "#{table_name}_taggings"
49
+ end
50
+
51
+ def tags_alias
52
+ "#{table_name}_tags"
53
+ end
54
+
55
+ def default_find_options_for_tagged_with
56
+ { :select => "DISTINCT #{table_name}.*",
57
+ :joins => "LEFT OUTER JOIN #{Tagging.table_name} #{taggings_alias} ON #{taggings_alias}.taggable_id = #{table_name}.#{primary_key} AND #{taggings_alias}.taggable_type = #{quote_value(base_class.name)} " +
58
+ "LEFT OUTER JOIN #{Tag.table_name} #{tags_alias} ON #{tags_alias}.id = #{taggings_alias}.tag_id",
59
+ # :group => group
60
+ }
61
+ end
62
+
63
+ # TODO: add back in options as needed.
64
+ # Options:
65
+ # :exclude - Find models that are not tagged with the given tags.
66
+ # :match_all - Find models that match all of the given tags, not just one (doesn't work with machine tags yet).
67
+ def old_find_options_for_find_tagged_with(tags, options = {}) #:nodoc:
68
+ # options.reverse_merge!(:match_all=>true)
69
+ machine_tag_used = false
70
+ if options.delete(:exclude)
71
+ tags_conditions = tags.map { |t| sanitize_sql(["#{Tag.table_name}.name = ?", t]) }.join(" OR ")
72
+ conditions << sanitize_sql(["#{table_name}.id NOT IN (SELECT #{Tagging.table_name}.taggable_id FROM #{Tagging.table_name} LEFT OUTER JOIN #{Tag.table_name} ON #{Tagging.table_name}.tag_id = #{Tag.table_name}.id WHERE (#{tags_conditions}) AND #{Tagging.table_name}.taggable_type = #{quote_value(base_class.name)})", tags])
73
+ else
74
+ conditions << condition_from_tags(tags)
75
+
76
+ if options.delete(:match_all)
77
+ group = "#{taggings_alias}.taggable_id HAVING COUNT(#{taggings_alias}.taggable_id) = "
78
+ if machine_tag_used
79
+ #Since a machine tag matches multiple tags per given tag, we need to dynamically calculate the count
80
+ #TODO: this select needs to return differently for each taggable_id
81
+ group += "(SELECT count(id) FROM #{Tag.table_name} #{tags_alias} WHERE #{tag_sql})"
82
+ else
83
+ group += tags.size.to_s
84
+ end
85
+ end
86
+ end
87
+ default_find_options_for_tagged_with.update(:conditions=>conditions.join(" AND ")).update(options)
88
+ end
89
+ # :startdoc:
90
+ end
91
+ end
@@ -0,0 +1,3 @@
1
+ class ::Tag < ActiveRecord::Base #:nodoc:
2
+ include HasMachineTags::TagMethods
3
+ end
@@ -0,0 +1,32 @@
1
+ module HasMachineTags
2
+ module TagConsole #:nodoc:
3
+ def self.included(base)
4
+ base.class_eval %[
5
+ named_scope :namespace_counts, :select=>'*, namespace as counter, count(namespace) as count', :group=>"namespace HAVING count(namespace)>=1"
6
+ named_scope :predicate_counts, :select=>'*, predicate as counter, count(predicate) as count', :group=>"predicate HAVING count(predicate)>=1"
7
+ named_scope :value_counts, :select=>'*, value as counter, count(value) as count', :group=>"value HAVING count(value)>=1"
8
+ named_scope :distinct_namespaces, :select=>"distinct namespace"
9
+ named_scope :distinct_predicates, :select=>"distinct predicate"
10
+ named_scope :distinct_values, :select=>"distinct value"
11
+ ]
12
+ base.extend ClassMethods
13
+ end
14
+
15
+ module ClassMethods
16
+ #:stopdoc:
17
+ def namespaces; distinct_namespaces.map(&:namespace).compact; end
18
+ def predicates; distinct_predicates.map(&:predicate).compact; end
19
+ def values; distinct_values.map(&:value).compact; end
20
+ #:startdoc:
21
+
22
+ # To be used with the *counts methods.
23
+ # For example:
24
+ # stat(:namespace_counts)
25
+ # This prints out pairs of a namespaces and their counts in the tags table.
26
+ def stat(type)
27
+ shortcuts = {:n=>:namespace_counts, :p=>:predicate_counts, :v=>:value_counts }
28
+ send(shortcuts[type] || type).map {|e| [e.counter, e.count] }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,69 @@
1
+ module HasMachineTags
2
+ class TagList < Array
3
+ cattr_accessor :delimiter
4
+ self.delimiter = ','
5
+ cattr_accessor :default_predicate
6
+ self.default_predicate = 'tags'
7
+ QUICK_MODE_DELIMITER = ';'
8
+
9
+ # ==== Options:
10
+ # [:quick_mode]
11
+ # When true enables a quick mode for inputing multiple machine tags under the same namespace.
12
+ # These machine tags are delimited by QUICK_MODE_DELIMITER. If a predicate is not specified, default_predicate() is used.
13
+ # Examples:
14
+ # # Namespace is added to tag 'type=test'.
15
+ # HasMachineTags::TagList.new("gem:name=flog;type=test, user:name=seattlerb", :quick_mode=>true)
16
+ # => ["gem:name=flog", "gem:type=test", "user:name=seattlerb"]
17
+ #
18
+ # # Namespace and default predicate (tags) are added to tag 'git'.
19
+ # HasMachineTags::TagList.new("gem:name=grit;git, user:name=mojombo")
20
+ # => ["gem:name=grit", "gem:tags=git", "user:name=mojombo"]
21
+ def initialize(string_or_array, options={})
22
+ @options = options
23
+ array = string_or_array.is_a?(Array) ? string_or_array : string_or_array.split(/\s*#{delimiter}\s*/)
24
+ array = parse_quick_mode(array) if @options[:quick_mode]
25
+ concat array
26
+ end
27
+
28
+ def parse_quick_mode(mtag_list) #:nodoc:
29
+ mtag_list = mtag_list.map {|e|
30
+ if e.include?(Tag::PREDICATE_DELIMITER)
31
+ namespace, remainder = e.split(Tag::PREDICATE_DELIMITER)
32
+ remainder.split(QUICK_MODE_DELIMITER).map {|e|
33
+ e.include?(Tag::VALUE_DELIMITER) ? "#{namespace}#{Tag::PREDICATE_DELIMITER}#{e}" :
34
+ (@options[:default_predicate] ? Tag.build_machine_tag(namespace, @options[:default_predicate].call(e, namespace), e) :
35
+ Tag.build_machine_tag(namespace, default_predicate, e))
36
+ }
37
+ else
38
+ e
39
+ end
40
+ }.flatten
41
+ end
42
+
43
+ def namespace_hashes #:nodoc:
44
+ self.inject({}) {|h, e|
45
+ namespace, *predicate_value = Tag.split_machine_tag(e)
46
+ (h[namespace] ||= []) << predicate_value unless namespace.nil?
47
+ h
48
+ }
49
+ end
50
+
51
+ def non_machine_tags
52
+ self.reject {|e| Tag.machine_tag?(e)}
53
+ end
54
+
55
+ # Converts tag_list to a stringified version of quick_mode.
56
+ def to_quick_mode_string
57
+ machine_tags = namespace_hashes.map {|namespace, predicate_values|
58
+ "#{namespace}:" + predicate_values.map {|pred, value|
59
+ pred == self.default_predicate ? value : "#{pred}#{Tag::VALUE_DELIMITER}#{value}"
60
+ }.join(QUICK_MODE_DELIMITER)
61
+ }
62
+ (machine_tags + non_machine_tags).join("#{delimiter} ")
63
+ end
64
+
65
+ def to_s #:nodoc:
66
+ join("#{delimiter} ")
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,153 @@
1
+ module HasMachineTags
2
+ # == Machine Tags
3
+ # Machine tags, also known as triple tags, are in the format:
4
+ # [namespace]:[predicate]=[value]
5
+ #
6
+ # As explained here[http://www.flickr.com/groups/api/discuss/72157594497877875],
7
+ # a namespace and predicate must start with a letter a-z while its remaining characters can be any lowercase alphanumeric character
8
+ # and underscore. A value can contain any characters that normal tags use.
9
+ #
10
+ # == Wildcard Machine Tags
11
+ # Wildcard machine tag syntax is used with Tag.machine_tags() and {tagged_with() or find_tagged_with()}[link:classes/HasMachineTags/Finder.html] of tagged objects.
12
+ # This syntax allows one to fetch items that fall under a group of tags, as specified by namespace, predicate, value or
13
+ # a combination of these ways. While this plugin supports {Flickr's wildcard format}[http://code.flickr.com/blog/2008/07/18/wildcard-machine-tag-urls/],
14
+ # it also supports its own slightly shorter format.
15
+ #
16
+ # === Examples
17
+ #
18
+ # For a tag 'user:name=john', the following wildcards would match it:
19
+ #
20
+ # * Wild namespace (any tag with namespace user)
21
+ # Tag.machine_tags 'user:' # Our way
22
+ # Tag.machine_tags 'user:*=' # Flickr way
23
+ #
24
+ # * Wild predicate (any tag with predicate name)
25
+ # Tag.machine_tags 'name=' # Our way
26
+ # Tag.machine_tags '*:name=' # Flickr way
27
+ #
28
+ # * Wild predicate (any tag with value john)
29
+ # Tag.machine_tags '=john' # Our way
30
+ # Tag.machine_tags '*:*=john' # Flickr way
31
+ #
32
+ # * Wild namespace and predicate (any tag with namespace user and predicate name)
33
+ # Tag.machine_tags 'user:name' # Our way
34
+ # Tag.machine_tags 'user:name=' # Flickr way
35
+ #
36
+ # * Wild predicate and value (any tag with predicate name and value john)
37
+ # Tag.machine_tags 'name=john' # Our way
38
+ # Tag.machine_tags '*:name=john' # Flickr way
39
+ #
40
+ # * Wild namespace and value (any tag with namespace user and value john)
41
+ # Tag.machine_tags 'user.john' # Our way
42
+ # Tag.machine_tags 'user:*=john' # Flickr way
43
+ #
44
+ # == Food For Thought
45
+ # So what's so great about being able to give a tag a namespace and a predicate?
46
+ # * It allows for more fine-grained tag queries by giving multiple contexts:
47
+ #
48
+ # Say instead of having tagged with 'user:name=john' we had tagged with the traditional separate
49
+ # tags: user, name and john. How would we know that we had meant an item to be tagged
50
+ # as a user ie with namespace user? We wouldn't know. Any query for 'user' would return
51
+ # all user-tagged items <b>without context</b>. With machine tags, we can have 'user' refer
52
+ # to a particular combination of namespace, predicate and value.
53
+ #
54
+ # * It keeps tag-spaces cleaner because there are more contexts:
55
+ #
56
+ # With traditional separate tags, tags just have a global context. So
57
+ # if different users decide to give different meaning to the same tag, the tag starts to become
58
+ # polluted and loses its usefulness. With the limitless contexts provided by machine tags,
59
+ # a machine tag is less likely to pollute other tags.
60
+ #
61
+ # * It allows tagging to serve as a medium for defining relationships between objects:
62
+ #
63
+ # Since a machine tag tracks three attributes (namespace, predicate and value), it's possible to develop relationships
64
+ # between the attributes. This means namespaces can have many predicates and predicates can have many values.
65
+ # Since this closely resembles object modeling, we can start to use tagging to form relationships between tagged items and other objects.
66
+ module TagMethods
67
+ NAMESPACE_REGEX = "[a-z](?:[a-z0-9_]+)"
68
+ PREDICATE_REGEX = "[a-z](?:[a-z0-9_-]+)"
69
+ VALUE_REGEX = '.+'
70
+ #TODO: use delimiters in this file
71
+ PREDICATE_DELIMITER = ':'
72
+ VALUE_DELIMITER = '='
73
+
74
+ def self.included(base) #:nodoc:
75
+ name_format = /\A(([^\*\=\:\.,]+)|(#{NAMESPACE_REGEX}\:#{PREDICATE_REGEX}\=#{VALUE_REGEX}))\Z/
76
+ base.class_eval %[
77
+ has_many :taggings
78
+
79
+ validates_presence_of :name
80
+ validates_uniqueness_of :name
81
+ before_save :update_name_related_columns
82
+
83
+ #disallow machine tags special characters and tag list delimiter OR allow machine tag format
84
+ validates_format_of :name, :with=>name_format
85
+ ]
86
+ base.extend(ClassMethods)
87
+ base.send :include, InstanceMethods
88
+ end
89
+
90
+ module ClassMethods
91
+ # Takes a wildcard machine tag and returns matching tags.
92
+ def machine_tags(name)
93
+ conditions = if (match = match_wildcard_machine_tag(name))
94
+ match.map {|k,v|
95
+ sanitize_sql(["#{k} = ?", v])
96
+ }.join(" AND ")
97
+ else
98
+ sanitize_sql(["name = ?", name])
99
+ end
100
+ find(:all, :conditions=>conditions)
101
+ end
102
+
103
+ # Builds a machine tag string given namespace, predicate and value.
104
+ def build_machine_tag(namespace, predicate, value)
105
+ "#{namespace}:#{predicate}=#{value}"
106
+ end
107
+
108
+ # Returns an array of machine tag parts: [namespace, predicate, value]
109
+ def split_machine_tag(machine_tag)
110
+ extract_from_name(machine_tag) || []
111
+ end
112
+
113
+ # Boolean indicating if given tag is a machine tag.
114
+ def machine_tag?(machine_tag)
115
+ !extract_from_name(machine_tag).nil?
116
+ end
117
+
118
+ def extract_from_name(tag_name) #:nodoc:
119
+ (tag_name =~ /^(#{NAMESPACE_REGEX})\:(#{PREDICATE_REGEX})\=(#{VALUE_REGEX})$/) ? [$1, $2, $3] : nil
120
+ end
121
+
122
+ # Valid wildcards with their equivalent shortcuts
123
+ # namespace:*=* -> namespace:
124
+ # *:predicate=* -> predicate=
125
+ # *:*=value -> :value
126
+ def match_wildcard_machine_tag(name) #:nodoc:
127
+ if name =~ /^(#{NAMESPACE_REGEX}|\*)\:(#{PREDICATE_REGEX}|\*)\=(#{VALUE_REGEX}|\*)?$/
128
+ result = [[:namespace, $1], [:predicate, $2], [:value, $3]].select {|k,v| ![nil,'*'].include?(v) }
129
+ result.size == 3 ? nil : result
130
+ #duo shortcuts
131
+ elsif name =~ /^(#{NAMESPACE_REGEX}\:#{PREDICATE_REGEX})|(#{PREDICATE_REGEX}\=#{VALUE_REGEX})|(#{NAMESPACE_REGEX}\.#{VALUE_REGEX})$/
132
+ $1 ? [:namespace, :predicate].zip($1.split(":")) : ($2 ? [:predicate, :value].zip($2.split("=")) :
133
+ [:namespace, :value].zip($3.split('.')) )
134
+ #single shortcuts
135
+ elsif name =~ /^((#{NAMESPACE_REGEX})(?:\:)|(#{PREDICATE_REGEX})(?:\=)|(?:\=)(#{VALUE_REGEX}))$/
136
+ $2 ? [[:namespace, $2]] : ($3 ? [[:predicate, $3]] : [[:value, $4]])
137
+ else
138
+ nil
139
+ end
140
+ end
141
+ end
142
+
143
+ module InstanceMethods
144
+ private
145
+
146
+ def update_name_related_columns
147
+ if self.changed.include?('name') && (arr = self.class.extract_from_name(self.name))
148
+ self[:namespace], self[:predicate], self[:value] = arr
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,4 @@
1
+ class Tagging < ActiveRecord::Base #:nodoc:
2
+ belongs_to :tag
3
+ belongs_to :taggable, :polymorphic => true
4
+ end
@@ -0,0 +1,88 @@
1
+ current_dir = File.dirname(__FILE__)
2
+ $:.unshift(current_dir) unless $:.include?(current_dir) || $:.include?(File.expand_path(current_dir))
3
+ require 'has_machine_tags/finder'
4
+ require 'has_machine_tags/tag_list'
5
+ require 'has_machine_tags/console'
6
+
7
+ module HasMachineTags
8
+ def self.included(base) #:nodoc:
9
+ base.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ # ==== Options:
14
+ # [:console] When true, adds additional helper methods from HasMachineTags::Console to use mainly in irb.
15
+ # [:reverse_has_many] Defines a has_many :through from tags to the model using the plural of the model name.
16
+ # [:quick_mode] When true, enables a quick mode to input machine tags with HasMachineTags::InstanceMethods.tag_list=(). See examples at HasMachineTags::TagList.new().
17
+ def has_machine_tags(options={})
18
+ cattr_accessor :quick_mode
19
+ self.quick_mode = options[:quick_mode] || false
20
+ self.class_eval do
21
+ has_many :taggings, :as=>:taggable, :dependent=>:destroy
22
+ has_many :tags, :through=>:taggings
23
+ after_save :save_tags
24
+
25
+ include HasMachineTags::InstanceMethods
26
+ extend HasMachineTags::Finder
27
+ if options[:console]
28
+ include HasMachineTags::Console::InstanceMethods
29
+ extend HasMachineTags::Console::ClassMethods
30
+ end
31
+ if respond_to?(:named_scope)
32
+ named_scope :tagged_with, lambda{ |*args|
33
+ find_options_for_tagged_with(*args)
34
+ }
35
+ end
36
+ end
37
+ if options[:reverse_has_many]
38
+ model = self.to_s
39
+ 'Tag'.constantize.class_eval do
40
+ has_many(model.tableize, :through => :taggings, :source => :taggable, :source_type =>model)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ module InstanceMethods
47
+ # Set tag list with an array of tags or comma delimited string of tags.
48
+ def tag_list=(list)
49
+ @tag_list = current_tag_list(list)
50
+ end
51
+
52
+ def current_tag_list(list) #:nodoc:
53
+ TagList.new(list, :quick_mode=>self.quick_mode)
54
+ end
55
+
56
+ # Fetches latest tag list for an object
57
+ def tag_list
58
+ @tag_list ||= TagList.new(self.tags.map(&:name))
59
+ end
60
+
61
+ def quick_mode_tag_list
62
+ tag_list.to_quick_mode_string
63
+ end
64
+
65
+ protected
66
+ # :stopdoc:
67
+ def save_tags
68
+ self.class.transaction do
69
+ delete_unused_tags
70
+ add_new_tags
71
+ end
72
+ end
73
+
74
+ def delete_unused_tags
75
+ unused_tags = tags.select {|e| !tag_list.include?(e.name) }
76
+ tags.delete(*unused_tags)
77
+ end
78
+
79
+ def add_new_tags
80
+ new_tags = tag_list - (self.tags || []).map(&:name)
81
+ new_tags.each do |t|
82
+ self.tags << Tag.find_or_initialize_by_name(t)
83
+ end
84
+ end
85
+ #:startdoc:
86
+ end
87
+
88
+ end
data/rails/init.rb ADDED
@@ -0,0 +1,13 @@
1
+ require 'has_machine_tags'
2
+ require 'has_machine_tags/tag_methods'
3
+
4
+ #attempt to load constant
5
+ ::Tag rescue nil
6
+ if Object.const_defined? :Tag
7
+ ::Tag.class_eval %[include HasMachineTags::TagMethods]
8
+ else
9
+ require 'has_machine_tags/tag'
10
+ end
11
+
12
+ require 'has_machine_tags/tagging'
13
+ ActiveRecord::Base.send :include, HasMachineTags
@@ -0,0 +1,84 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class HasMachineTags::FinderTest < Test::Unit::TestCase
4
+ before(:each) {
5
+ [Tag, Tagging, TaggableModel].each {|e| e.delete_all}
6
+ }
7
+
8
+ def create_extra_taggable
9
+ TaggableModel.create(:tag_list=>"blah:blih=bluh")
10
+ end
11
+
12
+ context "TaggableModel" do
13
+ context "finds by" do
14
+ before(:each) {
15
+ @taggable = TaggableModel.create(:tag_list=>"url:lang=ruby")
16
+ create_extra_taggable
17
+ }
18
+
19
+ test "namespace wildcard machine tag" do
20
+ TaggableModel.tagged_with("url:").should == [@taggable]
21
+ end
22
+
23
+ test "predicate wildcard machine tag" do
24
+ TaggableModel.tagged_with("lang=").should == [@taggable]
25
+ end
26
+
27
+ test "value wildcard machine tag" do
28
+ TaggableModel.tagged_with("=ruby").should == [@taggable]
29
+ end
30
+
31
+ test "namespace-value wildcard machine tag" do
32
+ TaggableModel.tagged_with("url.ruby").should == [@taggable]
33
+ end
34
+
35
+ test "predicate-value wildcard machine tag" do
36
+ TaggableModel.tagged_with("lang=ruby").should == [@taggable]
37
+ end
38
+ end
39
+
40
+ context "finds with" do
41
+ test "multiple machine tags as an array" do
42
+ @taggable = TaggableModel.create(:tag_list=>"article:todo=later")
43
+ @taggable2 = TaggableModel.create(:tag_list=>"article:tags=funny")
44
+ create_extra_taggable
45
+ results = TaggableModel.tagged_with(["article:todo=later", "article:tags=funny"])
46
+ results.size.should == 2
47
+ results.include?(@taggable).should be(true)
48
+ results.include?(@taggable2).should be(true)
49
+ end
50
+
51
+ test "multiple machine tags as a delimited string" do
52
+ @taggable = TaggableModel.create(:tag_list=>"article:todo=later")
53
+ @taggable2 = TaggableModel.create(:tag_list=>"article:tags=funny")
54
+ create_extra_taggable
55
+ results = TaggableModel.tagged_with("article:todo=later, article:tags=funny")
56
+ results.size.should == 2
57
+ results.include?(@taggable).should be(true)
58
+ results.include?(@taggable2).should be(true)
59
+ end
60
+
61
+ test "condition option" do
62
+ @taggable = TaggableModel.create(:title=>"so limiting", :tag_list=>"url:tags=funny" )
63
+ create_extra_taggable
64
+ TaggableModel.tagged_with("url:tags=funny", :conditions=>"title = 'so limiting'").should == [@taggable]
65
+ end
66
+ end
67
+
68
+ context "when queried with normal tag" do
69
+ before(:each) { @taggable = TaggableModel.new }
70
+ test "doesn't find if machine tagged" do
71
+ @taggable.tag_list = 'url:tags=square'
72
+ @taggable.save
73
+ Tag.count.should == 1
74
+ TaggableModel.tagged_with("square").should == []
75
+ end
76
+
77
+ test "finds if tagged normally" do
78
+ @taggable.tag_list = 'square, some:machine=tag'
79
+ @taggable.save
80
+ TaggableModel.tagged_with("square").should == [@taggable]
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,72 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class HasMachineTagsTest < Test::Unit::TestCase
4
+ context "TagList" do
5
+ before(:each) { @taggable = TaggableModel.new }
6
+
7
+ test "sets tag list with array" do
8
+ arr = ['some', 'tag:name=blah']
9
+ @taggable.tag_list = arr
10
+ @taggable.tag_list.should == arr
11
+ end
12
+
13
+ test "sets tag list with delimited string" do
14
+ arr = ['more', 'tag:type=clever']
15
+ @taggable.tag_list = arr.join(", ")
16
+ @taggable.tag_list.should == arr
17
+ end
18
+
19
+ test "sets tag list with messy delimited string" do
20
+ arr = ['more', 'tag:type=dumb', 'really']
21
+ @taggable.tag_list = "more,tag:type=dumb, really"
22
+ @taggable.tag_list.should == arr
23
+ @taggable.tag_list.to_s.should == arr.join(", ")
24
+ end
25
+
26
+ context "with quick_mode" do
27
+ before(:all) { TaggableModel.quick_mode = true }
28
+ after(:all) { TaggableModel.quick_mode = false }
29
+
30
+ test "sets tag list normally with non quick_mode characters" do
31
+ arr = ['more', 'tag:type=dumb', 'really']
32
+ @taggable.tag_list = "more,tag:type=dumb, really"
33
+ @taggable.tag_list.should == arr
34
+ end
35
+
36
+ test "sets default predicate and infers namespace" do
37
+ @taggable.tag_list = "gem:irb;name=utility_belt, article"
38
+ @taggable.tag_list.should == ["gem:tags=irb", "gem:name=utility_belt", "article"]
39
+ end
40
+ end
41
+ end
42
+
43
+ context "InstanceMethods" do
44
+ before(:each) { @taggable = TaggableModel.new }
45
+
46
+ test "creates all tags" do
47
+ tags = ['some', 'tag:name=blah']
48
+ @taggable.tag_list = tags
49
+ @taggable.save!
50
+ @taggable.tags.map(&:name).should == tags
51
+ end
52
+
53
+ test "only creates new tags" do
54
+ @taggable.tag_list = "bling"
55
+ @taggable.save!
56
+ tag_count = Tag.count
57
+ @taggable.tag_list = "bling, bling2"
58
+ @taggable.save!
59
+ @taggable.taggings.size.should == 2
60
+ Tag.count.should == tag_count + 1
61
+ end
62
+
63
+ test "deletes unused tags" do
64
+ @taggable.tag_list == 'bling, bling3'
65
+ @taggable.save!
66
+ @taggable.tag_list = "bling4"
67
+ @taggable.save!
68
+ @taggable.taggings.size.should == 1
69
+ @taggable.tags.map(&:name).should == ['bling4']
70
+ end
71
+ end
72
+ end
data/test/schema.rb ADDED
@@ -0,0 +1,20 @@
1
+ ActiveRecord::Schema.define(:version => 0) do
2
+ create_table :taggable_models do |t|
3
+ t.string :title
4
+ end
5
+
6
+ create_table :tags do |t|
7
+ t.string :name
8
+ t.string :namespace
9
+ t.string :predicate
10
+ t.string :value
11
+ t.datetime :created_at
12
+ end
13
+
14
+ create_table :taggings do |t|
15
+ t.integer :tag_id
16
+ t.string :taggable_type
17
+ t.integer :taggable_id
18
+ t.datetime :created_at
19
+ end
20
+ end
@@ -0,0 +1,109 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ class HasMachineTags::TagMethodsTest < Test::Unit::TestCase
4
+ test "create with normal tag name only touches name" do
5
+ obj = Tag.create(:name=>'blah1')
6
+ [:name, :namespace, :predicate, :value].map {|e| obj.send(e)}.should == ['blah1', nil, nil, nil]
7
+ end
8
+
9
+ test "create with machine tag name sets all name fields" do
10
+ obj = Tag.create(:name=>'gem:name=machine')
11
+ [:name, :namespace, :predicate, :value].map {|e| obj.send(e)}.should == ['gem:name=machine', 'gem', 'name', 'machine']
12
+ end
13
+
14
+ context "update" do
15
+ before(:each) { @obj = Tag.new }
16
+
17
+ test "with normal tag name only touches name" do
18
+ @obj.update_attributes :name=> 'bling'
19
+ [:name, :namespace, :predicate, :value].map {|e| @obj.send(e)}.should == ['bling', nil, nil, nil]
20
+ end
21
+
22
+ test "with machine tag name sets all name fields" do
23
+ @obj.update_attributes :name=>'gem:prop=value'
24
+ [:name, :namespace, :predicate, :value].map {|e| @obj.send(e)}.should == ['gem:prop=value', 'gem', 'prop', 'value']
25
+ end
26
+
27
+ test "with no name sets no name fields" do
28
+ @obj.update_attributes :name=>'blah2'
29
+ @obj.update_attributes :predicate=>'changed'
30
+ @obj.name.should == 'blah2'
31
+ end
32
+ end
33
+
34
+ context "match_wildcard_machine_tag" do
35
+ test "matches namespace with asterisk" do
36
+ Tag.match_wildcard_machine_tag('name:*=').should == [[:namespace,'name']]
37
+ end
38
+
39
+ test "matches namespace without asterisk" do
40
+ Tag.match_wildcard_machine_tag('name:').should == [[:namespace,'name']]
41
+ end
42
+
43
+ test "matches predicate with asterisk" do
44
+ Tag.match_wildcard_machine_tag('*:pred=').should == [[:predicate,'pred']]
45
+ end
46
+
47
+ test "matches predicate without asterisk" do
48
+ Tag.match_wildcard_machine_tag('pred=').should == [[:predicate,'pred']]
49
+ end
50
+
51
+ test "matches value with asterisk" do
52
+ Tag.match_wildcard_machine_tag('*:*=val').should == [[:value, 'val']]
53
+ end
54
+
55
+ test "matches value without asterisk" do
56
+ Tag.match_wildcard_machine_tag('=val').should == [[:value, 'val']]
57
+ end
58
+
59
+ test "matches namespace and predicate without asterisk" do
60
+ Tag.match_wildcard_machine_tag('name:pred').should == [[:namespace, 'name'], [:predicate, 'pred']]
61
+ end
62
+
63
+ test "matches namespace and predicate with asterisk" do
64
+ Tag.match_wildcard_machine_tag('name:pred=').should == [[:namespace, 'name'], [:predicate, 'pred']]
65
+ end
66
+
67
+ test "matches predicate and value without asterisk" do
68
+ Tag.match_wildcard_machine_tag('pred=val').should == [[:predicate, 'pred'], [:value, 'val']]
69
+ end
70
+
71
+ test "matches predicate and value with asterisk" do
72
+ Tag.match_wildcard_machine_tag('*:pred=val').should == [[:predicate, 'pred'], [:value, 'val']]
73
+ end
74
+
75
+ test "matches namespace and value without asterisk" do
76
+ Tag.match_wildcard_machine_tag('name.val').should == [[:namespace, 'name'], [:value, 'val']]
77
+ end
78
+
79
+ test "matches namespace and value with asterisk" do
80
+ Tag.match_wildcard_machine_tag('name:*=val').should == [[:namespace, 'name'], [:value, 'val']]
81
+ end
82
+
83
+ test "doesn't match total wildcard" do
84
+ Tag.match_wildcard_machine_tag('*:*=').should == []
85
+ end
86
+
87
+ test "doesn't match machine tag" do
88
+ Tag.match_wildcard_machine_tag('name:pred=val').should == nil
89
+ end
90
+
91
+ test "doesn't match normal tag" do
92
+ Tag.match_wildcard_machine_tag('name').should == nil
93
+ end
94
+ end
95
+
96
+ test "validates name when no invalid characters" do
97
+ Tag.new(:name=>'valid!name_really?').valid?.should be(true)
98
+ end
99
+
100
+ test "validates name when machine tag format" do
101
+ Tag.new(:name=>'name:pred=value').valid?.should be(true)
102
+ end
103
+
104
+ test "invalidates name when invalid characters present" do
105
+ %w{some.tag another:tag so=invalid yet,another whoop*}.each do |e|
106
+ Tag.new(:name=>e).valid?.should be(false)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,26 @@
1
+ require 'rubygems'
2
+ require 'activerecord'
3
+ require 'test/unit'
4
+ require 'context' #gem install jeremymcanally-context -s http://gems.github.com
5
+ require 'matchy' #gem install jeremymcanally-matchy -s http://gems.github.com
6
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
7
+ require File.join(File.dirname(__FILE__), '..', 'init')
8
+
9
+ #Setup logger
10
+ require 'logger'
11
+ # ActiveRecord::Base.logger = Logger.new(File.join(File.dirname(__FILE__), "test.log"))
12
+ ActiveRecord::Base.logger = Logger.new(STDERR)
13
+ ActiveRecord::Base.logger.level = Logger::WARN
14
+
15
+ #Setup db
16
+ ActiveRecord::Base.configurations = {'sqlite3' => {:adapter => 'sqlite3', :database => ':memory:'}}
17
+ ActiveRecord::Base.establish_connection('sqlite3')
18
+
19
+ #Define schema
20
+ require File.join(File.dirname(__FILE__), 'schema')
21
+ class TaggableModel < ActiveRecord::Base
22
+ has_machine_tags
23
+ end
24
+
25
+ class Test::Unit::TestCase
26
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: has_machine_tags
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.6
5
+ platform: ruby
6
+ authors:
7
+ - Gabriel Horner
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-10-22 00:00:00 -04:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: This plugin implements Flickr's machine tags while still maintaining standard tagging behavior. This allows for more precise tagging as tags can have unlimited contexts provided by combinations of namespaces and predicates. These unlimited contexts also make machine tags ripe for modeling relationships between objects.
17
+ email: gabriel.horner@gmail.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - LICENSE.txt
24
+ - README.rdoc
25
+ files:
26
+ - CHANGELOG.rdoc
27
+ - LICENSE.txt
28
+ - README.rdoc
29
+ - Rakefile
30
+ - VERSION.yml
31
+ - generators/has_machine_tags_migration/has_machine_tags_migration_generator.rb
32
+ - generators/has_machine_tags_migration/templates/migration.rb
33
+ - init.rb
34
+ - lib/has_machine_tags.rb
35
+ - lib/has_machine_tags/console.rb
36
+ - lib/has_machine_tags/finder.rb
37
+ - lib/has_machine_tags/tag.rb
38
+ - lib/has_machine_tags/tag_console.rb
39
+ - lib/has_machine_tags/tag_list.rb
40
+ - lib/has_machine_tags/tag_methods.rb
41
+ - lib/has_machine_tags/tagging.rb
42
+ - rails/init.rb
43
+ - test/finder_test.rb
44
+ - test/has_machine_tags_test.rb
45
+ - test/schema.rb
46
+ - test/tag_methods_test.rb
47
+ - test/test_helper.rb
48
+ has_rdoc: true
49
+ homepage: http://tagaholic.me/has_machine_tags/
50
+ licenses: []
51
+
52
+ post_install_message:
53
+ rdoc_options:
54
+ - --charset=UTF-8
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: "0"
62
+ version:
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: "0"
68
+ version:
69
+ requirements: []
70
+
71
+ rubyforge_project:
72
+ - tagaholic
73
+ rubygems_version: 1.3.5
74
+ signing_key:
75
+ specification_version: 3
76
+ summary: A rails tagging plugin implementing flickr's machine tags + maybe more (semantic tags).
77
+ test_files:
78
+ - test/finder_test.rb
79
+ - test/has_machine_tags_test.rb
80
+ - test/schema.rb
81
+ - test/tag_methods_test.rb
82
+ - test/test_helper.rb