require 'stringio'
class ActiveRecord::Base
# Provides ability to add the translations for the model using delegate pattern.
# Uses has_many association to the ModelNameTranslation.
#
# For example you have model Article with attributes title and text.
# You want that attributes title and text to be translated.
# For this reason you need to generate new model ArticleTranslation.
# In migration you need to add:
#
# create_table :article_translations do |t|
# t.references :article, :null => false
# t.string :locale, :length => 2, :null => false
# t.string :name, :null => false
# end
#
# add_index :articles, [:article_id, :locale], :unique => true, :name => 'unique_locale_for_article_id'
#
# And in the Article model:
#
# translations :title, :text
#
# This will adds:
#
# * named_scope (translated) and has_many association to the Article model
# * locale presence validation to the ArticleTranslation model.
#
# Notice: if you want to have validates_presence_of :article, you should use :inverse_of.
# Support this by yourself. Better is always to use artile.translations.build() method.
#
# For more information please read API. Feel free to write me an email to:
# dmitry.polushkin@gmail.com.
#
# ===
#
# You also can pass attributes and options to the translations class method:
#
# translations :title, :text, :fallback => true, :writer => true, :nil => nil
#
# ===
#
# Configuration options:
#
# * :fallback - if translation for the current locale not found.
# By default true.
# Uses algorithm of fallback:
# 0) current translation (using I18n.locale);
# 1) default locale (using I18n.default_locale);
# 2) :nil value (see :nil configuration option)
# * :reader - add reader attributes to the model and delegate them
# to the translation model columns. Add's fallback if it is set to true.
# * :writer - add writer attributes to the model and assign them
# to the translation model attributes.
# * :nil - when reader cant find string, it returns by default an
# empty string. If you want to change this setting for example to nil,
# add :nil => nil
#
# ===
#
# When you are using :writer option, you can create translations using
# update_attributes method. For example:
#
# Article.create!
# Article.update_attributes(:title => 'title', :text => 'text')
#
# ===
#
# translated named_scope is useful when you want to find only those
# records that are translated to a specific locale.
# For example if you want to find all Articles that is translated to an english
# language, you can write: Article.translated(:en)
#
# has_translation?(locale) method, that returns true if object's model
# have a translation for a specified locale
#
# translation(locale) method finds translation with specified locale.
#
# all_translations method that returns all possible translations in
# ordered hash (useful when creating forms with nested attributes).
def self.translations(*attrs)
options = {
:fallback => true,
:reader => true,
:writer => false,
:nil => ''
}.merge(attrs.extract_options!)
options.assert_valid_keys([:fallback, :reader, :writer, :nil])
class << self
# adds :translations to :includes if current locale differs from default
#FIXME is this enough with find or need to create chain for find_last, find_first and others?
alias_method(:find_without_translations, :find) unless method_defined?(:find_without_translations)
def find(*args)
if args[0].kind_of?(Hash)
args[0][:include] ||= []
args[0][:include] << :translations
end unless I18n.locale == I18n.default_locale
find_without_translations(*args)
end
# Defines given class recursively
# Example:
# create_class('Cms::Text::Page', Object, ActiveRecord::Base)
# => Cms::Text::Page
def create_class(class_name, parent, superclass, &block)
first,*other = class_name.split("::")
if other.empty?
klass = Class.new superclass, &block
parent.const_set(first, klass)
else
klass = Class.new
parent = unless parent.const_defined?(first)
parent.const_set(first, klass)
else
first.constantize
end
create_class(other.join('::'), parent, superclass, &block)
end
end
# defines "ModelNameTranslation" if it's not defined manualy
def define_translation_class name, attrs
klass = name.constantize rescue nil
unless klass
klass = create_class(name, Object, ActiveRecord::Base) do
# set's real table name
set_table_name name.sub('Translation','').constantize.table_name.singularize + "_translations"
cattr_accessor :translate_attrs, :master_id
# override validate to vaidate only translate fields from master Class
def validate
item = self.class.name.sub('Translation','').constantize.new(self.attributes.clone.delete_if{|k,_| !self.class.translate_attrs.include?(k.to_sym)})
was_table_name = item.class.table_name
item.class.set_table_name self.class.table_name
item.valid? rescue
self.class.translate_attrs.each do |attr|
errors_on_attr = item.errors.on(attr)
self.errors.add(attr,errors_on_attr) if errors_on_attr
end
item.class.set_table_name was_table_name
end
extend TranslationClassMethods
end
klass.translate_attrs = attrs
else
unless klass.respond_to?(:translate_attrs)
klass.send(:cattr_accessor, :translate_attrs, :master_id)
klass.send(:extend,TranslationClassMethods)
klass.translate_attrs = attrs
end
end
klass.extract_master_id(name)
klass
end
# creates translation table and adds missing fields
# So at first add the "translations :name, :desc" in your model
# then put YourModel.sync_translation_table! in db/seed.rb and run "rake db:seed"
# Later adding more fields in translations array, just run agin "rake db:seed"
# If you want to remove fields do it manualy, it's safer
def sync_translation_table!
out = StringIO.new
$stdout = out
translations_class = reflections[:translations].class_name.constantize
translations_table = translations_class.table_name
unless ActiveRecord::Migration::table_exists?(translations_table)
ActiveRecord::Migration.create_table translations_table do |t|
t.integer translations_class.master_id, :null => false
t.string :locale, :null => false, :limit => 5
columns_has_translations.each do |col|
t.send(col.type,col.name)
end
end
ActiveRecord::Migration.add_index translations_table, [translations_class.master_id, :locale], :unique => true
translations_class.reset_column_information
else
changes = false
columns_has_translations.each do |col|
unless translations_class.columns_hash.has_key?(col.name)
ActiveRecord::Migration.add_column(translations_table, col.name, col.type)
changes = true
end
end
translations_class.reset_column_information if changes
end
$stdout = STDOUT
end
end
translation_class_name = "#{self.name}Translation"
translation_class = self.define_translation_class(translation_class_name, attrs)
belongs_to = self.name.demodulize.underscore.to_sym
write_inheritable_attribute :has_translations_options, options
class_inheritable_reader :has_translations_options
write_inheritable_attribute :columns_has_translations, (columns rescue []).collect{|col| col if attrs.include?(col.name.to_sym)}.compact
class_inheritable_reader :columns_has_translations
# forces given locale
# I18n.locale = :lv
# a = Article.find 18
# a.title
# => "LV title"
# a.in(:en).title
# => "EN title"
def in locale
locale.to_sym == I18n.default_locale ? self : find_translation(locale)
end
def find_or_build_translation(*args)
locale = args.first.to_s
build = args.second.present?
find_translation(locale) || (build ? self.translations.build(:locale => locale) : self.translations.new(:locale => locale))
end
def translation(locale)
find_translation(locale.to_s)
end
def all_translations
t = I18n.available_locales.map do |locale|
[locale, find_or_build_translation(locale)]
end
ActiveSupport::OrderedHash[t]
end
def has_translation?(locale)
return true if locale == I18n.default_locale
find_translation(locale).present?
end
# if object is new, then nested slaves ar built for all available locales
def build_nested_translations
if (I18n.available_locales.size - 1) > self.translations.size
I18n.available_locales.clone.delete_if{|l| l == I18n.default_locale}.each do |l|
options = {:locale => l.to_s}
options[self.class.reflections[:translations].class_name.constantize.master_id] = self.id unless self.new_record?
self.translations.build(options) unless self.translations.map(&:locale).include?(l.to_s)
end
end
end
if options[:reader]
attrs.each do |name|
send :define_method, name do
unless I18n.default_locale == I18n.locale
translation = self.translation(I18n.locale)
if translation.nil?
if has_translations_options[:fallback]
(self[name].nil? || self[name].blank?) ? has_translations_options[:nil] : self[name].set_origins(self,name)
else
has_translations_options[:nil]
end
else
if @return_raw_data
(self[name].nil? || self[name].blank?) ? has_translations_options[:nil] : self[name].set_origins(self,name)
else
value = translation.send(name) and value.set_origins(self,name)
end
end
else
(self[name].nil? || self[name].blank?) ? has_translations_options[:nil] : self[name].set_origins(self,name)
end
end
end
end
@translation_attrs = attrs
def self.translation_attrs
@translation_attrs
end
has_many :translations, :class_name => translation_class_name, :foreign_key => translation_class.master_id, :dependent => :destroy
accepts_nested_attributes_for :translations, :allow_destroy => true, :reject_if => proc { |attributes| columns_has_translations.collect{|col| attributes[col.name].blank? ? nil : 1}.compact.empty? }
translation_class.belongs_to belongs_to
translation_class.validates_presence_of :locale
translation_class.validates_uniqueness_of :locale, :scope => translation_class.master_id
# Workaround to support Rails 2
scope_method = if ActiveRecord::VERSION::MAJOR < 3 then :named_scope else :scope end
send scope_method, :translated, lambda { |locale| {:conditions => ["#{translation_class.table_name}.locale = ?", locale.to_s], :joins => :translations} }
#private is no good
def find_translation(locale)
locale = locale.to_s
translations.detect { |t| t.locale == locale }
end
end
end
module TranslationClassMethods
# sets real master_id it's aware of STI
def extract_master_id name
master_class = name.sub('Translation','').constantize
#FIXME why need to check superclass ?
class_name = master_class.name #!master_class.superclass.abstract_class? ? master_class.superclass.name : master_class.name
self.master_id = :"#{class_name.demodulize.underscore}_id"
end
end