dm-is-localizable 0.10.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.textile ADDED
@@ -0,0 +1,260 @@
1
+ h1. dm-is-localizable
2
+
3
+ Datamapper support for localization of (user entered) content in multilanguage applications
4
+
5
+ h3. Schema
6
+
7
+ * one xxx_translations table for every translatable resource
8
+ * xxx_translations belongs_to the resource to translate
9
+ * xxx_translations belongs_to a language
10
+ * properties to be translated are defined in xxx_translations
11
+
12
+
13
+ h4. Advantages
14
+
15
+ * Proper normalization and referential integrity
16
+ * Ease in adding a new language (add row to xxx_translations)
17
+ * Easy to query
18
+ * Columns keep their names
19
+
20
+
21
+ h4. Disadvantages (not really if you think about it)
22
+
23
+ * One table for every resource that needs translations
24
+
25
+
26
+ h3. Example definition of a localizable model
27
+
28
+ Currently, you need to define a @Language@ model yourself, to get @dm-is-localizable@ started. However, this is reasonably easy! If you do a @rake install@ after you cloned the repo (I guess it won't work if you do a simple @gem install@), it will print out the code for language.rb and will tell you where to put it.
29
+
30
+ <pre>
31
+ <code>
32
+ class Language
33
+
34
+ include DataMapper::Resource
35
+
36
+ # properties
37
+
38
+ property :id, Serial
39
+
40
+ property :code, String, :required => true, :unique => true, :unique_index => true
41
+ property :name, String, :required => true
42
+
43
+ # locale string like 'en-US'
44
+ validates_format :code, :with => /^[a-z]{2}-[A-Z]{2}$/
45
+
46
+
47
+ def self.[](code)
48
+ return nil if code.nil?
49
+ first :code => code.to_s.gsub('_', '-')
50
+ end
51
+
52
+ end
53
+ </code>
54
+ </pre>
55
+
56
+ Once you have this model in place, you can start defining your _localizable models_.
57
+
58
+ <pre>
59
+ <code>
60
+ class Item
61
+
62
+ include DataMapper::Resource
63
+
64
+ property :id, Serial
65
+
66
+ is :localizable do # same as is :localizable, :accept_nested_attributes => true do
67
+ property :name, String
68
+ property :desc, String
69
+ end
70
+
71
+ end
72
+ </code>
73
+ </pre>
74
+
75
+ The above @Item@ model will define and thus be able to @DataMapper.auto_migrate!@ the @ItemTranslation@ model. The _naming convention_ used here is @"#{ClassToBeLocalized.name}Translation"@.
76
+
77
+ Preliminary support for changing this is available by using the @:model@ option like so (note that this isn't specced yet).
78
+
79
+ <pre>
80
+ <code>
81
+ DataMapper::Model.is :localizable, :model => 'ItemLocalization'
82
+ </code>
83
+ </pre>
84
+
85
+ Furthermore, the above @Item@ will automatically have the following instance methods defined.
86
+
87
+ <pre>
88
+ <code>
89
+ #item_translations_attributes
90
+ #item_translations_attributes=
91
+
92
+ # and handy aliases for the above
93
+
94
+ #translations_attributes
95
+ #translations_attributes=
96
+ </code>
97
+ </pre>
98
+
99
+ These are generated by "dm-accepts_nested_attributes":http://github.com/snusnu/dm-accepts_nested_attributes and allow for easy manipulation of the localizable properties from say forms in a web application. For more information on working with nested attributes, have a look at the documentation at the "README":http://github.com/snusnu/dm-accepts_nested_attributes for "dm-accepts_nested_attributes":http://github.com/snusnu/dm-accepts_nested_attributes
100
+
101
+ Of course you can turn this behavior off by specifying the @is :localizable, :accept_nested_attributes => false do .. end@
102
+
103
+ The resulting model you get when calling @Item.is(:localizable)@ looks like this:
104
+
105
+ <pre>
106
+ <code>
107
+ class ItemTranslation
108
+
109
+ include DataMapper::Resource
110
+
111
+ property :id, Serial
112
+
113
+ property :item_id, Integer, :required => true, :unique_index => :unique_languages
114
+ property :language_id, Integer, :required => true, :unique_index => :unique_languages
115
+
116
+ property :name, String
117
+ property :desc, String
118
+
119
+ validates_is_unique :language_id, :scope => :item_id
120
+
121
+ belongs_to :item
122
+ belongs_to :language
123
+
124
+ end
125
+ </code>
126
+ </pre>
127
+
128
+ Furthermore, the following API gets defined on the @Item@ class:
129
+
130
+ <pre>
131
+ <code>
132
+ class Item
133
+
134
+ include DataMapper::Resource
135
+
136
+ property :id, Serial
137
+
138
+ is :localizable do
139
+ property :name, String
140
+ property :desc, String
141
+ end
142
+
143
+ # ----------------------------
144
+ # added by is :localizable
145
+ # ----------------------------
146
+
147
+ has n, :item_translations
148
+ has n, :languages, :through => :item_translations
149
+
150
+ # and a handy alias
151
+ alias :translations :item_translations
152
+
153
+ # helper to get at ItemTranslation
154
+ class_inheritable_reader :translation_model
155
+
156
+ # -------------------
157
+ # class level API
158
+ # -------------------
159
+
160
+ # list all available languages for Items
161
+ def self.available_languages
162
+ Language.all :id => translation_model.all.map { |t| t.language_id }.uniq
163
+ end
164
+
165
+ # the number of all available languages for the localizable model
166
+ def self.nr_of_available_languages
167
+ available_languages.size
168
+ end
169
+
170
+ # checks if all localizable resources are translated in all available languages
171
+ def self.translations_complete?
172
+ nr_of_available_languages * all.size == translation_model.all.size
173
+ end
174
+
175
+ # returns a list of symbols reflecting all localizable property names of this resource
176
+ def localizable_properties
177
+ translation_model.properties.map do |p|
178
+ p.name
179
+ end.select do |p|
180
+ # exclude properties that are'nt localizable
181
+ p != :id && p != :language_id && p != Extlib::Inflection.foreign_key(self.name).to_sym
182
+ end
183
+ end
184
+
185
+ # ----------------------
186
+ # instance level API
187
+ # ----------------------
188
+
189
+ # list all available languages for this instance
190
+ def available_languages
191
+ Language.all :id => translations.map { |t| t.language_id }.uniq
192
+ end
193
+
194
+ # the number of all available languages for this instance
195
+ def nr_of_available_languages
196
+ available_languages.size
197
+ end
198
+
199
+ # checks if this instance is translated into all available languages for this model
200
+ def translations_complete?
201
+ self.class.nr_of_available_languages == translations.size
202
+ end
203
+
204
+ # translates the given attribute to the language identified by the given language_code
205
+ def translate(attribute, language_code)
206
+ if language = Language[language_code]
207
+ t = translations.first(:language_id => language.id)
208
+ t.respond_to?(attribute) ? t.send(attribute) : nil
209
+ else
210
+ nil
211
+ end
212
+ end
213
+
214
+ # translates the :name property to the given language
215
+ def name(language_code)
216
+ translate(:name, language_code)
217
+ end
218
+
219
+ # translates the :desc property to the given language
220
+ def desc(language_code)
221
+ translate(:desc, language_code)
222
+ end
223
+
224
+
225
+ # ----------------------------------------
226
+ # added by dm-accepts_nested_attributes
227
+ # ----------------------------------------
228
+
229
+
230
+ def item_translations_attributes
231
+ # ...
232
+ end
233
+
234
+ def item_translations_attributes=(attributes_or_attributes_collection)
235
+ # ...
236
+ end
237
+
238
+ # and handy aliases for the above
239
+
240
+ alias :translations_attributes :item_translations_attributes
241
+ alias :translations_attributes= :item_translations_attributes
242
+
243
+
244
+ # TODO
245
+ # more API to support common usecases (and i18n/l10n solutions)
246
+
247
+ end
248
+ </code>
249
+ </pre>
250
+
251
+ h3. Inspired by (thx guys!)
252
+
253
+ * Neil Barnwell's comment on the top voted answer to "Schema for a multilanguage database":http://stackoverflow.com/questions/316780/schema-for-a-multilanguage-database
254
+
255
+ * Gabi Solomon's option (4) at this blog post on "Multilanguage database design approach":http://www.gsdesign.ro/blog/multilanguage-database-design-approach/
256
+
257
+
258
+ h3. Copyright
259
+
260
+ Copyright (c) 2009 Martin Gamsjaeger (snusnu). See "LICENSE":http://github.com/snusnu/dm-is-localizable/tree/master/LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,86 @@
1
+ require 'pathname'
2
+ require 'rake'
3
+
4
+ ROOT = Pathname(__FILE__).dirname.expand_path
5
+ JRUBY = RUBY_PLATFORM =~ /java/
6
+
7
+ begin
8
+
9
+ require 'jeweler'
10
+
11
+ Jeweler::Tasks.new do |gem|
12
+
13
+ gem.name = "dm-is-localizable"
14
+ gem.summary = %Q{Datamapper support for localization of content in multilanguage applications}
15
+ gem.email = "gamsnjaga@gmail.com"
16
+ gem.homepage = "http://github.com/snusnu/dm-is-localizable"
17
+ gem.authors = ["Martin Gamsjaeger (snusnu)"]
18
+
19
+ gem.add_dependency('dm-core', '>= 0.10.2')
20
+ gem.add_dependency('dm-is-remixable', '>= 0.10.2')
21
+ gem.add_dependency('dm-validations', '>= 0.10.2')
22
+
23
+ gem.add_development_dependency 'rspec', '~> 1.3'
24
+
25
+ Jeweler::GemcutterTasks.new
26
+
27
+ end
28
+
29
+ rescue LoadError
30
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
31
+ end
32
+
33
+ begin
34
+ require 'spec/rake/spectask'
35
+
36
+ task :default => [ :spec ]
37
+
38
+ desc 'Run specifications'
39
+ Spec::Rake::SpecTask.new(:spec) do |t|
40
+ t.spec_opts << '--options' << 'spec/spec.opts' if File.exists?('spec/spec.opts')
41
+ t.libs << 'lib' << 'spec' # needed for CI rake spec task, duplicated in spec_helper
42
+
43
+ begin
44
+ require 'rcov'
45
+ t.rcov = JRUBY ? false : (ENV.has_key?('NO_RCOV') ? ENV['NO_RCOV'] != 'true' : true)
46
+ t.rcov_opts << '--exclude' << 'spec'
47
+ t.rcov_opts << '--text-summary'
48
+ t.rcov_opts << '--sort' << 'coverage' << '--sort-reverse'
49
+ rescue LoadError
50
+ # rcov not installed
51
+ rescue SyntaxError
52
+ # rcov syntax invalid
53
+ end
54
+ end
55
+ rescue LoadError
56
+ # rspec not installed
57
+ end
58
+
59
+ begin
60
+ require 'cucumber/rake/task'
61
+ Cucumber::Rake::Task.new(:features)
62
+ rescue LoadError
63
+ task :features do
64
+ abort "Cucumber is not available. In order to run features, you must: sudo gem install cucumber"
65
+ end
66
+ end
67
+
68
+ task :default => :spec
69
+
70
+ require 'rake/rdoctask'
71
+ Rake::RDocTask.new do |rdoc|
72
+ if File.exist?('VERSION.yml')
73
+ config = YAML.load(File.read('VERSION.yml'))
74
+ version = "#{config[:major]}.#{config[:minor]}.#{config[:patch]}"
75
+ else
76
+ version = ""
77
+ end
78
+
79
+ rdoc.rdoc_dir = 'rdoc'
80
+ rdoc.title = "dm-is-localizable #{version}"
81
+ rdoc.rdoc_files.include('README*')
82
+ rdoc.rdoc_files.include('lib/**/*.rb')
83
+ end
84
+
85
+ # require all tasks below tasks
86
+ Pathname.glob(ROOT.join('tasks/**/*.rb').to_s).each { |f| require f }
data/TODO ADDED
@@ -0,0 +1,2 @@
1
+ * Add accessors for the localized properties in the localizable resource
2
+ * Think about validation and null constraints
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.10.1
@@ -0,0 +1,88 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = %q{dm-is-localizable}
8
+ s.version = "0.10.1"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Martin Gamsjaeger (snusnu)"]
12
+ s.date = %q{2010-02-04}
13
+ s.email = %q{gamsnjaga@gmail.com}
14
+ s.extra_rdoc_files = [
15
+ "LICENSE",
16
+ "README.textile",
17
+ "TODO"
18
+ ]
19
+ s.files = [
20
+ ".document",
21
+ ".gitignore",
22
+ "CHANGELOG",
23
+ "LICENSE",
24
+ "README.textile",
25
+ "Rakefile",
26
+ "TODO",
27
+ "VERSION",
28
+ "dm-is-localizable.gemspec",
29
+ "lib/dm-is-localizable.rb",
30
+ "lib/dm-is-localizable/is/localizable.rb",
31
+ "lib/dm-is-localizable/storage/language.rb",
32
+ "lib/dm-is-localizable/storage/translation.rb",
33
+ "spec/fixtures/item.rb",
34
+ "spec/lib/rspec_tmbundle_support.rb",
35
+ "spec/shared/shared_examples_spec.rb",
36
+ "spec/spec.opts",
37
+ "spec/spec_helper.rb",
38
+ "spec/unit/auto_migrate_spec.rb",
39
+ "spec/unit/class_level_api_spec.rb",
40
+ "spec/unit/instance_level_api_spec.rb",
41
+ "spec/unit/is_localizable_spec.rb",
42
+ "spec/unit/language_spec.rb",
43
+ "spec/unit/translation_spec.rb",
44
+ "tasks/changelog.rb",
45
+ "tasks/install.rb",
46
+ "tasks/whitespace.rb"
47
+ ]
48
+ s.homepage = %q{http://github.com/snusnu/dm-is-localizable}
49
+ s.rdoc_options = ["--charset=UTF-8"]
50
+ s.require_paths = ["lib"]
51
+ s.rubygems_version = %q{1.3.5}
52
+ s.summary = %q{Datamapper support for localization of content in multilanguage applications}
53
+ s.test_files = [
54
+ "spec/fixtures/item.rb",
55
+ "spec/lib/rspec_tmbundle_support.rb",
56
+ "spec/shared/shared_examples_spec.rb",
57
+ "spec/spec_helper.rb",
58
+ "spec/unit/auto_migrate_spec.rb",
59
+ "spec/unit/class_level_api_spec.rb",
60
+ "spec/unit/instance_level_api_spec.rb",
61
+ "spec/unit/is_localizable_spec.rb",
62
+ "spec/unit/language_spec.rb",
63
+ "spec/unit/translation_spec.rb"
64
+ ]
65
+
66
+ if s.respond_to? :specification_version then
67
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
68
+ s.specification_version = 3
69
+
70
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
71
+ s.add_runtime_dependency(%q<dm-core>, [">= 0.10.2"])
72
+ s.add_runtime_dependency(%q<dm-is-remixable>, [">= 0.10.2"])
73
+ s.add_runtime_dependency(%q<dm-validations>, [">= 0.10.2"])
74
+ s.add_development_dependency(%q<rspec>, ["~> 1.3"])
75
+ else
76
+ s.add_dependency(%q<dm-core>, [">= 0.10.2"])
77
+ s.add_dependency(%q<dm-is-remixable>, [">= 0.10.2"])
78
+ s.add_dependency(%q<dm-validations>, [">= 0.10.2"])
79
+ s.add_dependency(%q<rspec>, ["~> 1.3"])
80
+ end
81
+ else
82
+ s.add_dependency(%q<dm-core>, [">= 0.10.2"])
83
+ s.add_dependency(%q<dm-is-remixable>, [">= 0.10.2"])
84
+ s.add_dependency(%q<dm-validations>, [">= 0.10.2"])
85
+ s.add_dependency(%q<rspec>, ["~> 1.3"])
86
+ end
87
+ end
88
+
@@ -0,0 +1,135 @@
1
+ module DataMapper
2
+ module Is
3
+
4
+ module Localizable
5
+
6
+
7
+ def is_localizable(options = {}, &block)
8
+
9
+ extend ClassMethods
10
+ include InstanceMethods
11
+
12
+ options = {
13
+ :as => nil,
14
+ :model => "#{self}Translation",
15
+ :accept_nested_attributes => true
16
+ }.merge(options)
17
+
18
+ remixer_fk = Extlib::Inflection.foreign_key(self.name).to_sym
19
+ remixer = remixer_fk.to_s.gsub('_id', '').to_sym
20
+ remixee = Extlib::Inflection.tableize(options[:model]).to_sym
21
+
22
+ remix n, Translation, :as => options[:as], :model => options[:model]
23
+
24
+ @translation_model = Extlib::Inflection.constantize(options[:model])
25
+
26
+ enhance :translation, @translation_model do
27
+
28
+ property remixer_fk, Integer, :min => 1, :required => true, :unique_index => :unique_languages
29
+ property :language_id, Integer, :min => 1, :required => true, :unique_index => :unique_languages
30
+
31
+ belongs_to remixer
32
+ belongs_to :language
33
+
34
+ class_eval &block
35
+
36
+ validates_is_unique :language_id, :scope => remixer_fk
37
+
38
+ end
39
+
40
+ has n, :languages, :through => remixee, :constraint => :destroy
41
+
42
+ self.class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
43
+
44
+ alias :translations :#{remixee}
45
+
46
+ if options[:accept_nested_attributes]
47
+
48
+ # cannot accept_nested_attributes_for :translations
49
+ # since this is no valid relationship name, only an alias
50
+
51
+ accepts_nested_attributes_for :#{remixee}
52
+ alias :translations_attributes :#{remixee}_attributes
53
+
54
+ end
55
+
56
+ RUBY
57
+
58
+ localizable_properties.each do |property_name|
59
+ self.class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
60
+
61
+ def #{property_name}(language_code)
62
+ translate(:#{property_name.to_sym}, language_code)
63
+ end
64
+
65
+ RUBY
66
+ end
67
+
68
+ end
69
+
70
+ module ClassMethods
71
+
72
+ def translation_model
73
+ @translation_model
74
+ end
75
+
76
+ # list all available languages for the localizable model
77
+ def available_languages
78
+ ids = translation_model.all.map { |t| t.language_id }.uniq
79
+ ids.empty? ? [] : Language.all(:id => ids)
80
+ end
81
+
82
+ # the number of all available languages for the localizable model
83
+ def nr_of_available_languages
84
+ available_languages.size
85
+ end
86
+
87
+ # checks if all localizable resources are translated in all available languages
88
+ def translations_complete?
89
+ available_languages.size * all.size == translation_model.all.size
90
+ end
91
+
92
+ # returns a list of symbols reflecting all localizable property names of this resource
93
+ def localizable_properties
94
+ translation_model.properties.map { |p| p.name }.select do |p|
95
+ # exclude properties that are'nt localizable
96
+ p != :id && p != :language_id && p != Extlib::Inflection.foreign_key(self.name).to_sym
97
+ end
98
+ end
99
+
100
+ end
101
+
102
+ module InstanceMethods
103
+
104
+ # list all available languages for this instance
105
+ def available_languages
106
+ ids = translations.map { |t| t.language_id }.uniq
107
+ ids.empty? ? [] : Language.all(:id => ids)
108
+ end
109
+
110
+ # the number of all available languages for this instance
111
+ def nr_of_available_languages
112
+ available_languages.size
113
+ end
114
+
115
+ # checks if this instance is translated into all available languages for this model
116
+ def translations_complete?
117
+ self.class.nr_of_available_languages == translations.size
118
+ end
119
+
120
+ # translates the given attribute to the language identified by the given language_code
121
+ def translate(attribute, language_code)
122
+ if language = Language[language_code]
123
+ t = translations.first(:language_id => language.id)
124
+ t.respond_to?(attribute) ? t.send(attribute) : nil
125
+ else
126
+ nil
127
+ end
128
+ end
129
+
130
+ end
131
+
132
+ end
133
+
134
+ end
135
+ end
@@ -0,0 +1,21 @@
1
+ class Language
2
+
3
+ include DataMapper::Resource
4
+
5
+ # properties
6
+
7
+ property :id, Serial
8
+
9
+ property :code, String, :required => true, :unique => true, :unique_index => true
10
+ property :name, String, :required => true
11
+
12
+ # locale string like 'en-US'
13
+ validates_format :code, :with => /^[a-z]{2}-[A-Z]{2}$/
14
+
15
+
16
+ def self.[](code)
17
+ return nil if code.nil?
18
+ first :code => code.to_s.gsub('_', '-')
19
+ end
20
+
21
+ end
@@ -0,0 +1,18 @@
1
+ module DataMapper
2
+ module Is
3
+ module Localizable
4
+
5
+ module Translation
6
+
7
+ include DataMapper::Resource
8
+
9
+ is :remixable
10
+
11
+ property :id, Serial
12
+ property :language_id, Integer, :min => 1, :required => false
13
+
14
+ end
15
+
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,6 @@
1
+ require 'dm-is-localizable/is/localizable'
2
+ require 'dm-is-localizable/storage/language'
3
+ require 'dm-is-localizable/storage/translation'
4
+
5
+ # Include the plugin in Model
6
+ DataMapper::Model.append_extensions DataMapper::Is::Localizable
@@ -0,0 +1,12 @@
1
+ class Item
2
+
3
+ include DataMapper::Resource
4
+
5
+ property :id, Serial
6
+
7
+ is :localizable do
8
+ property :name, String
9
+ property :desc, String
10
+ end
11
+
12
+ end
@@ -0,0 +1,35 @@
1
+ # -----------------------------------------------
2
+ # support for nice html output in rspec tmbundle
3
+ # -----------------------------------------------
4
+
5
+ module RSpecTmBundleHelpers
6
+
7
+ class TextmateRspecLogger < DataMapper::Logger
8
+ def prep_msg(message, level)
9
+ "#{super}<br />"
10
+ end
11
+ end
12
+
13
+ def with_dm_logger(level = :debug)
14
+ DataMapper.logger.level = level
15
+ yield
16
+ ensure
17
+ DataMapper.logger.level = :off
18
+ end
19
+
20
+ def print_call_stack(from = 2, to = nil, html = true)
21
+ (from..(to ? to : caller.length)).each do |idx|
22
+ p "[#{idx}]: #{caller[idx]}#{html ? '<br />' : ''}"
23
+ end
24
+ end
25
+
26
+ def puth(html = nil)
27
+ print "#{h(html)}<br />"
28
+ end
29
+
30
+ ESCAPE_TABLE = { '&' => '&amp;', '<' => '&lt;', '>' => '&gt;', '"' => '&quot;', "'" => '&#039;' }
31
+ def h(value)
32
+ value.to_s.gsub(/[&<>"]/) {|s| ESCAPE_TABLE[s] }
33
+ end
34
+
35
+ end