ghost_reader 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|