has_easy 0.9.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/CHANGELOG +16 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +100 -0
- data/MIT-LICENSE +20 -0
- data/README +306 -0
- data/README.rdoc +306 -0
- data/Rakefile +53 -0
- data/VERSION +1 -0
- data/lib/generators/has_easy_migration/USAGE +0 -0
- data/lib/generators/has_easy_migration/has_easy_migration_generator.rb +19 -0
- data/lib/generators/has_easy_migration/templates/has_easy_migration.rb +16 -0
- data/lib/has_easy/association_extension.rb +53 -0
- data/lib/has_easy/configurator.rb +87 -0
- data/lib/has_easy/definition.rb +56 -0
- data/lib/has_easy/errors.rb +4 -0
- data/lib/has_easy/helpers.rb +11 -0
- data/lib/has_easy.rb +133 -0
- data/lib/has_easy_thing.rb +49 -0
- data/lib/tasks/has_easy_tasks.rake +4 -0
- data/test/test_has_easy.rb +337 -0
- metadata +167 -0
data/CHANGELOG
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
06/23/2008
|
2
|
+
* added :postprocess option
|
3
|
+
* only the "underscore accessors" are affected by :preprocess and :postprocess
|
4
|
+
|
5
|
+
06/20/2008
|
6
|
+
* added :default_dynamic option
|
7
|
+
|
8
|
+
06/19/2008
|
9
|
+
* added validation via naming a method with a Symbol
|
10
|
+
* added support for custom validation error messages
|
11
|
+
|
12
|
+
06/18/2008
|
13
|
+
* added :alias and :aliases options to has_easy
|
14
|
+
|
15
|
+
03/28/2012
|
16
|
+
* converted to a gem, version 0.9.0 (Jeff Wigal)
|
data/Gemfile
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
# Add dependencies required to use your gem here.
|
3
|
+
# Example:
|
4
|
+
# gem "activesupport", ">= 2.3.5"
|
5
|
+
|
6
|
+
# Add dependencies to develop your gem here.
|
7
|
+
# Include everything needed to run rake, tests, features, etc.
|
8
|
+
group :development do
|
9
|
+
gem "shoulda", ">= 0"
|
10
|
+
gem "bundler", "~> 1.0.0"
|
11
|
+
gem "jeweler", "~> 1.6.4"
|
12
|
+
gem "rcov", ">= 0"
|
13
|
+
gem "sqlite3"
|
14
|
+
end
|
15
|
+
|
16
|
+
gem "rails", ">= 3.0"
|
data/Gemfile.lock
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
actionmailer (3.1.3)
|
5
|
+
actionpack (= 3.1.3)
|
6
|
+
mail (~> 2.3.0)
|
7
|
+
actionpack (3.1.3)
|
8
|
+
activemodel (= 3.1.3)
|
9
|
+
activesupport (= 3.1.3)
|
10
|
+
builder (~> 3.0.0)
|
11
|
+
erubis (~> 2.7.0)
|
12
|
+
i18n (~> 0.6)
|
13
|
+
rack (~> 1.3.5)
|
14
|
+
rack-cache (~> 1.1)
|
15
|
+
rack-mount (~> 0.8.2)
|
16
|
+
rack-test (~> 0.6.1)
|
17
|
+
sprockets (~> 2.0.3)
|
18
|
+
activemodel (3.1.3)
|
19
|
+
activesupport (= 3.1.3)
|
20
|
+
builder (~> 3.0.0)
|
21
|
+
i18n (~> 0.6)
|
22
|
+
activerecord (3.1.3)
|
23
|
+
activemodel (= 3.1.3)
|
24
|
+
activesupport (= 3.1.3)
|
25
|
+
arel (~> 2.2.1)
|
26
|
+
tzinfo (~> 0.3.29)
|
27
|
+
activeresource (3.1.3)
|
28
|
+
activemodel (= 3.1.3)
|
29
|
+
activesupport (= 3.1.3)
|
30
|
+
activesupport (3.1.3)
|
31
|
+
multi_json (~> 1.0)
|
32
|
+
arel (2.2.3)
|
33
|
+
builder (3.0.0)
|
34
|
+
erubis (2.7.0)
|
35
|
+
git (1.2.5)
|
36
|
+
hike (1.2.1)
|
37
|
+
i18n (0.6.0)
|
38
|
+
jeweler (1.6.4)
|
39
|
+
bundler (~> 1.0)
|
40
|
+
git (>= 1.2.5)
|
41
|
+
rake
|
42
|
+
json (1.6.5)
|
43
|
+
mail (2.3.2)
|
44
|
+
i18n (>= 0.4.0)
|
45
|
+
mime-types (~> 1.16)
|
46
|
+
treetop (~> 1.4.8)
|
47
|
+
mime-types (1.17.2)
|
48
|
+
multi_json (1.1.0)
|
49
|
+
polyglot (0.3.3)
|
50
|
+
rack (1.3.6)
|
51
|
+
rack-cache (1.2)
|
52
|
+
rack (>= 0.4)
|
53
|
+
rack-mount (0.8.3)
|
54
|
+
rack (>= 1.0.0)
|
55
|
+
rack-ssl (1.3.2)
|
56
|
+
rack
|
57
|
+
rack-test (0.6.1)
|
58
|
+
rack (>= 1.0)
|
59
|
+
rails (3.1.3)
|
60
|
+
actionmailer (= 3.1.3)
|
61
|
+
actionpack (= 3.1.3)
|
62
|
+
activerecord (= 3.1.3)
|
63
|
+
activeresource (= 3.1.3)
|
64
|
+
activesupport (= 3.1.3)
|
65
|
+
bundler (~> 1.0)
|
66
|
+
railties (= 3.1.3)
|
67
|
+
railties (3.1.3)
|
68
|
+
actionpack (= 3.1.3)
|
69
|
+
activesupport (= 3.1.3)
|
70
|
+
rack-ssl (~> 1.3.2)
|
71
|
+
rake (>= 0.8.7)
|
72
|
+
rdoc (~> 3.4)
|
73
|
+
thor (~> 0.14.6)
|
74
|
+
rake (0.9.2.2)
|
75
|
+
rcov (1.0.0)
|
76
|
+
rdoc (3.12)
|
77
|
+
json (~> 1.4)
|
78
|
+
shoulda (2.11.3)
|
79
|
+
sprockets (2.0.3)
|
80
|
+
hike (~> 1.2)
|
81
|
+
rack (~> 1.0)
|
82
|
+
tilt (~> 1.1, != 1.3.0)
|
83
|
+
sqlite3 (1.3.5)
|
84
|
+
thor (0.14.6)
|
85
|
+
tilt (1.3.3)
|
86
|
+
treetop (1.4.10)
|
87
|
+
polyglot
|
88
|
+
polyglot (>= 0.3.1)
|
89
|
+
tzinfo (0.3.32)
|
90
|
+
|
91
|
+
PLATFORMS
|
92
|
+
ruby
|
93
|
+
|
94
|
+
DEPENDENCIES
|
95
|
+
bundler (~> 1.0.0)
|
96
|
+
jeweler (~> 1.6.4)
|
97
|
+
rails (>= 3.0)
|
98
|
+
rcov
|
99
|
+
shoulda
|
100
|
+
sqlite3
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2008 [name of plugin creator]
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README
ADDED
@@ -0,0 +1,306 @@
|
|
1
|
+
Easy access and creation of "has many" relationships.
|
2
|
+
|
3
|
+
What's the difference between flags, preferences and options? Nothing really, they are just "has many" relationships. So why should I install a separate plugin for each one? This plugin can be used to add preferences, flags, options, etc to any model.
|
4
|
+
|
5
|
+
==Installation
|
6
|
+
In your Gemfile:
|
7
|
+
|
8
|
+
gem "has_easy"
|
9
|
+
|
10
|
+
At the command prompt:
|
11
|
+
rails g has_easy_migration
|
12
|
+
rake db:migrate
|
13
|
+
|
14
|
+
==Example
|
15
|
+
|
16
|
+
class User < ActiveRecord::Base
|
17
|
+
has_easy :preferences do |p|
|
18
|
+
p.define :color
|
19
|
+
p.define :theme
|
20
|
+
end
|
21
|
+
has_easy :flags do |f|
|
22
|
+
f.define :is_admin
|
23
|
+
f.define :is_spammer
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
user = User.new
|
28
|
+
|
29
|
+
# hash like access
|
30
|
+
user.preferences[:color] = 'red'
|
31
|
+
user.preferences[:color] # => 'red'
|
32
|
+
|
33
|
+
# object like access
|
34
|
+
user.preferences.theme? # => false, shorthand for !!user.preferences.theme
|
35
|
+
user.preferences.theme = "savage thunder"
|
36
|
+
user.preferences.theme # => "savage thunder"
|
37
|
+
user.preferences.theme? # => true
|
38
|
+
|
39
|
+
# easy access for form inputs
|
40
|
+
user.flags_is_admin? # => false, shorthand for !!user.flags_is_admin
|
41
|
+
user.flags_is_admin = true
|
42
|
+
user.flags_is_admin # => true
|
43
|
+
user.flags_is_admin? # => true
|
44
|
+
|
45
|
+
# save user's preferences
|
46
|
+
user.preferences.save # will trickle down validation errors to user
|
47
|
+
user.errors.empty? # hopefully true
|
48
|
+
|
49
|
+
# save user's flags
|
50
|
+
user.flags.save! # will raise exception on validation errors
|
51
|
+
|
52
|
+
|
53
|
+
==Advanced Usage
|
54
|
+
There are a lot of options that you can use with has_easy:
|
55
|
+
* aliasing
|
56
|
+
* default values
|
57
|
+
* inheriting default values from parent associations
|
58
|
+
* calculated default values
|
59
|
+
* type checking values
|
60
|
+
* validating values
|
61
|
+
* preprocessing values
|
62
|
+
In this section, we'll go over how to use each option and explain why it's useful.
|
63
|
+
|
64
|
+
===:alias and :aliases
|
65
|
+
These options go on the has_easy method call and specify alternate ways of invoking the association.
|
66
|
+
class User < ActiveRecord::Base
|
67
|
+
has_easy :preferences, :aliases => [:prefs, :options] do |p|
|
68
|
+
p.define :likes_cheese
|
69
|
+
end
|
70
|
+
has_easy :flags, :alias => :status do |p|
|
71
|
+
p.define :is_admin
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
user.preferences.likes_cheese = 'yes'
|
76
|
+
user.prefs.likes_cheese => 'yes'
|
77
|
+
user.options_likes_cheese => 'yes'
|
78
|
+
user.prefs[:likes_cheese] => 'yes'
|
79
|
+
user.options.likes_cheese? => true
|
80
|
+
...etc...
|
81
|
+
|
82
|
+
===:default
|
83
|
+
Very simple. It does what you think it does.
|
84
|
+
class User < ActiveRecord::Base
|
85
|
+
has_easy :options do |p|
|
86
|
+
p.define :gender, :default => 'female'
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
User.new.options.gender # => 'female'
|
91
|
+
|
92
|
+
===:default_through
|
93
|
+
Allows the model to inherit it's default value from an association.
|
94
|
+
class Client < ActiveRecord::Base
|
95
|
+
has_many :users
|
96
|
+
has_easy :options do |p|
|
97
|
+
p.define :gender, :default => 'male'
|
98
|
+
end
|
99
|
+
end
|
100
|
+
class User < ActiveRecord::Base
|
101
|
+
belongs_to :client
|
102
|
+
has_easy :options do |p|
|
103
|
+
p.define :gender, :default_through => :client, :default => 'female'
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
client = Client.create
|
108
|
+
user = client.users.create
|
109
|
+
user.options.gender # => 'male'
|
110
|
+
|
111
|
+
client.options.gender = 'asexual'
|
112
|
+
client.options.save
|
113
|
+
user.client(true) # reload association
|
114
|
+
user.options.gender # => 'asexual'
|
115
|
+
|
116
|
+
User.new.options.gender => 'female'
|
117
|
+
|
118
|
+
|
119
|
+
===:default_dynamic
|
120
|
+
Allows for calculated default values.
|
121
|
+
class User < ActiveRecord::Base
|
122
|
+
has_easy 'prefs' do |t|
|
123
|
+
t.define :likes_cheese, :default_dynamic => :defaults_to_like_cheese
|
124
|
+
t.define :is_dumb, :default_dynamic => Proc.new{ |user| user.dumb_post_count > 10 }
|
125
|
+
end
|
126
|
+
|
127
|
+
def defaults_to_like_cheese
|
128
|
+
cheesy_post_count > 10
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
user = User.new :cheesy_post_count => 5
|
133
|
+
user.prefs.likes_cheese? => false
|
134
|
+
|
135
|
+
user = User.new :cheesy_post_count => 11
|
136
|
+
user.prefs.likes_cheese? => true
|
137
|
+
|
138
|
+
user = User.new :dumb_post_count => 5
|
139
|
+
user.prefs.is_dumb? => false
|
140
|
+
|
141
|
+
user = User.new :dumb_post_count => 11
|
142
|
+
user.prefs.is_dumb? => true
|
143
|
+
|
144
|
+
|
145
|
+
===:type_check
|
146
|
+
Allows type checking of values (for people who are into that).
|
147
|
+
class User < ActiveRecord::Base
|
148
|
+
has_easy :prefs do |p|
|
149
|
+
p.define :theme, :type_check => String
|
150
|
+
p.define :dollars, :type_check => [Fixnum, Bignum]
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
user.prefs.theme = 123
|
155
|
+
user.prefs.save! # ActiveRecord::InvalidRecord exception raised with message like:
|
156
|
+
# 'theme' for has_easy('prefs') failed type check
|
157
|
+
|
158
|
+
user.prefs.dollars = "hello world"
|
159
|
+
user.prefs.save
|
160
|
+
user.errors.empty? # => false
|
161
|
+
user.errors.on(:prefs) # => 'dollars' for has_easy('prefs') failed type check
|
162
|
+
|
163
|
+
|
164
|
+
===:validate
|
165
|
+
Make sure that values fit some kind of criteria. If you use a Proc or name a method with a Symbol to do validation, there are three ways to specify failure:
|
166
|
+
1. return false
|
167
|
+
2. raise a HasEasy::ValidationError
|
168
|
+
3. return an array of custom validation error messages
|
169
|
+
class User < ActiveRecord::Base
|
170
|
+
has_easy :prefs do |p|
|
171
|
+
p.define :foreground, :validate => ['red', 'blue', 'green']
|
172
|
+
p.define :background, :validate => Proc.new{ |value| %w[black white grey].include?(value) }
|
173
|
+
p.define :midground, :validate => :midground_validator
|
174
|
+
end
|
175
|
+
def midground_validator(value)
|
176
|
+
return ["msg1", msg2] unless %w[yellow brown purple].include?(value)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
user.prefs.foreground = 'yellow'
|
181
|
+
user.prefs.save! # ActiveRecord::InvalidRecord exception raised with message like:
|
182
|
+
# 'theme' for has_easy('prefs') failed validation
|
183
|
+
|
184
|
+
user.prefs.background = "pink"
|
185
|
+
user.prefs.save
|
186
|
+
user.errors.empty? => false
|
187
|
+
user.errors.on(:prefs) => 'background' for has_easy('prefs') failed validation
|
188
|
+
|
189
|
+
user.prefs.midground = "black"
|
190
|
+
user.prefs.save
|
191
|
+
user.errors.on(:prefs)[0] => "msg1"
|
192
|
+
user.errors.on(:prefs)[1] => "msg2"
|
193
|
+
|
194
|
+
|
195
|
+
===:preprocess
|
196
|
+
Alter the value before it goes through type checking and/or validation. This is useful when working with forms and boolean values. CAREFUL!! This option only applies to the underscore accessors, i.e. <tt>prefs_likes_cheese=</tt>, not <tt>prefs.likes_cheese=</tt> or <tt>prefs[:likes_cheese]=</tt>.
|
197
|
+
class User < ActiveRecord::Base
|
198
|
+
has_easy :prefs do |p|
|
199
|
+
p.define :likes_cheese, :validate => [true, false],
|
200
|
+
:preprocess => Proc.new{ |value| ['true', 'yes'].include?(value) ? true : false }
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
user.prefs.likes_cheese = 'yes' # :preprocess NOT invoked; it only applies to underscore accessors!!
|
205
|
+
user.prefs.likes_cheese
|
206
|
+
=> 'yes'
|
207
|
+
user.prefs.save! # exception, validation failed
|
208
|
+
|
209
|
+
user.prefs_likes_cheese = 'yes' # :preprocess invoked
|
210
|
+
user.prefs.likes_cheese
|
211
|
+
=> true
|
212
|
+
user.prefs.save! # no exception
|
213
|
+
|
214
|
+
|
215
|
+
===:postprocess
|
216
|
+
Alter the value when it is read. This is useful when working with forms and boolean values. CAREFUL!! This option only applies to the underscore accessors, i.e. <tt>prefs_likes_cheese</tt>, not <tt>prefs.likes_cheese</tt> or <tt>prefs[:likes_cheese]</tt>.
|
217
|
+
class User < ActiveRecord::Base
|
218
|
+
has_easy :prefs do |p|
|
219
|
+
p.define :likes_cheese, :validate => [true, false],
|
220
|
+
:postprocess => Proc.new{ |value| value ? 'yes' : 'no' }
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
user.prefs.likes_cheese = true
|
225
|
+
user.prefs.likes_cheese # :postprocess NOT invoked, it only applies to underscore accessors
|
226
|
+
=> true
|
227
|
+
user.prefs_likes_cheese # :postprocess invoked
|
228
|
+
=> 'yes'
|
229
|
+
|
230
|
+
==Using with Forms
|
231
|
+
Suppose you have a <tt>has_easy</tt> field defined as a boolean and you want to use it with a checkbox in <tt>form_for</tt>.
|
232
|
+
|
233
|
+
(model)
|
234
|
+
|
235
|
+
class User < ActiveRecord::Base
|
236
|
+
has_easy :prefs do |p|
|
237
|
+
p.define :likes_cheese, :type_check => [TrueClass, FalseClass],
|
238
|
+
:preprocess => Proc.new{ |value| value == 'yes' },
|
239
|
+
:postprocess => Proc.new{ |value| value ? 'yes' : 'no' }
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
(view)
|
244
|
+
|
245
|
+
<% form_for(@user) do |f| %>
|
246
|
+
<%= f.check_box 'user', 'prefs_likes_cheese', {}, 'yes', 'no' %> # invokes @user.prefs_likes_cheese which does the :postprocess
|
247
|
+
<% end %>
|
248
|
+
|
249
|
+
(controller)
|
250
|
+
|
251
|
+
@user.update_attributes(params[:user]) # invokes @user.prefs_likes_cheese= which does the :preprocess
|
252
|
+
@user.prefs.save
|
253
|
+
@user.prefs.likes_cheese
|
254
|
+
=> true or false
|
255
|
+
@user.prefs_likes_cheese # remember, only underscore accessors invoke the :preprocess and :postprocess options
|
256
|
+
=> 'yes' or 'no'
|
257
|
+
|
258
|
+
The general idea is that we make the form use <tt>prefs_likes_cheese=</tt> and <tt>prefs_likes_cheese</tt> accessors which in turn use the :preprocess and :postprocess options. Then in our normal code, we use <tt>prefs.likes_cheese</tt> or <tt>prefs[:likes_cheese]</tt> accessors to get our expected boolean values.
|
259
|
+
|
260
|
+
==Missing Features
|
261
|
+
|
262
|
+
===Autovivification
|
263
|
+
For when we want to use fields without having to define them first.
|
264
|
+
class User < ActiveRecord::Base
|
265
|
+
has_easy :prefs, :autovivify => true do |p|
|
266
|
+
p.define :likes_cheese, :default => 'yes'
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
user.prefs.likes_cheese => 'yes'
|
271
|
+
user.prefs.likes_pizza => nil
|
272
|
+
user.prefs.likes_pizza = true
|
273
|
+
user.prefs.likes_pizza => true
|
274
|
+
|
275
|
+
|
276
|
+
===Scoping to other models
|
277
|
+
Ehh, can't think of a way to describe this other than example. Also, the syntax is completely up in the air, there are so many different ways to do it, I have no idea which way to go with. Please tell me your ideas.
|
278
|
+
class User < ActiveRecord::Base
|
279
|
+
has_easy :prefs do |p|
|
280
|
+
p.define :subscribed, :scoped => Post
|
281
|
+
p.define :color, :scoped => [Car, Motorcycle] # polymorphic but must be Car or Motorcycle
|
282
|
+
p.define :hair_color, :scoped => true # polymorphic no restrictions
|
283
|
+
p.define :likes_cheese, :scoped => [Food, NilClass] # scoped and not scoped at the same time
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
post = Post.find :first, :conditions => {:topic => 'rails'}
|
288
|
+
me.prefs.subscribed? :to => post
|
289
|
+
=> true
|
290
|
+
|
291
|
+
vette = Car.find :first, :conditions => {:model => 'corvette'}
|
292
|
+
me.prefs.color :for => vette
|
293
|
+
=> 'black'
|
294
|
+
|
295
|
+
gf = Girl.find :first, :conditions => {:name => 'aimee'}
|
296
|
+
me.prefs.hair_color :on => gf
|
297
|
+
=> 'brown'
|
298
|
+
|
299
|
+
watermelon = Food.find :first, :conditions => {:kind => 'watermelon'}
|
300
|
+
my.prefs.likes_cheese? # not scoped; do I like cheese in general?
|
301
|
+
=> true
|
302
|
+
my.prefs.likes_cheese? :on => watermelon # scoped; do I like cheese on watermelon?
|
303
|
+
=> false
|
304
|
+
|
305
|
+
|
306
|
+
Copyright (c) 2008 Christopher J. Bottaro <cjbottaro@alumni.cs.utexas.edu>, released under the MIT license
|