├── lib ├── .gitignore ├── tolk │ ├── version.rb │ ├── engine.rb │ ├── import.rb │ ├── config.rb │ └── sync.rb ├── tolk.rb ├── generators │ └── tolk │ │ ├── templates │ │ ├── initializer.erb │ │ └── migration.rb │ │ ├── utils.rb │ │ └── install_generator.rb └── tasks │ └── tolk_tasks.rake ├── public ├── favicon.ico ├── images │ └── rails.png ├── javascripts │ ├── application.js │ └── dragdrop.js ├── robots.txt ├── dispatch.cgi ├── dispatch.rb ├── dispatch.fcgi ├── 422.html ├── 404.html └── 500.html ├── log └── .gitignore ├── vendor ├── .gitignore └── plugins │ └── .gitignore ├── db ├── .gitignore ├── migrate │ └── 20110824135256_migrate_tolk.rb └── schema.rb ├── test ├── dummy │ ├── db │ │ ├── migrate │ │ │ └── .gitkeep │ │ ├── development.sqlite3 │ │ └── schema.rb │ ├── public │ │ ├── favicon.ico │ │ ├── stylesheets │ │ │ └── .gitkeep │ │ ├── javascripts │ │ │ ├── application.js │ │ │ ├── rails.js │ │ │ └── dragdrop.js │ │ ├── 422.html │ │ ├── 404.html │ │ └── 500.html │ ├── config │ │ ├── locales │ │ │ └── sync │ │ │ │ └── .gitkeep │ │ ├── routes.rb │ │ ├── environment.rb │ │ ├── initializers │ │ │ ├── mime_types.rb │ │ │ ├── inflections.rb │ │ │ ├── session_store.rb │ │ │ ├── backtrace_silencers.rb │ │ │ └── secret_token.rb │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── environments │ │ │ ├── development.rb │ │ │ ├── test.rb │ │ │ └── production.rb │ │ └── application.rb │ ├── app │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── controllers │ │ │ └── application_controller.rb │ │ └── views │ │ │ └── layouts │ │ │ └── application.html.erb │ ├── config.ru │ ├── Rakefile │ └── script │ │ └── rails ├── locales │ ├── basic │ │ ├── se.yml │ │ ├── da.yml │ │ └── en.yml │ ├── import │ │ ├── gem.en.yml │ │ └── en.yml │ ├── sync │ │ └── en.yml │ └── formats │ │ └── en.yml ├── fixtures │ ├── tolk_locales.yml │ ├── tolk_phrases.yml │ └── tolk_translations.yml ├── support │ ├── integration_case.rb │ ├── test_case.rb │ └── active_record_shared_connections.rb ├── unit │ ├── config_test.rb │ ├── import_test.rb │ ├── translation_test.rb │ ├── locale_test.rb │ ├── format_test.rb │ └── sync_test.rb ├── integration │ ├── authentication_test.rb │ └── translation_process_test.rb └── test_helper.rb ├── app ├── assets │ ├── stylesheets │ │ └── tolk │ │ │ ├── application.css │ │ │ ├── reset.css │ │ │ └── screen.css │ └── javascripts │ │ └── tolk │ │ └── application.js ├── views │ ├── tolk │ │ ├── searches │ │ │ ├── _form.html.erb │ │ │ └── show.html.erb │ │ └── locales │ │ │ ├── show.atom.builder │ │ │ ├── index.html.erb │ │ │ ├── all.html.erb │ │ │ └── show.html.erb │ └── layouts │ │ └── tolk │ │ └── application.html.erb ├── controllers │ └── tolk │ │ ├── searches_controller.rb │ │ ├── application_controller.rb │ │ └── locales_controller.rb ├── models │ └── tolk │ │ ├── phrase.rb │ │ ├── translation.rb │ │ └── locale.rb └── helpers │ └── tolk │ └── application_helper.rb ├── .gitignore ├── .travis.yml ├── doc ├── DEPLOY.md └── README_FOR_APP ├── init.rb ├── config └── routes.rb ├── script └── rails ├── Gemfile ├── CHANGELOG.md ├── tolk.gemspec ├── MIT-LICENSE ├── Rakefile └── README.md /lib/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log -------------------------------------------------------------------------------- /vendor/.gitignore: -------------------------------------------------------------------------------- 1 | rails -------------------------------------------------------------------------------- /db/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite3 2 | -------------------------------------------------------------------------------- /vendor/plugins/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/stylesheets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/config/locales/sync/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tolk/version.rb: -------------------------------------------------------------------------------- 1 | module Tolk 2 | VERSION = "1.3.2" 3 | end -------------------------------------------------------------------------------- /test/locales/basic/se.yml: -------------------------------------------------------------------------------- 1 | --- 2 | se: 3 | hello_world: "Hejsan Verdon" 4 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/locales/import/gem.en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | gem_hello_world: Gem Hello World 4 | -------------------------------------------------------------------------------- /public/images/rails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hughevans/tolk/master/public/images/rails.png -------------------------------------------------------------------------------- /app/assets/stylesheets/tolk/application.css: -------------------------------------------------------------------------------- 1 | # =require 'tolk/reset.css' 2 | # =require 'tolk/screen.css' 3 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.routes.draw do 2 | mount Tolk::Engine => "/tolk" 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/db/development.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hughevans/tolk/master/test/dummy/db/development.sqlite3 -------------------------------------------------------------------------------- /lib/tolk/engine.rb: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | 3 | module Tolk 4 | class Engine < Rails::Engine 5 | isolate_namespace Tolk 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery 3 | end 4 | -------------------------------------------------------------------------------- /test/fixtures/tolk_locales.yml: -------------------------------------------------------------------------------- 1 | en: 2 | id: 1 3 | name: en 4 | 5 | da: 6 | id: 2 7 | name: da 8 | 9 | se: 10 | id: 3 11 | name: se 12 | -------------------------------------------------------------------------------- /test/locales/basic/da.yml: -------------------------------------------------------------------------------- 1 | --- 2 | da: 3 | cozy: Hyggeligt 4 | hello_world: "Hej Verden" 5 | nested: 6 | hello_world: "Nedarvet Hej Verden" 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/**/* 2 | test/dummy/db/migrate/*.tolk.rb 3 | test/dummy/log/* 4 | test/dummy/tmp/* 5 | test/dummy/db/*.sqlite3 6 | Gemfile.lock 7 | .bundle 8 | *.gem -------------------------------------------------------------------------------- /test/locales/basic/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | nested: 4 | hello_world: Nested Hello World 5 | hello_country: Nested Hello Country 6 | hello_world: Hello World 7 | -------------------------------------------------------------------------------- /app/assets/javascripts/tolk/application.js: -------------------------------------------------------------------------------- 1 | //= require tolk/prototype.js 2 | //= require tolk/controls.js 3 | //= require tolk/dragdrop.js 4 | //= require tolk/effects.js 5 | -------------------------------------------------------------------------------- /test/locales/import/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | nested: 4 | hello_world: Nested Hello World 5 | hello_country: Nested Hello Country 6 | hello_world: Hello World 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | bundler_args: --without development 3 | before_script: 4 | - "export DISPLAY=:99.0" 5 | - "sh -e /etc/init.d/xvfb start" 6 | rvm: 7 | - 1.9.3 8 | -------------------------------------------------------------------------------- /public/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // Place your application-specific JavaScript functions and classes here 2 | // This file is automatically included by javascript_include_tag :defaults 3 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Dummy::Application 5 | -------------------------------------------------------------------------------- /test/locales/sync/en.yml: -------------------------------------------------------------------------------- 1 | --- 2 | en: 3 | nested: 4 | hello_country: Nested Hello Country 5 | hello_world: Hello World 6 | i18n: 7 | plural: Locale specific pluralization rules 8 | -------------------------------------------------------------------------------- /doc/DEPLOY.md: -------------------------------------------------------------------------------- 1 | # Rubygem deployment 2 | 3 | * Update version in version.rb 4 | * Update changelog in version.rb 5 | * Tag Repository in git 6 | * `gem build tolk.gemspec` 7 | * `gem push tolk-1.X.0.gem` -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the rails application 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the rails application 5 | Dummy::Application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/public/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // Place your application-specific JavaScript functions and classes here 2 | // This file is automatically included by javascript_include_tag :defaults 3 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | Mime::Type.register_alias "text/yaml", :yml 2 | 3 | $KCODE = 'UTF8' 4 | begin 5 | require 'ya2yaml' 6 | rescue LoadError => e 7 | Rails.logger.debug "[Tolk] Could not load ya2yaml" 8 | end 9 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Tolk::Engine.routes.draw do 2 | root :to => 'locales#index' 3 | resources :locales do 4 | member do 5 | get :all 6 | get :updated 7 | end 8 | end 9 | resource :search 10 | end 11 | -------------------------------------------------------------------------------- /doc/README_FOR_APP: -------------------------------------------------------------------------------- 1 | Use this README file to introduce your application and point to useful places in the API for learning more. 2 | Run "rake doc:app" to generate API documentation for your models, controllers, helpers, and libraries. 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-Agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | # Mime::Type.register_alias "text/html", :iphone 6 | -------------------------------------------------------------------------------- /test/support/integration_case.rb: -------------------------------------------------------------------------------- 1 | # Define a bare test case to use with Capybara 2 | class ActiveSupport::IntegrationCase < ActiveSupport::TestCase 3 | include Capybara::DSL 4 | include Rails.application.routes.url_helpers 5 | include Rails.application.routes.mounted_helpers 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | gemfile = File.expand_path('../../../../Gemfile', __FILE__) 3 | 4 | if File.exist?(gemfile) 5 | ENV['BUNDLE_GEMFILE'] = gemfile 6 | require 'bundler' 7 | Bundler.setup 8 | end 9 | 10 | $:.unshift File.expand_path('../../../../lib', __FILE__) -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | require 'rake' 6 | 7 | Dummy::Application.load_tasks 8 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= stylesheet_link_tag :all %> 6 | <%= javascript_include_tag :defaults %> 7 | <%= csrf_meta_tag %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /script/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. 3 | 4 | APP_PATH = File.expand_path('../../config/application', __FILE__) 5 | require File.expand_path('../../config/boot', __FILE__) 6 | require 'rails/commands' 7 | -------------------------------------------------------------------------------- /test/dummy/script/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. 3 | 4 | APP_PATH = File.expand_path('../../config/application', __FILE__) 5 | require File.expand_path('../../config/boot', __FILE__) 6 | require 'rails/commands' 7 | -------------------------------------------------------------------------------- /test/fixtures/tolk_phrases.yml: -------------------------------------------------------------------------------- 1 | hello_world: 2 | id: 1 3 | key: hello_world 4 | 5 | nested_hello_world: 6 | id: 2 7 | key: nested.hello_world 8 | 9 | nested_hello_country: 10 | id: 3 11 | key: nested.hello_country 12 | 13 | cozy: 14 | id: 4 15 | key: cozy 16 | 17 | human_format_precision: 18 | id: 5 19 | key: number.human.format.precision -------------------------------------------------------------------------------- /lib/tolk.rb: -------------------------------------------------------------------------------- 1 | require 'will_paginate' 2 | require 'ya2yaml' 3 | require 'tolk/config' 4 | require 'tolk/engine' 5 | require 'tolk/sync' 6 | require 'tolk/import' 7 | 8 | module Tolk 9 | # Setup Tolk 10 | def self.config(&block) 11 | if block_given? 12 | block.call(Tolk::Config) 13 | else 14 | Tolk::Config 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/test_case.rb: -------------------------------------------------------------------------------- 1 | class ActiveSupport::TestCase 2 | Tolk::Config.reset 3 | 4 | self.use_transactional_fixtures = true 5 | self.use_instantiated_fixtures = false 6 | 7 | fixtures :all 8 | 9 | self.fixture_class_names = {:tolk_locales => 'Tolk::Locale', :tolk_phrases => 'Tolk::Phrase', :tolk_translations => 'Tolk::Translation'} 10 | end 11 | -------------------------------------------------------------------------------- /app/views/tolk/searches/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_tag tolk.search_path, :method => :get do %> 2 | <%= hidden_field_tag :locale, locale.name %> 3 | Search for 4 | <%= scope_selector_for(@locale) %> 5 | phrase: 6 | <%= text_field_tag :q, params[:q] %> 7 | within key: 8 | <%= text_field_tag :k, params[:k] %> 9 | <%= submit_tag "Search", :name => nil %> 10 | <% end %> 11 | -------------------------------------------------------------------------------- /app/views/tolk/locales/show.atom.builder: -------------------------------------------------------------------------------- 1 | atom_feed do |feed| 2 | feed.title "Missing Translations for #{@locale.language_name} locale" 3 | 4 | @phrases.each do |phrase| 5 | feed.entry(phrase, :url => tolk_locale_url(@locale)) do |entry| 6 | entry.title(phrase.key) 7 | entry.content(phrase.key) 8 | entry.author {|author| author.name("Tolk") } 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/unit/config_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'fileutils' 3 | 4 | class ConfigTest < ActiveSupport::TestCase 5 | test "config default values" do 6 | assert_equal Proc, Tolk.config.dump_path.class 7 | assert_equal "#{Rails.root}/config/locales", Tolk::Locale._dump_path 8 | assert Tolk.config.mapping.keys.include?('ar') 9 | assert_equal 'Arabic',Tolk.config.mapping['ar'] 10 | end 11 | end -------------------------------------------------------------------------------- /lib/generators/tolk/templates/initializer.erb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | # Tolk config file. Generated on <%= DateTime.now.to_s(:long) %> 4 | # See github.com/tolk/tolk for more informations 5 | 6 | Tolk.config do |config| 7 | 8 | # If you need to add a mapping do it like this : 9 | # May we suggest you use http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes 10 | # config.mapping["fr-ES"] = 'Frañol !' 11 | 12 | end -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem "rails", "3.2.6" 4 | 5 | gem 'will_paginate' 6 | gem "ya2yaml" 7 | 8 | group 'test' do 9 | gem "capybara", :git => "https://github.com/jnicklas/capybara.git" 10 | gem "factory_girl_rails" 11 | gem "sqlite3" 12 | gem "mocha" 13 | gem 'launchy' 14 | end 15 | 16 | group 'development' do 17 | if RUBY_VERSION < '1.9' 18 | gem "ruby-debug", ">= 0.10.3" 19 | end 20 | end -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format 4 | # (all these examples are active by default): 5 | # ActiveSupport::Inflector.inflections do |inflect| 6 | # inflect.plural /^(ox)$/i, '\1en' 7 | # inflect.singular /^(ox)en/i, '\1' 8 | # inflect.irregular 'person', 'people' 9 | # inflect.uncountable %w( fish sheep ) 10 | # end 11 | -------------------------------------------------------------------------------- /test/support/active_record_shared_connections.rb: -------------------------------------------------------------------------------- 1 | class ActiveRecord::Base 2 | mattr_accessor :shared_connection 3 | @@shared_connection = nil 4 | 5 | def self.connection 6 | @@shared_connection || retrieve_connection 7 | end 8 | end 9 | 10 | # Forces all threads to share the same connection. This works on 11 | # Capybara because it starts the web server in a thread. 12 | ActiveRecord::Base.shared_connection = ActiveRecord::Base.connection 13 | -------------------------------------------------------------------------------- /app/controllers/tolk/searches_controller.rb: -------------------------------------------------------------------------------- 1 | module Tolk 2 | class SearchesController < Tolk::ApplicationController 3 | before_filter :find_locale 4 | 5 | def show 6 | @phrases = @locale.search_phrases(params[:q], params[:scope].to_sym, params[:k], params[:page]) 7 | end 8 | 9 | private 10 | 11 | def find_locale 12 | @locale = Tolk::Locale.where('UPPER(name) = UPPER(?)', params[:locale]).first! 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Dummy::Application.config.session_store :cookie_store, :key => '_dummy_session' 4 | 5 | # Use the database for sessions instead of the cookie-based default, 6 | # which shouldn't be used to store highly confidential information 7 | # (create the session table with "rake db:sessions:create") 8 | # Dummy::Application.config.session_store :active_record_store 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | # Make sure the secret is at least 30 characters and all random, 6 | # no regular words or you'll be exposed to dictionary attacks. 7 | Dummy::Application.config.secret_token = '15bf55619161ab494a0d716093754246f33c4ee07881afbf4d96b65d81075184aa798ce676d3a0b1b662ef8407e274e2a7b892721165cbac3ca72de3e9d882b1' 8 | -------------------------------------------------------------------------------- /app/controllers/tolk/application_controller.rb: -------------------------------------------------------------------------------- 1 | require 'tolk/application_controller' 2 | 3 | module Tolk 4 | class ApplicationController < ActionController::Base 5 | helper :all 6 | protect_from_forgery 7 | 8 | cattr_accessor :authenticator 9 | before_filter :authenticate 10 | 11 | def authenticate 12 | self.authenticator.bind(self).call if self.authenticator && self.authenticator.respond_to?(:call) 13 | end 14 | 15 | def ensure_no_primary_locale 16 | redirect_to tolk.locales_path if @locale.primary? 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /public/dispatch.cgi: -------------------------------------------------------------------------------- 1 | #!/System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/bin/ruby 2 | 3 | require File.dirname(__FILE__) + "/../config/environment" unless defined?(RAILS_ROOT) 4 | 5 | # If you're using RubyGems and mod_ruby, this require should be changed to an absolute path one, like: 6 | # "/usr/local/lib/ruby/gems/1.8/gems/rails-0.8.0/lib/dispatcher" -- otherwise performance is severely impaired 7 | require "dispatcher" 8 | 9 | ADDITIONAL_LOAD_PATHS.reverse.each { |dir| $:.unshift(dir) if File.directory?(dir) } if defined?(Apache::RubyRun) 10 | Dispatcher.dispatch 11 | -------------------------------------------------------------------------------- /public/dispatch.rb: -------------------------------------------------------------------------------- 1 | #!/System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/bin/ruby 2 | 3 | require File.dirname(__FILE__) + "/../config/environment" unless defined?(RAILS_ROOT) 4 | 5 | # If you're using RubyGems and mod_ruby, this require should be changed to an absolute path one, like: 6 | # "/usr/local/lib/ruby/gems/1.8/gems/rails-0.8.0/lib/dispatcher" -- otherwise performance is severely impaired 7 | require "dispatcher" 8 | 9 | ADDITIONAL_LOAD_PATHS.reverse.each { |dir| $:.unshift(dir) if File.directory?(dir) } if defined?(Apache::RubyRun) 10 | Dispatcher.dispatch 11 | -------------------------------------------------------------------------------- /test/locales/formats/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | number: 1 3 | string: I am just a stupid string :( 4 | variables: "String with variables {{hello}} and %{world}" 5 | variables_in_struct: 6 | none: "Without variable" 7 | one: "With {{variables}}" 8 | other: "With even %{more} {{variables}}" 9 | number_array: [1, 2, 3] 10 | string_array: ['sun', 'moon'] 11 | pluralization: 12 | other: Hello 13 | not_pluralization: 14 | other: World 15 | lifo: fifo 16 | activerecord: 17 | models: 18 | person: Dude 19 | attributes: 20 | person: 21 | login: Handle -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3-ruby (not necessary on OS X Leopard) 3 | development: 4 | adapter: sqlite3 5 | database: db/development.sqlite3 6 | pool: 5 7 | timeout: 5000 8 | 9 | # Warning: The database defined as "test" will be erased and 10 | # re-generated from your development database when you run "rake". 11 | # Do not set this db to the same as development or production. 12 | test: 13 | adapter: sqlite3 14 | database: db/test.sqlite3 15 | pool: 5 16 | timeout: 5000 17 | 18 | production: 19 | adapter: sqlite3 20 | database: db/production.sqlite3 21 | pool: 5 22 | timeout: 5000 23 | -------------------------------------------------------------------------------- /app/assets/stylesheets/tolk/reset.css: -------------------------------------------------------------------------------- 1 | /*------------------------------------------------- 2 | RESET 3 | -------------------------------------------------*/ 4 | 5 | body,div,dl,dt,dd,ul,ol,li,h1,h2,h3,h4,h5,h6,pre,form,fieldset,input,textarea,p,blockquote,th,td { 6 | margin:0; 7 | padding:0; 8 | } 9 | table { 10 | border-collapse:collapse; 11 | border-spacing:0; 12 | } 13 | fieldset,img {border:0;} 14 | address,caption,cite,code,dfn,th,var { 15 | font-style:normal; 16 | font-weight:normal; 17 | } 18 | 19 | h1,h2,h3,h4,h5,h6 { 20 | font-size:100%; 21 | font-weight:normal; 22 | } 23 | 24 | ol,ul {list-style:none;} 25 | caption,th {text-align:left;} 26 | q:before,q:after {content:'';} 27 | abbr,acronym {border:0;} 28 | 29 | img {border: none;} 30 | em em {font-style: normal;} -------------------------------------------------------------------------------- /app/models/tolk/phrase.rb: -------------------------------------------------------------------------------- 1 | module Tolk 2 | class Phrase < ActiveRecord::Base 3 | self.table_name = "tolk_phrases" 4 | 5 | attr_accessible :key 6 | 7 | validates_uniqueness_of :key 8 | 9 | cattr_accessor :per_page 10 | self.per_page = 30 11 | 12 | has_many :translations, :class_name => 'Tolk::Translation', :dependent => :destroy do 13 | def primary 14 | to_a.detect {|t| t.locale_id == Tolk::Locale.primary_locale.id} 15 | end 16 | 17 | def for(locale) 18 | to_a.detect {|t| t.locale_id == locale.id} 19 | end 20 | end 21 | 22 | attr_accessor :translation 23 | 24 | scope :containing_text, lambda { |query| 25 | { :conditions => ["tolk_phrases.key LIKE ?", "%#{query}%"] } 26 | } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/unit/import_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'fileutils' 3 | 4 | class ImportTest < ActiveSupport::TestCase 5 | def setup 6 | Tolk::Locale.delete_all 7 | Tolk::Translation.delete_all 8 | Tolk::Phrase.delete_all 9 | 10 | Tolk::Locale.locales_config_path = Rails.root.join("../locales/import/") 11 | 12 | I18n.backend.reload! 13 | I18n.load_path = [Tolk::Locale.locales_config_path + 'en.yml'] 14 | I18n.backend.send :init_translations 15 | 16 | Tolk::Locale.primary_locale(true) 17 | end 18 | 19 | test "skips gem translations files (xxx.en.yml)" do 20 | Tolk::Locale.sync! 21 | Tolk::Locale.import_secondary_locales 22 | 23 | 24 | assert_equal 0, Tolk::Phrase.where(key: 'gem_hello_world').count 25 | 26 | end 27 | 28 | end -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | * Tolk 1.3.2 2 | * fixed a crash and made tests passing (gshilin) 3 | 4 | * Tolk 1.3.0 5 | * Improved header (bquorning) 6 | * Locales are ordered by name in locales/index 7 | * Tolk ignore files with name of type xxx.en.yml 8 | * Default mapping use http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes 9 | * Adding a config file for Tolk in the hosted application 10 | * Allow to config Mappings 11 | * Allow to config dump_path 12 | 13 | * Tolk 1.2.0 [May 16th, 2012] 14 | * Adding search within key (ZenCocoon) 15 | * Update for Rails 3.2.3 whitelist attributes compatibility 16 | 17 | 18 | * Tolk 1.1.0 [May 15th, 2012] 19 | * Update for Rails 3.2 compatibility 20 | * Added Travis CI 21 | 22 | * Tolk 1.0.0 [June 6th, 2010] 23 | * First public release as a Rails 3 engine gem -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The change you wanted was rejected.

23 |

Maybe you tried to change something you didn't have access to.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The page you were looking for doesn't exist.

23 |

You may have mistyped the address or the page may have moved.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

We're sorry, but something went wrong.

23 |

We've been notified about this issue and we'll take a look at it shortly.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /test/fixtures/tolk_translations.yml: -------------------------------------------------------------------------------- 1 | hello_world_en: 2 | id: 1 3 | phrase_id: 1 4 | locale_id: 1 5 | text: Hello World 6 | 7 | hello_world_da: 8 | id: 2 9 | phrase_id: 1 10 | locale_id: 2 11 | text: Hej Verden 12 | 13 | hello_world_se: 14 | id: 3 15 | phrase_id: 1 16 | locale_id: 3 17 | text: Hejsan Verdon 18 | 19 | nested_hello_world_en: 20 | id: 4 21 | phrase_id: 2 22 | locale_id: 1 23 | text: Nested Hello World 24 | 25 | nested_hello_country_en: 26 | id: 5 27 | phrase_id: 3 28 | locale_id: 1 29 | text: Nested Hello Country 30 | 31 | nested_hello_world_da: 32 | id: 6 33 | phrase_id: 2 34 | locale_id: 2 35 | text: Nedarvet Hej Verden 36 | 37 | cozy_da: 38 | id: 7 39 | phrase_id: 4 40 | locale_id: 2 41 | text: Hyggeligt 42 | 43 | human_format_precision_en: 44 | id: 8 45 | phrase_id: 5 46 | locale_id: 1 47 | text: "1" 48 | -------------------------------------------------------------------------------- /app/views/layouts/tolk/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tolk 7 | 8 | <%= javascript_include_tag "tolk/application" %> 9 | <%= stylesheet_link_tag "tolk/application" %> 10 | <%= yield :head %> 11 | 12 | 13 | 14 |
15 | 18 | <%= yield %> 19 |
20 | 21 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /test/integration/authentication_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class AuthenticationTest < ActionController::IntegrationTest 4 | self.fixture_path = Rails.root.to_s + "/../fixtures" 5 | fixtures :all 6 | 7 | def setup 8 | Tolk::ApplicationController.authenticator = proc do 9 | authenticate_or_request_with_http_basic {|user_name, password| user_name == 'lifo' && password == 'pass' } 10 | end 11 | end 12 | 13 | def teardown 14 | Tolk::ApplicationController.authenticator = nil 15 | end 16 | 17 | test "failed authentication" do 18 | get '/tolk' 19 | assert_response 401 20 | end 21 | 22 | test "successful authentication" do 23 | get '/tolk', nil, 'HTTP_AUTHORIZATION' => encode_credentials('lifo', 'pass') 24 | assert_response :success 25 | end 26 | 27 | protected 28 | 29 | def encode_credentials(username, password) 30 | "Basic #{Base64.encode64("#{username}:#{password}")}" 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /tolk.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.expand_path('../lib/tolk/version', __FILE__) 3 | 4 | Gem::Specification.new do |s| 5 | s.name = 'tolk' 6 | s.version = Tolk::VERSION 7 | s.summary = 'Rails engine providing web interface for managing i18n yaml files' 8 | s.description = 'Tolk is a web interface for doing i18n translations packaged as an engine for Rails applications.' 9 | 10 | s.authors = ['David Heinemeier Hansson', 'Piotr Sarnacki', 'Emilio Tagua', 'Thomas Darde'] 11 | s.email = 'david@loudthinking.com' 12 | s.homepage = 'http://github.com/tolk/tolk' 13 | 14 | s.platform = Gem::Platform::RUBY 15 | s.add_dependency('will_paginate') 16 | s.add_dependency('ya2yaml', '~> 0.26') 17 | 18 | if File.exists?('UPGRADING') 19 | s.post_install_message = File.read("UPGRADING") 20 | end 21 | 22 | s.files = Dir['README', 'MIT-LICENSE', 'config/routes.rb', 'init.rb', 'lib/**/*', 'app/**/*', 'public/tolk/**/*'] 23 | 24 | s.require_path = 'lib' 25 | end 26 | -------------------------------------------------------------------------------- /app/views/tolk/locales/index.html.erb: -------------------------------------------------------------------------------- 1 |

Locales Primary locale is <%= Tolk::Locale.primary_locale.language_name %>

2 | <% if @locales.any? %> 3 | 14 | 15 | <% else %> 16 |

No locales yet.

17 | 18 | <% end %> 19 | <%= form_for(Tolk::Locale.new) do |f| %> 20 |
21 |

22 | <%= f.label "Add a new Locale" %> 23 | 26 | <%= f.submit 'Add' %> 27 |

28 |
29 | <% end %> 30 | 31 | 32 | -------------------------------------------------------------------------------- /public/dispatch.fcgi: -------------------------------------------------------------------------------- 1 | #!/System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/bin/ruby 2 | # 3 | # You may specify the path to the FastCGI crash log (a log of unhandled 4 | # exceptions which forced the FastCGI instance to exit, great for debugging) 5 | # and the number of requests to process before running garbage collection. 6 | # 7 | # By default, the FastCGI crash log is RAILS_ROOT/log/fastcgi.crash.log 8 | # and the GC period is nil (turned off). A reasonable number of requests 9 | # could range from 10-100 depending on the memory footprint of your app. 10 | # 11 | # Example: 12 | # # Default log path, normal GC behavior. 13 | # RailsFCGIHandler.process! 14 | # 15 | # # Default log path, 50 requests between GC. 16 | # RailsFCGIHandler.process! nil, 50 17 | # 18 | # # Custom log path, normal GC behavior. 19 | # RailsFCGIHandler.process! '/var/log/myapp_fcgi_crash.log' 20 | # 21 | require File.dirname(__FILE__) + "/../config/environment" 22 | require 'fcgi_handler' 23 | 24 | RailsFCGIHandler.process! 25 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | The change you wanted was rejected (422) 9 | 21 | 22 | 23 | 24 | 25 |
26 |

The change you wanted was rejected.

27 |

Maybe you tried to change something you didn't have access to.

28 |
29 | 30 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | The page you were looking for doesn't exist (404) 9 | 21 | 22 | 23 | 24 | 25 |
26 |

The page you were looking for doesn't exist.

27 |

You may have mistyped the address or the page may have moved.

28 |
29 | 30 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/environment.rb 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the webserver when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Log error messages when you accidentally call methods on nil. 10 | config.whiny_nils = true 11 | 12 | # Show full error reports and disable caching 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger 20 | config.active_support.deprecation = :log 21 | 22 | # Only use best-standards-support built into browsers 23 | config.action_dispatch.best_standards_support = :builtin 24 | end 25 | 26 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2004-2010 David Heinemeier Hansson 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. -------------------------------------------------------------------------------- /app/helpers/tolk/application_helper.rb: -------------------------------------------------------------------------------- 1 | module Tolk 2 | module ApplicationHelper 3 | def format_i18n_value(value) 4 | h(yaml_value(value)).gsub(/\n/, '
').html_safe 5 | end 6 | 7 | def format_i18n_text_area_value(value) 8 | yaml_value(value) 9 | end 10 | 11 | def yaml_value(value) 12 | if value.present? 13 | unless value.is_a?(String) 14 | value = value.respond_to?(:ya2yaml) ? value.ya2yaml(:syck_compatible => true) : value.to_yaml 15 | end 16 | end 17 | 18 | value 19 | end 20 | 21 | def tolk_locale_selection 22 | existing_locale_names = Tolk::Locale.all.map(&:name) 23 | 24 | pairs = Tolk.config.mapping.to_a.map(&:reverse).sort_by(&:first) 25 | pairs.reject {|pair| existing_locale_names.include?(pair.last) } 26 | end 27 | 28 | def scope_selector_for(locale) 29 | select_tag 'scope', options_for_select([[Tolk::Locale.primary_locale.language_name, "origin"], 30 | [locale.language_name, "target"]], params[:scope]) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/generators/tolk/templates/migration.rb: -------------------------------------------------------------------------------- 1 | class CreateTolkTables < ActiveRecord::Migration 2 | def self.up 3 | create_table :tolk_locales do |t| 4 | t.string :name 5 | t.datetime :created_at 6 | t.datetime :updated_at 7 | end 8 | 9 | add_index :tolk_locales, :name, :unique => true 10 | 11 | create_table :tolk_phrases do |t| 12 | t.text :key 13 | t.datetime :created_at 14 | t.datetime :updated_at 15 | end 16 | 17 | create_table :tolk_translations do |t| 18 | t.integer :phrase_id 19 | t.integer :locale_id 20 | t.text :text 21 | t.text :previous_text 22 | t.boolean :primary_updated, :default => false 23 | t.datetime :created_at 24 | t.datetime :updated_at 25 | end 26 | 27 | add_index :tolk_translations, [:phrase_id, :locale_id], :unique => true 28 | end 29 | 30 | def self.down 31 | remove_index :tolk_translations, :column => [:phrase_id, :locale_id] 32 | remove_index :tolk_locales, :column => :name 33 | 34 | drop_table :tolk_translations 35 | drop_table :tolk_phrases 36 | drop_table :tolk_locales 37 | end 38 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require 'rake' 3 | require 'rdoc/task' 4 | require 'rubygems/package_task' 5 | 6 | require 'rake/testtask' 7 | 8 | Rake::TestTask.new(:test) do |t| 9 | t.libs << 'lib' 10 | t.libs << 'test' 11 | t.pattern = 'test/**/*_test.rb' 12 | t.verbose = false 13 | end 14 | 15 | task :default => :test 16 | 17 | Rake::RDocTask.new(:rdoc) do |rdoc| 18 | rdoc.rdoc_dir = 'rdoc' 19 | rdoc.title = 'Tolk' 20 | rdoc.options << '--line-numbers' << '--inline-source' 21 | rdoc.rdoc_files.include('README.rdoc') 22 | rdoc.rdoc_files.include('lib/**/*.rb') 23 | end 24 | 25 | spec = Gem::Specification.new do |s| 26 | s.name = "tolk" 27 | s.summary = "Rails engine providing web interface for managing i18n yaml files" 28 | s.description = "Tolk is a web interface for doing i18n translations packaged as an engine for Rails applications." 29 | s.files = FileList["[A-Z]*", "lib/**/*"] 30 | s.version = "2.0.0.beta" 31 | end 32 | 33 | Gem::PackageTask.new(spec) do |pkg| 34 | end 35 | 36 | desc "Install the gem #{spec.name}-#{spec.version}.gem" 37 | task :install do 38 | system("gem install pkg/#{spec.name}-#{spec.version}.gem --no-ri --no-rdoc") 39 | end 40 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | We're sorry, but something went wrong (500) 9 | 21 | 22 | 23 | 24 | 25 |
26 |

We're sorry, but something went wrong.

27 |

We've been notified about this issue and we'll take a look at it shortly.

28 |

(If you're the administrator of this website, then please read 29 | the log file "<%=h RAILS_ENV %>.log" 30 | to find out what went wrong.)

31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /lib/generators/tolk/utils.rb: -------------------------------------------------------------------------------- 1 | module Tolk 2 | module Generators 3 | module Utils 4 | module InstanceMethods 5 | def display(output, color = :green) 6 | say(" - #{output}", color) 7 | end 8 | 9 | def ask_for(wording, default_value = nil, override_if_present_value = nil) 10 | override_if_present_value.present? ? 11 | display("Using [#{override_if_present_value}] for question '#{wording}'") && override_if_present_value : 12 | ask(" ? #{wording} Press for [#{default_value}] >", :yellow).presence || default_value 13 | end 14 | 15 | def ask_boolean(wording, default_value = nil) 16 | value = ask_for(wording, 'Y') 17 | value = (value == 'Y') 18 | end 19 | end 20 | 21 | module ClassMethods 22 | def next_migration_number(dirname) 23 | if ActiveRecord::Base.timestamped_migrations 24 | migration_number = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i 25 | migration_number += 1 26 | migration_number.to_s 27 | else 28 | "%.3d" % (current_migration_number(dirname) + 1) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | 36 | -------------------------------------------------------------------------------- /db/migrate/20110824135256_migrate_tolk.rb: -------------------------------------------------------------------------------- 1 | class MigrateTolk < ActiveRecord::Migration 2 | def self.up 3 | create_table "tolk_locales", :force => true do |t| 4 | t.string "name" 5 | t.datetime "created_at" 6 | t.datetime "updated_at" 7 | end 8 | 9 | add_index "tolk_locales", ["name"], :name => "index_tolk_locales_on_name", :unique => true 10 | 11 | create_table "tolk_phrases", :force => true do |t| 12 | t.text "key" 13 | t.datetime "created_at" 14 | t.datetime "updated_at" 15 | end 16 | 17 | add_index "tolk_phrases", ["key"], :name => "index_tolk_phrases_on_key", :unique => true 18 | 19 | create_table "tolk_translations", :force => true do |t| 20 | t.integer "phrase_id" 21 | t.integer "locale_id" 22 | t.text "text" 23 | t.datetime "created_at" 24 | t.datetime "updated_at" 25 | t.boolean "primary_updated", :default => false 26 | t.text "previous_text" 27 | end 28 | 29 | add_index "tolk_translations", ["phrase_id", "locale_id"], :name => "index_tolk_translations_on_phrase_id_and_locale_id", :unique => true 30 | end 31 | 32 | def self.down 33 | drop_table :tolk_translations 34 | drop_table :tolk_phrases 35 | drop_table :tolk_locales 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Envinronment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require File.expand_path("../dummy/config/environment.rb", __FILE__) 5 | ActiveRecord::IdentityMap.enabled = false 6 | require "rails/test_help" 7 | 8 | ActiveSupport::TestCase.fixture_path = Rails.root.to_s + "/../fixtures" 9 | ActiveSupport::TestCase.use_transactional_fixtures = true 10 | 11 | ActionMailer::Base.delivery_method = :test 12 | ActionMailer::Base.perform_deliveries = true 13 | ActionMailer::Base.default_url_options[:host] = "test.com" 14 | 15 | Rails.backtrace_cleaner.remove_silencers! 16 | 17 | # Configure capybara for integration testing 18 | require "capybara/rails" 19 | Capybara.default_driver = :selenium 20 | Capybara.default_selector = :css 21 | 22 | # Run any available migrations 23 | # A bit of hacks, find a nicer way 24 | FileUtils.rm(Dir[File.expand_path("../dummy/db/test.sqlite3", __FILE__)]) 25 | FileUtils.rm(Dir[File.expand_path("../dummy/db/migrate/*.tolk.rb", __FILE__)]) 26 | ActiveRecord::Migration.copy File.expand_path("../dummy/db/migrate/", __FILE__), { :tolk => File.expand_path("../../db/migrate/", __FILE__) } 27 | ActiveRecord::Migrator.migrate File.expand_path("../dummy/db/migrate/", __FILE__) 28 | 29 | # Load support files 30 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 31 | -------------------------------------------------------------------------------- /lib/tasks/tolk_tasks.rake: -------------------------------------------------------------------------------- 1 | namespace :tolk do 2 | desc "Update locale" 3 | task :update_locale, [:old_name, :new_name] => :environment do |t, args| 4 | old_name, new_name = args[:old_name], args[:new_name] 5 | puts Tolk::Locale.rename(old_name, new_name) 6 | end 7 | 8 | desc "Add database tables, copy over the assets, and import existing translations" 9 | task :setup => :environment do 10 | system 'rails g tolk:install' 11 | 12 | Rake::Task['db:migrate'].invoke 13 | Rake::Task['tolk:sync'].invoke 14 | Rake::Task['tolk:import'].invoke 15 | end 16 | 17 | desc "Sync Tolk with the default locale's yml file" 18 | task :sync => :environment do 19 | Tolk::Locale.sync! 20 | end 21 | 22 | desc "Generate yml files for all the locales defined in Tolk" 23 | task :dump_all => :environment do 24 | Tolk::Locale.dump_all 25 | end 26 | 27 | desc "Imports data all non default locale yml files to Tolk" 28 | task :import => :environment do 29 | Rake::Task['tolk:sync'].invoke 30 | Tolk::Locale.import_secondary_locales 31 | end 32 | 33 | desc "Show all the keys potentially containing HTML values and no _html postfix" 34 | task :html_keys => :environment do 35 | bad_translations = Tolk::Locale.primary_locale.translations_with_html 36 | bad_translations.each do |bt| 37 | puts "#{bt.phrase.key} - #{bt.text}" 38 | end 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /app/controllers/tolk/locales_controller.rb: -------------------------------------------------------------------------------- 1 | module Tolk 2 | class LocalesController < Tolk::ApplicationController 3 | before_filter :find_locale, :only => [:show, :all, :update, :updated] 4 | before_filter :ensure_no_primary_locale, :only => [:all, :update, :show, :updated] 5 | 6 | def index 7 | @locales = Tolk::Locale.secondary_locales.sort_by(&:language_name) 8 | end 9 | 10 | def show 11 | respond_to do |format| 12 | format.html do 13 | @phrases = @locale.phrases_without_translation(params[:page]) 14 | end 15 | format.atom { @phrases = @locale.phrases_without_translation(params[:page], :per_page => 50) } 16 | format.yaml { render :text => @locale.to_hash.ya2yaml(:syck_compatible => true) } 17 | end 18 | end 19 | 20 | def update 21 | @locale.translations_attributes = params[:translations] 22 | @locale.save 23 | redirect_to request.referrer 24 | end 25 | 26 | def all 27 | @phrases = @locale.phrases_with_translation(params[:page]) 28 | end 29 | 30 | def updated 31 | @phrases = @locale.phrases_with_updated_translation(params[:page]) 32 | render :all 33 | end 34 | 35 | def create 36 | Tolk::Locale.create!(params[:tolk_locale]) 37 | redirect_to :action => :index 38 | end 39 | 40 | private 41 | 42 | def find_locale 43 | @locale = Tolk::Locale.where('UPPER(name) = UPPER(?)', params[:id]).first! 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/unit/translation_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'fileutils' 3 | 4 | class TranslationTest < ActiveSupport::TestCase 5 | fixtures :tolk_translations 6 | 7 | def setup 8 | Tolk::Locale.primary_locale(true) 9 | end 10 | 11 | test "translation is inavlid when a duplicate exists" do 12 | translation = Tolk::Translation.new :phrase => tolk_translations(:hello_world_da).phrase, :locale => tolk_translations(:hello_world_da).locale 13 | translation.text = "Revised Hello World" 14 | assert translation.invalid? 15 | assert translation.errors[:phrase_id] 16 | end 17 | 18 | test "translation is not changed when text is assigned an equal value in numberic form" do 19 | translation = tolk_translations(:human_format_precision_en) 20 | assert_equal "1", translation.text 21 | translation.text = "1" 22 | assert_equal false, translation.changed? 23 | translation.text = 1 24 | assert_equal false, translation.changed? 25 | end 26 | 27 | test "translation with string value" do 28 | assert_equal "Hello World", tolk_translations(:hello_world_en).value 29 | end 30 | 31 | test "translation with string value with variables" do 32 | text = "{{attribute}} {{message}}" 33 | assert_equal text, Tolk::Translation.new(:text => text).value 34 | end 35 | 36 | test "translation with numeric value" do 37 | assert_equal 1, tolk_translations(:human_format_precision_en).value 38 | end 39 | 40 | test "translation with hash value" do 41 | hash = {:foo => "bar"} 42 | assert_equal hash, Tolk::Translation.new(:text => hash).value 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/environment.rb 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Log error messages when you accidentally call methods on nil. 11 | config.whiny_nils = true 12 | 13 | # Show full error reports and disable caching 14 | config.consider_all_requests_local = true 15 | config.action_controller.perform_caching = false 16 | 17 | # Raise exceptions instead of rendering exception templates 18 | config.action_dispatch.show_exceptions = false 19 | 20 | # Disable request forgery protection in test environment 21 | config.action_controller.allow_forgery_protection = false 22 | 23 | # Tell Action Mailer not to deliver emails to the real world. 24 | # The :test delivery method accumulates sent emails in the 25 | # ActionMailer::Base.deliveries array. 26 | config.action_mailer.delivery_method = :test 27 | 28 | # Use SQL instead of Active Record's schema dumper when creating the test database. 29 | # This is necessary if your schema can't be completely dumped by the schema dumper, 30 | # like if you have constraints or database-specific column types 31 | # config.active_record.schema_format = :sql 32 | 33 | # Print deprecation notices to the stderr 34 | config.active_support.deprecation = :stderr 35 | end 36 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended to check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(:version => 20100910193132) do 14 | 15 | create_table "tolk_locales", :force => true do |t| 16 | t.string "name" 17 | t.datetime "created_at" 18 | t.datetime "updated_at" 19 | end 20 | 21 | add_index "tolk_locales", ["name"], :name => "index_tolk_locales_on_name", :unique => true 22 | 23 | create_table "tolk_phrases", :force => true do |t| 24 | t.text "key" 25 | t.datetime "created_at" 26 | t.datetime "updated_at" 27 | end 28 | 29 | create_table "tolk_translations", :force => true do |t| 30 | t.integer "phrase_id" 31 | t.integer "locale_id" 32 | t.text "text" 33 | t.datetime "created_at" 34 | t.datetime "updated_at" 35 | t.boolean "primary_updated", :default => false 36 | t.text "previous_text" 37 | end 38 | 39 | add_index "tolk_translations", ["phrase_id", "locale_id"], :name => "index_tolk_translations_on_phrase_id_and_locale_id", :unique => true 40 | 41 | end 42 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead of editing this file, 2 | # please use the migrations feature of Active Record to incrementally modify your database, and 3 | # then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your database schema. If you need 6 | # to create the application database on another system, you should be using db:schema:load, not running 7 | # all the migrations from scratch. The latter is a flawed and unsustainable approach (the more migrations 8 | # you'll amass, the slower it'll run and the greater likelihood for issues). 9 | # 10 | # It's strongly recommended to check this file into your version control system. 11 | 12 | ActiveRecord::Schema.define(:version => 20100409194926) do 13 | 14 | create_table "tolk_locales", :force => true do |t| 15 | t.string "name" 16 | t.datetime "created_at" 17 | t.datetime "updated_at" 18 | end 19 | 20 | add_index "tolk_locales", ["name"], :name => "index_tolk_locales_on_name", :unique => true 21 | 22 | create_table "tolk_phrases", :force => true do |t| 23 | t.text "key" 24 | t.datetime "created_at" 25 | t.datetime "updated_at" 26 | end 27 | 28 | add_index "tolk_phrases", ["key"], :name => "index_tolk_phrases_on_key", :unique => true 29 | 30 | create_table "tolk_translations", :force => true do |t| 31 | t.integer "phrase_id" 32 | t.integer "locale_id" 33 | t.text "text" 34 | t.datetime "created_at" 35 | t.datetime "updated_at" 36 | t.boolean "primary_updated", :default => false 37 | t.text "previous_text" 38 | end 39 | 40 | add_index "tolk_translations", ["phrase_id", "locale_id"], :name => "index_tolk_translations_on_phrase_id_and_locale_id", :unique => true 41 | 42 | end 43 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/environment.rb 3 | 4 | # The production environment is meant for finished, "live" apps. 5 | # Code is not reloaded between requests 6 | config.cache_classes = true 7 | 8 | # Full error reports are disabled and caching is turned on 9 | config.consider_all_requests_local = false 10 | config.action_controller.perform_caching = true 11 | 12 | # Specifies the header that your server uses for sending files 13 | config.action_dispatch.x_sendfile_header = "X-Sendfile" 14 | 15 | # For nginx: 16 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' 17 | 18 | # If you have no front-end server that supports something like X-Sendfile, 19 | # just comment this out and Rails will serve the files 20 | 21 | # See everything in the log (default is :info) 22 | # config.log_level = :debug 23 | 24 | # Use a different logger for distributed setups 25 | # config.logger = SyslogLogger.new 26 | 27 | # Use a different cache store in production 28 | # config.cache_store = :mem_cache_store 29 | 30 | # Disable Rails's static asset server 31 | # In production, Apache or nginx will already do this 32 | config.serve_static_assets = false 33 | 34 | # Enable serving of images, stylesheets, and javascripts from an asset server 35 | # config.action_controller.asset_host = "http://assets.example.com" 36 | 37 | # Disable delivery errors, bad email addresses will be ignored 38 | # config.action_mailer.raise_delivery_errors = false 39 | 40 | # Enable threaded mode 41 | # config.threadsafe! 42 | 43 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 44 | # the I18n.default_locale when a translation can not be found) 45 | config.i18n.fallbacks = true 46 | 47 | # Send deprecation notices to registered listeners 48 | config.active_support.deprecation = :notify 49 | end 50 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require "active_model/railtie" 4 | require "active_record/railtie" 5 | require "action_controller/railtie" 6 | require "action_view/railtie" 7 | require "action_mailer/railtie" 8 | 9 | Bundler.require 10 | require "tolk" 11 | 12 | module Dummy 13 | class Application < Rails::Application 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration should go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded. 17 | 18 | # Custom directories with classes and modules you want to be autoloadable. 19 | # config.autoload_paths += %W(#{config.root}/extras) 20 | 21 | # Only load the plugins named here, in the order given (default is alphabetical). 22 | # :all can be used as a placeholder for all plugins not explicitly named. 23 | # config.plugins = [ :exception_notification, :ssl_requirement, :all ] 24 | 25 | # Activate observers that should always be running. 26 | # config.active_record.observers = :cacher, :garbage_collector, :forum_observer 27 | 28 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 29 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 30 | # config.time_zone = 'Central Time (US & Canada)' 31 | 32 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 33 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 34 | # config.i18n.default_locale = :de 35 | 36 | # JavaScript files you want as :defaults (application.js is always included). 37 | # config.action_view.javascript_expansions[:defaults] = %w(jquery rails) 38 | 39 | # Configure the default encoding used in templates for Ruby 1.9. 40 | config.encoding = "utf-8" 41 | 42 | # Configure sensitive parameters which will be filtered from the log file. 43 | config.filter_parameters += [:password] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/tolk/import.rb: -------------------------------------------------------------------------------- 1 | module Tolk 2 | module Import 3 | def self.included(base) 4 | base.send :extend, ClassMethods 5 | end 6 | 7 | module ClassMethods 8 | 9 | def import_secondary_locales 10 | locales = Dir.entries(self.locales_config_path) 11 | 12 | locale_block_filter = Proc.new { 13 | |l| ['.', '..'].include?(l) || 14 | !l.ends_with?('.yml') || 15 | l.match(/(.*\.){2,}/) # reject files of type xxx.en.yml 16 | } 17 | locales = locales.reject(&locale_block_filter).map {|x| x.split('.').first } 18 | locales = locales - [Tolk::Locale.primary_locale.name] 19 | locales.each {|l| import_locale(l) } 20 | end 21 | 22 | def import_locale(locale_name) 23 | locale = Tolk::Locale.find_or_create_by_name(locale_name) 24 | data = locale.read_locale_file 25 | return unless data 26 | 27 | phrases = Tolk::Phrase.all 28 | count = 0 29 | 30 | data.each do |key, value| 31 | phrase = phrases.detect {|p| p.key == key} 32 | 33 | if phrase 34 | translation = locale.translations.new(:text => value, :phrase => phrase) 35 | count = count + 1 if translation.save 36 | else 37 | puts "[ERROR] Key '#{key}' was found in #{locale_name}.yml but #{Tolk::Locale.primary_language_name} translation is missing" 38 | end 39 | end 40 | 41 | puts "[INFO] Imported #{count} keys from #{locale_name}.yml" 42 | end 43 | 44 | end 45 | 46 | def read_locale_file 47 | locale_file = "#{self.locales_config_path}/#{self.name}.yml" 48 | raise "Locale file #{locale_file} does not exists" unless File.exists?(locale_file) 49 | 50 | puts "[INFO] Reading #{locale_file} for locale #{self.name}" 51 | begin 52 | self.class.flat_hash(YAML::load(IO.read(locale_file))[self.name]) 53 | rescue 54 | puts "[ERROR] File #{locale_file} expected to declare #{self.name} locale, but it does not. Skipping this file." 55 | nil 56 | end 57 | 58 | end 59 | 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/generators/tolk/install_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require File.expand_path('../utils', __FILE__) 3 | 4 | # http://guides.rubyonrails.org/generators.html 5 | # http://rdoc.info/github/wycats/thor/master/Thor/Actions.html 6 | # keep generator idempotent, thanks 7 | # Thanks to https://github.com/sferik/rails_admin ! 8 | 9 | module Tolk 10 | class InstallGenerator < Rails::Generators::Base 11 | 12 | source_root File.expand_path("../templates", __FILE__) 13 | include Rails::Generators::Migration 14 | include Generators::Utils::InstanceMethods 15 | extend Generators::Utils::ClassMethods 16 | 17 | argument :_namespace, :type => :string, :required => false, :desc => "Tolk url namespace" 18 | desc "Tolk installation generator" 19 | def install 20 | routes = File.open(Rails.root.join("config/routes.rb")).try :read 21 | initializer = (File.open(Rails.root.join("config/initializers/tolk.rb")) rescue nil).try :read 22 | 23 | display "Hello, Tolk installer will help you sets things up!", :black 24 | unless initializer 25 | install_generator = ask_boolean("Do you wan't to install the optional configuration file (to change mappings, locales dump location etc..) ?") 26 | template "initializer.erb", "config/initializers/tolk.rb" if install_generator 27 | else 28 | display "You already have a config file. You're updating, heh? I'm generating a new 'tolk.rb.example' that you can review." 29 | template "initializer.erb", "config/initializers/tolk.rb.example" 30 | end 31 | 32 | display "Adding a migration..." 33 | migration_template 'migration.rb', 'db/migrate/create_tolk_tables.rb' rescue display $!.message 34 | 35 | namespace = ask_for("Where do you want to mount tolk?", "tolk", _namespace) 36 | gsub_file "config/routes.rb", /mount Tolk::Engine => \'\/.+\', :as => \'tolk\'/, '' 37 | gsub_file "config/routes.rb", /mount Tolk::Engine => \'\/.+\'/, '' 38 | route("mount Tolk::Engine => '/#{namespace}', :as => 'tolk'") 39 | 40 | display "Job's done: migrate, start your server and visit '/#{namespace}'!", :blue 41 | 42 | end 43 | end 44 | end -------------------------------------------------------------------------------- /lib/tolk/config.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/class/attribute_accessors' 2 | 3 | module Tolk 4 | module Config 5 | 6 | 7 | class << self 8 | # Mapping : a hash of the type { 'ar' => 'Arabic' } 9 | attr_accessor :mapping 10 | 11 | # Dump locale path by default the locales folder (config/locales) 12 | attr_accessor :dump_path 13 | 14 | def reset 15 | @dump_path = Proc.new { "#{Rails.application.root}/config/locales" } 16 | 17 | @mapping = { 18 | 'ar' => 'Arabic', 19 | 'bs' => 'Bosnian', 20 | 'bg' => 'Bulgarian', 21 | 'ca' => 'Catalan', 22 | 'cs' => 'Czech', 23 | 'da' => 'Danish', 24 | 'de' => 'German', 25 | 'el' => 'Greek', 26 | 'en' => 'English', 27 | 'es' => 'Spanish', 28 | 'et' => 'Estonian', 29 | 'fa' => 'Persian', 30 | 'fi' => 'Finnish', 31 | 'fr' => 'French', 32 | 'he' => 'Hebrew', 33 | 'hr' => 'Croatian', 34 | 'hu' => 'Hungarian', 35 | 'id' => 'Indonesian', 36 | 'is' => 'Icelandic', 37 | 'it' => 'Italian', 38 | 'ja' => 'Japanese', 39 | 'ko' => 'Korean', 40 | 'lo' => 'Lao', 41 | 'lt' => 'Lithuanian', 42 | 'lv' => 'Latvian', 43 | 'mk' => 'Macedonian', 44 | 'nl' => 'Dutch', 45 | 'no' => 'Norwegian', 46 | 'pl' => 'Polish', 47 | 'pt-br' => 'Portuguese (Brazilian)', 48 | 'pt-PT' => 'Portuguese (Portugal)', 49 | 'ro' => 'Romanian', 50 | 'ru' => 'Russian', 51 | 'sv' => 'Swedish', 52 | 'sk' => 'Slovak', 53 | 'sl' => 'Slovene', 54 | 'sr' => 'Serbian', 55 | 'sw' => 'Swahili', 56 | 'th' => 'Thai', 57 | 'tr' => 'Turkish', 58 | 'uk' => 'Ukrainian', 59 | 'vi' => 'Vietnamese', 60 | 'zh-CN' => 'Chinese (Simplified)', 61 | 'zh-TW' => 'Chinese (Traditional)' 62 | } 63 | end 64 | end 65 | 66 | # Set default values for configuration options on load 67 | self.reset 68 | end 69 | end -------------------------------------------------------------------------------- /app/views/tolk/searches/show.html.erb: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | <%= link_to "Phrases missing translation", @locale %> 7 |   8 | <%= link_to "Completed translations", tolk.all_locale_path(@locale) %> 9 |
10 | 11 |
12 | <% if @phrases.any? %> 13 | <%= form_for @locale do |locale_form| %> 14 | 15 | 16 | 17 | 18 | 19 | <% @phrases.each do |phrase| %> 20 | 21 | <% if translation = phrase.translations.find_by_locale_id(@locale.id) || Tolk::Translation.new(:locale => @locale, :phrase => phrase) %> 22 | 23 | 29 | 39 | 40 | <% end %> 41 | <% end %> 42 |
<%= @locale.language_name -%><%= Tolk::Locale.primary_language_name -%>
24 | <%= hidden_field_tag :"translations[][id]", translation.id, :id => "#{translation.object_id}_id" %> 25 | <%= hidden_field_tag :"translations[][phrase_id]", phrase.id, :id => "#{translation.object_id}_phrase_id" %> 26 | <%= hidden_field_tag :"translations[][locale_id]", translation.locale_id, :id => "#{translation.object_id}_locale_id" %> 27 | <%= text_area_tag :"translations[][text]", format_i18n_text_area_value(translation.text), :class => "locale", :id => "#{translation.object_id}_text", :onfocus => "$(this).up('tr').addClassName('active');", :onblur => "$(this).up('tr').removeClassName('active');" %> 28 | 30 | <% if params[:q].present? -%> 31 | <%= highlight(format_i18n_value(phrase.translations.primary.text), params[:q]) -%> 32 | <% else -%> 33 | <%= format_i18n_value(phrase.translations.primary.text) -%> 34 | <% end -%> 35 | <%= params[:k].present? ? 36 | highlight(h(truncate(phrase.key, :length => 100)), params[:k]) : 37 | h(truncate(phrase.key, :length => 100)) %> 38 |
43 |
44 |

<%= locale_form.submit "Save changes" %>

45 |
46 | <% end %> 47 |
48 | <%= will_paginate @phrases %> 49 |
50 | <% else %> 51 |

No search results.

52 | <% end %> 53 |
54 | -------------------------------------------------------------------------------- /app/views/tolk/locales/all.html.erb: -------------------------------------------------------------------------------- 1 |

Completed translations (<%= link_to 'See phrases missing translation', @locale %>)

2 | 3 | <% if @locale.has_updated_translations? && action_name != 'updated' %> 4 | Some phrases have changed. <%= link_to "Update translations", tolk.updated_locale_path(@locale) %>. 5 | <% end %> 6 | 7 | 10 | 11 |
12 | <% if @phrases.any? %> 13 | <%= form_for @locale do |locale_form| %> 14 | 15 | 16 | 17 | 18 | 19 | <% @phrases.each do |phrase| %> 20 | <% if phrase.translations.primary %> 21 | 22 | 28 | 45 | 46 | <% end %> 47 | <% end %> 48 |
<%= @locale.language_name -%><%= Tolk::Locale.primary_language_name -%>
23 | <%= hidden_field_tag :"translations[][id]", phrase.translation.id %> 24 | <%= hidden_field_tag :"translations[][locale_id]", phrase.translation.locale_id %> 25 | <%= text_area_tag :"translations[][text]", format_i18n_text_area_value(phrase.translation.text), :class => 'locale', 26 | :onfocus => "$(this).up('tr').addClassName('active');", :onblur => "$(this).up('tr').removeClassName('active');" %> 27 | 29 | 30 | <% if action_name == 'updated' %> 31 |
32 | Updated 33 | <%= format_i18n_value(phrase.translations.primary.text) -%> 34 |
35 |
36 | Original 37 | <%= format_i18n_value(phrase.translations.primary.previous_text) -%> 38 |
39 | <% else %> 40 | <%= format_i18n_value(phrase.translations.primary.text) -%> 41 | <% end %> 42 | 43 | <%= phrase.key %> 44 |
49 |
50 |

<%= locale_form.submit "Save changes" %>

51 |
52 |
53 | <%= will_paginate @phrases %> 54 |
55 | <% end %> 56 | <% else %> 57 |

There aren't any completed translations for this locale.

58 |

 

59 | <% end %> 60 |
61 | 62 | -------------------------------------------------------------------------------- /test/integration/translation_process_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TranslationProcessTest < ActiveSupport::IntegrationCase 4 | setup :setup_locales 5 | 6 | def test_adding_locale 7 | assert_difference('Tolk::Locale.count') { add_locale 'German' } 8 | end 9 | 10 | def test_adding_missing_translations_and_updating_translations 11 | Tolk.config.mapping['xx'] = "Pirate" 12 | 13 | locale = add_locale("Pirate") 14 | assert locale.translations.empty? 15 | 16 | # Adding a new translation 17 | pirate_path = tolk.locale_path(locale) 18 | visit pirate_path 19 | 20 | fill_in_first_translation :with => "Dead men don't bite" 21 | click_button 'Save changes' 22 | 23 | assert_equal current_path, pirate_path 24 | assert_equal 1, locale.translations.count 25 | 26 | # Updating the translation added above 27 | click_link 'See completed translations' 28 | assert page.has_content?("Dead men don't bite") 29 | 30 | fill_in_first_translation :with => "Arrrr!" 31 | click_button 'Save changes' 32 | 33 | assert_equal current_path, tolk.all_locale_path(locale) 34 | assert_equal 1, locale.translations.count 35 | assert_equal 'Arrrr!', locale.translations(true).first.text 36 | end 37 | 38 | def test_search_phrase_within_key 39 | Tolk.config.mapping['xx'] = "Pirate" 40 | locale = add_locale("Pirate") 41 | assert locale.translations.empty? 42 | 43 | # Adding a new translation 44 | pirate_path = tolk.locale_path(locale) 45 | visit pirate_path 46 | 47 | fill_in 'q', :with => 'hello_country' 48 | fill_in 'k', :with => 'nested' 49 | 50 | click_button 'Search' 51 | assert_equal true, page.has_selector?('td.translation', :count => 1) 52 | end 53 | 54 | private 55 | 56 | def fill_in_first_translation(with_hash) 57 | within(:xpath, '//table[@class = "translations"]//tr[2]/td[@class = "translation"][1]') do 58 | fill_in 'translations[][text]', with_hash 59 | end 60 | 61 | end 62 | 63 | def add_locale(name) 64 | visit tolk.root_path 65 | select name, :from => "select_tolk_locale_name" 66 | click_button 'Add' 67 | 68 | Tolk::Locale.find_by_name!(Tolk.config.mapping.key(name)) 69 | end 70 | 71 | def setup_locales 72 | Tolk::Locale.delete_all 73 | Tolk::Translation.delete_all 74 | Tolk::Phrase.delete_all 75 | 76 | Tolk::Locale.locales_config_path = File.join(Rails.root, "../locales/sync/") 77 | 78 | I18n.backend.reload! 79 | I18n.load_path = [Tolk::Locale.locales_config_path + 'en.yml'] 80 | I18n.backend.send :init_translations 81 | 82 | Tolk::Locale.primary_locale(true) 83 | 84 | Tolk::Locale.sync! 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /app/views/tolk/locales/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :head do %> 2 | 3 | <% end %> 4 | 5 |

Phrases missing translation (<%= @locale.count_phrases_without_translation %>) (<%= link_to 'See completed translations', tolk.all_locale_path(@locale) %>)

6 | 7 | <% if @locale.has_updated_translations? && action_name != 'updated' %> 8 | Some phrases have changed. <%= link_to "Update translations", tolk.updated_locale_path(@locale) %>. 9 | <% end %> 10 | 11 | 14 | 15 |
16 | <% if @phrases.any? %> 17 | <%= form_for @locale do |locale_form| %> 18 | 19 | 20 | 21 | 22 | 23 | <% @phrases.each do |phrase| %> 24 | 25 | <% translation = Tolk::Translation.new(:locale => @locale, :phrase => phrase) %> 26 | 32 | 40 | 41 | <% end %> 42 |
<%= @locale.language_name -%><%= Tolk::Locale.primary_language_name -%>
27 | <%= hidden_field_tag :"translations[][id]", translation.id, :id => "#{translation.object_id}_id" %> 28 | <%= hidden_field_tag :"translations[][phrase_id]", phrase.id, :id => "#{translation.object_id}_phrase_id" %> 29 | <%= hidden_field_tag :"translations[][locale_id]", translation.locale_id, :id => "#{translation.object_id}_locale_id" %> 30 | <%= text_area_tag :"translations[][text]", format_i18n_text_area_value(translation.text), :class => "locale", :id => "#{translation.object_id}_text", :onfocus => "$(this).up('tr').addClassName('active');", :onblur => "$(this).up('tr').removeClassName('active');" %> 31 | 33 | <% if params[:q].present? -%> 34 | <%= highlight(format_i18n_value(phrase.translations.primary.text), params[:q]) -%> 35 | <% else -%> 36 | <%= format_i18n_value(phrase.translations.primary.text) -%> 37 | <% end -%> 38 | <%= truncate(phrase.key, :length => 100) %> 39 |
43 |
44 |

<%= locale_form.submit "Save changes" %>

45 |
46 | <% end %> 47 |
48 | <%= will_paginate @phrases %> 49 |
50 | <% else %> 51 |

There aren't any missing or updated phrases that need translation.

52 |

 

53 | <% end %> 54 |
55 | -------------------------------------------------------------------------------- /test/unit/locale_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class LocaleTest < ActiveSupport::TestCase 4 | fixtures :all 5 | 6 | test "turning locale without nested phrases into a hash" do 7 | assert_equal({ "se" => { "hello_world" => "Hejsan Verdon" } }, tolk_locales(:se).to_hash) 8 | end 9 | 10 | test "turning locale with nested phrases into a hash" do 11 | assert_equal({ "en" => { 12 | "number"=>{"human"=>{"format"=>{"precision"=>1}}}, 13 | "hello_world" => "Hello World", 14 | "nested" => { 15 | "hello_world" => "Nested Hello World", 16 | "hello_country" => "Nested Hello Country" 17 | } 18 | }}, tolk_locales(:en).to_hash) 19 | end 20 | 21 | test "phrases without translations" do 22 | assert tolk_locales(:en).phrases_without_translation.include?(tolk_phrases(:cozy)) 23 | end 24 | 25 | test "searching phrases without translations" do 26 | assert !tolk_locales(:en).search_phrases_without_translation("cozy").include?(tolk_phrases(:hello_world)) 27 | end 28 | 29 | test "paginating phrases without translations" do 30 | Tolk::Phrase.per_page = 2 31 | locale = tolk_locales(:se) 32 | 33 | page1 = locale.phrases_without_translation 34 | assert_equal [4, 3], page1.map(&:id) 35 | 36 | page2 = locale.phrases_without_translation(2) 37 | assert_equal [2, 5], page2.map(&:id) 38 | 39 | page3 = locale.phrases_without_translation(3) 40 | assert page3.blank? 41 | end 42 | 43 | test "paginating phrases with translations" do 44 | Tolk::Phrase.per_page = 4 45 | locale = tolk_locales(:en) 46 | 47 | page1 = locale.phrases_with_translation 48 | assert_equal [1, 3, 2, 5], page1.map(&:id) 49 | 50 | page2 = locale.phrases_with_translation(2) 51 | assert page2.blank? 52 | end 53 | 54 | test "counting missing translations" do 55 | assert_equal 2, tolk_locales(:da).count_phrases_without_translation 56 | assert_equal 4, tolk_locales(:se).count_phrases_without_translation 57 | end 58 | 59 | test "dumping all locales to yml" do 60 | Tolk::Locale.primary_locale_name = 'en' 61 | Tolk::Locale.primary_locale(true) 62 | 63 | begin 64 | FileUtils.mkdir_p(Rails.root.join("../../tmp/locales")) 65 | Tolk::Locale.dump_all(Rails.root.join("../../tmp/locales")) 66 | 67 | %w( da se ).each do |locale| 68 | assert_equal \ 69 | File.read(Rails.root.join("../locales/basic/#{locale}.yml")), 70 | File.read(Rails.root.join("../../tmp/locales/#{locale}.yml")) 71 | end 72 | 73 | # Make sure dump doesn't generate en.yml 74 | assert ! File.exists?(Rails.root.join("../../tmp/locales/en.yml")) 75 | ensure 76 | FileUtils.rm_rf(Rails.root.join("../../tmp/locales")) 77 | end 78 | end 79 | 80 | test "human language name" do 81 | assert_equal 'English', tolk_locales(:en).language_name 82 | assert_equal 'pirate', Tolk::Locale.new(:name => 'pirate').language_name 83 | end 84 | 85 | end 86 | -------------------------------------------------------------------------------- /lib/tolk/sync.rb: -------------------------------------------------------------------------------- 1 | module Tolk 2 | module Sync 3 | def self.included(base) 4 | base.send :extend, ClassMethods 5 | end 6 | 7 | module ClassMethods 8 | def sync! 9 | sync_phrases(load_translations) 10 | end 11 | 12 | def load_translations 13 | I18n.backend.send :init_translations unless I18n.backend.initialized? # force load 14 | translations = flat_hash(I18n.backend.send(:translations)[primary_locale.name.to_sym]) 15 | filter_out_i18n_keys(translations.merge(read_primary_locale_file)) 16 | end 17 | 18 | def read_primary_locale_file 19 | primary_file = "#{self.locales_config_path}/#{self.primary_locale_name}.yml" 20 | File.exists?(primary_file) ? flat_hash(YAML::load(IO.read(primary_file))[self.primary_locale_name]) : {} 21 | end 22 | 23 | def flat_hash(data, prefix = '', result = {}) 24 | data.each do |key, value| 25 | current_prefix = prefix.present? ? "#{prefix}.#{key}" : key 26 | 27 | if !value.is_a?(Hash) || Tolk::Locale.pluralization_data?(value) 28 | result[current_prefix] = value.respond_to?(:stringify_keys) ? value.stringify_keys : value 29 | else 30 | flat_hash(value, current_prefix, result) 31 | end 32 | end 33 | 34 | result.stringify_keys 35 | end 36 | 37 | private 38 | 39 | def sync_phrases(translations) 40 | primary_locale = self.primary_locale 41 | secondary_locales = self.secondary_locales 42 | 43 | # Handle deleted phrases 44 | translations.present? ? Tolk::Phrase.destroy_all(["tolk_phrases.key NOT IN (?)", translations.keys]) : Tolk::Phrase.destroy_all 45 | 46 | phrases = Tolk::Phrase.all 47 | 48 | translations.each do |key, value| 49 | next if value.is_a?(Proc) 50 | # Create phrase and primary translation if missing 51 | existing_phrase = phrases.detect {|p| p.key == key} || Tolk::Phrase.create!(:key => key) 52 | translation = existing_phrase.translations.primary || primary_locale.translations.build(:phrase_id => existing_phrase.id) 53 | translation.text = value 54 | 55 | if translation.changed? && !translation.new_record? 56 | # Set the primary updated flag if the primary translation has changed and it is not a new record. 57 | secondary_locales.each do |locale| 58 | if existing_translation = existing_phrase.translations.detect {|t| t.locale_id == locale.id } 59 | existing_translation.force_set_primary_update = true 60 | existing_translation.save! 61 | end 62 | end 63 | end 64 | 65 | translation.primary = true 66 | translation.save! 67 | end 68 | end 69 | 70 | def filter_out_i18n_keys(flat_hash) 71 | flat_hash.reject { |key, value| key.starts_with? "i18n" } 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tolk 2 | [![Travis](https://secure.travis-ci.org/tolk/tolk.png?branch=master)](http://travis-ci.org/#!/tolk/tolk) 3 | 4 | [![Dependency Status](https://gemnasium.com/tolk/tolk.png?travis)](https://gemnasium.com/tolk/tolk) 5 | 6 | This is now the official tolk repository. 7 | 8 | Tolk is a Rails 3 engine designed to facilitate the translators doing the dirty work of translating your application to other languages. 9 | 10 | ## Installation & Setup 11 | 12 | To install add the following to your Gemfile: 13 | 14 | ```ruby 15 | gem 'tolk' 16 | ``` 17 | 18 | To setup just run: 19 | 20 | ```bash 21 | $ rake tolk:setup 22 | ``` 23 | 24 | and follow the guide ! 25 | 26 | ## Usage 27 | 28 | Tolk treats `I18n.default_locale` as the master source of strings to be translated. If you want the master source to be different from `I18n.default_locale`, you can override it by setting `Tolk::Locale.primary_locale_name`. Developers are expected to make all the changes to the master locale file ( en.yml by default ) and treat all the other locale.yml files as readonly files. 29 | 30 | As tolk stores all the keys and translated strings in the database, you need to ask Tolk to update it's database from the primary yml file : 31 | 32 | ```bash 33 | $ rake tolk:sync 34 | ``` 35 | 36 | The above will fetch all the new keys from en.yml and put them in the database. Additionally, it'll also get rid of the deleted keys from the database and reflect updated translations - if any. 37 | 38 | If you already have data in your non primary locale files, you will need to import those to Tolk as a one time thing : 39 | 40 | ```bash 41 | $ rake tolk:import 42 | ``` 43 | 44 | Upon visiting http://your_app.com/tolk - you will be presented with different options like creating new locale or providing translations for the existing locales. Once done with translating all the pending strings, you are can write back the new locales to filesystem : 45 | 46 | ```bash 47 | $ rake tolk:dump_all 48 | ``` 49 | 50 | This will generate yml files for all non primary locales and put them in #{Rails.root}/config/locales/ directory by default. 51 | 52 | You can use the dump_all method defined in Tolk::Locale directly and pass directory path as the argument if you want the generated files to be at a different location : 53 | 54 | ```bash 55 | $ script/runner "Tolk::Locale.dump_all('/Users/lifo')" 56 | ``` 57 | 58 | You can even download the yml file using Tolk web interface by appending '.yml' to the locale url. E.g http://your_app.com/tolk/locales/de.yml 59 | 60 | ## Authentication 61 | 62 | If you want to authenticate users who can access Tolk, you need to provide Tolk::ApplicationController.authenticator proc. For example : 63 | 64 | ```ruby 65 | # config/initializers/tolk.rb 66 | Tolk::ApplicationController.authenticator = proc { 67 | authenticate_or_request_with_http_basic do |user_name, password| 68 | user_name == 'translator' && password == 'transpass' 69 | end 70 | } 71 | ``` 72 | 73 | Authenticator proc will be run from a before filter in controller context. 74 | 75 | ## Handling blank and non-string values 76 | 77 | Tolk speaks YAML for non strings values. If you want to enter a nil values, you could just enter '~'. Similarly, for an Array value, you could enter : 78 | 79 | ```yml 80 | --- 81 | - Sun 82 | - Mon 83 | ``` 84 | 85 | And Tolk will take care of generating the appropriate entry in the YAML file. 86 | -------------------------------------------------------------------------------- /app/models/tolk/translation.rb: -------------------------------------------------------------------------------- 1 | module Tolk 2 | class Translation < ActiveRecord::Base 3 | self.table_name = "tolk_translations" 4 | 5 | scope :containing_text, lambda {|query| where("tolk_translations.text LIKE ?", "%#{query}%") } 6 | 7 | serialize :text 8 | validates_presence_of :text, :if => proc {|r| r.primary.blank? && !r.explicit_nil } 9 | validate :check_matching_variables, :if => proc { |tr| tr.primary_translation.present? } 10 | 11 | validates_uniqueness_of :phrase_id, :scope => :locale_id 12 | 13 | belongs_to :phrase, :class_name => 'Tolk::Phrase' 14 | belongs_to :locale, :class_name => 'Tolk::Locale' 15 | validates_presence_of :locale_id 16 | 17 | attr_accessible :phrase_id, :locale_id, :text, :primary_updated, :previous_text, :locale, :phrase 18 | 19 | attr_accessor :force_set_primary_update 20 | before_save :set_primary_updated 21 | 22 | before_save :set_previous_text 23 | 24 | attr_accessor :primary 25 | before_validation :fix_text_type, :unless => proc {|r| r.primary } 26 | 27 | attr_accessor :explicit_nil 28 | before_validation :set_explicit_nil 29 | 30 | def up_to_date? 31 | not out_of_date? 32 | end 33 | 34 | def out_of_date? 35 | primary_updated? 36 | end 37 | 38 | def primary_translation 39 | @_primary_translation ||= begin 40 | if locale && !locale.primary? 41 | phrase.translations.primary 42 | end 43 | end 44 | end 45 | 46 | def text=(value) 47 | value = value.to_s if value.kind_of?(Fixnum) 48 | super unless value.to_s == text 49 | end 50 | 51 | def value 52 | if text.is_a?(String) && /^\d+$/.match(text) 53 | text.to_i 54 | else 55 | text 56 | end 57 | end 58 | 59 | def self.detect_variables(search_in) 60 | case search_in 61 | when String then Set.new(search_in.scan(/\{\{(\w+)\}\}/).flatten + search_in.scan(/\%\{(\w+)\}/).flatten) 62 | when Array then search_in.inject(Set[]) { |carry, item| carry + detect_variables(item) } 63 | when Hash then search_in.values.inject(Set[]) { |carry, item| carry + detect_variables(item) } 64 | else Set[] 65 | end 66 | end 67 | 68 | def variables 69 | self.class.detect_variables(text) 70 | end 71 | 72 | def variables_match? 73 | self.variables == primary_translation.variables 74 | end 75 | 76 | private 77 | 78 | def set_explicit_nil 79 | if self.text == '~' 80 | self.text = nil 81 | self.explicit_nil = true 82 | end 83 | end 84 | 85 | def fix_text_type 86 | if primary_translation.present? 87 | if self.text.is_a?(String) && !primary_translation.text.is_a?(String) 88 | self.text = begin 89 | YAML.load(self.text.strip) 90 | rescue ArgumentError 91 | nil 92 | end 93 | end 94 | 95 | self.text = nil if primary_translation.text.class != self.text.class 96 | end 97 | 98 | true 99 | end 100 | 101 | def set_primary_updated 102 | self.primary_updated = self.force_set_primary_update ? true : false 103 | true 104 | end 105 | 106 | def set_previous_text 107 | self.previous_text = self.text_was if text_changed? 108 | true 109 | end 110 | 111 | def check_matching_variables 112 | unless variables_match? 113 | if primary_translation.variables.empty? || primary_translation.variables.class == Set 114 | self.errors.add(:text, "The original does not contain variables, so they should not be included.") 115 | else 116 | self.errors.add(:text, "The translation should contain the variables #{primary_translation.to_a.to_sentence}.") 117 | end 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/unit/format_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'fileutils' 3 | 4 | class FormatTest < ActiveSupport::TestCase 5 | def setup 6 | Tolk::Locale.delete_all 7 | Tolk::Translation.delete_all 8 | Tolk::Phrase.delete_all 9 | 10 | Tolk::Locale.locales_config_path = Rails.root.join("../locales/formats") 11 | 12 | I18n.backend.reload! 13 | I18n.load_path = [Tolk::Locale.locales_config_path + 'en.yml'] 14 | I18n.backend.send :init_translations 15 | 16 | @en = Tolk::Locale.primary_locale(true) 17 | @spanish = Tolk::Locale.create!(:name => 'es') 18 | 19 | Tolk::Locale.sync! 20 | end 21 | 22 | def test_all_formats_are_loaded_properly 23 | # TODO : Investigate why the fuck does this test fail 24 | # assert_equal 1, @en['number'] 25 | assert_equal 'I am just a stupid string :(', @en.get('string') 26 | assert_equal [1, 2, 3], @en.get('number_array') 27 | assert_equal ['sun', 'moon'], @en.get('string_array') 28 | end 29 | 30 | def test_pluaralization 31 | result = {'other' => 'Hello'} 32 | assert_equal result, @en.get('pluralization') 33 | 34 | assert ! @en.get('not_pluralization') 35 | assert_equal 'World', @en.get('not_pluralization.other') 36 | assert_equal 'fifo', @en.get('not_pluralization.lifo') 37 | end 38 | 39 | # def test_specail_activerecord_keys_and_prefixes 40 | # Special key 41 | # result = {'person' => 'Dude'} 42 | # assert_equal result, @en['activerecord.models'] 43 | 44 | # Special prefix 45 | # result = {'login' => 'Handle'} 46 | # assert_equal result, @en['activerecord.attributes.person'] 47 | # end 48 | 49 | def test_creating_translations_fails_on_mismatch_with_primary_translation 50 | # Invalid type 51 | assert_raises(ActiveRecord::RecordInvalid) { @spanish.translations.create!(:text => 'hola', :phrase => ph('number_array')) } 52 | 53 | # Invalid YAML 54 | assert_raises(ActiveRecord::RecordInvalid) { @spanish.translations.create!(:text => "1\n- 2\n", :phrase => ph('number_array')) } 55 | 56 | success = @spanish.translations.create!(:text => [1, 2], :phrase => ph('number_array')) 57 | assert_equal [1, 2], success.text 58 | 59 | success.text = "--- \n- 1\n- 2\n" 60 | success.save! 61 | assert_equal [1, 2], success.text 62 | end 63 | 64 | def test_creating_translations_fails_with_unmatching_variables 65 | # Check that variable detection works correctly 66 | assert_equal Set['hello', 'world'], ph('variables').translations.primary.variables 67 | assert_equal Set['more', 'variables'], ph('variables_in_struct').translations.primary.variables 68 | 69 | # Allow different ordering and multiple occurences of variables 70 | assert @spanish.translations.build(:text => '{{world}} y {{hello}} y {{hello}} y {{world}}', :phrase => ph('variables')).valid? 71 | 72 | # Do not allow missing or wrong variables 73 | assert_raises(ActiveRecord::RecordInvalid) { @spanish.translations.create!(:text => 'Hola', :phrase => ph('variables')) } 74 | assert_raises(ActiveRecord::RecordInvalid) { @spanish.translations.create!(:text => '{{other}} variable', :phrase => ph('variables')) } 75 | 76 | # Do not allow variables if the origin does not contain any 77 | assert_equal Set[], ph('string').translations.primary.variables 78 | assert_raises(ActiveRecord::RecordInvalid) { @spanish.translations.create!(:text => 'Hola {{mundo}}', :phrase => ph('string')) } 79 | end 80 | 81 | def test_creating_translations_with_nil_values 82 | # implicit nil value 83 | assert_raises(ActiveRecord::RecordInvalid) { @spanish.translations.create!(:phrase => ph('string')) } 84 | 85 | # explicit nil value 86 | niltrans = @spanish.translations.create!(:phrase => ph('string'), :text => '~') 87 | assert ! niltrans.text 88 | end 89 | 90 | def test_creating_translation_with_wrong_type 91 | assert_raises(ActiveRecord::RecordInvalid) { @spanish.translations.create!(:text => [1, 2], :phrase => ph('string')) } 92 | 93 | translation = @spanish.translations.create!(:text => "one more silly string", :phrase => ph('string')) 94 | assert_equal "one more silly string", translation.text 95 | end 96 | 97 | def test_bulk_translations_update_with_some_invalid_formats 98 | @spanish.translations_attributes = [ 99 | {"locale_id" => @spanish.id, "phrase_id" => ph('string_array').id, "text" => 'invalid format'}, 100 | {"locale_id" => @spanish.id, "phrase_id" => ph('string').id, "text" => 'spanish string'}, 101 | {"locale_id" => @spanish.id, "phrase_id" => ph('number').id, "text" => '2'} 102 | ] 103 | 104 | assert_difference('Tolk::Translation.count', 2) { @spanish.save } 105 | 106 | @spanish.reload 107 | 108 | assert_equal 'spanish string', @spanish.get('string') 109 | assert_equal '2', @spanish.get('number') 110 | assert ! @spanish.get('string_array') 111 | end 112 | 113 | def test_bulk_update_saves_unchanged_record 114 | data = {"locale_id" => @spanish.id, "phrase_id" => ph('string').id, "text" => 'spanish string'} 115 | @spanish.translations_attributes = [data] 116 | @spanish.save! 117 | 118 | spanish_string = @spanish.translations.first 119 | spanish_string.force_set_primary_update = true 120 | spanish_string.save! 121 | assert spanish_string.primary_updated? 122 | 123 | @spanish.reload 124 | 125 | @spanish.translations_attributes = [data.merge('id' => spanish_string.id)] 126 | @spanish.save! 127 | 128 | spanish_string.reload 129 | assert ! spanish_string.primary_updated? 130 | end 131 | 132 | private 133 | 134 | def ph(key) 135 | Tolk::Phrase.all.detect {|p| p.key == key} 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/dummy/public/javascripts/rails.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | // Technique from Juriy Zaytsev 3 | // http://thinkweb2.com/projects/prototype/detecting-event-support-without-browser-sniffing/ 4 | function isEventSupported(eventName) { 5 | var el = document.createElement('div'); 6 | eventName = 'on' + eventName; 7 | var isSupported = (eventName in el); 8 | if (!isSupported) { 9 | el.setAttribute(eventName, 'return;'); 10 | isSupported = typeof el[eventName] == 'function'; 11 | } 12 | el = null; 13 | return isSupported; 14 | } 15 | 16 | function isForm(element) { 17 | return Object.isElement(element) && element.nodeName.toUpperCase() == 'FORM' 18 | } 19 | 20 | function isInput(element) { 21 | if (Object.isElement(element)) { 22 | var name = element.nodeName.toUpperCase() 23 | return name == 'INPUT' || name == 'SELECT' || name == 'TEXTAREA' 24 | } 25 | else return false 26 | } 27 | 28 | var submitBubbles = isEventSupported('submit'), 29 | changeBubbles = isEventSupported('change') 30 | 31 | if (!submitBubbles || !changeBubbles) { 32 | // augment the Event.Handler class to observe custom events when needed 33 | Event.Handler.prototype.initialize = Event.Handler.prototype.initialize.wrap( 34 | function(init, element, eventName, selector, callback) { 35 | init(element, eventName, selector, callback) 36 | // is the handler being attached to an element that doesn't support this event? 37 | if ( (!submitBubbles && this.eventName == 'submit' && !isForm(this.element)) || 38 | (!changeBubbles && this.eventName == 'change' && !isInput(this.element)) ) { 39 | // "submit" => "emulated:submit" 40 | this.eventName = 'emulated:' + this.eventName 41 | } 42 | } 43 | ) 44 | } 45 | 46 | if (!submitBubbles) { 47 | // discover forms on the page by observing focus events which always bubble 48 | document.on('focusin', 'form', function(focusEvent, form) { 49 | // special handler for the real "submit" event (one-time operation) 50 | if (!form.retrieve('emulated:submit')) { 51 | form.on('submit', function(submitEvent) { 52 | var emulated = form.fire('emulated:submit', submitEvent, true) 53 | // if custom event received preventDefault, cancel the real one too 54 | if (emulated.returnValue === false) submitEvent.preventDefault() 55 | }) 56 | form.store('emulated:submit', true) 57 | } 58 | }) 59 | } 60 | 61 | if (!changeBubbles) { 62 | // discover form inputs on the page 63 | document.on('focusin', 'input, select, texarea', function(focusEvent, input) { 64 | // special handler for real "change" events 65 | if (!input.retrieve('emulated:change')) { 66 | input.on('change', function(changeEvent) { 67 | input.fire('emulated:change', changeEvent, true) 68 | }) 69 | input.store('emulated:change', true) 70 | } 71 | }) 72 | } 73 | 74 | function handleRemote(element) { 75 | var method, url, params; 76 | 77 | var event = element.fire("ajax:before"); 78 | if (event.stopped) return false; 79 | 80 | if (element.tagName.toLowerCase() === 'form') { 81 | method = element.readAttribute('method') || 'post'; 82 | url = element.readAttribute('action'); 83 | params = element.serialize(); 84 | } else { 85 | method = element.readAttribute('data-method') || 'get'; 86 | url = element.readAttribute('href'); 87 | params = {}; 88 | } 89 | 90 | new Ajax.Request(url, { 91 | method: method, 92 | parameters: params, 93 | evalScripts: true, 94 | 95 | onComplete: function(request) { element.fire("ajax:complete", request); }, 96 | onSuccess: function(request) { element.fire("ajax:success", request); }, 97 | onFailure: function(request) { element.fire("ajax:failure", request); } 98 | }); 99 | 100 | element.fire("ajax:after"); 101 | } 102 | 103 | function handleMethod(element) { 104 | var method = element.readAttribute('data-method'), 105 | url = element.readAttribute('href'), 106 | csrf_param = $$('meta[name=csrf-param]')[0], 107 | csrf_token = $$('meta[name=csrf-token]')[0]; 108 | 109 | var form = new Element('form', { method: "POST", action: url, style: "display: none;" }); 110 | element.parentNode.insert(form); 111 | 112 | if (method !== 'post') { 113 | var field = new Element('input', { type: 'hidden', name: '_method', value: method }); 114 | form.insert(field); 115 | } 116 | 117 | if (csrf_param) { 118 | var param = csrf_param.readAttribute('content'), 119 | token = csrf_token.readAttribute('content'), 120 | field = new Element('input', { type: 'hidden', name: param, value: token }); 121 | form.insert(field); 122 | } 123 | 124 | form.submit(); 125 | } 126 | 127 | 128 | document.on("click", "*[data-confirm]", function(event, element) { 129 | var message = element.readAttribute('data-confirm'); 130 | if (!confirm(message)) event.stop(); 131 | }); 132 | 133 | document.on("click", "a[data-remote]", function(event, element) { 134 | if (event.stopped) return; 135 | handleRemote(element); 136 | event.stop(); 137 | }); 138 | 139 | document.on("click", "a[data-method]", function(event, element) { 140 | if (event.stopped) return; 141 | handleMethod(element); 142 | event.stop(); 143 | }); 144 | 145 | document.on("submit", function(event) { 146 | var element = event.findElement(), 147 | message = element.readAttribute('data-confirm'); 148 | if (message && !confirm(message)) { 149 | event.stop(); 150 | return false; 151 | } 152 | 153 | var inputs = element.select("input[type=submit][data-disable-with]"); 154 | inputs.each(function(input) { 155 | input.disabled = true; 156 | input.writeAttribute('data-original-value', input.value); 157 | input.value = input.readAttribute('data-disable-with'); 158 | }); 159 | 160 | var element = event.findElement("form[data-remote]"); 161 | if (element) { 162 | handleRemote(element); 163 | event.stop(); 164 | } 165 | }); 166 | 167 | document.on("ajax:after", "form", function(event, element) { 168 | var inputs = element.select("input[type=submit][disabled=true][data-disable-with]"); 169 | inputs.each(function(input) { 170 | input.value = input.readAttribute('data-original-value'); 171 | input.removeAttribute('data-original-value'); 172 | input.disabled = false; 173 | }); 174 | }); 175 | })(); 176 | -------------------------------------------------------------------------------- /app/assets/stylesheets/tolk/screen.css: -------------------------------------------------------------------------------- 1 | /*------------------------------------------------- 2 | Defaults 3 | -------------------------------------------------*/ 4 | 5 | acronym, abbr { 6 | font-variant: small-caps; 7 | text-transform: lowercase; 8 | font-weight: bold; 9 | } 10 | 11 | .center {text-align: center;} 12 | .large {font-size: larger;} 13 | .small {font-size: smaller;} 14 | strong {font-weight: bold;} 15 | em {font-style: italic;} 16 | 17 | .clear {clear: both;} 18 | .clearfix:after { content: "."; display: block; height: 0; clear: both; visibility: hidden; } 19 | .clearfix { display: inline-block; } 20 | .clearfix{ display: block; } 21 | 22 | a {color: #888;} 23 | a:hover {color: #000;} 24 | 25 | /*------------------------------------------------- 26 | Layout 27 | -------------------------------------------------*/ 28 | 29 | body { 30 | font-family: "Lucida Sans", "Lucida Grande", "Lucida Sans Unicode", sans-serif; 31 | background: #e5e5e5; 32 | color: #333; 33 | margin: 0; 34 | padding: 0; 35 | font-size: 14px; 36 | line-height: 21px; 37 | text-align: left; 38 | } 39 | 40 | div#container { 41 | margin: 2% 4%; 42 | padding: 25px; 43 | background: #fff; 44 | -webkit-border-radius: 20px; 45 | -moz-border-radius: 20px; 46 | border-radius: 20px; 47 | box-shadow: 0px 0px 15px rgba(00,00,00,0.5); 48 | -moz-box-shadow: 0px 0px 15px rgba(00,00,00,0.5); 49 | -webkit-box-shadow: 0px 0px 15px rgba(00,00,00,0.5); 50 | } 51 | 52 | div#head { 53 | margin: -25px -25px 0; 54 | background: #111; 55 | color: #999; 56 | padding: 25px 25px 20px 15px; 57 | -webkit-border-top-left-radius: 20px; 58 | -webkit-border-top-right-radius: 20px; 59 | -moz-border-radius-topleft: 20px; 60 | -moz-border-radius-topright: 20px; 61 | border-top-left-radius: 20px; 62 | border-top-right-radius: 20px; 63 | font-size: 18px; 64 | } 65 | 66 | div#head a { 67 | color: #2fadcf; 68 | font-weight: bold; 69 | text-decoration: none; 70 | } 71 | 72 | div#head span { 73 | padding: 8px 6px; 74 | margin-right: 1px; 75 | } 76 | 77 | div#head span.home { 78 | background: #333; 79 | } 80 | 81 | div#head span.locale { 82 | background: #444; 83 | } 84 | 85 | div#head span.locale a { 86 | color: #fff; 87 | } 88 | 89 | div#head span:first-child { 90 | padding-left: 12px; 91 | -webkit-border-top-left-radius: 10px; 92 | -webkit-border-bottom-left-radius: 10px; 93 | -moz-border-radius-topleft: 10px; 94 | -moz-border-radius-bottomleft: 10px; 95 | border-top-left-radius: 10px; 96 | border-bottom-left-radius: 10px; 97 | } 98 | 99 | div#head span:last-child { 100 | padding-right: 12px; 101 | -webkit-border-top-right-radius: 10px; 102 | -webkit-border-bottom-right-radius: 10px; 103 | -moz-border-radius-topright: 10px; 104 | -moz-border-radius-bottomright: 10px; 105 | border-top-right-radius: 10px; 106 | border-bottom-right-radius: 10px; 107 | } 108 | 109 | h2, 110 | h3 { 111 | font-size: 18px; 112 | color: #2fadcf; 113 | margin: 25px 0 10px; 114 | } 115 | 116 | h2 span, 117 | h3 span { 118 | font-size: 13px; 119 | color: #888; 120 | } 121 | 122 | ul.locales { 123 | margin: 25px 0; 124 | } 125 | 126 | ul.locales li { 127 | width: 300px; 128 | display: block; 129 | float: left; 130 | margin: 0 10px 10px 0; 131 | /* background: #fff; 132 | -webkit-border-radius: 5px; 133 | -moz-border-radius: 5px; 134 | border-radius: 5px; 135 | border: 1px solid #e5e5e5; 136 | padding: 5px 10px;*/ 137 | } 138 | 139 | ul.locales a { 140 | font-weight: bold; 141 | text-decoration: underline; 142 | color: #2fadcf; 143 | } 144 | 145 | /*ul.locales li:hover { 146 | background: #f5f5f5; 147 | border-color: #ccc; 148 | }*/ 149 | 150 | ul.locales span.missing_translations { 151 | color: #fff; 152 | font-weight: bold; 153 | background: #c00; 154 | font-size: 9px; 155 | padding: 3px; 156 | -webkit-border-radius: 3px; 157 | -moz-border-radius: 3px; 158 | border-radius: 3px; 159 | line-height: 9px; 160 | vertical-align: top; 161 | display: inline-block; 162 | } 163 | 164 | ul.locales span { 165 | font-size: 13px; 166 | color: #666; 167 | } 168 | 169 | div.submit { 170 | background: #f5f5f5; 171 | margin: 25px -25px -25px; 172 | padding: 15px 25px; 173 | border-top: 1px dashed #ccc; 174 | -webkit-border-bottom-right-radius: 20px; 175 | -webkit-border-bottom-left-radius: 20px; 176 | -moz-border-radius-bottomright: 20px; 177 | -moz-border-radius-bottomleft: 20px; 178 | border-bottom-right-radius: 20px; 179 | border-bottom-left-radius: 20px; 180 | } 181 | 182 | span.updated { 183 | font-size: 11px; 184 | padding: 1px; 185 | background: #ffc; 186 | color: #777; 187 | margin-bottom: 10px; 188 | float: right; 189 | } 190 | 191 | div.table_submit { 192 | background: #f5f5f5; 193 | margin: -25px 0 0; 194 | padding: 12px 15px 15px; 195 | text-align: left; 196 | } 197 | 198 | div.translations { 199 | width: 96%; 200 | text-align: center; 201 | } 202 | 203 | span.notice { 204 | background: #ffc; 205 | color: #666; 206 | font-size: 12px; 207 | padding: 2px 5px; 208 | margin: -5px 0 15px; 209 | display: inline-block; 210 | } 211 | 212 | div.original { 213 | color: #999; 214 | margin: 5px 0; 215 | padding: 1px 8px 4px; 216 | } 217 | 218 | div.updated { 219 | background: #ffc; 220 | padding: 1px 8px 4px; 221 | } 222 | 223 | table.translations div.original span.key { 224 | margin: 0 0 -2px; 225 | padding: 0; 226 | } 227 | 228 | table.translations div.updated span.key { 229 | margin: 0 0 -2px; 230 | color: orange !important; 231 | padding: 0; 232 | } 233 | 234 | /*------------------------------------------------- 235 | Translation tables 236 | -------------------------------------------------*/ 237 | 238 | table.translations { 239 | margin: 0 0 25px; 240 | width: 100%; 241 | text-align: left; 242 | } 243 | 244 | table.translations td, 245 | table.translations th { 246 | font-size: 14px; 247 | color: #222; 248 | padding: 12px 8px; 249 | border-bottom: 1px solid #e5e5e5; 250 | vertical-align: top; 251 | width: 50%; 252 | } 253 | 254 | table.translations th { 255 | border-bottom-color: #bbb; 256 | font-size: 11px; 257 | font-weight: bold; 258 | text-transform: uppercase; 259 | color: #999; 260 | padding-bottom: 2px; 261 | } 262 | 263 | table.translations textarea.locale { 264 | font-family: "Lucida Sans", "Lucida Grande", "Lucida Sans Unicode", sans-serif; 265 | font-size: 14px; 266 | line-height: 21px; 267 | color: #222; 268 | padding: 1px; 269 | width: 100%; 270 | height: 42px; 271 | } 272 | 273 | table.translations span.key { 274 | color: #aaa; 275 | font-size: 9px; 276 | display: block; 277 | } 278 | 279 | table.translations td.translation { 280 | padding: 10px 8px; 281 | } 282 | 283 | table.translations tr.active td { 284 | background: #edf9fe; 285 | } 286 | 287 | table.translations .highlight { 288 | background-color: yellow; 289 | } 290 | 291 | table.translations .phrase .carriage_return { 292 | color: #2fadcf; 293 | font-weight: bold; 294 | } 295 | 296 | /*------------------------------------------------- 297 | Pagination 298 | -------------------------------------------------*/ 299 | 300 | div.paginate { 301 | margin: 15px auto 20px; 302 | font-size: 12px; 303 | color: #777; 304 | } 305 | 306 | div.paginate a, 307 | div.paginate span { 308 | padding: 2px 6px; 309 | text-decoration: none; 310 | } 311 | 312 | div.paginate a:hover, 313 | div.paginate span.current { 314 | -webkit-border-radius: 5px; 315 | -moz-border-radius: 5px; 316 | border-radius: 5px; 317 | background: #eee; 318 | color: #333; 319 | } 320 | 321 | div.paginate .next_page, 322 | div.paginate .prev_page { 323 | margin: 0 15px; 324 | -webkit-border-radius: 5px; 325 | -moz-border-radius: 5px; 326 | border-radius: 5px; 327 | border: 1px solid #bbb; 328 | padding: 4px 8px; 329 | } 330 | 331 | div.paginate .disabled { 332 | color: #ccc; 333 | border-color: #eee; 334 | } 335 | -------------------------------------------------------------------------------- /app/models/tolk/locale.rb: -------------------------------------------------------------------------------- 1 | require 'tolk/config' 2 | 3 | module Tolk 4 | class Locale < ActiveRecord::Base 5 | self.table_name = "tolk_locales" 6 | 7 | def self._dump_path 8 | # Necessary to acces rails.root at runtime ! 9 | @dump_path ||= Tolk.config.dump_path.is_a?(Proc) ? instance_eval(&Tolk.config.dump_path) : Tolk.config.dump_path 10 | end 11 | 12 | has_many :phrases, :through => :translations, :class_name => 'Tolk::Phrase' 13 | has_many :translations, :class_name => 'Tolk::Translation', :dependent => :destroy 14 | 15 | accepts_nested_attributes_for :translations, :reject_if => proc { |attributes| attributes['text'].blank? } 16 | before_validation :remove_invalid_translations_from_target, :on => :update 17 | 18 | attr_accessible :name 19 | cattr_accessor :locales_config_path 20 | self.locales_config_path = self._dump_path 21 | 22 | cattr_accessor :primary_locale_name 23 | self.primary_locale_name = I18n.default_locale.to_s 24 | 25 | include Tolk::Sync 26 | include Tolk::Import 27 | 28 | validates_uniqueness_of :name 29 | validates_presence_of :name 30 | 31 | cattr_accessor :special_prefixes 32 | self.special_prefixes = ['activerecord.attributes'] 33 | 34 | cattr_accessor :special_keys 35 | self.special_keys = ['activerecord.models'] 36 | 37 | class << self 38 | def primary_locale(reload = false) 39 | @_primary_locale = nil if reload 40 | @_primary_locale ||= begin 41 | raise "Primary locale is not set. Please set Locale.primary_locale_name in your application's config file" unless self.primary_locale_name 42 | find_or_create_by_name(self.primary_locale_name) 43 | end 44 | end 45 | 46 | def primary_language_name 47 | primary_locale.language_name 48 | end 49 | 50 | def secondary_locales 51 | all - [primary_locale] 52 | end 53 | 54 | def dump_all(to = self.locales_config_path) 55 | secondary_locales.each do |locale| 56 | File.open("#{to}/#{locale.name}.yml", "w+") do |file| 57 | data = locale.to_hash 58 | data.respond_to?(:ya2yaml) ? file.write(data.ya2yaml(:syck_compatible => true)) : YAML.dump(locale.to_hash, file) 59 | end 60 | end 61 | end 62 | 63 | def special_key_or_prefix?(prefix, key) 64 | self.special_prefixes.include?(prefix) || self.special_keys.include?(key) 65 | end 66 | 67 | PLURALIZATION_KEYS = ['none', 'one', 'two', 'few', 'many', 'other'] 68 | def pluralization_data?(data) 69 | keys = data.keys.map(&:to_s) 70 | keys.all? {|k| PLURALIZATION_KEYS.include?(k) } 71 | end 72 | end 73 | 74 | def has_updated_translations? 75 | translations.count(:conditions => {:'tolk_translations.primary_updated' => true}) > 0 76 | end 77 | 78 | def phrases_with_translation(page = nil) 79 | find_phrases_with_translations(page, :'tolk_translations.primary_updated' => false) 80 | end 81 | 82 | def phrases_with_updated_translation(page = nil) 83 | find_phrases_with_translations(page, :'tolk_translations.primary_updated' => true) 84 | end 85 | 86 | def count_phrases_without_translation 87 | existing_ids = self.translations.all(:select => 'tolk_translations.phrase_id').map(&:phrase_id).uniq 88 | Tolk::Phrase.count - existing_ids.count 89 | end 90 | 91 | def phrases_without_translation(page = nil, options = {}) 92 | phrases = Tolk::Phrase.scoped(:order => 'tolk_phrases.key ASC') 93 | 94 | existing_ids = self.translations.all(:select => 'tolk_translations.phrase_id').map(&:phrase_id).uniq 95 | phrases = phrases.scoped(:conditions => ['tolk_phrases.id NOT IN (?)', existing_ids]) if existing_ids.present? 96 | 97 | result = phrases.paginate({:page => page, :per_page => Phrase.per_page}.merge(options)) 98 | ActiveRecord::Associations::Preloader.new result, :translations 99 | result 100 | end 101 | 102 | def search_phrases(query, scope, key_query, page = nil, options = {}) 103 | return [] unless query.present? || key_query.present? 104 | 105 | translations = case scope 106 | when :origin 107 | Tolk::Locale.primary_locale.translations.containing_text(query) 108 | else # :target 109 | self.translations.containing_text(query) 110 | end 111 | 112 | phrases = Tolk::Phrase.scoped(:order => 'tolk_phrases.key ASC') 113 | phrases = phrases.containing_text(key_query) 114 | 115 | phrases = phrases.scoped(:conditions => ['tolk_phrases.id IN(?)', translations.map(&:phrase_id).uniq]) 116 | phrases.paginate({:page => page}.merge(options)) 117 | end 118 | 119 | def search_phrases_without_translation(query, page = nil, options = {}) 120 | return phrases_without_translation(page, options) unless query.present? 121 | 122 | phrases = Tolk::Phrase.scoped(:order => 'tolk_phrases.key ASC') 123 | 124 | found_translations_ids = Tolk::Locale.primary_locale.translations.all(:conditions => ["tolk_translations.text LIKE ?", "%#{query}%"], :select => 'tolk_translations.phrase_id').map(&:phrase_id).uniq 125 | existing_ids = self.translations.all(:select => 'tolk_translations.phrase_id').map(&:phrase_id).uniq 126 | phrases = phrases.scoped(:conditions => ['tolk_phrases.id NOT IN (?) AND tolk_phrases.id IN(?)', existing_ids, found_translations_ids]) if existing_ids.present? 127 | 128 | result = phrases.paginate({:page => page}.merge(options)) 129 | ActiveRecord::Associations::Preloader.new result, :translations 130 | result 131 | end 132 | 133 | def to_hash 134 | { name => translations.each_with_object({}) do |translation, locale| 135 | if translation.phrase.key.include?(".") 136 | locale.deep_merge!(unsquish(translation.phrase.key, translation.value)) 137 | else 138 | locale[translation.phrase.key] = translation.value 139 | end 140 | end } 141 | end 142 | 143 | def to_param 144 | name.parameterize 145 | end 146 | 147 | def primary? 148 | name == self.class.primary_locale_name 149 | end 150 | 151 | def language_name 152 | Tolk.config.mapping[self.name] || self.name 153 | end 154 | 155 | def get(key) 156 | if phrase = Tolk::Phrase.find_by_key(key) 157 | t = self.translations.where(:phrase_id => phrase.id).first 158 | t.text if t 159 | end 160 | end 161 | 162 | def translations_with_html 163 | translations = self.translations.all(:conditions => "tolk_translations.text LIKE '%>%' AND 164 | tolk_translations.text LIKE '%<%' AND tolk_phrases.key NOT LIKE '%_html'", :joins => :phrase) 165 | ActiveRecord::Associations::Preloader.new translations, :phrase 166 | translations 167 | end 168 | 169 | def self.rename(old_name, new_name) 170 | if old_name.blank? || new_name.blank? 171 | "You need to provide both names, aborting." 172 | else 173 | if locale = find_by_name(old_name) 174 | locale.name = new_name 175 | locale.save 176 | "Locale ' #{old_name}' was renamed '#{new_name}'" 177 | else 178 | "Locale with name '#{old_name}' not found." 179 | end 180 | end 181 | end 182 | 183 | private 184 | 185 | 186 | def remove_invalid_translations_from_target 187 | self.translations.target.dup.each do |t| 188 | unless t.valid? 189 | self.translations.target.delete(t) 190 | else 191 | t.updated_at = Time.current # Silly hax to fool autosave into saving the record 192 | end 193 | end 194 | 195 | true 196 | end 197 | 198 | def find_phrases_with_translations(page, conditions = {}) 199 | result = Tolk::Phrase.paginate(:page => page, 200 | :conditions => { :'tolk_translations.locale_id' => self.id }.merge(conditions), 201 | :joins => :translations, :order => 'tolk_phrases.key ASC') 202 | 203 | result.each do |phrase| 204 | phrase.translation = phrase.translations.for(self) 205 | end 206 | 207 | ActiveRecord::Associations::Preloader.new result, :translations 208 | 209 | result 210 | end 211 | 212 | def unsquish(string, value) 213 | if string.is_a?(String) 214 | unsquish(string.split("."), value) 215 | elsif string.size == 1 216 | { string.first => value } 217 | else 218 | key = string[0] 219 | rest = string[1..-1] 220 | { key => unsquish(rest, value) } 221 | end 222 | end 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /test/unit/sync_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'fileutils' 3 | 4 | class SyncTest < ActiveSupport::TestCase 5 | def setup 6 | Tolk::Locale.delete_all 7 | Tolk::Translation.delete_all 8 | Tolk::Phrase.delete_all 9 | 10 | Tolk::Locale.locales_config_path = Rails.root.join("../locales/sync/") 11 | 12 | I18n.backend.reload! 13 | I18n.load_path = [Tolk::Locale.locales_config_path + 'en.yml'] 14 | I18n.backend.send :init_translations 15 | 16 | Tolk::Locale.primary_locale(true) 17 | end 18 | 19 | def test_flat_hash 20 | data = {'home' => {'hello' => 'hola', 'sidebar' => {'title' => 'something'}}} 21 | result = Tolk::Locale.send(:flat_hash, data) 22 | 23 | assert_equal 2, result.keys.size 24 | assert_equal ['home.hello', 'home.sidebar.title'], result.keys.sort 25 | assert_equal ['hola', 'something'], result.values.sort 26 | end 27 | 28 | def test_sync_sets_previous_text_for_primary_locale 29 | Tolk::Locale.expects(:load_translations).returns({"hello_world" => "Hello World"}).at_least_once 30 | Tolk::Locale.sync! 31 | 32 | # Change 'Hello World' to 'Hello Super World' 33 | Tolk::Locale.expects(:load_translations).returns({"hello_world" => "Hello Super World"}).at_least_once 34 | Tolk::Locale.sync! 35 | 36 | translation = Tolk::Locale.primary_locale(true).translations.first 37 | assert_equal 'Hello Super World', translation.text 38 | assert_equal 'Hello World', translation.previous_text 39 | end 40 | 41 | def test_sync_sets_primary_updated_for_secondary_translations_on_update 42 | spanish = Tolk::Locale.create!(:name => 'es') 43 | 44 | Tolk::Locale.expects(:load_translations).returns({"hello_world" => "Hello World", 'nested.hello_country' => 'Nested Hello Country'}).at_least_once 45 | Tolk::Locale.sync! 46 | 47 | phrase1 = Tolk::Phrase.all.detect {|p| p.key == 'hello_world'} 48 | t1 = spanish.translations.create!(:text => 'hola', :phrase => phrase1) 49 | phrase2 = Tolk::Phrase.all.detect {|p| p.key == 'nested.hello_country'} 50 | t2 = spanish.translations.create!(:text => 'nested hola', :phrase => phrase2) 51 | 52 | # Change 'Hello World' to 'Hello Super World'. But don't change nested.hello_country 53 | Tolk::Locale.expects(:load_translations).returns({'hello_world' => 'Hello Super World', 'nested.hello_country' => 'Nested Hello Country'}).at_least_once 54 | Tolk::Locale.sync! 55 | 56 | t1.reload 57 | t2.reload 58 | 59 | assert t1.primary_updated? 60 | assert ! t2.primary_updated? 61 | end 62 | 63 | def test_sync_marks_translations_for_review_when_the_primary_translation_has_changed 64 | Tolk::Locale.create!(:name => 'es') 65 | 66 | phrase = Tolk::Phrase.create! :key => 'number.precision' 67 | english_translation = phrase.translations.create!(:text => "1", :locale => Tolk::Locale.find_by_name("en")) 68 | spanish_translation = phrase.translations.create!(:text => "1", :locale => Tolk::Locale.find_by_name("es")) 69 | 70 | Tolk::Locale.expects(:load_translations).returns({'number.precision' => "1"}).at_least_once 71 | Tolk::Locale.sync! and spanish_translation.reload 72 | assert spanish_translation.up_to_date? 73 | 74 | Tolk::Locale.expects(:load_translations).returns({'number.precision' => "2"}).at_least_once 75 | Tolk::Locale.sync! and spanish_translation.reload 76 | assert spanish_translation.out_of_date? 77 | 78 | spanish_translation.text = "2" 79 | spanish_translation.save! and spanish_translation.reload 80 | assert spanish_translation.up_to_date? 81 | 82 | Tolk::Locale.expects(:load_translations).returns({'number.precision' => 2}).at_least_once 83 | Tolk::Locale.sync! and spanish_translation.reload 84 | assert spanish_translation.up_to_date? 85 | 86 | Tolk::Locale.expects(:load_translations).returns({'number.precision' => 1}).at_least_once 87 | Tolk::Locale.sync! and spanish_translation.reload 88 | assert spanish_translation.out_of_date? 89 | end 90 | 91 | def test_sync_creates_locale_phrases_translations 92 | Tolk::Locale.expects(:load_translations).returns({'hello_world' => 'Hello World', 'nested.hello_country' => 'Nested Hello Country'}).at_least_once 93 | Tolk::Locale.sync! 94 | 95 | # Created by sync! 96 | primary_locale = Tolk::Locale.find_by_name!(Tolk::Locale.primary_locale_name) 97 | 98 | assert_equal ["Hello World", "Nested Hello Country"], primary_locale.translations.map(&:text).sort 99 | assert_equal ["hello_world", "nested.hello_country"], Tolk::Phrase.all.map(&:key).sort 100 | end 101 | 102 | def test_sync_deletes_stale_translations_for_secondary_locales_on_delete_all 103 | spanish = Tolk::Locale.create!(:name => 'es') 104 | 105 | Tolk::Locale.expects(:load_translations).returns({'hello_world' => 'Hello World', 'nested.hello_country' => 'Nested Hello Country'}).at_least_once 106 | Tolk::Locale.sync! 107 | 108 | phrase = Tolk::Phrase.all.detect {|p| p.key == 'hello_world'} 109 | hola = spanish.translations.create!(:text => 'hola', :phrase => phrase) 110 | 111 | # Mimic deleting all the translations 112 | Tolk::Locale.expects(:load_translations).returns({}).at_least_once 113 | Tolk::Locale.sync! 114 | 115 | assert_equal 0, Tolk::Phrase.count 116 | assert_equal 0, Tolk::Translation.count 117 | 118 | assert_raises(ActiveRecord::RecordNotFound) { hola.reload } 119 | end 120 | 121 | def test_sync_deletes_stale_translations_for_secondary_locales_on_delete_some 122 | spanish = Tolk::Locale.create!(:name => 'es') 123 | 124 | Tolk::Locale.expects(:load_translations).returns({'hello_world' => 'Hello World', 'nested.hello_country' => 'Nested Hello Country'}).at_least_once 125 | Tolk::Locale.sync! 126 | 127 | phrase = Tolk::Phrase.all.detect {|p| p.key == 'hello_world'} 128 | hola = spanish.translations.create!(:text => 'hola', :phrase => phrase) 129 | 130 | # Mimic deleting 'hello_world' 131 | Tolk::Locale.expects(:load_translations).returns({'nested.hello_country' => 'Nested Hello World'}).at_least_once 132 | Tolk::Locale.sync! 133 | 134 | assert_equal 1, Tolk::Phrase.count 135 | assert_equal 1, Tolk::Translation.count 136 | assert_equal 0, spanish.translations.count 137 | 138 | assert_raises(ActiveRecord::RecordNotFound) { hola.reload } 139 | end 140 | 141 | def test_sync_handles_deleted_keys_and_updated_translations 142 | Tolk::Locale.sync! 143 | 144 | # Mimic deleting 'nested.hello_country' and updating 'hello_world' 145 | Tolk::Locale.expects(:load_translations).returns({"hello_world" => "Hello Super World"}).at_least_once 146 | Tolk::Locale.sync! 147 | 148 | primary_locale = Tolk::Locale.find_by_name!(Tolk::Locale.primary_locale_name) 149 | 150 | assert_equal ['Hello Super World'], primary_locale.translations.map(&:text) 151 | assert_equal ['hello_world'], Tolk::Phrase.all.map(&:key).sort 152 | end 153 | 154 | def test_sync_doesnt_mess_with_existing_translations 155 | spanish = Tolk::Locale.create!(:name => 'es') 156 | 157 | Tolk::Locale.expects(:load_translations).returns({"hello_world" => "Hello Super World"}).at_least_once 158 | Tolk::Locale.sync! 159 | 160 | phrase = Tolk::Phrase.all.detect {|p| p.key == 'hello_world'} 161 | hola = spanish.translations.create!(:text => 'hola', :phrase => phrase) 162 | 163 | # Mimic deleting 'nested.hello_country' and updating 'hello_world' 164 | Tolk::Locale.expects(:load_translations).returns({"hello_world" => "Hello Super World"}).at_least_once 165 | Tolk::Locale.sync! 166 | 167 | hola.reload 168 | assert_equal 'hola', hola.text 169 | end 170 | 171 | def test_sync_array_values 172 | spanish = Tolk::Locale.create!(:name => 'es') 173 | 174 | data = {"weekend" => ['Friday', 'Saturday', 'Sunday']} 175 | Tolk::Locale.expects(:load_translations).returns(data).at_least_once 176 | Tolk::Locale.sync! 177 | 178 | assert_equal 1, Tolk::Locale.primary_locale.translations.count 179 | 180 | translation = Tolk::Locale.primary_locale.translations.first 181 | assert_equal data['weekend'], translation.text 182 | 183 | yaml = ['Saturday', 'Sunday'].to_yaml 184 | spanish_weekends = spanish.translations.create!(:text => yaml, :phrase => Tolk::Phrase.first) 185 | assert_equal YAML.load(yaml), spanish_weekends.text 186 | end 187 | 188 | def test_dump_all_after_sync 189 | spanish = Tolk::Locale.create!(:name => 'es') 190 | 191 | Tolk::Locale.sync! 192 | 193 | phrase = Tolk::Phrase.all.detect {|p| p.key == 'hello_world'} 194 | hola = spanish.translations.create!(:text => 'hola', :phrase => phrase) 195 | 196 | tmpdir = Rails.root.join("../../tmp/sync/locales") 197 | FileUtils.mkdir_p(tmpdir) 198 | Tolk::Locale.dump_all(tmpdir) 199 | 200 | spanish_file = "#{tmpdir}/es.yml" 201 | data = YAML::load(IO.read(spanish_file))['es'] 202 | assert_equal ['hello_world'], data.keys 203 | assert_equal 'hola', data['hello_world'] 204 | ensure 205 | FileUtils.rm_f(tmpdir) 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /test/dummy/public/javascripts/dragdrop.js: -------------------------------------------------------------------------------- 1 | // script.aculo.us dragdrop.js v1.8.3, Thu Oct 08 11:23:33 +0200 2009 2 | 3 | // Copyright (c) 2005-2009 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us) 4 | // 5 | // script.aculo.us is freely distributable under the terms of an MIT-style license. 6 | // For details, see the script.aculo.us web site: http://script.aculo.us/ 7 | 8 | if(Object.isUndefined(Effect)) 9 | throw("dragdrop.js requires including script.aculo.us' effects.js library"); 10 | 11 | var Droppables = { 12 | drops: [], 13 | 14 | remove: function(element) { 15 | this.drops = this.drops.reject(function(d) { return d.element==$(element) }); 16 | }, 17 | 18 | add: function(element) { 19 | element = $(element); 20 | var options = Object.extend({ 21 | greedy: true, 22 | hoverclass: null, 23 | tree: false 24 | }, arguments[1] || { }); 25 | 26 | // cache containers 27 | if(options.containment) { 28 | options._containers = []; 29 | var containment = options.containment; 30 | if(Object.isArray(containment)) { 31 | containment.each( function(c) { options._containers.push($(c)) }); 32 | } else { 33 | options._containers.push($(containment)); 34 | } 35 | } 36 | 37 | if(options.accept) options.accept = [options.accept].flatten(); 38 | 39 | Element.makePositioned(element); // fix IE 40 | options.element = element; 41 | 42 | this.drops.push(options); 43 | }, 44 | 45 | findDeepestChild: function(drops) { 46 | deepest = drops[0]; 47 | 48 | for (i = 1; i < drops.length; ++i) 49 | if (Element.isParent(drops[i].element, deepest.element)) 50 | deepest = drops[i]; 51 | 52 | return deepest; 53 | }, 54 | 55 | isContained: function(element, drop) { 56 | var containmentNode; 57 | if(drop.tree) { 58 | containmentNode = element.treeNode; 59 | } else { 60 | containmentNode = element.parentNode; 61 | } 62 | return drop._containers.detect(function(c) { return containmentNode == c }); 63 | }, 64 | 65 | isAffected: function(point, element, drop) { 66 | return ( 67 | (drop.element!=element) && 68 | ((!drop._containers) || 69 | this.isContained(element, drop)) && 70 | ((!drop.accept) || 71 | (Element.classNames(element).detect( 72 | function(v) { return drop.accept.include(v) } ) )) && 73 | Position.within(drop.element, point[0], point[1]) ); 74 | }, 75 | 76 | deactivate: function(drop) { 77 | if(drop.hoverclass) 78 | Element.removeClassName(drop.element, drop.hoverclass); 79 | this.last_active = null; 80 | }, 81 | 82 | activate: function(drop) { 83 | if(drop.hoverclass) 84 | Element.addClassName(drop.element, drop.hoverclass); 85 | this.last_active = drop; 86 | }, 87 | 88 | show: function(point, element) { 89 | if(!this.drops.length) return; 90 | var drop, affected = []; 91 | 92 | this.drops.each( function(drop) { 93 | if(Droppables.isAffected(point, element, drop)) 94 | affected.push(drop); 95 | }); 96 | 97 | if(affected.length>0) 98 | drop = Droppables.findDeepestChild(affected); 99 | 100 | if(this.last_active && this.last_active != drop) this.deactivate(this.last_active); 101 | if (drop) { 102 | Position.within(drop.element, point[0], point[1]); 103 | if(drop.onHover) 104 | drop.onHover(element, drop.element, Position.overlap(drop.overlap, drop.element)); 105 | 106 | if (drop != this.last_active) Droppables.activate(drop); 107 | } 108 | }, 109 | 110 | fire: function(event, element) { 111 | if(!this.last_active) return; 112 | Position.prepare(); 113 | 114 | if (this.isAffected([Event.pointerX(event), Event.pointerY(event)], element, this.last_active)) 115 | if (this.last_active.onDrop) { 116 | this.last_active.onDrop(element, this.last_active.element, event); 117 | return true; 118 | } 119 | }, 120 | 121 | reset: function() { 122 | if(this.last_active) 123 | this.deactivate(this.last_active); 124 | } 125 | }; 126 | 127 | var Draggables = { 128 | drags: [], 129 | observers: [], 130 | 131 | register: function(draggable) { 132 | if(this.drags.length == 0) { 133 | this.eventMouseUp = this.endDrag.bindAsEventListener(this); 134 | this.eventMouseMove = this.updateDrag.bindAsEventListener(this); 135 | this.eventKeypress = this.keyPress.bindAsEventListener(this); 136 | 137 | Event.observe(document, "mouseup", this.eventMouseUp); 138 | Event.observe(document, "mousemove", this.eventMouseMove); 139 | Event.observe(document, "keypress", this.eventKeypress); 140 | } 141 | this.drags.push(draggable); 142 | }, 143 | 144 | unregister: function(draggable) { 145 | this.drags = this.drags.reject(function(d) { return d==draggable }); 146 | if(this.drags.length == 0) { 147 | Event.stopObserving(document, "mouseup", this.eventMouseUp); 148 | Event.stopObserving(document, "mousemove", this.eventMouseMove); 149 | Event.stopObserving(document, "keypress", this.eventKeypress); 150 | } 151 | }, 152 | 153 | activate: function(draggable) { 154 | if(draggable.options.delay) { 155 | this._timeout = setTimeout(function() { 156 | Draggables._timeout = null; 157 | window.focus(); 158 | Draggables.activeDraggable = draggable; 159 | }.bind(this), draggable.options.delay); 160 | } else { 161 | window.focus(); // allows keypress events if window isn't currently focused, fails for Safari 162 | this.activeDraggable = draggable; 163 | } 164 | }, 165 | 166 | deactivate: function() { 167 | this.activeDraggable = null; 168 | }, 169 | 170 | updateDrag: function(event) { 171 | if(!this.activeDraggable) return; 172 | var pointer = [Event.pointerX(event), Event.pointerY(event)]; 173 | // Mozilla-based browsers fire successive mousemove events with 174 | // the same coordinates, prevent needless redrawing (moz bug?) 175 | if(this._lastPointer && (this._lastPointer.inspect() == pointer.inspect())) return; 176 | this._lastPointer = pointer; 177 | 178 | this.activeDraggable.updateDrag(event, pointer); 179 | }, 180 | 181 | endDrag: function(event) { 182 | if(this._timeout) { 183 | clearTimeout(this._timeout); 184 | this._timeout = null; 185 | } 186 | if(!this.activeDraggable) return; 187 | this._lastPointer = null; 188 | this.activeDraggable.endDrag(event); 189 | this.activeDraggable = null; 190 | }, 191 | 192 | keyPress: function(event) { 193 | if(this.activeDraggable) 194 | this.activeDraggable.keyPress(event); 195 | }, 196 | 197 | addObserver: function(observer) { 198 | this.observers.push(observer); 199 | this._cacheObserverCallbacks(); 200 | }, 201 | 202 | removeObserver: function(element) { // element instead of observer fixes mem leaks 203 | this.observers = this.observers.reject( function(o) { return o.element==element }); 204 | this._cacheObserverCallbacks(); 205 | }, 206 | 207 | notify: function(eventName, draggable, event) { // 'onStart', 'onEnd', 'onDrag' 208 | if(this[eventName+'Count'] > 0) 209 | this.observers.each( function(o) { 210 | if(o[eventName]) o[eventName](eventName, draggable, event); 211 | }); 212 | if(draggable.options[eventName]) draggable.options[eventName](draggable, event); 213 | }, 214 | 215 | _cacheObserverCallbacks: function() { 216 | ['onStart','onEnd','onDrag'].each( function(eventName) { 217 | Draggables[eventName+'Count'] = Draggables.observers.select( 218 | function(o) { return o[eventName]; } 219 | ).length; 220 | }); 221 | } 222 | }; 223 | 224 | /*--------------------------------------------------------------------------*/ 225 | 226 | var Draggable = Class.create({ 227 | initialize: function(element) { 228 | var defaults = { 229 | handle: false, 230 | reverteffect: function(element, top_offset, left_offset) { 231 | var dur = Math.sqrt(Math.abs(top_offset^2)+Math.abs(left_offset^2))*0.02; 232 | new Effect.Move(element, { x: -left_offset, y: -top_offset, duration: dur, 233 | queue: {scope:'_draggable', position:'end'} 234 | }); 235 | }, 236 | endeffect: function(element) { 237 | var toOpacity = Object.isNumber(element._opacity) ? element._opacity : 1.0; 238 | new Effect.Opacity(element, {duration:0.2, from:0.7, to:toOpacity, 239 | queue: {scope:'_draggable', position:'end'}, 240 | afterFinish: function(){ 241 | Draggable._dragging[element] = false 242 | } 243 | }); 244 | }, 245 | zindex: 1000, 246 | revert: false, 247 | quiet: false, 248 | scroll: false, 249 | scrollSensitivity: 20, 250 | scrollSpeed: 15, 251 | snap: false, // false, or xy or [x,y] or function(x,y){ return [x,y] } 252 | delay: 0 253 | }; 254 | 255 | if(!arguments[1] || Object.isUndefined(arguments[1].endeffect)) 256 | Object.extend(defaults, { 257 | starteffect: function(element) { 258 | element._opacity = Element.getOpacity(element); 259 | Draggable._dragging[element] = true; 260 | new Effect.Opacity(element, {duration:0.2, from:element._opacity, to:0.7}); 261 | } 262 | }); 263 | 264 | var options = Object.extend(defaults, arguments[1] || { }); 265 | 266 | this.element = $(element); 267 | 268 | if(options.handle && Object.isString(options.handle)) 269 | this.handle = this.element.down('.'+options.handle, 0); 270 | 271 | if(!this.handle) this.handle = $(options.handle); 272 | if(!this.handle) this.handle = this.element; 273 | 274 | if(options.scroll && !options.scroll.scrollTo && !options.scroll.outerHTML) { 275 | options.scroll = $(options.scroll); 276 | this._isScrollChild = Element.childOf(this.element, options.scroll); 277 | } 278 | 279 | Element.makePositioned(this.element); // fix IE 280 | 281 | this.options = options; 282 | this.dragging = false; 283 | 284 | this.eventMouseDown = this.initDrag.bindAsEventListener(this); 285 | Event.observe(this.handle, "mousedown", this.eventMouseDown); 286 | 287 | Draggables.register(this); 288 | }, 289 | 290 | destroy: function() { 291 | Event.stopObserving(this.handle, "mousedown", this.eventMouseDown); 292 | Draggables.unregister(this); 293 | }, 294 | 295 | currentDelta: function() { 296 | return([ 297 | parseInt(Element.getStyle(this.element,'left') || '0'), 298 | parseInt(Element.getStyle(this.element,'top') || '0')]); 299 | }, 300 | 301 | initDrag: function(event) { 302 | if(!Object.isUndefined(Draggable._dragging[this.element]) && 303 | Draggable._dragging[this.element]) return; 304 | if(Event.isLeftClick(event)) { 305 | // abort on form elements, fixes a Firefox issue 306 | var src = Event.element(event); 307 | if((tag_name = src.tagName.toUpperCase()) && ( 308 | tag_name=='INPUT' || 309 | tag_name=='SELECT' || 310 | tag_name=='OPTION' || 311 | tag_name=='BUTTON' || 312 | tag_name=='TEXTAREA')) return; 313 | 314 | var pointer = [Event.pointerX(event), Event.pointerY(event)]; 315 | var pos = this.element.cumulativeOffset(); 316 | this.offset = [0,1].map( function(i) { return (pointer[i] - pos[i]) }); 317 | 318 | Draggables.activate(this); 319 | Event.stop(event); 320 | } 321 | }, 322 | 323 | startDrag: function(event) { 324 | this.dragging = true; 325 | if(!this.delta) 326 | this.delta = this.currentDelta(); 327 | 328 | if(this.options.zindex) { 329 | this.originalZ = parseInt(Element.getStyle(this.element,'z-index') || 0); 330 | this.element.style.zIndex = this.options.zindex; 331 | } 332 | 333 | if(this.options.ghosting) { 334 | this._clone = this.element.cloneNode(true); 335 | this._originallyAbsolute = (this.element.getStyle('position') == 'absolute'); 336 | if (!this._originallyAbsolute) 337 | Position.absolutize(this.element); 338 | this.element.parentNode.insertBefore(this._clone, this.element); 339 | } 340 | 341 | if(this.options.scroll) { 342 | if (this.options.scroll == window) { 343 | var where = this._getWindowScroll(this.options.scroll); 344 | this.originalScrollLeft = where.left; 345 | this.originalScrollTop = where.top; 346 | } else { 347 | this.originalScrollLeft = this.options.scroll.scrollLeft; 348 | this.originalScrollTop = this.options.scroll.scrollTop; 349 | } 350 | } 351 | 352 | Draggables.notify('onStart', this, event); 353 | 354 | if(this.options.starteffect) this.options.starteffect(this.element); 355 | }, 356 | 357 | updateDrag: function(event, pointer) { 358 | if(!this.dragging) this.startDrag(event); 359 | 360 | if(!this.options.quiet){ 361 | Position.prepare(); 362 | Droppables.show(pointer, this.element); 363 | } 364 | 365 | Draggables.notify('onDrag', this, event); 366 | 367 | this.draw(pointer); 368 | if(this.options.change) this.options.change(this); 369 | 370 | if(this.options.scroll) { 371 | this.stopScrolling(); 372 | 373 | var p; 374 | if (this.options.scroll == window) { 375 | with(this._getWindowScroll(this.options.scroll)) { p = [ left, top, left+width, top+height ]; } 376 | } else { 377 | p = Position.page(this.options.scroll); 378 | p[0] += this.options.scroll.scrollLeft + Position.deltaX; 379 | p[1] += this.options.scroll.scrollTop + Position.deltaY; 380 | p.push(p[0]+this.options.scroll.offsetWidth); 381 | p.push(p[1]+this.options.scroll.offsetHeight); 382 | } 383 | var speed = [0,0]; 384 | if(pointer[0] < (p[0]+this.options.scrollSensitivity)) speed[0] = pointer[0]-(p[0]+this.options.scrollSensitivity); 385 | if(pointer[1] < (p[1]+this.options.scrollSensitivity)) speed[1] = pointer[1]-(p[1]+this.options.scrollSensitivity); 386 | if(pointer[0] > (p[2]-this.options.scrollSensitivity)) speed[0] = pointer[0]-(p[2]-this.options.scrollSensitivity); 387 | if(pointer[1] > (p[3]-this.options.scrollSensitivity)) speed[1] = pointer[1]-(p[3]-this.options.scrollSensitivity); 388 | this.startScrolling(speed); 389 | } 390 | 391 | // fix AppleWebKit rendering 392 | if(Prototype.Browser.WebKit) window.scrollBy(0,0); 393 | 394 | Event.stop(event); 395 | }, 396 | 397 | finishDrag: function(event, success) { 398 | this.dragging = false; 399 | 400 | if(this.options.quiet){ 401 | Position.prepare(); 402 | var pointer = [Event.pointerX(event), Event.pointerY(event)]; 403 | Droppables.show(pointer, this.element); 404 | } 405 | 406 | if(this.options.ghosting) { 407 | if (!this._originallyAbsolute) 408 | Position.relativize(this.element); 409 | delete this._originallyAbsolute; 410 | Element.remove(this._clone); 411 | this._clone = null; 412 | } 413 | 414 | var dropped = false; 415 | if(success) { 416 | dropped = Droppables.fire(event, this.element); 417 | if (!dropped) dropped = false; 418 | } 419 | if(dropped && this.options.onDropped) this.options.onDropped(this.element); 420 | Draggables.notify('onEnd', this, event); 421 | 422 | var revert = this.options.revert; 423 | if(revert && Object.isFunction(revert)) revert = revert(this.element); 424 | 425 | var d = this.currentDelta(); 426 | if(revert && this.options.reverteffect) { 427 | if (dropped == 0 || revert != 'failure') 428 | this.options.reverteffect(this.element, 429 | d[1]-this.delta[1], d[0]-this.delta[0]); 430 | } else { 431 | this.delta = d; 432 | } 433 | 434 | if(this.options.zindex) 435 | this.element.style.zIndex = this.originalZ; 436 | 437 | if(this.options.endeffect) 438 | this.options.endeffect(this.element); 439 | 440 | Draggables.deactivate(this); 441 | Droppables.reset(); 442 | }, 443 | 444 | keyPress: function(event) { 445 | if(event.keyCode!=Event.KEY_ESC) return; 446 | this.finishDrag(event, false); 447 | Event.stop(event); 448 | }, 449 | 450 | endDrag: function(event) { 451 | if(!this.dragging) return; 452 | this.stopScrolling(); 453 | this.finishDrag(event, true); 454 | Event.stop(event); 455 | }, 456 | 457 | draw: function(point) { 458 | var pos = this.element.cumulativeOffset(); 459 | if(this.options.ghosting) { 460 | var r = Position.realOffset(this.element); 461 | pos[0] += r[0] - Position.deltaX; pos[1] += r[1] - Position.deltaY; 462 | } 463 | 464 | var d = this.currentDelta(); 465 | pos[0] -= d[0]; pos[1] -= d[1]; 466 | 467 | if(this.options.scroll && (this.options.scroll != window && this._isScrollChild)) { 468 | pos[0] -= this.options.scroll.scrollLeft-this.originalScrollLeft; 469 | pos[1] -= this.options.scroll.scrollTop-this.originalScrollTop; 470 | } 471 | 472 | var p = [0,1].map(function(i){ 473 | return (point[i]-pos[i]-this.offset[i]) 474 | }.bind(this)); 475 | 476 | if(this.options.snap) { 477 | if(Object.isFunction(this.options.snap)) { 478 | p = this.options.snap(p[0],p[1],this); 479 | } else { 480 | if(Object.isArray(this.options.snap)) { 481 | p = p.map( function(v, i) { 482 | return (v/this.options.snap[i]).round()*this.options.snap[i] }.bind(this)); 483 | } else { 484 | p = p.map( function(v) { 485 | return (v/this.options.snap).round()*this.options.snap }.bind(this)); 486 | } 487 | }} 488 | 489 | var style = this.element.style; 490 | if((!this.options.constraint) || (this.options.constraint=='horizontal')) 491 | style.left = p[0] + "px"; 492 | if((!this.options.constraint) || (this.options.constraint=='vertical')) 493 | style.top = p[1] + "px"; 494 | 495 | if(style.visibility=="hidden") style.visibility = ""; // fix gecko rendering 496 | }, 497 | 498 | stopScrolling: function() { 499 | if(this.scrollInterval) { 500 | clearInterval(this.scrollInterval); 501 | this.scrollInterval = null; 502 | Draggables._lastScrollPointer = null; 503 | } 504 | }, 505 | 506 | startScrolling: function(speed) { 507 | if(!(speed[0] || speed[1])) return; 508 | this.scrollSpeed = [speed[0]*this.options.scrollSpeed,speed[1]*this.options.scrollSpeed]; 509 | this.lastScrolled = new Date(); 510 | this.scrollInterval = setInterval(this.scroll.bind(this), 10); 511 | }, 512 | 513 | scroll: function() { 514 | var current = new Date(); 515 | var delta = current - this.lastScrolled; 516 | this.lastScrolled = current; 517 | if(this.options.scroll == window) { 518 | with (this._getWindowScroll(this.options.scroll)) { 519 | if (this.scrollSpeed[0] || this.scrollSpeed[1]) { 520 | var d = delta / 1000; 521 | this.options.scroll.scrollTo( left + d*this.scrollSpeed[0], top + d*this.scrollSpeed[1] ); 522 | } 523 | } 524 | } else { 525 | this.options.scroll.scrollLeft += this.scrollSpeed[0] * delta / 1000; 526 | this.options.scroll.scrollTop += this.scrollSpeed[1] * delta / 1000; 527 | } 528 | 529 | Position.prepare(); 530 | Droppables.show(Draggables._lastPointer, this.element); 531 | Draggables.notify('onDrag', this); 532 | if (this._isScrollChild) { 533 | Draggables._lastScrollPointer = Draggables._lastScrollPointer || $A(Draggables._lastPointer); 534 | Draggables._lastScrollPointer[0] += this.scrollSpeed[0] * delta / 1000; 535 | Draggables._lastScrollPointer[1] += this.scrollSpeed[1] * delta / 1000; 536 | if (Draggables._lastScrollPointer[0] < 0) 537 | Draggables._lastScrollPointer[0] = 0; 538 | if (Draggables._lastScrollPointer[1] < 0) 539 | Draggables._lastScrollPointer[1] = 0; 540 | this.draw(Draggables._lastScrollPointer); 541 | } 542 | 543 | if(this.options.change) this.options.change(this); 544 | }, 545 | 546 | _getWindowScroll: function(w) { 547 | var T, L, W, H; 548 | with (w.document) { 549 | if (w.document.documentElement && documentElement.scrollTop) { 550 | T = documentElement.scrollTop; 551 | L = documentElement.scrollLeft; 552 | } else if (w.document.body) { 553 | T = body.scrollTop; 554 | L = body.scrollLeft; 555 | } 556 | if (w.innerWidth) { 557 | W = w.innerWidth; 558 | H = w.innerHeight; 559 | } else if (w.document.documentElement && documentElement.clientWidth) { 560 | W = documentElement.clientWidth; 561 | H = documentElement.clientHeight; 562 | } else { 563 | W = body.offsetWidth; 564 | H = body.offsetHeight; 565 | } 566 | } 567 | return { top: T, left: L, width: W, height: H }; 568 | } 569 | }); 570 | 571 | Draggable._dragging = { }; 572 | 573 | /*--------------------------------------------------------------------------*/ 574 | 575 | var SortableObserver = Class.create({ 576 | initialize: function(element, observer) { 577 | this.element = $(element); 578 | this.observer = observer; 579 | this.lastValue = Sortable.serialize(this.element); 580 | }, 581 | 582 | onStart: function() { 583 | this.lastValue = Sortable.serialize(this.element); 584 | }, 585 | 586 | onEnd: function() { 587 | Sortable.unmark(); 588 | if(this.lastValue != Sortable.serialize(this.element)) 589 | this.observer(this.element) 590 | } 591 | }); 592 | 593 | var Sortable = { 594 | SERIALIZE_RULE: /^[^_\-](?:[A-Za-z0-9\-\_]*)[_](.*)$/, 595 | 596 | sortables: { }, 597 | 598 | _findRootElement: function(element) { 599 | while (element.tagName.toUpperCase() != "BODY") { 600 | if(element.id && Sortable.sortables[element.id]) return element; 601 | element = element.parentNode; 602 | } 603 | }, 604 | 605 | options: function(element) { 606 | element = Sortable._findRootElement($(element)); 607 | if(!element) return; 608 | return Sortable.sortables[element.id]; 609 | }, 610 | 611 | destroy: function(element){ 612 | element = $(element); 613 | var s = Sortable.sortables[element.id]; 614 | 615 | if(s) { 616 | Draggables.removeObserver(s.element); 617 | s.droppables.each(function(d){ Droppables.remove(d) }); 618 | s.draggables.invoke('destroy'); 619 | 620 | delete Sortable.sortables[s.element.id]; 621 | } 622 | }, 623 | 624 | create: function(element) { 625 | element = $(element); 626 | var options = Object.extend({ 627 | element: element, 628 | tag: 'li', // assumes li children, override with tag: 'tagname' 629 | dropOnEmpty: false, 630 | tree: false, 631 | treeTag: 'ul', 632 | overlap: 'vertical', // one of 'vertical', 'horizontal' 633 | constraint: 'vertical', // one of 'vertical', 'horizontal', false 634 | containment: element, // also takes array of elements (or id's); or false 635 | handle: false, // or a CSS class 636 | only: false, 637 | delay: 0, 638 | hoverclass: null, 639 | ghosting: false, 640 | quiet: false, 641 | scroll: false, 642 | scrollSensitivity: 20, 643 | scrollSpeed: 15, 644 | format: this.SERIALIZE_RULE, 645 | 646 | // these take arrays of elements or ids and can be 647 | // used for better initialization performance 648 | elements: false, 649 | handles: false, 650 | 651 | onChange: Prototype.emptyFunction, 652 | onUpdate: Prototype.emptyFunction 653 | }, arguments[1] || { }); 654 | 655 | // clear any old sortable with same element 656 | this.destroy(element); 657 | 658 | // build options for the draggables 659 | var options_for_draggable = { 660 | revert: true, 661 | quiet: options.quiet, 662 | scroll: options.scroll, 663 | scrollSpeed: options.scrollSpeed, 664 | scrollSensitivity: options.scrollSensitivity, 665 | delay: options.delay, 666 | ghosting: options.ghosting, 667 | constraint: options.constraint, 668 | handle: options.handle }; 669 | 670 | if(options.starteffect) 671 | options_for_draggable.starteffect = options.starteffect; 672 | 673 | if(options.reverteffect) 674 | options_for_draggable.reverteffect = options.reverteffect; 675 | else 676 | if(options.ghosting) options_for_draggable.reverteffect = function(element) { 677 | element.style.top = 0; 678 | element.style.left = 0; 679 | }; 680 | 681 | if(options.endeffect) 682 | options_for_draggable.endeffect = options.endeffect; 683 | 684 | if(options.zindex) 685 | options_for_draggable.zindex = options.zindex; 686 | 687 | // build options for the droppables 688 | var options_for_droppable = { 689 | overlap: options.overlap, 690 | containment: options.containment, 691 | tree: options.tree, 692 | hoverclass: options.hoverclass, 693 | onHover: Sortable.onHover 694 | }; 695 | 696 | var options_for_tree = { 697 | onHover: Sortable.onEmptyHover, 698 | overlap: options.overlap, 699 | containment: options.containment, 700 | hoverclass: options.hoverclass 701 | }; 702 | 703 | // fix for gecko engine 704 | Element.cleanWhitespace(element); 705 | 706 | options.draggables = []; 707 | options.droppables = []; 708 | 709 | // drop on empty handling 710 | if(options.dropOnEmpty || options.tree) { 711 | Droppables.add(element, options_for_tree); 712 | options.droppables.push(element); 713 | } 714 | 715 | (options.elements || this.findElements(element, options) || []).each( function(e,i) { 716 | var handle = options.handles ? $(options.handles[i]) : 717 | (options.handle ? $(e).select('.' + options.handle)[0] : e); 718 | options.draggables.push( 719 | new Draggable(e, Object.extend(options_for_draggable, { handle: handle }))); 720 | Droppables.add(e, options_for_droppable); 721 | if(options.tree) e.treeNode = element; 722 | options.droppables.push(e); 723 | }); 724 | 725 | if(options.tree) { 726 | (Sortable.findTreeElements(element, options) || []).each( function(e) { 727 | Droppables.add(e, options_for_tree); 728 | e.treeNode = element; 729 | options.droppables.push(e); 730 | }); 731 | } 732 | 733 | // keep reference 734 | this.sortables[element.identify()] = options; 735 | 736 | // for onupdate 737 | Draggables.addObserver(new SortableObserver(element, options.onUpdate)); 738 | 739 | }, 740 | 741 | // return all suitable-for-sortable elements in a guaranteed order 742 | findElements: function(element, options) { 743 | return Element.findChildren( 744 | element, options.only, options.tree ? true : false, options.tag); 745 | }, 746 | 747 | findTreeElements: function(element, options) { 748 | return Element.findChildren( 749 | element, options.only, options.tree ? true : false, options.treeTag); 750 | }, 751 | 752 | onHover: function(element, dropon, overlap) { 753 | if(Element.isParent(dropon, element)) return; 754 | 755 | if(overlap > .33 && overlap < .66 && Sortable.options(dropon).tree) { 756 | return; 757 | } else if(overlap>0.5) { 758 | Sortable.mark(dropon, 'before'); 759 | if(dropon.previousSibling != element) { 760 | var oldParentNode = element.parentNode; 761 | element.style.visibility = "hidden"; // fix gecko rendering 762 | dropon.parentNode.insertBefore(element, dropon); 763 | if(dropon.parentNode!=oldParentNode) 764 | Sortable.options(oldParentNode).onChange(element); 765 | Sortable.options(dropon.parentNode).onChange(element); 766 | } 767 | } else { 768 | Sortable.mark(dropon, 'after'); 769 | var nextElement = dropon.nextSibling || null; 770 | if(nextElement != element) { 771 | var oldParentNode = element.parentNode; 772 | element.style.visibility = "hidden"; // fix gecko rendering 773 | dropon.parentNode.insertBefore(element, nextElement); 774 | if(dropon.parentNode!=oldParentNode) 775 | Sortable.options(oldParentNode).onChange(element); 776 | Sortable.options(dropon.parentNode).onChange(element); 777 | } 778 | } 779 | }, 780 | 781 | onEmptyHover: function(element, dropon, overlap) { 782 | var oldParentNode = element.parentNode; 783 | var droponOptions = Sortable.options(dropon); 784 | 785 | if(!Element.isParent(dropon, element)) { 786 | var index; 787 | 788 | var children = Sortable.findElements(dropon, {tag: droponOptions.tag, only: droponOptions.only}); 789 | var child = null; 790 | 791 | if(children) { 792 | var offset = Element.offsetSize(dropon, droponOptions.overlap) * (1.0 - overlap); 793 | 794 | for (index = 0; index < children.length; index += 1) { 795 | if (offset - Element.offsetSize (children[index], droponOptions.overlap) >= 0) { 796 | offset -= Element.offsetSize (children[index], droponOptions.overlap); 797 | } else if (offset - (Element.offsetSize (children[index], droponOptions.overlap) / 2) >= 0) { 798 | child = index + 1 < children.length ? children[index + 1] : null; 799 | break; 800 | } else { 801 | child = children[index]; 802 | break; 803 | } 804 | } 805 | } 806 | 807 | dropon.insertBefore(element, child); 808 | 809 | Sortable.options(oldParentNode).onChange(element); 810 | droponOptions.onChange(element); 811 | } 812 | }, 813 | 814 | unmark: function() { 815 | if(Sortable._marker) Sortable._marker.hide(); 816 | }, 817 | 818 | mark: function(dropon, position) { 819 | // mark on ghosting only 820 | var sortable = Sortable.options(dropon.parentNode); 821 | if(sortable && !sortable.ghosting) return; 822 | 823 | if(!Sortable._marker) { 824 | Sortable._marker = 825 | ($('dropmarker') || Element.extend(document.createElement('DIV'))). 826 | hide().addClassName('dropmarker').setStyle({position:'absolute'}); 827 | document.getElementsByTagName("body").item(0).appendChild(Sortable._marker); 828 | } 829 | var offsets = dropon.cumulativeOffset(); 830 | Sortable._marker.setStyle({left: offsets[0]+'px', top: offsets[1] + 'px'}); 831 | 832 | if(position=='after') 833 | if(sortable.overlap == 'horizontal') 834 | Sortable._marker.setStyle({left: (offsets[0]+dropon.clientWidth) + 'px'}); 835 | else 836 | Sortable._marker.setStyle({top: (offsets[1]+dropon.clientHeight) + 'px'}); 837 | 838 | Sortable._marker.show(); 839 | }, 840 | 841 | _tree: function(element, options, parent) { 842 | var children = Sortable.findElements(element, options) || []; 843 | 844 | for (var i = 0; i < children.length; ++i) { 845 | var match = children[i].id.match(options.format); 846 | 847 | if (!match) continue; 848 | 849 | var child = { 850 | id: encodeURIComponent(match ? match[1] : null), 851 | element: element, 852 | parent: parent, 853 | children: [], 854 | position: parent.children.length, 855 | container: $(children[i]).down(options.treeTag) 856 | }; 857 | 858 | /* Get the element containing the children and recurse over it */ 859 | if (child.container) 860 | this._tree(child.container, options, child); 861 | 862 | parent.children.push (child); 863 | } 864 | 865 | return parent; 866 | }, 867 | 868 | tree: function(element) { 869 | element = $(element); 870 | var sortableOptions = this.options(element); 871 | var options = Object.extend({ 872 | tag: sortableOptions.tag, 873 | treeTag: sortableOptions.treeTag, 874 | only: sortableOptions.only, 875 | name: element.id, 876 | format: sortableOptions.format 877 | }, arguments[1] || { }); 878 | 879 | var root = { 880 | id: null, 881 | parent: null, 882 | children: [], 883 | container: element, 884 | position: 0 885 | }; 886 | 887 | return Sortable._tree(element, options, root); 888 | }, 889 | 890 | /* Construct a [i] index for a particular node */ 891 | _constructIndex: function(node) { 892 | var index = ''; 893 | do { 894 | if (node.id) index = '[' + node.position + ']' + index; 895 | } while ((node = node.parent) != null); 896 | return index; 897 | }, 898 | 899 | sequence: function(element) { 900 | element = $(element); 901 | var options = Object.extend(this.options(element), arguments[1] || { }); 902 | 903 | return $(this.findElements(element, options) || []).map( function(item) { 904 | return item.id.match(options.format) ? item.id.match(options.format)[1] : ''; 905 | }); 906 | }, 907 | 908 | setSequence: function(element, new_sequence) { 909 | element = $(element); 910 | var options = Object.extend(this.options(element), arguments[2] || { }); 911 | 912 | var nodeMap = { }; 913 | this.findElements(element, options).each( function(n) { 914 | if (n.id.match(options.format)) 915 | nodeMap[n.id.match(options.format)[1]] = [n, n.parentNode]; 916 | n.parentNode.removeChild(n); 917 | }); 918 | 919 | new_sequence.each(function(ident) { 920 | var n = nodeMap[ident]; 921 | if (n) { 922 | n[1].appendChild(n[0]); 923 | delete nodeMap[ident]; 924 | } 925 | }); 926 | }, 927 | 928 | serialize: function(element) { 929 | element = $(element); 930 | var options = Object.extend(Sortable.options(element), arguments[1] || { }); 931 | var name = encodeURIComponent( 932 | (arguments[1] && arguments[1].name) ? arguments[1].name : element.id); 933 | 934 | if (options.tree) { 935 | return Sortable.tree(element, arguments[1]).children.map( function (item) { 936 | return [name + Sortable._constructIndex(item) + "[id]=" + 937 | encodeURIComponent(item.id)].concat(item.children.map(arguments.callee)); 938 | }).flatten().join('&'); 939 | } else { 940 | return Sortable.sequence(element, arguments[1]).map( function(item) { 941 | return name + "[]=" + encodeURIComponent(item); 942 | }).join('&'); 943 | } 944 | } 945 | }; 946 | 947 | // Returns true if child is contained within element 948 | Element.isParent = function(child, element) { 949 | if (!child.parentNode || child == element) return false; 950 | if (child.parentNode == element) return true; 951 | return Element.isParent(child.parentNode, element); 952 | }; 953 | 954 | Element.findChildren = function(element, only, recursive, tagName) { 955 | if(!element.hasChildNodes()) return null; 956 | tagName = tagName.toUpperCase(); 957 | if(only) only = [only].flatten(); 958 | var elements = []; 959 | $A(element.childNodes).each( function(e) { 960 | if(e.tagName && e.tagName.toUpperCase()==tagName && 961 | (!only || (Element.classNames(e).detect(function(v) { return only.include(v) })))) 962 | elements.push(e); 963 | if(recursive) { 964 | var grandchildren = Element.findChildren(e, only, recursive, tagName); 965 | if(grandchildren) elements.push(grandchildren); 966 | } 967 | }); 968 | 969 | return (elements.length>0 ? elements.flatten() : []); 970 | }; 971 | 972 | Element.offsetSize = function (element, type) { 973 | return element['offset' + ((type=='vertical' || type=='height') ? 'Height' : 'Width')]; 974 | }; -------------------------------------------------------------------------------- /public/javascripts/dragdrop.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2005-2008 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us) 2 | // (c) 2005-2007 Sammi Williams (http://www.oriontransfer.co.nz, sammi@oriontransfer.co.nz) 3 | // 4 | // script.aculo.us is freely distributable under the terms of an MIT-style license. 5 | // For details, see the script.aculo.us web site: http://script.aculo.us/ 6 | 7 | if(Object.isUndefined(Effect)) 8 | throw("dragdrop.js requires including script.aculo.us' effects.js library"); 9 | 10 | var Droppables = { 11 | drops: [], 12 | 13 | remove: function(element) { 14 | this.drops = this.drops.reject(function(d) { return d.element==$(element) }); 15 | }, 16 | 17 | add: function(element) { 18 | element = $(element); 19 | var options = Object.extend({ 20 | greedy: true, 21 | hoverclass: null, 22 | tree: false 23 | }, arguments[1] || { }); 24 | 25 | // cache containers 26 | if(options.containment) { 27 | options._containers = []; 28 | var containment = options.containment; 29 | if(Object.isArray(containment)) { 30 | containment.each( function(c) { options._containers.push($(c)) }); 31 | } else { 32 | options._containers.push($(containment)); 33 | } 34 | } 35 | 36 | if(options.accept) options.accept = [options.accept].flatten(); 37 | 38 | Element.makePositioned(element); // fix IE 39 | options.element = element; 40 | 41 | this.drops.push(options); 42 | }, 43 | 44 | findDeepestChild: function(drops) { 45 | deepest = drops[0]; 46 | 47 | for (i = 1; i < drops.length; ++i) 48 | if (Element.isParent(drops[i].element, deepest.element)) 49 | deepest = drops[i]; 50 | 51 | return deepest; 52 | }, 53 | 54 | isContained: function(element, drop) { 55 | var containmentNode; 56 | if(drop.tree) { 57 | containmentNode = element.treeNode; 58 | } else { 59 | containmentNode = element.parentNode; 60 | } 61 | return drop._containers.detect(function(c) { return containmentNode == c }); 62 | }, 63 | 64 | isAffected: function(point, element, drop) { 65 | return ( 66 | (drop.element!=element) && 67 | ((!drop._containers) || 68 | this.isContained(element, drop)) && 69 | ((!drop.accept) || 70 | (Element.classNames(element).detect( 71 | function(v) { return drop.accept.include(v) } ) )) && 72 | Position.within(drop.element, point[0], point[1]) ); 73 | }, 74 | 75 | deactivate: function(drop) { 76 | if(drop.hoverclass) 77 | Element.removeClassName(drop.element, drop.hoverclass); 78 | this.last_active = null; 79 | }, 80 | 81 | activate: function(drop) { 82 | if(drop.hoverclass) 83 | Element.addClassName(drop.element, drop.hoverclass); 84 | this.last_active = drop; 85 | }, 86 | 87 | show: function(point, element) { 88 | if(!this.drops.length) return; 89 | var drop, affected = []; 90 | 91 | this.drops.each( function(drop) { 92 | if(Droppables.isAffected(point, element, drop)) 93 | affected.push(drop); 94 | }); 95 | 96 | if(affected.length>0) 97 | drop = Droppables.findDeepestChild(affected); 98 | 99 | if(this.last_active && this.last_active != drop) this.deactivate(this.last_active); 100 | if (drop) { 101 | Position.within(drop.element, point[0], point[1]); 102 | if(drop.onHover) 103 | drop.onHover(element, drop.element, Position.overlap(drop.overlap, drop.element)); 104 | 105 | if (drop != this.last_active) Droppables.activate(drop); 106 | } 107 | }, 108 | 109 | fire: function(event, element) { 110 | if(!this.last_active) return; 111 | Position.prepare(); 112 | 113 | if (this.isAffected([Event.pointerX(event), Event.pointerY(event)], element, this.last_active)) 114 | if (this.last_active.onDrop) { 115 | this.last_active.onDrop(element, this.last_active.element, event); 116 | return true; 117 | } 118 | }, 119 | 120 | reset: function() { 121 | if(this.last_active) 122 | this.deactivate(this.last_active); 123 | } 124 | } 125 | 126 | var Draggables = { 127 | drags: [], 128 | observers: [], 129 | 130 | register: function(draggable) { 131 | if(this.drags.length == 0) { 132 | this.eventMouseUp = this.endDrag.bindAsEventListener(this); 133 | this.eventMouseMove = this.updateDrag.bindAsEventListener(this); 134 | this.eventKeypress = this.keyPress.bindAsEventListener(this); 135 | 136 | Event.observe(document, "mouseup", this.eventMouseUp); 137 | Event.observe(document, "mousemove", this.eventMouseMove); 138 | Event.observe(document, "keypress", this.eventKeypress); 139 | } 140 | this.drags.push(draggable); 141 | }, 142 | 143 | unregister: function(draggable) { 144 | this.drags = this.drags.reject(function(d) { return d==draggable }); 145 | if(this.drags.length == 0) { 146 | Event.stopObserving(document, "mouseup", this.eventMouseUp); 147 | Event.stopObserving(document, "mousemove", this.eventMouseMove); 148 | Event.stopObserving(document, "keypress", this.eventKeypress); 149 | } 150 | }, 151 | 152 | activate: function(draggable) { 153 | if(draggable.options.delay) { 154 | this._timeout = setTimeout(function() { 155 | Draggables._timeout = null; 156 | window.focus(); 157 | Draggables.activeDraggable = draggable; 158 | }.bind(this), draggable.options.delay); 159 | } else { 160 | window.focus(); // allows keypress events if window isn't currently focused, fails for Safari 161 | this.activeDraggable = draggable; 162 | } 163 | }, 164 | 165 | deactivate: function() { 166 | this.activeDraggable = null; 167 | }, 168 | 169 | updateDrag: function(event) { 170 | if(!this.activeDraggable) return; 171 | var pointer = [Event.pointerX(event), Event.pointerY(event)]; 172 | // Mozilla-based browsers fire successive mousemove events with 173 | // the same coordinates, prevent needless redrawing (moz bug?) 174 | if(this._lastPointer && (this._lastPointer.inspect() == pointer.inspect())) return; 175 | this._lastPointer = pointer; 176 | 177 | this.activeDraggable.updateDrag(event, pointer); 178 | }, 179 | 180 | endDrag: function(event) { 181 | if(this._timeout) { 182 | clearTimeout(this._timeout); 183 | this._timeout = null; 184 | } 185 | if(!this.activeDraggable) return; 186 | this._lastPointer = null; 187 | this.activeDraggable.endDrag(event); 188 | this.activeDraggable = null; 189 | }, 190 | 191 | keyPress: function(event) { 192 | if(this.activeDraggable) 193 | this.activeDraggable.keyPress(event); 194 | }, 195 | 196 | addObserver: function(observer) { 197 | this.observers.push(observer); 198 | this._cacheObserverCallbacks(); 199 | }, 200 | 201 | removeObserver: function(element) { // element instead of observer fixes mem leaks 202 | this.observers = this.observers.reject( function(o) { return o.element==element }); 203 | this._cacheObserverCallbacks(); 204 | }, 205 | 206 | notify: function(eventName, draggable, event) { // 'onStart', 'onEnd', 'onDrag' 207 | if(this[eventName+'Count'] > 0) 208 | this.observers.each( function(o) { 209 | if(o[eventName]) o[eventName](eventName, draggable, event); 210 | }); 211 | if(draggable.options[eventName]) draggable.options[eventName](draggable, event); 212 | }, 213 | 214 | _cacheObserverCallbacks: function() { 215 | ['onStart','onEnd','onDrag'].each( function(eventName) { 216 | Draggables[eventName+'Count'] = Draggables.observers.select( 217 | function(o) { return o[eventName]; } 218 | ).length; 219 | }); 220 | } 221 | } 222 | 223 | /*--------------------------------------------------------------------------*/ 224 | 225 | var Draggable = Class.create({ 226 | initialize: function(element) { 227 | var defaults = { 228 | handle: false, 229 | reverteffect: function(element, top_offset, left_offset) { 230 | var dur = Math.sqrt(Math.abs(top_offset^2)+Math.abs(left_offset^2))*0.02; 231 | new Effect.Move(element, { x: -left_offset, y: -top_offset, duration: dur, 232 | queue: {scope:'_draggable', position:'end'} 233 | }); 234 | }, 235 | endeffect: function(element) { 236 | var toOpacity = Object.isNumber(element._opacity) ? element._opacity : 1.0; 237 | new Effect.Opacity(element, {duration:0.2, from:0.7, to:toOpacity, 238 | queue: {scope:'_draggable', position:'end'}, 239 | afterFinish: function(){ 240 | Draggable._dragging[element] = false 241 | } 242 | }); 243 | }, 244 | zindex: 1000, 245 | revert: false, 246 | quiet: false, 247 | scroll: false, 248 | scrollSensitivity: 20, 249 | scrollSpeed: 15, 250 | snap: false, // false, or xy or [x,y] or function(x,y){ return [x,y] } 251 | delay: 0 252 | }; 253 | 254 | if(!arguments[1] || Object.isUndefined(arguments[1].endeffect)) 255 | Object.extend(defaults, { 256 | starteffect: function(element) { 257 | element._opacity = Element.getOpacity(element); 258 | Draggable._dragging[element] = true; 259 | new Effect.Opacity(element, {duration:0.2, from:element._opacity, to:0.7}); 260 | } 261 | }); 262 | 263 | var options = Object.extend(defaults, arguments[1] || { }); 264 | 265 | this.element = $(element); 266 | 267 | if(options.handle && Object.isString(options.handle)) 268 | this.handle = this.element.down('.'+options.handle, 0); 269 | 270 | if(!this.handle) this.handle = $(options.handle); 271 | if(!this.handle) this.handle = this.element; 272 | 273 | if(options.scroll && !options.scroll.scrollTo && !options.scroll.outerHTML) { 274 | options.scroll = $(options.scroll); 275 | this._isScrollChild = Element.childOf(this.element, options.scroll); 276 | } 277 | 278 | Element.makePositioned(this.element); // fix IE 279 | 280 | this.options = options; 281 | this.dragging = false; 282 | 283 | this.eventMouseDown = this.initDrag.bindAsEventListener(this); 284 | Event.observe(this.handle, "mousedown", this.eventMouseDown); 285 | 286 | Draggables.register(this); 287 | }, 288 | 289 | destroy: function() { 290 | Event.stopObserving(this.handle, "mousedown", this.eventMouseDown); 291 | Draggables.unregister(this); 292 | }, 293 | 294 | currentDelta: function() { 295 | return([ 296 | parseInt(Element.getStyle(this.element,'left') || '0'), 297 | parseInt(Element.getStyle(this.element,'top') || '0')]); 298 | }, 299 | 300 | initDrag: function(event) { 301 | if(!Object.isUndefined(Draggable._dragging[this.element]) && 302 | Draggable._dragging[this.element]) return; 303 | if(Event.isLeftClick(event)) { 304 | // abort on form elements, fixes a Firefox issue 305 | var src = Event.element(event); 306 | if((tag_name = src.tagName.toUpperCase()) && ( 307 | tag_name=='INPUT' || 308 | tag_name=='SELECT' || 309 | tag_name=='OPTION' || 310 | tag_name=='BUTTON' || 311 | tag_name=='TEXTAREA')) return; 312 | 313 | var pointer = [Event.pointerX(event), Event.pointerY(event)]; 314 | var pos = Position.cumulativeOffset(this.element); 315 | this.offset = [0,1].map( function(i) { return (pointer[i] - pos[i]) }); 316 | 317 | Draggables.activate(this); 318 | Event.stop(event); 319 | } 320 | }, 321 | 322 | startDrag: function(event) { 323 | this.dragging = true; 324 | if(!this.delta) 325 | this.delta = this.currentDelta(); 326 | 327 | if(this.options.zindex) { 328 | this.originalZ = parseInt(Element.getStyle(this.element,'z-index') || 0); 329 | this.element.style.zIndex = this.options.zindex; 330 | } 331 | 332 | if(this.options.ghosting) { 333 | this._clone = this.element.cloneNode(true); 334 | this.element._originallyAbsolute = (this.element.getStyle('position') == 'absolute'); 335 | if (!this.element._originallyAbsolute) 336 | Position.absolutize(this.element); 337 | this.element.parentNode.insertBefore(this._clone, this.element); 338 | } 339 | 340 | if(this.options.scroll) { 341 | if (this.options.scroll == window) { 342 | var where = this._getWindowScroll(this.options.scroll); 343 | this.originalScrollLeft = where.left; 344 | this.originalScrollTop = where.top; 345 | } else { 346 | this.originalScrollLeft = this.options.scroll.scrollLeft; 347 | this.originalScrollTop = this.options.scroll.scrollTop; 348 | } 349 | } 350 | 351 | Draggables.notify('onStart', this, event); 352 | 353 | if(this.options.starteffect) this.options.starteffect(this.element); 354 | }, 355 | 356 | updateDrag: function(event, pointer) { 357 | if(!this.dragging) this.startDrag(event); 358 | 359 | if(!this.options.quiet){ 360 | Position.prepare(); 361 | Droppables.show(pointer, this.element); 362 | } 363 | 364 | Draggables.notify('onDrag', this, event); 365 | 366 | this.draw(pointer); 367 | if(this.options.change) this.options.change(this); 368 | 369 | if(this.options.scroll) { 370 | this.stopScrolling(); 371 | 372 | var p; 373 | if (this.options.scroll == window) { 374 | with(this._getWindowScroll(this.options.scroll)) { p = [ left, top, left+width, top+height ]; } 375 | } else { 376 | p = Position.page(this.options.scroll); 377 | p[0] += this.options.scroll.scrollLeft + Position.deltaX; 378 | p[1] += this.options.scroll.scrollTop + Position.deltaY; 379 | p.push(p[0]+this.options.scroll.offsetWidth); 380 | p.push(p[1]+this.options.scroll.offsetHeight); 381 | } 382 | var speed = [0,0]; 383 | if(pointer[0] < (p[0]+this.options.scrollSensitivity)) speed[0] = pointer[0]-(p[0]+this.options.scrollSensitivity); 384 | if(pointer[1] < (p[1]+this.options.scrollSensitivity)) speed[1] = pointer[1]-(p[1]+this.options.scrollSensitivity); 385 | if(pointer[0] > (p[2]-this.options.scrollSensitivity)) speed[0] = pointer[0]-(p[2]-this.options.scrollSensitivity); 386 | if(pointer[1] > (p[3]-this.options.scrollSensitivity)) speed[1] = pointer[1]-(p[3]-this.options.scrollSensitivity); 387 | this.startScrolling(speed); 388 | } 389 | 390 | // fix AppleWebKit rendering 391 | if(Prototype.Browser.WebKit) window.scrollBy(0,0); 392 | 393 | Event.stop(event); 394 | }, 395 | 396 | finishDrag: function(event, success) { 397 | this.dragging = false; 398 | 399 | if(this.options.quiet){ 400 | Position.prepare(); 401 | var pointer = [Event.pointerX(event), Event.pointerY(event)]; 402 | Droppables.show(pointer, this.element); 403 | } 404 | 405 | if(this.options.ghosting) { 406 | if (!this.element._originallyAbsolute) 407 | Position.relativize(this.element); 408 | delete this.element._originallyAbsolute; 409 | Element.remove(this._clone); 410 | this._clone = null; 411 | } 412 | 413 | var dropped = false; 414 | if(success) { 415 | dropped = Droppables.fire(event, this.element); 416 | if (!dropped) dropped = false; 417 | } 418 | if(dropped && this.options.onDropped) this.options.onDropped(this.element); 419 | Draggables.notify('onEnd', this, event); 420 | 421 | var revert = this.options.revert; 422 | if(revert && Object.isFunction(revert)) revert = revert(this.element); 423 | 424 | var d = this.currentDelta(); 425 | if(revert && this.options.reverteffect) { 426 | if (dropped == 0 || revert != 'failure') 427 | this.options.reverteffect(this.element, 428 | d[1]-this.delta[1], d[0]-this.delta[0]); 429 | } else { 430 | this.delta = d; 431 | } 432 | 433 | if(this.options.zindex) 434 | this.element.style.zIndex = this.originalZ; 435 | 436 | if(this.options.endeffect) 437 | this.options.endeffect(this.element); 438 | 439 | Draggables.deactivate(this); 440 | Droppables.reset(); 441 | }, 442 | 443 | keyPress: function(event) { 444 | if(event.keyCode!=Event.KEY_ESC) return; 445 | this.finishDrag(event, false); 446 | Event.stop(event); 447 | }, 448 | 449 | endDrag: function(event) { 450 | if(!this.dragging) return; 451 | this.stopScrolling(); 452 | this.finishDrag(event, true); 453 | Event.stop(event); 454 | }, 455 | 456 | draw: function(point) { 457 | var pos = Position.cumulativeOffset(this.element); 458 | if(this.options.ghosting) { 459 | var r = Position.realOffset(this.element); 460 | pos[0] += r[0] - Position.deltaX; pos[1] += r[1] - Position.deltaY; 461 | } 462 | 463 | var d = this.currentDelta(); 464 | pos[0] -= d[0]; pos[1] -= d[1]; 465 | 466 | if(this.options.scroll && (this.options.scroll != window && this._isScrollChild)) { 467 | pos[0] -= this.options.scroll.scrollLeft-this.originalScrollLeft; 468 | pos[1] -= this.options.scroll.scrollTop-this.originalScrollTop; 469 | } 470 | 471 | var p = [0,1].map(function(i){ 472 | return (point[i]-pos[i]-this.offset[i]) 473 | }.bind(this)); 474 | 475 | if(this.options.snap) { 476 | if(Object.isFunction(this.options.snap)) { 477 | p = this.options.snap(p[0],p[1],this); 478 | } else { 479 | if(Object.isArray(this.options.snap)) { 480 | p = p.map( function(v, i) { 481 | return (v/this.options.snap[i]).round()*this.options.snap[i] }.bind(this)) 482 | } else { 483 | p = p.map( function(v) { 484 | return (v/this.options.snap).round()*this.options.snap }.bind(this)) 485 | } 486 | }} 487 | 488 | var style = this.element.style; 489 | if((!this.options.constraint) || (this.options.constraint=='horizontal')) 490 | style.left = p[0] + "px"; 491 | if((!this.options.constraint) || (this.options.constraint=='vertical')) 492 | style.top = p[1] + "px"; 493 | 494 | if(style.visibility=="hidden") style.visibility = ""; // fix gecko rendering 495 | }, 496 | 497 | stopScrolling: function() { 498 | if(this.scrollInterval) { 499 | clearInterval(this.scrollInterval); 500 | this.scrollInterval = null; 501 | Draggables._lastScrollPointer = null; 502 | } 503 | }, 504 | 505 | startScrolling: function(speed) { 506 | if(!(speed[0] || speed[1])) return; 507 | this.scrollSpeed = [speed[0]*this.options.scrollSpeed,speed[1]*this.options.scrollSpeed]; 508 | this.lastScrolled = new Date(); 509 | this.scrollInterval = setInterval(this.scroll.bind(this), 10); 510 | }, 511 | 512 | scroll: function() { 513 | var current = new Date(); 514 | var delta = current - this.lastScrolled; 515 | this.lastScrolled = current; 516 | if(this.options.scroll == window) { 517 | with (this._getWindowScroll(this.options.scroll)) { 518 | if (this.scrollSpeed[0] || this.scrollSpeed[1]) { 519 | var d = delta / 1000; 520 | this.options.scroll.scrollTo( left + d*this.scrollSpeed[0], top + d*this.scrollSpeed[1] ); 521 | } 522 | } 523 | } else { 524 | this.options.scroll.scrollLeft += this.scrollSpeed[0] * delta / 1000; 525 | this.options.scroll.scrollTop += this.scrollSpeed[1] * delta / 1000; 526 | } 527 | 528 | Position.prepare(); 529 | Droppables.show(Draggables._lastPointer, this.element); 530 | Draggables.notify('onDrag', this); 531 | if (this._isScrollChild) { 532 | Draggables._lastScrollPointer = Draggables._lastScrollPointer || $A(Draggables._lastPointer); 533 | Draggables._lastScrollPointer[0] += this.scrollSpeed[0] * delta / 1000; 534 | Draggables._lastScrollPointer[1] += this.scrollSpeed[1] * delta / 1000; 535 | if (Draggables._lastScrollPointer[0] < 0) 536 | Draggables._lastScrollPointer[0] = 0; 537 | if (Draggables._lastScrollPointer[1] < 0) 538 | Draggables._lastScrollPointer[1] = 0; 539 | this.draw(Draggables._lastScrollPointer); 540 | } 541 | 542 | if(this.options.change) this.options.change(this); 543 | }, 544 | 545 | _getWindowScroll: function(w) { 546 | var T, L, W, H; 547 | with (w.document) { 548 | if (w.document.documentElement && documentElement.scrollTop) { 549 | T = documentElement.scrollTop; 550 | L = documentElement.scrollLeft; 551 | } else if (w.document.body) { 552 | T = body.scrollTop; 553 | L = body.scrollLeft; 554 | } 555 | if (w.innerWidth) { 556 | W = w.innerWidth; 557 | H = w.innerHeight; 558 | } else if (w.document.documentElement && documentElement.clientWidth) { 559 | W = documentElement.clientWidth; 560 | H = documentElement.clientHeight; 561 | } else { 562 | W = body.offsetWidth; 563 | H = body.offsetHeight 564 | } 565 | } 566 | return { top: T, left: L, width: W, height: H }; 567 | } 568 | }); 569 | 570 | Draggable._dragging = { }; 571 | 572 | /*--------------------------------------------------------------------------*/ 573 | 574 | var SortableObserver = Class.create({ 575 | initialize: function(element, observer) { 576 | this.element = $(element); 577 | this.observer = observer; 578 | this.lastValue = Sortable.serialize(this.element); 579 | }, 580 | 581 | onStart: function() { 582 | this.lastValue = Sortable.serialize(this.element); 583 | }, 584 | 585 | onEnd: function() { 586 | Sortable.unmark(); 587 | if(this.lastValue != Sortable.serialize(this.element)) 588 | this.observer(this.element) 589 | } 590 | }); 591 | 592 | var Sortable = { 593 | SERIALIZE_RULE: /^[^_\-](?:[A-Za-z0-9\-\_]*)[_](.*)$/, 594 | 595 | sortables: { }, 596 | 597 | _findRootElement: function(element) { 598 | while (element.tagName.toUpperCase() != "BODY") { 599 | if(element.id && Sortable.sortables[element.id]) return element; 600 | element = element.parentNode; 601 | } 602 | }, 603 | 604 | options: function(element) { 605 | element = Sortable._findRootElement($(element)); 606 | if(!element) return; 607 | return Sortable.sortables[element.id]; 608 | }, 609 | 610 | destroy: function(element){ 611 | var s = Sortable.options(element); 612 | 613 | if(s) { 614 | Draggables.removeObserver(s.element); 615 | s.droppables.each(function(d){ Droppables.remove(d) }); 616 | s.draggables.invoke('destroy'); 617 | 618 | delete Sortable.sortables[s.element.id]; 619 | } 620 | }, 621 | 622 | create: function(element) { 623 | element = $(element); 624 | var options = Object.extend({ 625 | element: element, 626 | tag: 'li', // assumes li children, override with tag: 'tagname' 627 | dropOnEmpty: false, 628 | tree: false, 629 | treeTag: 'ul', 630 | overlap: 'vertical', // one of 'vertical', 'horizontal' 631 | constraint: 'vertical', // one of 'vertical', 'horizontal', false 632 | containment: element, // also takes array of elements (or id's); or false 633 | handle: false, // or a CSS class 634 | only: false, 635 | delay: 0, 636 | hoverclass: null, 637 | ghosting: false, 638 | quiet: false, 639 | scroll: false, 640 | scrollSensitivity: 20, 641 | scrollSpeed: 15, 642 | format: this.SERIALIZE_RULE, 643 | 644 | // these take arrays of elements or ids and can be 645 | // used for better initialization performance 646 | elements: false, 647 | handles: false, 648 | 649 | onChange: Prototype.emptyFunction, 650 | onUpdate: Prototype.emptyFunction 651 | }, arguments[1] || { }); 652 | 653 | // clear any old sortable with same element 654 | this.destroy(element); 655 | 656 | // build options for the draggables 657 | var options_for_draggable = { 658 | revert: true, 659 | quiet: options.quiet, 660 | scroll: options.scroll, 661 | scrollSpeed: options.scrollSpeed, 662 | scrollSensitivity: options.scrollSensitivity, 663 | delay: options.delay, 664 | ghosting: options.ghosting, 665 | constraint: options.constraint, 666 | handle: options.handle }; 667 | 668 | if(options.starteffect) 669 | options_for_draggable.starteffect = options.starteffect; 670 | 671 | if(options.reverteffect) 672 | options_for_draggable.reverteffect = options.reverteffect; 673 | else 674 | if(options.ghosting) options_for_draggable.reverteffect = function(element) { 675 | element.style.top = 0; 676 | element.style.left = 0; 677 | }; 678 | 679 | if(options.endeffect) 680 | options_for_draggable.endeffect = options.endeffect; 681 | 682 | if(options.zindex) 683 | options_for_draggable.zindex = options.zindex; 684 | 685 | // build options for the droppables 686 | var options_for_droppable = { 687 | overlap: options.overlap, 688 | containment: options.containment, 689 | tree: options.tree, 690 | hoverclass: options.hoverclass, 691 | onHover: Sortable.onHover 692 | } 693 | 694 | var options_for_tree = { 695 | onHover: Sortable.onEmptyHover, 696 | overlap: options.overlap, 697 | containment: options.containment, 698 | hoverclass: options.hoverclass 699 | } 700 | 701 | // fix for gecko engine 702 | Element.cleanWhitespace(element); 703 | 704 | options.draggables = []; 705 | options.droppables = []; 706 | 707 | // drop on empty handling 708 | if(options.dropOnEmpty || options.tree) { 709 | Droppables.add(element, options_for_tree); 710 | options.droppables.push(element); 711 | } 712 | 713 | (options.elements || this.findElements(element, options) || []).each( function(e,i) { 714 | var handle = options.handles ? $(options.handles[i]) : 715 | (options.handle ? $(e).select('.' + options.handle)[0] : e); 716 | options.draggables.push( 717 | new Draggable(e, Object.extend(options_for_draggable, { handle: handle }))); 718 | Droppables.add(e, options_for_droppable); 719 | if(options.tree) e.treeNode = element; 720 | options.droppables.push(e); 721 | }); 722 | 723 | if(options.tree) { 724 | (Sortable.findTreeElements(element, options) || []).each( function(e) { 725 | Droppables.add(e, options_for_tree); 726 | e.treeNode = element; 727 | options.droppables.push(e); 728 | }); 729 | } 730 | 731 | // keep reference 732 | this.sortables[element.id] = options; 733 | 734 | // for onupdate 735 | Draggables.addObserver(new SortableObserver(element, options.onUpdate)); 736 | 737 | }, 738 | 739 | // return all suitable-for-sortable elements in a guaranteed order 740 | findElements: function(element, options) { 741 | return Element.findChildren( 742 | element, options.only, options.tree ? true : false, options.tag); 743 | }, 744 | 745 | findTreeElements: function(element, options) { 746 | return Element.findChildren( 747 | element, options.only, options.tree ? true : false, options.treeTag); 748 | }, 749 | 750 | onHover: function(element, dropon, overlap) { 751 | if(Element.isParent(dropon, element)) return; 752 | 753 | if(overlap > .33 && overlap < .66 && Sortable.options(dropon).tree) { 754 | return; 755 | } else if(overlap>0.5) { 756 | Sortable.mark(dropon, 'before'); 757 | if(dropon.previousSibling != element) { 758 | var oldParentNode = element.parentNode; 759 | element.style.visibility = "hidden"; // fix gecko rendering 760 | dropon.parentNode.insertBefore(element, dropon); 761 | if(dropon.parentNode!=oldParentNode) 762 | Sortable.options(oldParentNode).onChange(element); 763 | Sortable.options(dropon.parentNode).onChange(element); 764 | } 765 | } else { 766 | Sortable.mark(dropon, 'after'); 767 | var nextElement = dropon.nextSibling || null; 768 | if(nextElement != element) { 769 | var oldParentNode = element.parentNode; 770 | element.style.visibility = "hidden"; // fix gecko rendering 771 | dropon.parentNode.insertBefore(element, nextElement); 772 | if(dropon.parentNode!=oldParentNode) 773 | Sortable.options(oldParentNode).onChange(element); 774 | Sortable.options(dropon.parentNode).onChange(element); 775 | } 776 | } 777 | }, 778 | 779 | onEmptyHover: function(element, dropon, overlap) { 780 | var oldParentNode = element.parentNode; 781 | var droponOptions = Sortable.options(dropon); 782 | 783 | if(!Element.isParent(dropon, element)) { 784 | var index; 785 | 786 | var children = Sortable.findElements(dropon, {tag: droponOptions.tag, only: droponOptions.only}); 787 | var child = null; 788 | 789 | if(children) { 790 | var offset = Element.offsetSize(dropon, droponOptions.overlap) * (1.0 - overlap); 791 | 792 | for (index = 0; index < children.length; index += 1) { 793 | if (offset - Element.offsetSize (children[index], droponOptions.overlap) >= 0) { 794 | offset -= Element.offsetSize (children[index], droponOptions.overlap); 795 | } else if (offset - (Element.offsetSize (children[index], droponOptions.overlap) / 2) >= 0) { 796 | child = index + 1 < children.length ? children[index + 1] : null; 797 | break; 798 | } else { 799 | child = children[index]; 800 | break; 801 | } 802 | } 803 | } 804 | 805 | dropon.insertBefore(element, child); 806 | 807 | Sortable.options(oldParentNode).onChange(element); 808 | droponOptions.onChange(element); 809 | } 810 | }, 811 | 812 | unmark: function() { 813 | if(Sortable._marker) Sortable._marker.hide(); 814 | }, 815 | 816 | mark: function(dropon, position) { 817 | // mark on ghosting only 818 | var sortable = Sortable.options(dropon.parentNode); 819 | if(sortable && !sortable.ghosting) return; 820 | 821 | if(!Sortable._marker) { 822 | Sortable._marker = 823 | ($('dropmarker') || Element.extend(document.createElement('DIV'))). 824 | hide().addClassName('dropmarker').setStyle({position:'absolute'}); 825 | document.getElementsByTagName("body").item(0).appendChild(Sortable._marker); 826 | } 827 | var offsets = Position.cumulativeOffset(dropon); 828 | Sortable._marker.setStyle({left: offsets[0]+'px', top: offsets[1] + 'px'}); 829 | 830 | if(position=='after') 831 | if(sortable.overlap == 'horizontal') 832 | Sortable._marker.setStyle({left: (offsets[0]+dropon.clientWidth) + 'px'}); 833 | else 834 | Sortable._marker.setStyle({top: (offsets[1]+dropon.clientHeight) + 'px'}); 835 | 836 | Sortable._marker.show(); 837 | }, 838 | 839 | _tree: function(element, options, parent) { 840 | var children = Sortable.findElements(element, options) || []; 841 | 842 | for (var i = 0; i < children.length; ++i) { 843 | var match = children[i].id.match(options.format); 844 | 845 | if (!match) continue; 846 | 847 | var child = { 848 | id: encodeURIComponent(match ? match[1] : null), 849 | element: element, 850 | parent: parent, 851 | children: [], 852 | position: parent.children.length, 853 | container: $(children[i]).down(options.treeTag) 854 | } 855 | 856 | /* Get the element containing the children and recurse over it */ 857 | if (child.container) 858 | this._tree(child.container, options, child) 859 | 860 | parent.children.push (child); 861 | } 862 | 863 | return parent; 864 | }, 865 | 866 | tree: function(element) { 867 | element = $(element); 868 | var sortableOptions = this.options(element); 869 | var options = Object.extend({ 870 | tag: sortableOptions.tag, 871 | treeTag: sortableOptions.treeTag, 872 | only: sortableOptions.only, 873 | name: element.id, 874 | format: sortableOptions.format 875 | }, arguments[1] || { }); 876 | 877 | var root = { 878 | id: null, 879 | parent: null, 880 | children: [], 881 | container: element, 882 | position: 0 883 | } 884 | 885 | return Sortable._tree(element, options, root); 886 | }, 887 | 888 | /* Construct a [i] index for a particular node */ 889 | _constructIndex: function(node) { 890 | var index = ''; 891 | do { 892 | if (node.id) index = '[' + node.position + ']' + index; 893 | } while ((node = node.parent) != null); 894 | return index; 895 | }, 896 | 897 | sequence: function(element) { 898 | element = $(element); 899 | var options = Object.extend(this.options(element), arguments[1] || { }); 900 | 901 | return $(this.findElements(element, options) || []).map( function(item) { 902 | return item.id.match(options.format) ? item.id.match(options.format)[1] : ''; 903 | }); 904 | }, 905 | 906 | setSequence: function(element, new_sequence) { 907 | element = $(element); 908 | var options = Object.extend(this.options(element), arguments[2] || { }); 909 | 910 | var nodeMap = { }; 911 | this.findElements(element, options).each( function(n) { 912 | if (n.id.match(options.format)) 913 | nodeMap[n.id.match(options.format)[1]] = [n, n.parentNode]; 914 | n.parentNode.removeChild(n); 915 | }); 916 | 917 | new_sequence.each(function(ident) { 918 | var n = nodeMap[ident]; 919 | if (n) { 920 | n[1].appendChild(n[0]); 921 | delete nodeMap[ident]; 922 | } 923 | }); 924 | }, 925 | 926 | serialize: function(element) { 927 | element = $(element); 928 | var options = Object.extend(Sortable.options(element), arguments[1] || { }); 929 | var name = encodeURIComponent( 930 | (arguments[1] && arguments[1].name) ? arguments[1].name : element.id); 931 | 932 | if (options.tree) { 933 | return Sortable.tree(element, arguments[1]).children.map( function (item) { 934 | return [name + Sortable._constructIndex(item) + "[id]=" + 935 | encodeURIComponent(item.id)].concat(item.children.map(arguments.callee)); 936 | }).flatten().join('&'); 937 | } else { 938 | return Sortable.sequence(element, arguments[1]).map( function(item) { 939 | return name + "[]=" + encodeURIComponent(item); 940 | }).join('&'); 941 | } 942 | } 943 | } 944 | 945 | // Returns true if child is contained within element 946 | Element.isParent = function(child, element) { 947 | if (!child.parentNode || child == element) return false; 948 | if (child.parentNode == element) return true; 949 | return Element.isParent(child.parentNode, element); 950 | } 951 | 952 | Element.findChildren = function(element, only, recursive, tagName) { 953 | if(!element.hasChildNodes()) return null; 954 | tagName = tagName.toUpperCase(); 955 | if(only) only = [only].flatten(); 956 | var elements = []; 957 | $A(element.childNodes).each( function(e) { 958 | if(e.tagName && e.tagName.toUpperCase()==tagName && 959 | (!only || (Element.classNames(e).detect(function(v) { return only.include(v) })))) 960 | elements.push(e); 961 | if(recursive) { 962 | var grandchildren = Element.findChildren(e, only, recursive, tagName); 963 | if(grandchildren) elements.push(grandchildren); 964 | } 965 | }); 966 | 967 | return (elements.length>0 ? elements.flatten() : []); 968 | } 969 | 970 | Element.offsetSize = function (element, type) { 971 | return element['offset' + ((type=='vertical' || type=='height') ? 'Height' : 'Width')]; 972 | } 973 | --------------------------------------------------------------------------------