TextTractor 0.1.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 +6 -0
- data/Gemfile +4 -0
- data/MIT-LICENSE +20 -0
- data/README.md +66 -0
- data/Rakefile +6 -0
- data/assets/images/blankpad.png +0 -0
- data/assets/images/stale.png +0 -0
- data/assets/images/translated.png +0 -0
- data/assets/images/untranslated.png +0 -0
- data/assets/js/application.js +38 -0
- data/assets/js/jquery.js +16 -0
- data/assets/js/jquery.pjax.js +188 -0
- data/config.ru +20 -0
- data/lib/text_tractor.rb +31 -0
- data/lib/text_tractor/api_server.rb +93 -0
- data/lib/text_tractor/base.rb +25 -0
- data/lib/text_tractor/config.rb +48 -0
- data/lib/text_tractor/phrase.rb +67 -0
- data/lib/text_tractor/projects.rb +202 -0
- data/lib/text_tractor/ui_server.rb +152 -0
- data/lib/text_tractor/users.rb +49 -0
- data/lib/text_tractor/users_spec.rb +10 -0
- data/lib/text_tractor/version.rb +3 -0
- data/spec/api_server_spec.rb +224 -0
- data/spec/config_spec.rb +56 -0
- data/spec/phrase_spec.rb +71 -0
- data/spec/project_spec.rb +292 -0
- data/spec/spec_helper.rb +51 -0
- data/spec/ui_server/authentication_spec.rb +60 -0
- data/spec/ui_server/project_management_spec.rb +103 -0
- data/spec/ui_server/project_viewing_spec.rb +137 -0
- data/spec/ui_server_spec.rb +6 -0
- data/spec/users_spec.rb +123 -0
- data/text_tractor.gemspec +33 -0
- data/views/blurbs/_blurb.haml +1 -0
- data/views/blurbs/edit.haml +5 -0
- data/views/blurbs/value.haml +9 -0
- data/views/index.haml +13 -0
- data/views/layout.haml +26 -0
- data/views/projects/getting_started.haml +16 -0
- data/views/projects/new.haml +18 -0
- data/views/projects/show.haml +23 -0
- data/views/styles.scss +218 -0
- data/views/users.haml +29 -0
- data/watchr.rb +8 -0
- metadata +225 -0
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'sinatra'
|
2
|
+
require 'json'
|
3
|
+
require 'redis'
|
4
|
+
require 'redis-namespace'
|
5
|
+
require 'digest/md5'
|
6
|
+
require 'rack/etag'
|
7
|
+
|
8
|
+
module TextTractor
|
9
|
+
class Base < Sinatra::Application
|
10
|
+
use Rack::ETag
|
11
|
+
use Rack::ConditionalGet
|
12
|
+
|
13
|
+
def initialize(app=nil)
|
14
|
+
super
|
15
|
+
end
|
16
|
+
|
17
|
+
def redis
|
18
|
+
TextTractor.redis
|
19
|
+
end
|
20
|
+
|
21
|
+
def not_authorised
|
22
|
+
[ 403, "Not Authorised" ]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
3
|
+
module TextTractor
|
4
|
+
def self.configuration
|
5
|
+
@configuration ||= TextTractor::Config.new
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.config
|
9
|
+
yield self.configuration if block_given?
|
10
|
+
self.configuration
|
11
|
+
end
|
12
|
+
|
13
|
+
class Config
|
14
|
+
attr_accessor :redis, :environment, :default_username, :default_password, :salt, :hostname, :port, :ssl
|
15
|
+
|
16
|
+
def redis
|
17
|
+
@redis ||= {}
|
18
|
+
end
|
19
|
+
|
20
|
+
def redis=(value)
|
21
|
+
if value.is_a? String
|
22
|
+
uri = URI.parse(value)
|
23
|
+
@redis = {
|
24
|
+
host: uri.host,
|
25
|
+
port: uri.port,
|
26
|
+
username: uri.user,
|
27
|
+
password: uri.password,
|
28
|
+
ns: uri.path.gsub(/^\//, '')
|
29
|
+
}
|
30
|
+
else
|
31
|
+
@redis = value
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def port
|
36
|
+
@port ||= 80
|
37
|
+
end
|
38
|
+
|
39
|
+
def ssl=(value)
|
40
|
+
@ssl = value
|
41
|
+
end
|
42
|
+
|
43
|
+
def ssl
|
44
|
+
@ssl = true if @ssl.nil?
|
45
|
+
@ssl
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module TextTractor
|
2
|
+
class Phrase
|
3
|
+
attr_reader :translations, :project
|
4
|
+
|
5
|
+
def initialize(project, phrase = {})
|
6
|
+
@project = project
|
7
|
+
@translations = {}
|
8
|
+
|
9
|
+
phrase.each do |locale, value|
|
10
|
+
@translations[locale.to_s] = Translation.new(self, locale.to_s, value["text"], value["translated_at"] && Time.parse(value["translated_at"]))
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def default_locale
|
15
|
+
project.default_locale
|
16
|
+
end
|
17
|
+
|
18
|
+
def [](locale)
|
19
|
+
@translations[locale.to_s] ||= Translation.new(self, locale.to_s)
|
20
|
+
@translations[locale.to_s]
|
21
|
+
end
|
22
|
+
|
23
|
+
def []=(locale, value)
|
24
|
+
self[locale.to_s].text = value
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_hash
|
28
|
+
hash = {}
|
29
|
+
|
30
|
+
@translations.each do |locale, value|
|
31
|
+
hash[locale.to_s] = { "text" => value.text, "translated_at" => value.translated_at ? value.translated_at.to_s : nil }
|
32
|
+
end
|
33
|
+
|
34
|
+
hash
|
35
|
+
end
|
36
|
+
|
37
|
+
class Translation
|
38
|
+
attr_accessor :phrase, :locale, :text, :translated_at
|
39
|
+
|
40
|
+
def initialize(phrase, locale, text = nil, translated_at = nil, phrase_created_at = nil)
|
41
|
+
@phrase = phrase
|
42
|
+
@locale = locale
|
43
|
+
@text = text
|
44
|
+
@translated_at = translated_at
|
45
|
+
@created_at = phrase_created_at
|
46
|
+
end
|
47
|
+
|
48
|
+
def text=(value)
|
49
|
+
@text = value
|
50
|
+
@translated_at = Time.now
|
51
|
+
end
|
52
|
+
|
53
|
+
def to_s
|
54
|
+
text || ""
|
55
|
+
end
|
56
|
+
|
57
|
+
def default_locale
|
58
|
+
phrase.default_locale
|
59
|
+
end
|
60
|
+
|
61
|
+
def state
|
62
|
+
return :untranslated if translated_at.nil?
|
63
|
+
translated_at >= phrase[default_locale].translated_at ? :translated : :stale
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,202 @@
|
|
1
|
+
module TextTractor
|
2
|
+
class Project
|
3
|
+
attr_accessor :name, :api_key, :default_locale, :users
|
4
|
+
|
5
|
+
def initialize(attributes = {})
|
6
|
+
attributes.each do |key, value|
|
7
|
+
send("#{key}=", value)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def redis
|
12
|
+
TextTractor.redis
|
13
|
+
end
|
14
|
+
|
15
|
+
def api_key
|
16
|
+
@api_key ||= Projects.random_key
|
17
|
+
end
|
18
|
+
|
19
|
+
def users
|
20
|
+
@users || []
|
21
|
+
end
|
22
|
+
|
23
|
+
def default_locale
|
24
|
+
@default_locale || "en"
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_json(state = nil)
|
28
|
+
# I don't know what the generator state is used for, but it gets passed sometimes. Just accepting it as an argument seems to be
|
29
|
+
# enough to work in this situation.
|
30
|
+
|
31
|
+
{ "name" => name, "api_key" => api_key, "default_locale" => default_locale, "users" => users }.reject { |k,v| v.nil? }.to_json
|
32
|
+
end
|
33
|
+
|
34
|
+
def [](key)
|
35
|
+
send(key)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Set the overwrite option to true to force overwriting existing translations.
|
39
|
+
def update_blurb(state, locale, key, value, overwrite = false)
|
40
|
+
id = key
|
41
|
+
key = "projects:#{api_key}:#{state}_blurbs:#{key}"
|
42
|
+
|
43
|
+
current_value = redis.sismember("projects:#{api_key}:#{state}_blurb_keys", id) ? JSON.parse(redis.get(key)) : {}
|
44
|
+
phrase = Phrase.new(self, current_value)
|
45
|
+
|
46
|
+
# A new value is only written if no previous translation was present, or overwriting is enabled.
|
47
|
+
if overwrite || phrase[locale].text.nil?
|
48
|
+
phrase[locale] = value
|
49
|
+
|
50
|
+
redis.sadd "projects:#{api_key}:#{state}_blurb_keys", id
|
51
|
+
redis.sadd "projects:#{api_key}:locales", locale
|
52
|
+
redis.set key, phrase.to_hash.to_json
|
53
|
+
redis.set "projects:#{api_key}:#{state}_blurbs_etag", Projects.random_key
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def update_blurbs(state, blurbs = {}, options = {})
|
58
|
+
options[:overwrite] = false if options[:overwrite].nil?
|
59
|
+
|
60
|
+
changed = false
|
61
|
+
blurbs.each do |key, value|
|
62
|
+
locale, phrase = key.split(".", 2)
|
63
|
+
update_blurb(state, locale, phrase, value, options[:overwrite])
|
64
|
+
end
|
65
|
+
|
66
|
+
true
|
67
|
+
end
|
68
|
+
|
69
|
+
def update_draft_blurbs(blurbs = {}, options = {})
|
70
|
+
update_blurbs "draft", blurbs, options
|
71
|
+
end
|
72
|
+
|
73
|
+
def update_published_blurbs(blurbs = {}, options = {})
|
74
|
+
update_blurbs "published", blurbs, options
|
75
|
+
end
|
76
|
+
|
77
|
+
def blurbs(state)
|
78
|
+
blurbs = {}
|
79
|
+
redis.smembers("projects:#{api_key}:#{state}_blurb_keys").each do |key|
|
80
|
+
translations = JSON.parse(redis.get("projects:#{api_key}:#{state}_blurbs:#{key}"))
|
81
|
+
translations.each do |locale, value|
|
82
|
+
blurbs["#{locale}.#{key}"] = value["text"]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
blurbs
|
87
|
+
end
|
88
|
+
|
89
|
+
def draft_blurbs
|
90
|
+
blurbs("draft")
|
91
|
+
end
|
92
|
+
|
93
|
+
def published_blurbs
|
94
|
+
blurbs("published")
|
95
|
+
end
|
96
|
+
|
97
|
+
def phrases(state)
|
98
|
+
phrases = {}
|
99
|
+
redis.smembers("projects:#{api_key}:#{state}_blurb_keys").each do |key|
|
100
|
+
phrases[key] = Phrase.new(self, JSON.parse(redis.get("projects:#{api_key}:#{state}_blurbs:#{key}")))
|
101
|
+
end
|
102
|
+
|
103
|
+
phrases
|
104
|
+
end
|
105
|
+
|
106
|
+
def draft_phrases
|
107
|
+
phrases("draft")
|
108
|
+
end
|
109
|
+
|
110
|
+
def published_phrases
|
111
|
+
phrases("published")
|
112
|
+
end
|
113
|
+
|
114
|
+
def locales
|
115
|
+
locales = redis.smembers("projects:#{api_key}:locales")
|
116
|
+
locales ? locales.sort : []
|
117
|
+
end
|
118
|
+
|
119
|
+
def configuration_block
|
120
|
+
<<EOF
|
121
|
+
Copycopter::Client.configure do |config|
|
122
|
+
config.api_key = "#{api_key}"
|
123
|
+
config.host = "#{TextTractor.configuration.hostname}"
|
124
|
+
config.port = #{TextTractor.configuration.port}
|
125
|
+
config.secure = #{TextTractor.configuration.ssl ? "true" : "false"}
|
126
|
+
end
|
127
|
+
EOF
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
module Projects
|
132
|
+
class DuplicateProjectName < Exception; end
|
133
|
+
|
134
|
+
def self.redis
|
135
|
+
TextTractor.redis
|
136
|
+
end
|
137
|
+
|
138
|
+
def self.random_key
|
139
|
+
Digest::MD5.hexdigest("#{Kernel.rand(9999999999999)}.#{Time.now.to_i}")
|
140
|
+
end
|
141
|
+
|
142
|
+
def self.exists?(api_key)
|
143
|
+
redis.sismember "projects", api_key
|
144
|
+
end
|
145
|
+
|
146
|
+
def self.create(attributes = {})
|
147
|
+
attributes = TextTractor.stringify_keys(attributes)
|
148
|
+
|
149
|
+
if redis.sismember "project_names", attributes["name"]
|
150
|
+
raise DuplicateProjectName.new
|
151
|
+
else
|
152
|
+
project = Project.new(attributes)
|
153
|
+
|
154
|
+
redis.set "projects:#{project.api_key}", project.to_json
|
155
|
+
redis.sadd "projects", project.api_key
|
156
|
+
redis.sadd "project_names", project.name
|
157
|
+
project.users.each { |user| assign_user(user, project.api_key) }
|
158
|
+
|
159
|
+
project
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def self.assign_user(user, api_key)
|
164
|
+
if redis.sismember "projects", api_key
|
165
|
+
redis.sadd "project_users:#{api_key}", user
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def self.get(api_key)
|
170
|
+
json = redis.get("projects:#{api_key}")
|
171
|
+
return Project.new(JSON.parse(json)) if json
|
172
|
+
end
|
173
|
+
|
174
|
+
def self.for_user(user)
|
175
|
+
projects = []
|
176
|
+
redis.smembers("projects").each do |p|
|
177
|
+
projects << get(p) if authorised? user, p
|
178
|
+
end
|
179
|
+
|
180
|
+
projects.reject { |p| p.nil? }.sort { |a, b| a.name <=> b.name }
|
181
|
+
end
|
182
|
+
|
183
|
+
def self.authorised?(user, api_key)
|
184
|
+
user["superuser"] || redis.sismember("project_users:#{api_key}", user["username"])
|
185
|
+
end
|
186
|
+
|
187
|
+
def self.update_datastore
|
188
|
+
redis.smembers("projects").each do |api_key|
|
189
|
+
project = get(api_key)
|
190
|
+
|
191
|
+
redis.smembers("projects:#{api_key}:draft_blurb_keys").each do |blurb|
|
192
|
+
value = redis.get("projects:#{api_key}:draft_blurbs:#{blurb}")
|
193
|
+
locale, phrase = blurb.split(".", 2)
|
194
|
+
|
195
|
+
project.update_blurb("draft", locale, phrase, value, true)
|
196
|
+
redis.del("projects:#{api_key}:draft_blurbs:#{blurb}")
|
197
|
+
redis.srem("projects:#{api_key}:draft_blurb_keys", blurb)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
require 'haml'
|
2
|
+
require 'sass'
|
3
|
+
require 'digest/md5'
|
4
|
+
|
5
|
+
module TextTractor
|
6
|
+
class UiServer < TextTractor::Base
|
7
|
+
helpers do
|
8
|
+
def current_user
|
9
|
+
Users.get(env["REMOTE_USER"])
|
10
|
+
end
|
11
|
+
|
12
|
+
def pjax?
|
13
|
+
env.key? "HTTP_X_PJAX" || params["layout"] == "false"
|
14
|
+
end
|
15
|
+
|
16
|
+
def projects
|
17
|
+
Projects.for_user(current_user).sort { |a, b| b.name <=> a.name }
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
use Rack::Auth::Basic do |username, password|
|
22
|
+
unless Users.exists?(TextTractor.configuration.default_username)
|
23
|
+
Users.create(username: TextTractor.configuration.default_username, password: TextTractor.configuration.default_password, name: "Default User", superuser: true)
|
24
|
+
end
|
25
|
+
|
26
|
+
Users.authenticate(username, password)
|
27
|
+
end
|
28
|
+
|
29
|
+
set :environment, TextTractor.configuration.environment
|
30
|
+
|
31
|
+
set :public, File.expand_path("../../../assets", __FILE__)
|
32
|
+
set :views, File.expand_path("../../../views", __FILE__)
|
33
|
+
|
34
|
+
def initialize(app=nil)
|
35
|
+
super
|
36
|
+
end
|
37
|
+
|
38
|
+
def render_haml(template)
|
39
|
+
haml template, :layout => !pjax?
|
40
|
+
end
|
41
|
+
|
42
|
+
get '/' do
|
43
|
+
@projects = Projects.for_user(current_user)
|
44
|
+
render_haml :index
|
45
|
+
end
|
46
|
+
|
47
|
+
get '/styles.css' do
|
48
|
+
scss :styles
|
49
|
+
end
|
50
|
+
|
51
|
+
get '/users' do
|
52
|
+
return not_authorised unless current_user["superuser"]
|
53
|
+
|
54
|
+
@users = Users.all
|
55
|
+
render_haml :users
|
56
|
+
end
|
57
|
+
|
58
|
+
post '/users' do
|
59
|
+
return not_authorised unless current_user["superuser"]
|
60
|
+
|
61
|
+
Users.create(params[:user])
|
62
|
+
redirect "/users"
|
63
|
+
end
|
64
|
+
|
65
|
+
get '/projects/new' do
|
66
|
+
return not_authorised unless current_user["superuser"]
|
67
|
+
|
68
|
+
@users = Users.all
|
69
|
+
render_haml :"projects/new"
|
70
|
+
end
|
71
|
+
|
72
|
+
get '/projects/:api_key/:locale/*' do |api_key, locale, path|
|
73
|
+
@api_key = api_key
|
74
|
+
@locale = locale
|
75
|
+
@path = path
|
76
|
+
@key = path.gsub("/", ".")
|
77
|
+
@project = Projects.get(api_key)
|
78
|
+
@phrase = Phrase.new(@project, JSON.parse(redis.get("projects:#{@api_key}:draft_blurbs:#{@key}")))
|
79
|
+
|
80
|
+
render_haml :"blurbs/edit"
|
81
|
+
end
|
82
|
+
|
83
|
+
post '/projects/:api_key/:locale/*' do |api_key, locale, path|
|
84
|
+
@api_key = api_key
|
85
|
+
@locale = locale
|
86
|
+
@project = Projects.get(api_key)
|
87
|
+
@phrase_key = path.gsub("/", ".")
|
88
|
+
@key = "#{locale}.#{@phrase_key}"
|
89
|
+
@value = params[:blurb]
|
90
|
+
|
91
|
+
@project.update_draft_blurbs({ @key => @value }, { :overwrite => true })
|
92
|
+
@phrase = Phrase.new(@project, JSON.parse(redis.get("projects:#{@api_key}:draft_blurbs:#{@phrase_key}")))
|
93
|
+
|
94
|
+
if pjax?
|
95
|
+
@value = haml :"blurbs/value", :layout => false, :locals => {
|
96
|
+
phrase: @phrase[@locale],
|
97
|
+
key: @key.split(".", 2).last,
|
98
|
+
locale: @locale,
|
99
|
+
original: @phrase[@project.default_locale].to_s,
|
100
|
+
show_original: @project.default_locale != @locale
|
101
|
+
}
|
102
|
+
|
103
|
+
haml :"blurbs/_blurb", :layout => false
|
104
|
+
else
|
105
|
+
redirect "/projects/#{api_key}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def phrase_list(api_key, locale = nil, state = "all")
|
110
|
+
return not_authorised unless Projects.authorised?(current_user, api_key)
|
111
|
+
|
112
|
+
@project = Projects.get(api_key)
|
113
|
+
@locale = locale || @project.default_locale
|
114
|
+
@phrases = @project.draft_phrases
|
115
|
+
unless state.nil? || state == "all"
|
116
|
+
@phrases = @phrases.select { |key, value|
|
117
|
+
valid_states = case state
|
118
|
+
when "translated"
|
119
|
+
[ :translated ]
|
120
|
+
when "untranslated"
|
121
|
+
[ :untranslated ]
|
122
|
+
when "stale"
|
123
|
+
[ :stale ]
|
124
|
+
when "needs_work"
|
125
|
+
[ :stale, :untranslated ]
|
126
|
+
end
|
127
|
+
|
128
|
+
valid_states.include? value[locale].state
|
129
|
+
}
|
130
|
+
end
|
131
|
+
|
132
|
+
if @phrases.size > 0 || params.key?("state")
|
133
|
+
render_haml :"projects/show"
|
134
|
+
else
|
135
|
+
render_haml :"projects/getting_started"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
get '/projects/:api_key' do |api_key|
|
140
|
+
return phrase_list(api_key, nil, params["state"])
|
141
|
+
end
|
142
|
+
|
143
|
+
get '/projects/:api_key/:locale' do |api_key, locale|
|
144
|
+
return phrase_list(api_key, locale, params["state"])
|
145
|
+
end
|
146
|
+
|
147
|
+
post '/projects' do
|
148
|
+
project = Projects.create(params[:project])
|
149
|
+
redirect "/projects/#{project["api_key"]}"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|