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