require 'stringio' module Lolita module Translation module SingletonMethods # 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 translations *attrs include Lolita::Translation::InstanceMethods options = { :fallback => true, :reader => true, :writer => false, :nil => '' }.merge(attrs.extract_options!) options.assert_valid_keys([:fallback, :reader, :writer, :nil,:table_name]) self.extend(Lolita::Translation::ClassMethods) self.class_eval do translation_class_name = "#{self.name}Translation" translation_class = self.define_translation_class(translation_class_name, attrs,options) 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 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 #adapter = Lolita::DBI::Base.create(self) 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 =:scope send scope_method, :translated, lambda { |locale| where("#{translation_class.table_name}.locale = ?", locale.to_s).joins(:translations) } class << self def find_with_translations(*args,&block) unless ::I18n.locale == ::I18n.default_locale if args && args[0].kind_of?(Hash) args[0][:include] ||=[] args[0][:include] << :translations end end find_without_translations *args, &block end alias_method_chain :find, :translations end end end end module ClassMethods # 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? # 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=nil, &block) first,*other = class_name.split("::") if other.empty? klass = superclass ? Class.new(superclass, &block) : Class.new(&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, options = {} klass = name.constantize rescue nil adapter = Lolita::DBI::Base.create(self) unless klass klass = create_class(name, Object, get_orm_class(adapter)) do if adapter.dbi.adapter_name == :mongoid include Mongoid::Document end # set's real table name translation_adapter = Lolita::DBI::Base.create(self) translation_adapter.collection_name = options[:table_name] || adapter.collection_name.to_s.singularize + "_translation" cattr_accessor :translate_attrs, :master_id # before friendly_id 4.x if adapter.klass.respond_to?(:uses_friendly_id?) && adapter.klass.send(:uses_friendly_id?) parent_config = adapter.klass.friendly_id_config has_friendly_id parent_config.method, :allow_nil => parent_config.allow_nil, :approximate_ascii => parent_config.approximate_ascii, :ascii_approximation_options => [:russian], :max_length => parent_config.max_length, :reserved_words => parent_config.reserved_words, :use_slug => parent_config.use_slug end # 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)}) item_adapter = Lolita::DBI::Adapter.add(item.class) self_adapter = Lolita::DBI::Adapter.add(self) was_table_name = item_adapter.collection_name item_adapter.collection_name = self_adapter.collection_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_adapter.collection_name = was_table_name end extend Lolita::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 def get_orm_class(adapter) adapter.dbi.adapter_name == :active_record ? ActiveRecord::Base : nil 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 self_adapter = Lolita::DBI::Base.create(self) translations_class = self.reflect_on_association(:translations).klass translations_adapter = Lolita::DBI::Base.create(translations_class) if translations_adapter.dbi.adapter_name == :active_record translations_table = translations_adapter.collection_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 elsif translations_adapter.dbi.adapter_name == :mongoid unless translations_class.fields["locale"] translations_class.class_eval do field(self.master_id, :type => Integer) field :locale, :type => String columns_has_translations.each do |col| field col.name, :type => col.type end index( [ [ self.master_id, Mongo::ASCENDING ], [ :locale, Mongo::ASCENDING ] ], unique: true ) end else columns_has_translations.each do |col| unless translations_class.fields[col.name.to_s] translations_class.field(col.name,:type => col.type) end end end end $stdout = STDOUT end end module InstanceMethods # 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} self_adapter = Lolita::DBI::Base.create(self.class) options[self_adapter.reflect_on_association(:translations).klass.master_id] = self.id unless self.new_record? self.translations.build(options) unless self.translations.map(&:locale).include?(l.to_s) end end end #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 end