ghost_reader 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +9 -0
- data/.rspec +1 -0
- data/.rvmrc +1 -0
- data/Gemfile +4 -0
- data/Guardfile +9 -0
- data/README.md +35 -0
- data/Rakefile +7 -0
- data/doc/ARCH.md +54 -0
- data/doc/SPEC.md +138 -0
- data/doc/request_mockups.rb +40 -0
- data/ghost_reader.gemspec +35 -0
- data/lib/ghost_reader/backend.rb +161 -0
- data/lib/ghost_reader/client.rb +91 -0
- data/lib/ghost_reader/version.rb +3 -0
- data/lib/ghost_reader.rb +4 -0
- data/lib/tasks/ghost_reader.rake +62 -0
- data/lib/tasks/templates/ghost_reader.rb +14 -0
- data/spec/ghost_reader/backend_spec.rb +76 -0
- data/spec/ghost_reader/client_spec.rb +97 -0
- data/spec/ghost_reader/ghost_reader_spec.rb +491 -0
- data/spec/spec_helper.rb +109 -0
- metadata +247 -0
data/.gitignore
ADDED
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--color
|
data/.rvmrc
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
rvm ruby-1.8.7@ghost_reader
|
data/Gemfile
ADDED
data/Guardfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
GhostReader
|
2
|
+
===========
|
3
|
+
|
4
|
+
i18n backend to ghost_writer service
|
5
|
+
|
6
|
+
## Usage
|
7
|
+
|
8
|
+
add a folowing to `config/initializers/ghost_reader.rb`
|
9
|
+
|
10
|
+
I18n.backend=GhostReader::Backend.new("HTTP_URL_TO_GHOST_SERVER",
|
11
|
+
:default_backend=>I18n.backend, :wait_time=>30,
|
12
|
+
:trace => Proc.new do |message|
|
13
|
+
Rails.logger.debug message
|
14
|
+
end)
|
15
|
+
|
16
|
+
### wait_time
|
17
|
+
The 'wait_time' is the minimum time in seconds after which reached a change
|
18
|
+
from the Ghost_Writer Ghost_Client. A low value minimizes the delay,
|
19
|
+
a high value minimizes the network-traffic.
|
20
|
+
Default-Value is 30
|
21
|
+
|
22
|
+
### default_backend
|
23
|
+
The Ghost_reader tries to find default-values for not found translations and
|
24
|
+
posts them to the server together with the statistical data.
|
25
|
+
|
26
|
+
### max_packet_size
|
27
|
+
For preventing Errors on receiving Server splits the ghost-reader the posts
|
28
|
+
in parts with max max_packet_size count of keys
|
29
|
+
|
30
|
+
### trace
|
31
|
+
Proc for logging connection handling to Server
|
32
|
+
|
33
|
+
|
34
|
+
|
35
|
+
|
data/Rakefile
ADDED
data/doc/ARCH.md
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
Client
|
2
|
+
======
|
3
|
+
|
4
|
+
class Client
|
5
|
+
|
6
|
+
# returns a Head with three keys
|
7
|
+
# :timestamp (the value of last-modified header)
|
8
|
+
# :data (a nested Hash of translations)
|
9
|
+
# :status (the reponse status)
|
10
|
+
def initial_request
|
11
|
+
end
|
12
|
+
|
13
|
+
# returns true if redirected, false otherwise
|
14
|
+
def reporting_request(data)
|
15
|
+
end
|
16
|
+
|
17
|
+
# returns a Head with three keys
|
18
|
+
# :timestamp (the value of last-modified header)
|
19
|
+
# :data (a nested Hash of translations)
|
20
|
+
# :status (the reponse status)
|
21
|
+
def incremental_request
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
Initializer
|
27
|
+
===========
|
28
|
+
|
29
|
+
options = {
|
30
|
+
:host => '',
|
31
|
+
:update_interval => 1.minute,
|
32
|
+
:reset_interval => 15.minutes,
|
33
|
+
:fallback => I18n.backend,
|
34
|
+
:api_key => '91885ca9ec4feb9b2ed2423cdbdeda32'
|
35
|
+
}
|
36
|
+
I18n.backend = GhostReader::I18nBackend.new(options).start_agents
|
37
|
+
|
38
|
+
|
39
|
+
Custom Initializer for Development
|
40
|
+
==================================
|
41
|
+
|
42
|
+
require File.expand_path(File.join(%w(.. .. .. .. ghost_reader lib ghost_reader)), __FILE__)
|
43
|
+
|
44
|
+
config = {
|
45
|
+
:report_interval => 5, # secs
|
46
|
+
:retrieval_interval => 10, # secs
|
47
|
+
:fallback => I18n.backend,
|
48
|
+
:logfile => File.join(Rails.root, %w(log ghostwriter.log)),
|
49
|
+
:service => {
|
50
|
+
:api_key => '9d07cf6d805ea2951383c9ed76db762e' # Ghost Dummy Project
|
51
|
+
}
|
52
|
+
}
|
53
|
+
|
54
|
+
I18n.backend = GhostReader::Backend.new(config).spawn_agents
|
data/doc/SPEC.md
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
GhostReader/Writer protocol (initial draft)
|
2
|
+
===========================================
|
3
|
+
|
4
|
+
# Purpose
|
5
|
+
|
6
|
+
The ghost/writer communication protocol should define the process for
|
7
|
+
efficient exchange of the missing i18n translations from the reader and
|
8
|
+
completed translations from the writer.
|
9
|
+
|
10
|
+
# Specification
|
11
|
+
|
12
|
+
## Prerequisites
|
13
|
+
|
14
|
+
* The client/server communication should use HTTPS
|
15
|
+
* The communication should use JSON for exchanging data
|
16
|
+
* Identification should be performed by use of an API key
|
17
|
+
* Authentication/Authorization are not yet addressed
|
18
|
+
|
19
|
+
## Use cases
|
20
|
+
|
21
|
+
* There are no requests specific to a locale. All request will update
|
22
|
+
or return translations for multiple locales.
|
23
|
+
* I18n keys may occur in two different forms:
|
24
|
+
- aggregated (string), e.g. `"this.is.a.sample.key"`
|
25
|
+
- nested (hash), e.g. (in JSON) `{"this":{"is":{"a":{"sample":{"key":null}}}}}`
|
26
|
+
|
27
|
+
### Application start (initial request)
|
28
|
+
|
29
|
+
* On application start a request should be made from server to send the
|
30
|
+
already completed translations
|
31
|
+
- Use rails caching for case when multiple instances of the application are started
|
32
|
+
- This request has to be nonlocking/async, and must not hang or
|
33
|
+
crash the server if it fails
|
34
|
+
- The server performs caching on this request. (Note: Because of
|
35
|
+
different output formats it might make sense to caching on object
|
36
|
+
level.)
|
37
|
+
- The server must set the "Last-Modified" HTTP header.
|
38
|
+
- Request scheme: (1) `GET https://ghostwriter/api/<APIKEY>/translations`
|
39
|
+
- The client will track the "Last-Modified" time for use in
|
40
|
+
'incremental requests'.
|
41
|
+
- This request will also respond to YAML & CSV for exporting.
|
42
|
+
|
43
|
+
### Client reports missing translations (reporting request)
|
44
|
+
|
45
|
+
* The client should gather a collection of all missing translations.
|
46
|
+
- If a translation is missing the lookup will cascade into other
|
47
|
+
sources (I18n backends, like YAML files). (Note: This cannot be
|
48
|
+
achieved by chaining backends since fallbacks will not be
|
49
|
+
propagated.)
|
50
|
+
- If the lookup yields a result the result will reported along with
|
51
|
+
the key as a default value.
|
52
|
+
|
53
|
+
* The client should POST data for the missing translations.
|
54
|
+
- the server must respond with a Redirect-After-Post redirecting to (1)
|
55
|
+
- Request scheme: (2) `POST https://ghostwriter/api/<APIKEY>/translations`
|
56
|
+
|
57
|
+
* The server will validate the keys and create or update untranslated
|
58
|
+
entries.
|
59
|
+
|
60
|
+
### Client recieves updated translations (incremental request)
|
61
|
+
|
62
|
+
* The client should GET data for updated translations
|
63
|
+
- The client must set the "If-Modified-Since" HTTP header. (Otherwise
|
64
|
+
the request equals the inital GET and all of the translations are sent.)
|
65
|
+
- The server will only send the transaltions that where updated
|
66
|
+
between the potint in time denoted by the "If-Modified-Since"
|
67
|
+
header and the time of the request (now).
|
68
|
+
- The server will set the "Last-Modified" HTTP header.
|
69
|
+
- The server will NOT perform any caching.
|
70
|
+
- Request scheme: (3) `GET https://ghostwriter/api/<APIKEY>/translations`,
|
71
|
+
with "If-Modified-Since" HTTP header set
|
72
|
+
* The client will merge the recieved data into it's current pool of
|
73
|
+
translations.
|
74
|
+
- Thereby the client will overwrite any conflicting translations
|
75
|
+
with the newly recieved data.
|
76
|
+
* The client will track the "Last-Modified" header for future requests.
|
77
|
+
|
78
|
+
### Error handling
|
79
|
+
|
80
|
+
* The client should log the error and retry the request.
|
81
|
+
* After a number of retries the client should inform the administrator
|
82
|
+
(through email, sms... etc.) that there is a problem.
|
83
|
+
|
84
|
+
## Data model definition
|
85
|
+
|
86
|
+
### Request
|
87
|
+
|
88
|
+
* The request data should contain the following:
|
89
|
+
- locale code
|
90
|
+
- the i18n keys (aggregated) which where requested but have no
|
91
|
+
translation
|
92
|
+
- the default values if fallback lookups yielded a result
|
93
|
+
- a count, indicating how often they were requested (can be used
|
94
|
+
as a proxy variable for importance)
|
95
|
+
- timestamp when the last request was made
|
96
|
+
(as HTTP header "Last-Modified" -> "If-Modified-Since")
|
97
|
+
* Sample Request, reporting missing (JSON)
|
98
|
+
|
99
|
+
```
|
100
|
+
{"sample.key_1":{"en":{"count":42,"default":"Sample translation 1."}},
|
101
|
+
"sample.key_2":{"en":{"count":23,"default":"Sample translation 2."}}}
|
102
|
+
```
|
103
|
+
|
104
|
+
### Response
|
105
|
+
|
106
|
+
* The response data should contain the following
|
107
|
+
- keys (nested) and their translations, nested in locales
|
108
|
+
- timestamp, when response was made ("Last-Modifed" HTTP header)
|
109
|
+
* Sample Response (JSON)
|
110
|
+
|
111
|
+
```
|
112
|
+
{"en":{"sample":{"key_1":"Sample translation 1.","key_2":"Sample translation 2."}}}
|
113
|
+
|
114
|
+
```
|
115
|
+
|
116
|
+
## Lookup
|
117
|
+
|
118
|
+
The client as a I18n backend will lookup translations in multiple sources.
|
119
|
+
|
120
|
+
1. Pool (maybe there is a better name for it)
|
121
|
+
- local
|
122
|
+
- maybe use Rails cache, to share pool over multiple instances
|
123
|
+
2. GhostWriter API
|
124
|
+
- Asynchronously!
|
125
|
+
- As long as translations are missing, the client will poll the
|
126
|
+
server for updated translations and merge any newly recieved
|
127
|
+
translations into the local Pool.
|
128
|
+
3. Fallback
|
129
|
+
- SimpleBackend (locale files)
|
130
|
+
- or whatever is used by the developers during coding
|
131
|
+
|
132
|
+
# Addendum
|
133
|
+
|
134
|
+
## HTTP header date formats are specified here
|
135
|
+
|
136
|
+
* http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3
|
137
|
+
* http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.29
|
138
|
+
* e.g. in ruby strftime format '%a, %d %b %Y %H:%M:%S %Z'
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'excon'
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
# Excon.ssl_verify_peer = false
|
6
|
+
|
7
|
+
address = 'http://0.0.0.0:3000/api/91885ca9ec4feb9b2ed2423cdbdeda32/translations.json'
|
8
|
+
excon = Excon.new(address)
|
9
|
+
puts
|
10
|
+
|
11
|
+
puts "(1) Initial request... (GET without If-Modified-Since)"
|
12
|
+
response = excon.get
|
13
|
+
puts
|
14
|
+
puts " Status: #{response.status}"
|
15
|
+
puts " Body size: #{response.body.size}"
|
16
|
+
@last_modified = response.get_header('Last-Modified')
|
17
|
+
puts " Last-Modified: #{@last_modified}"
|
18
|
+
puts
|
19
|
+
|
20
|
+
puts "(2) Reporting request... (POST)"
|
21
|
+
data = {
|
22
|
+
"sample.key_1" => {"en" => {"count" => 42, "default" => "Sample translation 1."}},
|
23
|
+
"sample.key_2" => {"en" => {"count" => 23, "default" => "Sample translation 2."}}
|
24
|
+
}
|
25
|
+
response = excon.post(:body => "data=#{data.to_json}")
|
26
|
+
puts
|
27
|
+
puts " Status: #{response.status}"
|
28
|
+
puts
|
29
|
+
|
30
|
+
puts "Sleeping a second to avoid automatic 304"
|
31
|
+
sleep 1
|
32
|
+
puts
|
33
|
+
|
34
|
+
puts "(3) Incremental request... (GET with If-Modified-Since)"
|
35
|
+
headers = { 'If-Modified-Since' => @last_modified }
|
36
|
+
response = excon.get(:headers => headers)
|
37
|
+
puts
|
38
|
+
puts " Status: #{response.status}"
|
39
|
+
puts " Body size: #{response.body.size}"
|
40
|
+
puts
|
@@ -0,0 +1,35 @@
|
|
1
|
+
$:.push File.expand_path("../lib", __FILE__)
|
2
|
+
require "ghost_reader/version"
|
3
|
+
|
4
|
+
Gem::Specification.new do |s|
|
5
|
+
s.name = "ghost_reader"
|
6
|
+
s.version = GhostReader::VERSION
|
7
|
+
s.platform = Gem::Platform::RUBY
|
8
|
+
s.authors = ["Andreas König", "Phil Hofmann"]
|
9
|
+
s.email = ["koa@panter.ch", "phil@branch14.org"]
|
10
|
+
s.homepage = "https://github.com/branch14/ghost_reader"
|
11
|
+
s.summary = %q{i18n backend to ghost_writer service}
|
12
|
+
s.description = %q{Loads I18n-Yaml-Files via http and exchanges statistical data
|
13
|
+
and updates with the ghost_server}
|
14
|
+
|
15
|
+
s.rubyforge_project = "ghost_reader"
|
16
|
+
|
17
|
+
s.files = `git ls-files`.split("\n")
|
18
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
19
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
20
|
+
s.require_paths = ["lib"]
|
21
|
+
|
22
|
+
s.add_dependency('i18n')
|
23
|
+
s.add_dependency('json')
|
24
|
+
s.add_dependency('excon')
|
25
|
+
|
26
|
+
s.add_development_dependency('ruby-debug')
|
27
|
+
s.add_development_dependency('guard')
|
28
|
+
s.add_development_dependency('guard-rspec')
|
29
|
+
|
30
|
+
s.add_development_dependency('rack')
|
31
|
+
s.add_development_dependency('rake')
|
32
|
+
s.add_development_dependency('rspec')
|
33
|
+
s.add_development_dependency('mongrel')
|
34
|
+
s.add_development_dependency('actionpack', '3.0.7')
|
35
|
+
end
|
@@ -0,0 +1,161 @@
|
|
1
|
+
require 'logger'
|
2
|
+
require 'ostruct'
|
3
|
+
require 'i18n/backend/transliterator' # i18n/backend/base fails to require this
|
4
|
+
require 'i18n/backend/base'
|
5
|
+
require 'i18n/backend/memoize'
|
6
|
+
require 'i18n/backend/flatten'
|
7
|
+
|
8
|
+
module GhostReader
|
9
|
+
class Backend
|
10
|
+
|
11
|
+
module DebugLookup
|
12
|
+
def lookup(*args)
|
13
|
+
config.logger.debug "Lookup: #{args.inspect}"
|
14
|
+
# debugger
|
15
|
+
super
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
module Implementation
|
20
|
+
|
21
|
+
attr_accessor :config, :missings
|
22
|
+
|
23
|
+
# for options see code of default_config
|
24
|
+
def initialize(conf={})
|
25
|
+
self.config = OpenStruct.new(default_config.merge(conf))
|
26
|
+
yield(config) if block_given?
|
27
|
+
config.logger = Logger.new(config.logfile || STDOUT)
|
28
|
+
config.logger.level = config.log_level || Logger::WARN
|
29
|
+
config.service[:logger] ||= config.logger
|
30
|
+
config.client = Client.new(config.service)
|
31
|
+
config.logger.info "Initialized GhostReader backend."
|
32
|
+
end
|
33
|
+
|
34
|
+
def spawn_agents
|
35
|
+
config.logger.debug "GhostReader spawning agents."
|
36
|
+
spawn_retriever
|
37
|
+
spawn_reporter
|
38
|
+
config.logger.debug "GhostReader spawned its agents."
|
39
|
+
self
|
40
|
+
end
|
41
|
+
|
42
|
+
protected
|
43
|
+
|
44
|
+
# this won't be called if memoize kicks in
|
45
|
+
def lookup(locale, key, scope = [], options = {})
|
46
|
+
raise 'no fallback given' if config.fallback.nil?
|
47
|
+
config.fallback.translate(locale, key, options).tap do |result|
|
48
|
+
# TODO results which are hashes need to be tracked disaggregated
|
49
|
+
track({ key => { locale => { 'default' => result } } }) unless result.is_a?(Hash)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def track(missings)
|
54
|
+
return if self.missings.nil? # not yet initialized
|
55
|
+
self.missings.deep_merge!(missings)
|
56
|
+
end
|
57
|
+
|
58
|
+
def memoize_merge!(data, options={ :method => :merge! })
|
59
|
+
flattend = flatten_translations_for_all_locales(data)
|
60
|
+
symbolized_flattend = symbolize_keys(flattend)
|
61
|
+
memoized_lookup.send(options[:method], symbolized_flattend)
|
62
|
+
end
|
63
|
+
|
64
|
+
# performs initial and incremental requests
|
65
|
+
def spawn_retriever
|
66
|
+
config.logger.debug "Spawning retriever."
|
67
|
+
Thread.new do
|
68
|
+
begin
|
69
|
+
config.logger.debug "Performing initial request."
|
70
|
+
response = config.client.initial_request
|
71
|
+
memoize_merge! response[:data]
|
72
|
+
self.missings = {} # initialized
|
73
|
+
config.logger.info "Initial request successfull."
|
74
|
+
until false
|
75
|
+
begin
|
76
|
+
sleep config.retrieval_interval
|
77
|
+
response = config.client.incremental_request
|
78
|
+
if response[:status] == 200
|
79
|
+
config.logger.info "Incremental request with data."
|
80
|
+
config.logger.debug "Data: #{response[:data].inspect}"
|
81
|
+
memoize_merge! response[:data], :method => :deep_merge!
|
82
|
+
else
|
83
|
+
config.logger.debug "Incremental request, but no data."
|
84
|
+
end
|
85
|
+
rescue
|
86
|
+
config.logger.error "Exception in retriever loop: #{ex}"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
rescue => ex
|
90
|
+
config.logger.error "Exception in retriever thread: #{ex}"
|
91
|
+
config.logger.debug ex.backtrace.join("\n")
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# performs reporting requests
|
97
|
+
def spawn_reporter
|
98
|
+
config.logger.debug "Spawning reporter."
|
99
|
+
Thread.new do
|
100
|
+
until false
|
101
|
+
begin
|
102
|
+
sleep config.report_interval
|
103
|
+
unless self.missings.nil?
|
104
|
+
unless self.missings.empty?
|
105
|
+
config.logger.info "Reporting request with #{self.missings.keys.size} missings."
|
106
|
+
config.client.reporting_request(missings)
|
107
|
+
missings.clear
|
108
|
+
else
|
109
|
+
config.logger.debug "Reporting request omitted, nothing to report."
|
110
|
+
end
|
111
|
+
else
|
112
|
+
config.logger.debug "Reporting request omitted, not yet initialized, waiting for intial request."
|
113
|
+
end
|
114
|
+
rescue => ex
|
115
|
+
config.logger.error "Exception in reporter thread: #{ex}"
|
116
|
+
config.logger.debug ex.backtrace.join("\n")
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# a wrapper for I18n::Backend::Flatten#flatten_translations
|
123
|
+
def flatten_translations_for_all_locales(data)
|
124
|
+
data.inject({}) do |result, key_value|
|
125
|
+
begin
|
126
|
+
key, value = key_value
|
127
|
+
result.merge key => flatten_translations(key, value, true, false)
|
128
|
+
rescue ArgumentError => ae
|
129
|
+
config.logger.error "Error: #{ae}"
|
130
|
+
result
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def symbolize_keys(hash)
|
136
|
+
hash.each.inject({}) do |symbolized_hash, key_value|
|
137
|
+
key, value = key_value
|
138
|
+
symbolized_hash.merge!({key.to_sym, value})
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def default_config
|
143
|
+
{
|
144
|
+
:retrieval_interval => 15,
|
145
|
+
:report_interval => 10,
|
146
|
+
:fallback => nil, # a I18n::Backend (mandatory)
|
147
|
+
:logfile => nil, # a path
|
148
|
+
:log_level => nil, # Log level, the options are Config::(FATAL, ERROR, WARN, INFO and DEBUG) (http://www.ruby-doc.org/stdlib/libdoc/logger/rdoc/Logger.html)
|
149
|
+
:service => {} # nested hash, see GhostReader::Client#default_config
|
150
|
+
}
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
include I18n::Backend::Base
|
155
|
+
include Implementation
|
156
|
+
include I18n::Backend::Memoize # provides @memoized_lookup
|
157
|
+
include I18n::Backend::Flatten # provides #flatten_translations
|
158
|
+
include DebugLookup
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'excon'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module GhostReader
|
5
|
+
class Client
|
6
|
+
|
7
|
+
attr_accessor :config, :last_modified
|
8
|
+
|
9
|
+
def initialize(conf=nil)
|
10
|
+
self.config = OpenStruct.new(default_config.merge(conf || {}))
|
11
|
+
config.logger ||= Logger.new(config.logfile || STDOUT)
|
12
|
+
end
|
13
|
+
|
14
|
+
# returns a Head with three keys
|
15
|
+
# :timestamp (the value of last-modified header)
|
16
|
+
# :data (a nested Hash of translations)
|
17
|
+
# :status (the reponse status)
|
18
|
+
def initial_request
|
19
|
+
response = connect_with_retry
|
20
|
+
self.last_modified = response.get_header('Last-Modified')
|
21
|
+
build_head(response)
|
22
|
+
end
|
23
|
+
|
24
|
+
# returns true if redirected, false otherwise
|
25
|
+
def reporting_request(data)
|
26
|
+
response = connect_with_retry(:post, :body => "data=#{data.to_json}")
|
27
|
+
config.logger.error "Reporting request not redirected" unless response.status == 302
|
28
|
+
{ :status => response.status }
|
29
|
+
end
|
30
|
+
|
31
|
+
# returns a Head with three keys
|
32
|
+
# :timestamp (the value of last-modified header)
|
33
|
+
# :data (a nested Hash of translations)
|
34
|
+
# :status (the reponse status)
|
35
|
+
def incremental_request
|
36
|
+
headers = { 'If-Modified-Since' => self.last_modified }
|
37
|
+
response = connect_with_retry(:get, :headers => headers)
|
38
|
+
self.last_modified = response.get_header('Last-Modified') if response.status == 200
|
39
|
+
build_head(response)
|
40
|
+
end
|
41
|
+
|
42
|
+
# this is just a wrapper to have a log message when the field is set
|
43
|
+
def last_modified=(value)
|
44
|
+
config.logger.debug "Last-Modified: #{value}"
|
45
|
+
@last_modified = value
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def build_head(excon_response)
|
51
|
+
{ :status => excon_response.status }.tap do |result|
|
52
|
+
result[:data] = JSON.parse(excon_response.body) if excon_response.status == 200
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def service
|
57
|
+
@service ||= Excon.new(address)
|
58
|
+
end
|
59
|
+
|
60
|
+
def address
|
61
|
+
raise 'no api_key provided' if config.api_key.nil?
|
62
|
+
@address ||= config.uri.sub(':api_key', config.api_key)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Wrapper method for retrying the connection
|
66
|
+
# :method - http method (post and get supported at the moment)
|
67
|
+
# :params - parameters sent to the service (excon)
|
68
|
+
def connect_with_retry(method = :get, params = {})
|
69
|
+
retries = self.config.connection_retries
|
70
|
+
while (retries > 0) do
|
71
|
+
response = service.send(method, params)
|
72
|
+
|
73
|
+
if response.status == 408
|
74
|
+
config.logger.error "Connection time-out. Retrying... #{retries}"
|
75
|
+
retries -= 1
|
76
|
+
else
|
77
|
+
retries = 0 # There is no timeout, no need to retry
|
78
|
+
end
|
79
|
+
end
|
80
|
+
response
|
81
|
+
end
|
82
|
+
|
83
|
+
def default_config
|
84
|
+
{
|
85
|
+
:uri => 'http://ghost.panter.ch/api/:api_key/translations.json',
|
86
|
+
:api_key => nil,
|
87
|
+
:connection_retries => 3
|
88
|
+
}
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
data/lib/ghost_reader.rb
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
namespace :ghost_reader do
|
2
|
+
|
3
|
+
desc "Fetch newest translations from ghost-writer and overwrite the local translations"
|
4
|
+
task :fetch => :environment do
|
5
|
+
unless I18n.backend.respond_to? :load_yaml_from_ghostwriter
|
6
|
+
raise "ERROR: Ghostwriter is not configured as I18n.backend"
|
7
|
+
end
|
8
|
+
|
9
|
+
begin
|
10
|
+
puts "Loading data from Ghostwriter and delete old translations"
|
11
|
+
yaml_data = I18n.backend.load_yaml_from_ghostwriter
|
12
|
+
rescue Exception => e
|
13
|
+
abort e.message
|
14
|
+
end
|
15
|
+
|
16
|
+
yaml_data.each_pair do |key,value|
|
17
|
+
outfile = Rails.root.join("config", "locales",
|
18
|
+
"#{key.to_s}.yml")
|
19
|
+
begin
|
20
|
+
puts "Deleting old translations: #{outfile}"
|
21
|
+
File.delete(outfile) if File.exists?(outfile)
|
22
|
+
rescue Exception => e
|
23
|
+
abort "Couldn't delete file: #{e.message}"
|
24
|
+
end
|
25
|
+
|
26
|
+
begin
|
27
|
+
puts "Writing new translations: #{outfile}"
|
28
|
+
File.open(outfile, "w") do |yaml_file|
|
29
|
+
yaml_file.write({key => value}.to_yaml)
|
30
|
+
end
|
31
|
+
rescue Exception => e
|
32
|
+
abort "Couldn't write file: #{e.message}"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
desc "Push all locally configured translations to ghost-writer"
|
38
|
+
task :push => :environment do
|
39
|
+
unless I18n.backend.respond_to? :push_all_backend_data
|
40
|
+
raise "ERROR: Ghostwriter is not configured as I18n.backend"
|
41
|
+
end
|
42
|
+
puts "Pushing data to Ghostwriter"
|
43
|
+
begin
|
44
|
+
I18n.backend.push_all_backend_data
|
45
|
+
rescue Exception => e
|
46
|
+
abort e.message
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
desc "Install default initializer for ghost reader"
|
51
|
+
task :install => :environment do
|
52
|
+
|
53
|
+
# TODO: this should work only when the ghost_reader is introduced as gem
|
54
|
+
infile = 'templates/ghost_reader.rb'
|
55
|
+
outdir = Rails.root.join("config", "initializers")
|
56
|
+
|
57
|
+
puts "Installing ghost_reader.rb initializer..."
|
58
|
+
FileUtils.copy_file(infile, outdir)
|
59
|
+
puts "Done."
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# NOTE: We won't need this probably because the ghost_reader will be loaded as a gem
|
2
|
+
# require File.expand_path(File.join(%w(.. .. .. .. ghost_reader lib ghost_reader)), __FILE__)
|
3
|
+
|
4
|
+
config = {
|
5
|
+
:report_interval => 5, # secs
|
6
|
+
:retrieval_interval => 10, # secs
|
7
|
+
:fallback => I18n.backend,
|
8
|
+
:logfile => File.join(Rails.root, %w(log ghostwriter.log)),
|
9
|
+
:service => {
|
10
|
+
:api_key => '9d07cf6d805ea2951383c9ed76db762e' # Ghost Dummy Project
|
11
|
+
}
|
12
|
+
}
|
13
|
+
|
14
|
+
I18n.backend = GhostReader::Backend.new(config).spawn_agents
|