slang 0.34.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.
@@ -0,0 +1,58 @@
1
+ module Slang
2
+ class Snapshot
3
+
4
+ # A template is an immutable object that contains a locale-specific (and possibly rule-specific) text string. It may
5
+ # contain interpolation variables, like {foo} or {bar}, that get replaced with values when interpolation is performed.
6
+ #
7
+ class Template
8
+
9
+ attr_reader :string
10
+
11
+ # Strict variable regex:
12
+ # 1. only lowercase letters (a-z), numerics (0-9), or underscores
13
+ # 2. minimum length 1, maximum length 63.
14
+ #
15
+ VARIABLE_NAME_REGEX = /\A[a-z0-9_]{1,63}\z/
16
+ VARIABLE_SCAN_REGEX = /\{([a-z0-9_]{1,63})\}/
17
+
18
+ # Create a template object. Scans the template for interpolation variables,
19
+ # and maintains a unique list. Assumes template variables are well-formed.
20
+ #
21
+ # @param [String] the base string for this template
22
+ #
23
+ def initialize(string)
24
+ @string = string.freeze
25
+ @interpolation_variables = string.scan(VARIABLE_SCAN_REGEX) # array of 1-element group arrays
26
+ @interpolation_variables.flatten!
27
+ @interpolation_variables.uniq!
28
+ end
29
+
30
+ # Performs variable substitutions on the template, returning a string. If there are any missing interpolation
31
+ # variables, the variable names are left uninterpolated in the string.
32
+ #
33
+ # @param [Hash{String => #to_s}] hash of variable names and their substitution values. Variable names should be
34
+ # downcased strings at this point.
35
+ #
36
+ # @return [String] if successful, the interpolate string (frozen)
37
+ # @return [Array<(String,Array<String>)>] if missing variables, the string (frozen) without the missing variable
38
+ # names interpolated, and an array of missing variable names.
39
+ #
40
+ def interpolate(variable_map)
41
+ return string if @interpolation_variables.empty?
42
+
43
+ missing_variables = []
44
+ s = string.dup
45
+ @interpolation_variables.each do |variable|
46
+ if variable_map.has_key?(variable) # explicitly check for key, thus allowing nil/false-y values
47
+ s.gsub!("{#{variable}}", variable_map[variable].to_s)
48
+ else
49
+ missing_variables << variable
50
+ end
51
+ end
52
+ s.freeze
53
+ missing_variables.empty? ? s : [ s, missing_variables ]
54
+ end
55
+
56
+ end # class
57
+ end
58
+ end
@@ -0,0 +1,135 @@
1
+ require "slang/snapshot/rules"
2
+ module Slang
3
+ class Snapshot
4
+
5
+ # A translation is an immutable object that references either a single template, or a group of templates that are
6
+ # selected via a rule pattern. For example, if the variable "count" is identified as a pluralization rule variable,
7
+ # its value will determine which pluralization template should be used. In English, the three templates would be:
8
+ # zero (count=0), one (count=1), and other (everything else).
9
+ #
10
+ class Translation
11
+ include Rules
12
+
13
+ # Strict key regex:
14
+ # 1. One or more dot separated components (namespaces).
15
+ # 2. Each component consists of lowercase a-z, numberics, or underscores.
16
+ # 3. Component length is 1-255.
17
+ # 4. Max number of components is 8
18
+ # Thus, max key length is 2047.
19
+ #
20
+ KEY_REGEX = /\A[a-z0-9_]{1,255}(\.[a-z0-9_]{1,255}){0,7}\z/
21
+
22
+ attr_reader :key
23
+ attr_reader :rule_pattern
24
+ attr_reader :rules
25
+
26
+ # Create a Translation object from a translation array.
27
+ #
28
+ # @param [Array<(String,String,Array<String,Array<String>>)>] the translation array of the form: key name,
29
+ # rules pattern, and an array whose elements are either all simple template Strings (if no rules), or all arrays
30
+ # alternating between a rule selector String and a subtemplate String.
31
+ #
32
+ def initialize(translation_array)
33
+ @key, @rule_pattern, *@translations = translation_array
34
+ @rules = rules_from_pattern(@rule_pattern)
35
+ if @rules.nil?
36
+ Slang.log_warn("Ignoring key '#{@key}' because of unknown rule pattern '#{@rule_pattern}'")
37
+ elsif @rules.empty?
38
+ @translations.map! { |string| Template.new(string) unless string.empty? }
39
+ else
40
+ @translations.map! do |array|
41
+ subtemplates = {}
42
+ array.each_slice(2) do |selector, string|
43
+ subtemplates[selector] = Template.new(string) unless string.empty?
44
+ end
45
+ subtemplates
46
+ end
47
+ end
48
+ @key.freeze # avoid string duplication when added to hash
49
+ end
50
+
51
+ # Evaluate this translation given a locale and variable map. The returned array consists of a symbolic code for
52
+ # result (typically :success, but see below for error conditions), followed by the resulting String, and in error
53
+ # conditions, an array of variable names (see below).
54
+ #
55
+ # @param [Locale] a locale object
56
+ # @param [Hash{String => #to_s}] hash of variable names and their substitution values. Variable names should be
57
+ # downcased strings at this point.
58
+ #
59
+ # @return [Array<(:success,String)] successful evaluation with resulting string
60
+ # @return [Array<(:missing_template)] missing template error (no string result)
61
+ # @return [Array<(:missing_interpolation_variables,String,String,Array<String>)] missing interpolation variables,
62
+ # string result, and array of missing interplotion variable names
63
+ # @return [Array<(:missing_rule_variables,String,Array<String>)] missing rule variables, string result, and
64
+ # array of missing rule variable names.
65
+ # @return [Array<(:missing_rule_templates,String,Array<String>)] missing rule template, string result, and
66
+ # array of attempted subtemplate selector names.
67
+ #
68
+ def evaluate(locale, variable_map)
69
+ result = template(locale, variable_map)
70
+ if result.first == :template
71
+ template, selector = result[1], result[2]
72
+ result = template.interpolate(variable_map)
73
+ if String === result
74
+ [ :success, result ]
75
+ else
76
+ string, missing_variable_names = *result
77
+ [ :missing_interpolation_variables, string, missing_variable_names, selector ]
78
+ end
79
+ else
80
+ result
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # If a simple template, returns it. Otherwise evaluates rules with passed variables to determine which rule
87
+ # template should be used.
88
+ #
89
+ # @param [Locale] a locale object
90
+ # @param [Hash{String => #to_s}] hash of variable names and their substitution values. Variable names should be
91
+ # downcased strings at this point.
92
+ #
93
+ # @return [Array<(:template,Template,String)>] if successful
94
+ # @return [Array<(:missing_rule_variables,String,Array<String>)>] if missing a required rule variable
95
+ # @return [Array<(:missing_template,String,Array<String>)>] if missing subtemplate
96
+ #
97
+ def template(locale, variable_map)
98
+ t = @translations[locale.translation_index]
99
+ return [ :missing_template ] if t.nil?
100
+ return [ :template, t, nil ] if @rules.empty?
101
+
102
+ # evaluate rules with passed variables/values
103
+ missing_rule_variables = []
104
+ results = []
105
+ @rules.each do |variable, rule_type|
106
+ if variable_map.has_key?(variable) # explicitly check for key, thus allowing nil/false-y values
107
+ results << evaluate_rule(locale, rule_type, variable_map[variable])
108
+ else
109
+ missing_rule_variables << variable
110
+ end
111
+ end
112
+ if missing_rule_variables.any?
113
+ # {{en:key:<apples>,<bananas>}}
114
+ string = "{{#{locale.code}:#{@key}:#{missing_rule_variables.map { |v| "<#{v}>" }.join(",")}}}"
115
+ return [ :missing_rule_variables, string, missing_rule_variables ]
116
+ end
117
+
118
+ # +results+ is an array of arrays - each subarray contains the resulting value (or values) after applying
119
+ # the rule's logic. For example, in English pluralization, this could be ["zero","other"] if n = 0. In this
120
+ # case we would first attempt to find the "zero" template. If it doesn't exist, the "other" template would be
121
+ # used instead -- thus allowing translators to specify optional templates.
122
+
123
+ selectors = results.shift.product(*results).map { |values| values.join(":") }
124
+ selectors.each do |selector|
125
+ template = t[selector]
126
+ return [ :template, template, selector ] if template
127
+ end
128
+ # {{en:key:[zero],[other]}}
129
+ string = "{{#{locale.code}:#{@key}:#{selectors.map { |v| "[#{v}]" }.join(",")}}}"
130
+ [ :missing_rule_templates, string, selectors ]
131
+ end
132
+
133
+ end # class
134
+ end
135
+ end
@@ -0,0 +1,102 @@
1
+ module Slang
2
+ class Snapshot
3
+
4
+ # Warning logic, mixed in to Snapshot class.
5
+ #
6
+ module Warnings
7
+ attr_reader :warnings
8
+
9
+ def initialize
10
+ @warnings = {}
11
+ @warnings_lock = Monitor.new
12
+ super
13
+ end
14
+
15
+ # Warn unless warning_id already exists.
16
+ #
17
+ # @yieldreturn [Hash] warning hash detatils.
18
+ #
19
+ def warn(warning_id)
20
+ @warnings_lock.synchronize do
21
+ unless warnings.include?(warning_id)
22
+ warnings[warning_id] = yield
23
+ end
24
+ end
25
+ end
26
+
27
+ MISSING_KEY_WARNING = 1
28
+ MISSING_INTERPOLATION_VARIABLES_WARNING = 2
29
+ MISSING_RULE_VARIABLES_WARNING = 3
30
+ MISSING_RULE_TEMPLATES_WARNING = 4
31
+
32
+ # Record missing keys.
33
+ #
34
+ def missing_key!(locale, key, variable_map)
35
+ warning_id = "#{MISSING_KEY_WARNING}:#{locale.code}:#{key}"
36
+ warn(warning_id) do
37
+ Slang.log_warn("Missing key '#{key}' in locale '#{locale.code}'.")
38
+ {
39
+ warning: MISSING_KEY_WARNING,
40
+ locale: locale.code,
41
+ key: key,
42
+ passed_variables: variable_map.keys,
43
+ timestamp: Time.now.to_i
44
+ }
45
+ end
46
+ end
47
+
48
+ # Record missing interpolation variables.
49
+ #
50
+ def missing_interpolation_variables!(locale, key, selector, missing_variables, variable_map)
51
+ warning_id = "#{MISSING_INTERPOLATION_VARIABLES_WARNING}:#{locale.code}:#{key}:#{selector}:#{missing_variables.join(',')}"
52
+ warn(warning_id) do
53
+ Slang.log_warn("Key '#{key}' #{selector ? "(#{selector}) " : ''}in locale '#{locale.code}' missing interpolation variables: #{missing_variables.join(', ')} (passed: #{variable_map.inspect})")
54
+ {
55
+ warning: MISSING_INTERPOLATION_VARIABLES_WARNING,
56
+ locale: locale.code,
57
+ key: key,
58
+ selector: selector,
59
+ missing_variables: missing_variables,
60
+ passed_variables: variable_map.keys,
61
+ timestamp: Time.now.to_i
62
+ }
63
+ end
64
+ end
65
+
66
+ # Record missing rule variables.
67
+ #
68
+ def missing_rule_variables!(locale, key, missing_variables, variable_map)
69
+ warning_id = "#{MISSING_RULE_VARIABLES_WARNING}:#{locale.code}:#{key}:#{missing_variables.join(",")}"
70
+ warn(warning_id) do
71
+ Slang.log_warn("Key '#{key}' in locale '#{locale.code}' is missing required rule variables: #{missing_variables.join(', ')} (passed: #{variable_map.inspect})")
72
+ {
73
+ warning: MISSING_RULE_VARIABLES_WARNING,
74
+ locale: locale.code,
75
+ key: key,
76
+ missing_variables: missing_variables,
77
+ passed_variables: variable_map.keys,
78
+ timestamp: Time.now.to_i
79
+ }
80
+ end
81
+ end
82
+
83
+ # Record missing selectors/templates.
84
+ #
85
+ def missing_rule_templates!(locale, key, missing_templates, variable_map)
86
+ warning_id = "#{MISSING_RULE_TEMPLATES_WARNING}:#{locale.code}:#{key}:#{missing_templates.join(',')}"
87
+ warn(warning_id) do
88
+ Slang.log_warn("Key '#{key}' in locale '#{locale.code}' is missing template selectors: #{missing_templates.join(', ')} (passed: #{variable_map.inspect})")
89
+ {
90
+ warning: MISSING_RULE_TEMPLATES_WARNING,
91
+ locale: locale.code,
92
+ key: key,
93
+ missing_templates: missing_templates,
94
+ passed_variables: variable_map.keys,
95
+ timestamp: Time.now.to_i
96
+ }
97
+ end
98
+ end
99
+
100
+ end # module
101
+ end
102
+ end
@@ -0,0 +1,74 @@
1
+ require "slang/updater/http_helpers"
2
+ module Slang
3
+ module Updater
4
+
5
+ class Abstract
6
+ include HTTPHelpers
7
+
8
+ attr_reader :uri
9
+ attr_reader :data_dir
10
+ attr_reader :shared_state
11
+ attr_reader :lock
12
+
13
+ def initialize(env, url, data_dir, embedded_path)
14
+ @env = env
15
+ @uri = URI(url)
16
+ @data_dir = File.expand_path(data_dir)
17
+ @shared_state = SharedState.new(data_file_path("state"))
18
+ @lock = Mutex.new
19
+
20
+ new_snapshot = nil
21
+ shared_state.exclusive_lock do |state|
22
+
23
+ # try cached first
24
+ if state[:snapshot_id]
25
+ path = data_file_path(state[:snapshot_id])
26
+ begin
27
+ new_snapshot = Snapshot.from_json(File.read(path))
28
+ Slang.log_info("Loaded cached snapshot.")
29
+ rescue => e
30
+ Slang.log_warn("Unable to load cached snapshot #{path} - #{e.message}")
31
+ FileUtils.rm(path, force: true)
32
+ end
33
+ end
34
+
35
+ unless new_snapshot
36
+ # then embedded
37
+ if embedded_path
38
+ begin
39
+ json = File.read(embedded_path)
40
+ new_snapshot = Snapshot.from_json(json)
41
+ Slang.log_info("Loaded embedded snapshot.")
42
+ rescue => e
43
+ Slang.log_warn("Unable to load embedded snapshot #{embedded_path} - #{e.message}")
44
+ end
45
+ end
46
+ unless new_snapshot
47
+ # then empty
48
+ json = Snapshot::EMPTY_SNAPSHOT_JSON
49
+ new_snapshot = Snapshot.from_json(json)
50
+ Slang.log_warn("Created an empty snapshot.")
51
+ end
52
+ File.write(data_file_path(new_snapshot.id), json)
53
+ state[:snapshot_id] = new_snapshot.id
54
+ if is_a?(Production)
55
+ state[:modified_at] = new_snapshot.timestamp
56
+ state[:check_at] = Time.now.to_f
57
+ end
58
+ end
59
+ end
60
+ activate(new_snapshot)
61
+ end
62
+
63
+ def data_file_path(filename)
64
+ File.join(data_dir, "#{@env}_#{filename}")
65
+ end
66
+
67
+ def activate(new_snapshot)
68
+ lock.synchronize { @snapshot = new_snapshot }
69
+ Slang.log_info("Activated snapshot - #{new_snapshot.id} (#{Time.at(new_snapshot.timestamp)}).")
70
+ end
71
+
72
+ end # class
73
+ end
74
+ end
@@ -0,0 +1,88 @@
1
+ require "slang/updater/abstract"
2
+ require "slang/updater/squelchable"
3
+ module Slang
4
+ module Updater
5
+
6
+ # Development-mode snapshot updater.
7
+ #
8
+ class Development < Abstract
9
+ include Squelchable
10
+
11
+ AUTO_CHECK = 60
12
+ RATE_LIMIT = 1
13
+
14
+ attr_accessor :dev_key
15
+
16
+ # Get the latest snapshot. Triggers a background update when accessed. Thread-safe.
17
+ #
18
+ # @return [Snapshot] the current snapshot.
19
+ #
20
+ def snapshot
21
+ pid = Process.pid
22
+ lock.synchronize do
23
+ unless @pid == pid
24
+ @pid = pid
25
+ @thread = Thread.new { loop { run_loop } }
26
+ Thread.new { loop { sleep(AUTO_CHECK + rand); @thread.wakeup } }
27
+ Slang.log_info("Started background update thread (pid=#{pid}).")
28
+ end
29
+ @thread.wakeup
30
+ @snapshot
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def run_loop
37
+ unless squelched?
38
+ begin
39
+ check_for_snapshot
40
+ squelch!(RATE_LIMIT)
41
+ rescue NetworkError => e
42
+ squelch!(e.retry_in, "network failure - #{e.message}")
43
+ rescue => e
44
+ squelch!(nil, "unexpected error - #{e.message}")
45
+ Slang.log_error(e.backtrace.join("\n")) unless SlangError === e
46
+ end
47
+ end
48
+ Thread.stop # snapshot requests awaken this thread
49
+ end
50
+
51
+ def check_for_snapshot
52
+ Slang.log_debug("Checking for new snapshot...")
53
+ body = get_dev_snapshot
54
+ if body.nil?
55
+ Slang.log_debug("...not modified.")
56
+ return
57
+ end
58
+
59
+ new_snapshot = Snapshot.from_json(body)
60
+ if new_snapshot.id == @snapshot.id
61
+ Slang.log_debug("...same snapshot, no changes.")
62
+ return
63
+ end
64
+
65
+ File.write(data_file_path(new_snapshot.id), body)
66
+ shared_state.exclusive_lock { |state| state[:snapshot_id] = new_snapshot.id }
67
+ FileUtils.rm(data_file_path(@snapshot.id), force: true)
68
+
69
+ activate(new_snapshot) # last
70
+ end
71
+
72
+ def get_dev_snapshot
73
+ response = get(uri, "X-Slang-Dev-Key" => dev_key, "If-Modified-Since" => Time.at(@snapshot.timestamp).httpdate)
74
+ case response
75
+ when Net::HTTPNotModified # 304
76
+ nil
77
+ when Net::HTTPOK # 200
78
+ response.body
79
+ when Net::HTTPServerError # 5xx
80
+ raise NetworkError.new(TEMPORARY_RETRY), "Server error (#{response.code})."
81
+ else
82
+ raise NetworkError.new(nil), "Unexpected response (#{response.code})."
83
+ end
84
+ end
85
+
86
+ end # class
87
+ end
88
+ end