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.
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