couch_i18n 0.0.3 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc CHANGED
@@ -9,10 +9,7 @@ database type:
9
9
  Now all translations are ported to the database. If you change then now in the
10
10
  yaml files, they will nolonger be displayed in the website. They should be managed
11
11
  in the database. This gem also provides a translation management system. To place this
12
- in your own design, create a file _app/views/layouts/couch_i18n/application.html.haml_ with the
13
- following content:
14
- = render :template => '/layouts/application'
15
- Or create your own really nice template. Let me know if there is a nice layout!
12
+ in your own design, read the layout section.
16
13
  Your Gemfile should look like:
17
14
  gem 'simply_stored', :git => 'git://github.com/bterkuile/simply_stored.git'
18
15
  gem 'couch_i18n'
@@ -20,12 +17,24 @@ Your Gemfile should look like:
20
17
  And in your config routes put:
21
18
  namespace :couch_i18n do
22
19
  root :to => "stores#index"
23
- resources :stores
20
+ resources :stores do
21
+ collection do
22
+ post :export
23
+ post :import
24
+ delete :destroy_offset
25
+ end
26
+ end
24
27
  end
25
28
  Or to put is at a different location:
26
29
  namespace :translations, :module => :couch_i18n, :as => 'couch_i18n' do
27
30
  root :to => "stores#index"
28
- resources :stores
31
+ resources :stores do
32
+ collection do
33
+ post :export
34
+ post :import
35
+ delete :destroy_offset
36
+ end
37
+ end
29
38
  end
30
39
  The intention is to make this a mountable application, but this will not happen
31
40
  before Rails 3.1 is officially out. But beware of using this if your application
@@ -52,16 +61,118 @@ initializer: _config/initializers/couch_I18n_modifications.rb_
52
61
  end
53
62
  And in _config/authorization_rules.rb_ put your personalized version of:
54
63
  role :translator do
55
- has_permission_on :couch_i18n_stores, :to => :manage
64
+ has_permission_on :couch_i18n_stores, :to => [:manage, :import, :export, :destroy_offset]
56
65
  end
57
66
  Beware that a permission denied message will appear when the server is restarted
58
67
  this way. This is because the current user is set in ApplicationController which
59
68
  is not part of the CouchI18n controller stack.
60
69
 
70
+ == couch_i18n translations
71
+ Ofcourse couch_i18n is working with translations as well. To get them working import
72
+ the following yamlL
73
+ en:
74
+ New: New
75
+ Edit: Edit
76
+ Show: Show
77
+ Back: Back
78
+ Save: Save
79
+ Create: Create
80
+ Are you sure: 'Are you sure?'
81
+ activemodel:
82
+ models:
83
+ couch_i18n:
84
+ store: Translation
85
+ attributes:
86
+ couch_i18n:
87
+ store:
88
+ key: Key
89
+ value: Value
90
+ action:
91
+ create:
92
+ successful: %{model} successfully created
93
+ update:
94
+ successful: %{model} successfully updated
95
+ destroy:
96
+ successful: %{model} successfully removed
97
+ chouch_i18n:
98
+ store:
99
+ index title: Translations
100
+ none found: No translations present
101
+ new title: New translation
102
+ show title: Translation
103
+ edit title: Edit translation
104
+ go to offset: Go to
105
+ got to zero offset: x
106
+ export: Export
107
+ import: Import
108
+ offset deleted: "%{count} translations with offset %{offset} are deleted"
109
+ no proper import extension: Files with extension%{extension} cannot be imported
110
+ no import file given: There is no file to be imported
111
+ cannot parse yaml: The file cannot be read
112
+ file imported: File %{filename} was successfully imported
113
+ # The following tranlation will only work if no are_you_sure helper is present
114
+ are you sure: 'Are you sure?'
115
+ destroy offset link: Delete all translations with current offset
116
+ # The following tranlation will only work if no site_title helper is present
117
+ site_title: Translations
118
+ == couch_i18n helpers
119
+ The following helpers are assumed:
120
+ module CouchI18nViewHelpers
121
+ def title(str)
122
+ content_for :title do
123
+ content_tag(:h1, str)
124
+ end
125
+ end
126
+ def link_to_new_content(obj)
127
+ t('New')
128
+ end
129
+ def link_to_edit_content(obj = nil)
130
+ t('Edit')
131
+ end
132
+ def link_to_show_content(obj = nil)
133
+ t('Show')
134
+ end
135
+ def link_to_index_content(singular_name)
136
+ t('Back')
137
+ end
138
+ def link_to_destroy_content(obj = nil)
139
+ t('Delete')
140
+ end
141
+ def update_button_text(obj = nil)
142
+ t('Save')
143
+ end
144
+ def create_button_text(obj = nil)
145
+ t('Create')
146
+ end
147
+ def boolean_text(truefalse)
148
+ truefalse ? t('boolean true') : t('boolean false')
149
+ end
150
+ def are_you_sure(obj = nil)
151
+ t('Are you sure')
152
+ end
153
+ end
154
+
155
+ == couch_i18n layout
156
+ This gem comes with its own layout file, but you can ofcourse use your own. To do this place a layout at your application with the
157
+ path: <tt>app/views/layouts/couch_i18n/application.html.haml</tt>
158
+ It should <tt>yield</tt> the following parts:
159
+ !!!
160
+ %html
161
+ %head
162
+ %title= defined?(site_title) ? site_title : I18n.t('couch_i18n.store.site_title')
163
+ = csrf_meta_tag
164
+ = yield :head
165
+ %body
166
+ #page-wrapper
167
+ #page-content
168
+ %h1= yield :title
169
+ = yield
170
+ #page-links= yield :page_links
61
171
  == TODO
62
172
  Here my todo list for this project. Makes it insightful for everybody what is on
63
173
  the planning of being made.
64
174
  * Check on locale inclusion with are you sure? force creation of new locale. Mostly a stupic mistake omitting these.
65
- * Add grouped deletes. Dangerous, but controllable using the declarative_authorization example
66
175
  * Add grouped search/replaces to move one group of translations to another section. Same comment as above
67
176
  * Search through values. If anyone has a better idea than searching by ruby through all the translations or adding lucene please feel free.
177
+ * A lot of testing
178
+ * Add error_messages partial to manual
@@ -11,7 +11,7 @@ module CouchI18n
11
11
  :offset => @levels[0..i].join('.')
12
12
  }
13
13
  end
14
- @couch_i18n_stores = CouchI18n::Store.find_all_by_key("#{params[:offset]}.".."#{params[:offset]}.\u9999", :page => params[:page], :per_page => 30)
14
+ @couch_i18n_stores = CouchI18n::Store.with_offset(params[:offset], :page => params[:page], :per_page => 30)
15
15
  @available_deeper_offsets = CouchI18n::Store.get_keys_by_level(@levels.size, :startkey => @levels, :endkey => @levels + [{}]).
16
16
  map{|dl| {:name => dl, :offset => [params[:offset], dl].join('.')}}
17
17
  else
@@ -37,9 +37,13 @@ module CouchI18n
37
37
  render :action => :new
38
38
  end
39
39
  end
40
+
41
+ # GET /couch_i18n/stores/:id/edit
40
42
  def edit
41
43
  @couch_i18n_store = CouchI18n::Store.find(params[:id])
42
44
  end
45
+
46
+ # PUT /couch_i18n/stores/:id
43
47
  def update
44
48
  @couch_i18n_store = CouchI18n::Store.find(params[:id])
45
49
  if @couch_i18n_store.update_attributes(params[:couch_i18n_store])
@@ -56,5 +60,68 @@ module CouchI18n
56
60
  end
57
61
  redirect_to({:action => :index, :offset => @couch_i18n_store.key.to_s.sub(/\.\w+$/, '')})
58
62
  end
63
+
64
+ # POST /couch_i18n/stores/export
65
+ # Export to yml, csv or json
66
+ def export
67
+ if params[:offset].present?
68
+ @couch_i18n_stores = CouchI18n::Store.with_offset(params[:offset])
69
+ else
70
+ @couch_i18n_stores = CouchI18n::Store.all
71
+ end
72
+ base_filename = "export#{Time.now.strftime('%Y%m%d%H%M')}"
73
+ if params[:exportformat] == 'csv'
74
+ response.headers['Content-Type'] = 'text/csv'
75
+ response.headers['Content-Disposition'] = %{attachment; filename="#{base_filename}.csv"}
76
+ render :text => @couch_i18n_stores.map{|s| [s.key, s.value.to_json].join(',')}.join("\n")
77
+ elsif params[:exportformat] == 'json'
78
+ response.headers['Content-Type'] = 'application/json'
79
+ response.headers['Content-Disposition'] = %{attachment; filename="#{base_filename}.json"}
80
+ # render :text => CouchI18n.indent_keys(@couch_i18n_stores).to_json # for indented json
81
+ render :json => @couch_i18n_stores.map{|s| {s.key => s.value}}.to_json
82
+ else #yaml
83
+ response.headers['Content-Type'] = 'application/x-yaml'
84
+ response.headers['Content-Disposition'] = %{attachment; filename="#{base_filename}.yml"}
85
+ render :text => CouchI18n.indent_keys(@couch_i18n_stores).to_yaml
86
+ end
87
+ end
88
+
89
+ # POST /couch_i18n/stores/import
90
+ # Import yml files
91
+ def import
92
+ redirect_to({:action => :index, :offset => params[:offset]}, :alert => I18n.t('couch_i18n.store.no import file given')) and return unless params[:importfile].present?
93
+ filename = params[:importfile].original_filename
94
+ extension = filename.sub(/.*\./, '')
95
+ if extension == 'yml'
96
+ hash = YAML.load_file(params[:importfile].tempfile.path) rescue nil
97
+ redirect_to({:action => :index, :offset => params[:offset]}, :alert => I18n.t('couch_i18n.store.cannot parse yaml')) and return unless hash
98
+ CouchI18n.traverse_flatten_keys(hash).each do |key, value|
99
+ existing = CouchI18n::Store.find_by_key(key)
100
+ if existing
101
+ if existing.value != value
102
+ existing.value = value
103
+ existing.save
104
+ end
105
+ else
106
+ CouchI18n::Store.create :key => key, :value => value
107
+ end
108
+ end
109
+ else
110
+ redirect_to({:action => :index, :offset => params[:offset]}, :alert => I18n.t('couch_i18n.store.no proper import extension', :extension => extension)) and return
111
+ end
112
+ redirect_to({:action => :index, :offset => params[:offset]}, :notice => I18n.t('couch_i18n.store.file imported', :filename => filename))
113
+ end
114
+
115
+ # Very dangarous action, please handle this with care, large removals are supported!
116
+ # DELETE /couch_i18n/stores/destroy_offset?...
117
+ def destroy_offset
118
+ if params[:offset].present?
119
+ @couch_i18n_stores = CouchI18n::Store.with_offset(params[:offset])
120
+ else
121
+ @couch_i18n_stores = CouchI18n::Store.all
122
+ end
123
+ @couch_i18n_stores.map(&:destroy)
124
+ redirect_to({:action => :index}, :notice => I18n.t('couch_i18n.store.offset deleted', :count => @couch_i18n_stores, :offset => params[:offset]))
125
+ end
59
126
  end
60
127
  end
@@ -5,6 +5,6 @@
5
5
  = f.text_field :key, :size => 70
6
6
  .field
7
7
  = f.label :value
8
- = f.text_field :value
8
+ = f.text_field :value, :size => 70
9
9
  .actions
10
10
  = f.submit @submit || update_button_text
@@ -1,4 +1,4 @@
1
- - title t("couch_i18n.store.Edit title")
1
+ - title t("couch_i18n.store.edit title")
2
2
  = render 'form'
3
3
  - content_for :page_links do
4
4
  = link_to link_to_index_content(:couch_i18n_stores), couch_i18n_stores_path(:offset => params[:offset] || @couch_i18n_store.key.to_s.sub(/\.[\w\s-]+$/, ''))
@@ -1,14 +1,29 @@
1
- - title t("couch_i18n.store.Index title")
2
- - for offset in @available_higher_offsets
3
- = link_to offset[:name], :offset => offset[:offset]
4
- = form_tag({}, :method => :get )do
5
- - if params[:offset].present?
6
- = link_to 'x', :offset => nil
7
- = text_field_tag :offset, params[:offset], :size => 60
8
- = "(#{@couch_i18n_stores.total_entries})"
9
- = submit_tag 'Select offset'
10
- - for offset in @available_deeper_offsets
11
- = link_to offset[:name], :offset => offset[:offset]
1
+ - title t("couch_i18n.store.index title")
2
+ .destroy-link
3
+ = link_to t('couch_i18n.store.destroy offset link'), destroy_offset_couch_i18n_stores_path(:offset => params[:offset]), :method => :delete, :confirm => (defined?(:are_you_sure) ? are_you_sure : t('couch_i18n.store.are you sure'))
4
+ .import-form
5
+ = form_tag({:action => :import}, :multipart => true) do
6
+ = file_field_tag :importfile
7
+ = submit_tag I18n.t('couch_i18n.store.import')
8
+ .export-form
9
+ = form_tag :action => :export do
10
+ = hidden_field_tag :offset, params[:offset]
11
+ = select_tag :exportformat, options_for_select(%w[yml csv json], params[:exportformat])
12
+ = submit_tag I18n.t('couch_i18n.store.export')
13
+ .offset-navigation-block.block
14
+ .offset-navigation-higher
15
+ - for offset in @available_higher_offsets
16
+ = link_to offset[:name], :offset => offset[:offset]
17
+ .offset-navigation-form
18
+ = form_tag({}, :method => :get )do
19
+ - if params[:offset].present?
20
+ = link_to I18n.t('couch_i18n.store.go to zero offset'), :offset => nil
21
+ = text_field_tag :offset, params[:offset], :size => 60
22
+ = "(#{@couch_i18n_stores.total_entries})"
23
+ = submit_tag I18n.t('couch_i18n.store.go to offset')
24
+ .offset-navigation-deeper
25
+ - for offset in @available_deeper_offsets
26
+ = link_to offset[:name], :offset => offset[:offset]
12
27
  - if @couch_i18n_stores.any?
13
28
  = paginate @couch_i18n_stores, :right => 3, :left => 3
14
29
  %table.index-table
@@ -25,7 +40,7 @@
25
40
  %td= link_to link_to_edit_content(couch_i18n_store), edit_couch_i18n_store_path(couch_i18n_store)
26
41
  %td= link_to link_to_destroy_content(couch_i18n_store),couch_i18n_store_path(couch_i18n_store), :confirm => are_you_sure, :method => :delete
27
42
  - else
28
- %h3= t("couch_i18n_store.None found")
43
+ %h3= t("couch_i18n_store.none found")
29
44
 
30
45
  - content_for :page_links do
31
46
  = link_to link_to_new_content(:couch_i18n_store), new_couch_i18n_store_path(:offset => params[:offset])
@@ -1,4 +1,4 @@
1
- - title t("couch_i18n.store.New title")
1
+ - title t("couch_i18n.store.new title")
2
2
  = render 'form'
3
3
  - content_for :page_links do
4
4
  = link_to link_to_index_content(:couch_i18n_stores), couch_i18n_stores_path(:offset => params[:offset])
@@ -0,0 +1,99 @@
1
+ require 'i18n/backend/base'
2
+ require 'active_support/json'
3
+ require 'active_support/ordered_hash' # active_support/json/encoding uses ActiveSupport::OrderedHash but does not require it
4
+
5
+ module CouchI18n
6
+ class Backend
7
+ # This is a basic backend for key value stores. It receives on
8
+ # initialization the store, which should respond to three methods:
9
+ #
10
+ # * store#[](key) - Used to get a value
11
+ # * store#[]=(key, value) - Used to set a value
12
+ # * store#keys - Used to get all keys
13
+ #
14
+ # Since these stores only supports string, all values are converted
15
+ # to JSON before being stored, allowing it to also store booleans,
16
+ # hashes and arrays. However, this store does not support Procs.
17
+ #
18
+ # As the ActiveRecord backend, Symbols are just supported when loading
19
+ # translations from the filesystem or through explicit store translations.
20
+ #
21
+ # Also, avoid calling I18n.available_locales since it's a somehow
22
+ # expensive operation in most stores.
23
+ #
24
+ # == Example
25
+ #
26
+ # To setup I18n to use TokyoCabinet in memory is quite straightforward:
27
+ #
28
+ # require 'rufus/tokyo/cabinet' # gem install rufus-tokyo
29
+ # I18n.backend = I18n::Backend::KeyValue.new(Rufus::Tokyo::Cabinet.new('*'))
30
+ #
31
+ # == Performance
32
+ #
33
+ # You may make this backend even faster by including the Memoize module.
34
+ # However, notice that you should properly clear the cache if you change
35
+ # values directly in the key-store.
36
+ #
37
+ # == Subtrees
38
+ #
39
+ # In most backends, you are allowed to retrieve part of a translation tree:
40
+ #
41
+ # I18n.backend.store_translations :en, :foo => { :bar => :baz }
42
+ # I18n.t "foo" #=> { :bar => :baz }
43
+ #
44
+ # This backend supports this feature by default, but it slows down the storage
45
+ # of new data considerably and makes hard to delete entries. That said, you are
46
+ # allowed to disable the storage of subtrees on initialization:
47
+ #
48
+ # I18n::Backend::KeyValue.new(@store, false)
49
+ #
50
+ # This is useful if you are using a KeyValue backend chained to a Simple backend.
51
+ module Implementation
52
+ attr_accessor :store
53
+
54
+ include I18n::Backend::Base, I18n::Backend::Flatten
55
+
56
+ def initialize(store, subtrees=true)
57
+ @store, @subtrees = store, subtrees
58
+ end
59
+
60
+ def store_translations(locale, data, options = {})
61
+ escape = options.fetch(:escape, true)
62
+ flatten_translations(locale, data, escape, @subtrees).each do |key, value|
63
+ key = "#{locale}.#{key}"
64
+
65
+ case value
66
+ when Hash
67
+ if @subtrees && (old_value = @store[key])
68
+ old_value = ActiveSupport::JSON.decode(old_value)
69
+ value = old_value.deep_symbolize_keys.deep_merge!(value) if old_value.is_a?(Hash)
70
+ end
71
+ when Proc
72
+ raise "Key-value stores cannot handle procs"
73
+ end
74
+
75
+ @store[key] = ActiveSupport::JSON.encode(value) unless value.is_a?(Symbol)
76
+ end
77
+ end
78
+
79
+ def available_locales
80
+ locales = @store.keys.map { |k| k =~ /\./; $` }
81
+ locales.uniq!
82
+ locales.compact!
83
+ locales.map! { |k| k.to_sym }
84
+ locales
85
+ end
86
+
87
+ protected
88
+
89
+ def lookup(locale, key, scope = [], options = {})
90
+ key = normalize_flat_keys(locale, key, scope, options[:separator])
91
+ value = @store["#{locale}.#{key}"]
92
+ #value = ActiveSupport::JSON.decode(value) if value
93
+ value.is_a?(Hash) ? value.deep_symbolize_keys : value
94
+ end
95
+ end
96
+
97
+ include Implementation
98
+ end
99
+ end
@@ -45,6 +45,11 @@ module CouchI18n
45
45
  translation.save
46
46
  end
47
47
 
48
+ # Shorthand for selecting all stored with a given offset
49
+ def self.with_offset(offset, options = {})
50
+ find_all_by_key("#{offset}.".."#{offset}.ZZZZZZZZZ", options)
51
+ end
52
+
48
53
  # alias for read
49
54
  def self.[](key, options=nil)
50
55
  find_by_key(key.to_s).try(:value) rescue nil
data/lib/couch_i18n.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require "i18n/backend/cache"
2
2
  require "couch_i18n/engine"
3
3
  require 'couch_i18n/store'
4
+ require 'couch_i18n/backend'
4
5
  I18n::Backend::KeyValue.send(:include, I18n::Backend::Cache)
5
6
  module CouchI18n
6
7
  # This method imports yaml translations to the couchdb version. When run again new ones will
@@ -12,27 +13,49 @@ module CouchI18n
12
13
 
13
14
  raise "Last backend not a I18n::Backend::Simple" unless yml_backend.is_a?(I18n::Backend::Simple)
14
15
  yml_backend.load_translations
15
- flattened_hash = traverse_flatten_keys({}, yml_backend.send(:translations))
16
+ flattened_hash = traverse_flatten_keys(yml_backend.send(:translations))
16
17
  flattened_hash.each do |key, value|
17
18
  CouchI18n::Store.create :key => key, :value => value
18
19
  end
19
20
  end
20
21
 
21
22
  # Recursive flattening.
22
- def self.traverse_flatten_keys(store, obj, path = nil)
23
+ def self.traverse_flatten_keys(obj, store = {}, path = nil)
23
24
  case obj
24
25
  when Hash
25
- obj.each{|k, v| traverse_flatten_keys(store, v, [path, k].compact.join('.'))}
26
+ obj.each{|k, v| traverse_flatten_keys(v, store, [path, k].compact.join('.'))}
26
27
  when Array
27
- store[path] = obj.to_json
28
- #obj.each_with_index{|v, i| traverse_flatten_keys(store, v, [path, i].compact.join('.'))}
28
+ # Do not store array for now. There is no good solution yet
29
+ # store[path] = obj # Keeyp arrays intact
30
+ # store[path] = obj.to_json
31
+ # obj.each_with_index{|v, i| traverse_flatten_keys(store, v, [path, i].compact.join('.'))}
29
32
  else
30
33
  store[path] = obj
31
34
  end
32
35
  return store
33
36
  end
37
+
38
+ # This will return an indented strucutre of a collection of stores. If no argument is given
39
+ # by default all the translations are indented. So a command:
40
+ # CouchI18n.indent_keys.to_yaml will return one big yaml string of the translations
41
+ def self.indent_keys(selection = Store.all)
42
+ traverse_indent_keys(selection.map{|kv| [kv.key.split('.'), kv.value]})
43
+ end
44
+
45
+ # Traversing helper for indent_keys
46
+ def self.traverse_indent_keys(ary, h = {})
47
+ for pair in ary
48
+ if pair.first.size == 1
49
+ h[pair.first.first] = pair.last
50
+ else
51
+ h[pair.first.first] ||= {}
52
+ traverse_indent_keys([[pair.first[1..-1], pair.last]], h[pair.first.first])
53
+ end
54
+ end
55
+ h
56
+ end
34
57
  end
35
58
 
36
59
  # Now extend the I18n backend
37
- I18n.backend = I18n::Backend::Chain.new(I18n::Backend::KeyValue.new(CouchI18n::Store), I18n.backend)
60
+ I18n.backend = I18n::Backend::Chain.new(CouchI18n::Backend.new(CouchI18n::Store), I18n.backend)
38
61
  I18n.cache_store = ActiveSupport::Cache.lookup_store(:memory_store)
metadata CHANGED
@@ -1,12 +1,8 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: couch_i18n
3
3
  version: !ruby/object:Gem::Version
4
- prerelease: false
5
- segments:
6
- - 0
7
- - 0
8
- - 3
9
- version: 0.0.3
4
+ prerelease:
5
+ version: 0.0.4
10
6
  platform: ruby
11
7
  authors:
12
8
  - Benjamin ter Kuile
@@ -14,7 +10,7 @@ autorequire:
14
10
  bindir: bin
15
11
  cert_chain: []
16
12
 
17
- date: 2011-05-16 00:00:00 +02:00
13
+ date: 2011-05-21 00:00:00 +02:00
18
14
  default_executable:
19
15
  dependencies: []
20
16
 
@@ -28,6 +24,7 @@ extra_rdoc_files: []
28
24
 
29
25
  files:
30
26
  - lib/couch_i18n/engine.rb
27
+ - lib/couch_i18n/backend.rb
31
28
  - lib/couch_i18n/store.rb
32
29
  - lib/couch_i18n.rb
33
30
  - lib/tasks/couch_i18n_tasks.rake
@@ -54,23 +51,21 @@ rdoc_options: []
54
51
  require_paths:
55
52
  - lib
56
53
  required_ruby_version: !ruby/object:Gem::Requirement
54
+ none: false
57
55
  requirements:
58
56
  - - ">="
59
57
  - !ruby/object:Gem::Version
60
- segments:
61
- - 0
62
58
  version: "0"
63
59
  required_rubygems_version: !ruby/object:Gem::Requirement
60
+ none: false
64
61
  requirements:
65
62
  - - ">="
66
63
  - !ruby/object:Gem::Version
67
- segments:
68
- - 0
69
64
  version: "0"
70
65
  requirements: []
71
66
 
72
67
  rubyforge_project: couch_i18n
73
- rubygems_version: 1.3.6
68
+ rubygems_version: 1.6.2
74
69
  signing_key:
75
70
  specification_version: 3
76
71
  summary: couch_i18n is an in database storage for I18n translations, tested for rails, with online management views