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 +19 -0
- data/LICENSE.txt +22 -0
- data/README.rdoc +143 -0
- data/Rakefile +50 -0
- data/VERSION.yml +4 -0
- data/generators/has_machine_tags_migration/has_machine_tags_migration_generator.rb +7 -0
- data/generators/has_machine_tags_migration/templates/migration.rb +26 -0
- data/init.rb +1 -0
- data/lib/has_machine_tags/console.rb +44 -0
- data/lib/has_machine_tags/finder.rb +91 -0
- data/lib/has_machine_tags/tag.rb +3 -0
- data/lib/has_machine_tags/tag_console.rb +32 -0
- data/lib/has_machine_tags/tag_list.rb +69 -0
- data/lib/has_machine_tags/tag_methods.rb +153 -0
- data/lib/has_machine_tags/tagging.rb +4 -0
- data/lib/has_machine_tags.rb +88 -0
- data/rails/init.rb +13 -0
- data/test/finder_test.rb +84 -0
- data/test/has_machine_tags_test.rb +72 -0
- data/test/schema.rb +20 -0
- data/test/tag_methods_test.rb +109 -0
- data/test/test_helper.rb +26 -0
- metadata +82 -0
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,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,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,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
|
data/test/finder_test.rb
ADDED
@@ -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
|
data/test/test_helper.rb
ADDED
@@ -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
|