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.
- checksums.yaml +7 -0
- data/.gitignore +6 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +28 -0
- data/LICENSE.md +20 -0
- data/README.md +242 -0
- data/Rakefile +12 -0
- data/lib/slang.rb +144 -0
- data/lib/slang/internal.rb +139 -0
- data/lib/slang/railtie.rb +16 -0
- data/lib/slang/snapshot.rb +131 -0
- data/lib/slang/snapshot/locale.rb +35 -0
- data/lib/slang/snapshot/rules.rb +239 -0
- data/lib/slang/snapshot/template.rb +58 -0
- data/lib/slang/snapshot/translation.rb +135 -0
- data/lib/slang/snapshot/warnings.rb +102 -0
- data/lib/slang/updater/abstract.rb +74 -0
- data/lib/slang/updater/development.rb +88 -0
- data/lib/slang/updater/http_helpers.rb +49 -0
- data/lib/slang/updater/key_reporter.rb +92 -0
- data/lib/slang/updater/production.rb +218 -0
- data/lib/slang/updater/shared_state.rb +59 -0
- data/lib/slang/updater/squelchable.rb +45 -0
- data/lib/slang/version.rb +3 -0
- data/slang.gemspec +20 -0
- data/test/data/snapshot.json +64 -0
- data/test/helper.rb +4 -0
- data/test/test_locale.rb +47 -0
- data/test/test_rules.rb +133 -0
- data/test/test_snapshot.rb +132 -0
- data/test/test_template.rb +49 -0
- data/test/test_translation.rb +94 -0
- data/test/test_warnings.rb +123 -0
- metadata +99 -0
@@ -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
|