TextTractor 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. data/.gitignore +6 -0
  2. data/Gemfile +4 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +66 -0
  5. data/Rakefile +6 -0
  6. data/assets/images/blankpad.png +0 -0
  7. data/assets/images/stale.png +0 -0
  8. data/assets/images/translated.png +0 -0
  9. data/assets/images/untranslated.png +0 -0
  10. data/assets/js/application.js +38 -0
  11. data/assets/js/jquery.js +16 -0
  12. data/assets/js/jquery.pjax.js +188 -0
  13. data/config.ru +20 -0
  14. data/lib/text_tractor.rb +31 -0
  15. data/lib/text_tractor/api_server.rb +93 -0
  16. data/lib/text_tractor/base.rb +25 -0
  17. data/lib/text_tractor/config.rb +48 -0
  18. data/lib/text_tractor/phrase.rb +67 -0
  19. data/lib/text_tractor/projects.rb +202 -0
  20. data/lib/text_tractor/ui_server.rb +152 -0
  21. data/lib/text_tractor/users.rb +49 -0
  22. data/lib/text_tractor/users_spec.rb +10 -0
  23. data/lib/text_tractor/version.rb +3 -0
  24. data/spec/api_server_spec.rb +224 -0
  25. data/spec/config_spec.rb +56 -0
  26. data/spec/phrase_spec.rb +71 -0
  27. data/spec/project_spec.rb +292 -0
  28. data/spec/spec_helper.rb +51 -0
  29. data/spec/ui_server/authentication_spec.rb +60 -0
  30. data/spec/ui_server/project_management_spec.rb +103 -0
  31. data/spec/ui_server/project_viewing_spec.rb +137 -0
  32. data/spec/ui_server_spec.rb +6 -0
  33. data/spec/users_spec.rb +123 -0
  34. data/text_tractor.gemspec +33 -0
  35. data/views/blurbs/_blurb.haml +1 -0
  36. data/views/blurbs/edit.haml +5 -0
  37. data/views/blurbs/value.haml +9 -0
  38. data/views/index.haml +13 -0
  39. data/views/layout.haml +26 -0
  40. data/views/projects/getting_started.haml +16 -0
  41. data/views/projects/new.haml +18 -0
  42. data/views/projects/show.haml +23 -0
  43. data/views/styles.scss +218 -0
  44. data/views/users.haml +29 -0
  45. data/watchr.rb +8 -0
  46. 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