scriptty 0.5.0-java
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.
- data/.gitattributes +1 -0
- data/.gitignore +3 -0
- data/COPYING +674 -0
- data/COPYING.LESSER +165 -0
- data/README.rdoc +31 -0
- data/Rakefile +49 -0
- data/VERSION +1 -0
- data/bin/scriptty-capture +5 -0
- data/bin/scriptty-dump-screens +4 -0
- data/bin/scriptty-replay +5 -0
- data/bin/scriptty-term-test +4 -0
- data/bin/scriptty-transcript-parse +4 -0
- data/examples/captures/xterm-overlong-line-prompt.bin +9 -0
- data/examples/captures/xterm-vim-session.bin +262 -0
- data/examples/demo-capture.rb +19 -0
- data/examples/telnet-nego.rb +55 -0
- data/lib/scriptty/apps/capture_app/console.rb +104 -0
- data/lib/scriptty/apps/capture_app/password_prompt.rb +65 -0
- data/lib/scriptty/apps/capture_app.rb +213 -0
- data/lib/scriptty/apps/dump_screens_app.rb +166 -0
- data/lib/scriptty/apps/replay_app.rb +229 -0
- data/lib/scriptty/apps/term_test_app.rb +124 -0
- data/lib/scriptty/apps/transcript_parse_app.rb +143 -0
- data/lib/scriptty/cursor.rb +39 -0
- data/lib/scriptty/exception.rb +38 -0
- data/lib/scriptty/expect.rb +392 -0
- data/lib/scriptty/multiline_buffer.rb +192 -0
- data/lib/scriptty/net/event_loop.rb +610 -0
- data/lib/scriptty/screen_pattern/generator.rb +398 -0
- data/lib/scriptty/screen_pattern/parser.rb +558 -0
- data/lib/scriptty/screen_pattern.rb +104 -0
- data/lib/scriptty/term/dg410/dg410-client-escapes.txt +37 -0
- data/lib/scriptty/term/dg410/dg410-escapes.txt +82 -0
- data/lib/scriptty/term/dg410/parser.rb +162 -0
- data/lib/scriptty/term/dg410.rb +489 -0
- data/lib/scriptty/term/xterm/xterm-escapes.txt +73 -0
- data/lib/scriptty/term/xterm.rb +661 -0
- data/lib/scriptty/term.rb +40 -0
- data/lib/scriptty/util/fsm/definition_parser.rb +111 -0
- data/lib/scriptty/util/fsm/scriptty_fsm_definition.treetop +189 -0
- data/lib/scriptty/util/fsm.rb +177 -0
- data/lib/scriptty/util/transcript/reader.rb +96 -0
- data/lib/scriptty/util/transcript/writer.rb +111 -0
- data/test/apps/capture_app_test.rb +123 -0
- data/test/apps/transcript_parse_app_test.rb +118 -0
- data/test/cursor_test.rb +51 -0
- data/test/fsm_definition_parser_test.rb +220 -0
- data/test/fsm_test.rb +322 -0
- data/test/multiline_buffer_test.rb +275 -0
- data/test/net/event_loop_test.rb +402 -0
- data/test/screen_pattern/generator_test.rb +408 -0
- data/test/screen_pattern/parser_test/explicit_cursor_pattern.txt +14 -0
- data/test/screen_pattern/parser_test/explicit_fields.txt +22 -0
- data/test/screen_pattern/parser_test/multiple_patterns.txt +42 -0
- data/test/screen_pattern/parser_test/simple_pattern.txt +14 -0
- data/test/screen_pattern/parser_test/truncated_heredoc.txt +12 -0
- data/test/screen_pattern/parser_test/utf16bebom_pattern.bin +0 -0
- data/test/screen_pattern/parser_test/utf16lebom_pattern.bin +0 -0
- data/test/screen_pattern/parser_test/utf8_pattern.bin +14 -0
- data/test/screen_pattern/parser_test/utf8_unix_pattern.bin +14 -0
- data/test/screen_pattern/parser_test/utf8bom_pattern.bin +14 -0
- data/test/screen_pattern/parser_test.rb +266 -0
- data/test/term/dg410/parser_test.rb +139 -0
- data/test/term/xterm_test.rb +327 -0
- data/test/test_helper.rb +3 -0
- data/test/util/transcript/reader_test.rb +131 -0
- data/test/util/transcript/writer_test.rb +126 -0
- data/test.watchr +29 -0
- metadata +175 -0
@@ -0,0 +1,398 @@
|
|
1
|
+
# = Generator for screen pattern files
|
2
|
+
# Copyright (C) 2010 Infonium Inc.
|
3
|
+
#
|
4
|
+
# This file is part of ScripTTY.
|
5
|
+
#
|
6
|
+
# ScripTTY is free software: you can redistribute it and/or modify
|
7
|
+
# it under the terms of the GNU Lesser General Public License as published
|
8
|
+
# by the Free Software Foundation, either version 3 of the License, or
|
9
|
+
# (at your option) any later version.
|
10
|
+
#
|
11
|
+
# ScripTTY is distributed in the hope that it will be useful,
|
12
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
13
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
14
|
+
# GNU Lesser General Public License for more details.
|
15
|
+
#
|
16
|
+
# You should have received a copy of the GNU Lesser General Public License
|
17
|
+
# along with ScripTTY. If not, see <http://www.gnu.org/licenses/>.
|
18
|
+
|
19
|
+
require 'multibyte'
|
20
|
+
require 'set'
|
21
|
+
require 'scriptty/screen_pattern'
|
22
|
+
require 'scriptty/screen_pattern/parser'
|
23
|
+
|
24
|
+
module ScripTTY
|
25
|
+
class ScreenPattern # reopen
|
26
|
+
class Generator
|
27
|
+
class <<self
|
28
|
+
# Generate a screen pattern from a specification
|
29
|
+
#
|
30
|
+
# Options:
|
31
|
+
# [:force_fields]
|
32
|
+
# If true, the fields will be positioned in-line, even if there is
|
33
|
+
# matching text or cursor there. :force_fields takes precedence over :force_cursor.
|
34
|
+
# [:force_cursor]
|
35
|
+
# If true, the cursor will be positioned in-line, even if there is
|
36
|
+
# matching text or fields there. :force_cursor may also be a
|
37
|
+
# Regexp, in which case the regexp must match in order for the field
|
38
|
+
# to be replaced. :force_fields takes precedence over :force_cursor.
|
39
|
+
# [:ignore]
|
40
|
+
# If specified, this is an array of [row, col0..col1] ranges.
|
41
|
+
def generate(name, properties_and_options={})
|
42
|
+
new(name, properties_and_options).generate
|
43
|
+
end
|
44
|
+
protected :new # Users should not instantiate this object directly
|
45
|
+
end
|
46
|
+
|
47
|
+
IGNORE_CHAR_CHOICES = ['.', '~', "'", "^", "-", "?", " ", "░"]
|
48
|
+
CURSOR_CHAR_CHOICES = ["@", "+", "&", "█"]
|
49
|
+
FIELD_CHAR_CHOICES = ["#", "*", "%", "=", "_", "◆"]
|
50
|
+
|
51
|
+
def initialize(name, properties={})
|
52
|
+
properties = properties.dup
|
53
|
+
@force_cursor = properties.delete(:force_cursor)
|
54
|
+
@force_fields = properties.delete(:force_fields)
|
55
|
+
@ignore = properties.delete(:ignore)
|
56
|
+
load_spec(name, properties)
|
57
|
+
make_grid
|
58
|
+
end
|
59
|
+
|
60
|
+
def generate
|
61
|
+
@out = []
|
62
|
+
@out << "[#{@name}]"
|
63
|
+
@out << "position: #{encode_tuple(@position)}" if @position and @position != [0,0]
|
64
|
+
@out << "size: #{encode_tuple(@size)}"
|
65
|
+
if @char_cursor
|
66
|
+
@out << "char_cursor: #{encode_string(@char_cursor)}"
|
67
|
+
elsif @cursor_pos
|
68
|
+
@out << "cursor_pos: #{encode_tuple(@cursor_pos)}"
|
69
|
+
end
|
70
|
+
@out << "char_ignore: #{encode_string(@char_ignore)}" if @char_ignore
|
71
|
+
@out << "char_field: #{encode_string(@char_field)}" if @char_field
|
72
|
+
if @explicit_fields
|
73
|
+
@out << "fields: <<END"
|
74
|
+
@explicit_fields.each { |f|
|
75
|
+
@out << " #{encode_tuple(f)}"
|
76
|
+
}
|
77
|
+
@out << "END"
|
78
|
+
end
|
79
|
+
if @text_lines
|
80
|
+
@out << "text: <<END"
|
81
|
+
@out += @text_lines
|
82
|
+
@out << "END"
|
83
|
+
end
|
84
|
+
@out.map{|line| "#{line}\n"}.join
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
def make_grid
|
90
|
+
# Initialize grid as 2D array of nils
|
91
|
+
height, width = @size
|
92
|
+
grid = (1..height).map { [nil] * width }
|
93
|
+
|
94
|
+
# Fill in matches
|
95
|
+
if @matches
|
96
|
+
@matches.each do |pos, string|
|
97
|
+
row, col = pos
|
98
|
+
string.chars.to_a.each do |char|
|
99
|
+
raise ArgumentError.new("overlapping match: #{[pos, string].inspect}") if grid[row][col]
|
100
|
+
grid[row][col] = char
|
101
|
+
col += 1
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Fill in ignore overrides
|
107
|
+
if @ignore
|
108
|
+
@ignore.each do |row, col_range|
|
109
|
+
row, col_range = rel_pos([row, col_range])
|
110
|
+
col_range.each do |col|
|
111
|
+
grid[row][col] = nil
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Fill in fields, possibly overwriting matches
|
117
|
+
if @fields
|
118
|
+
@explicit_fields = []
|
119
|
+
@implicit_fields_by_row = {}
|
120
|
+
@fields.each_with_index do |f, i|
|
121
|
+
name, row, col_range = f
|
122
|
+
first_col = col_range.first
|
123
|
+
explicit = false
|
124
|
+
if first_col > 0 and grid[row][first_col-1] == :field # adjacent fields
|
125
|
+
explicit = true
|
126
|
+
elsif !@force_fields
|
127
|
+
col_range.each do |col|
|
128
|
+
if grid[row][col]
|
129
|
+
explicit = true
|
130
|
+
break
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
if explicit
|
135
|
+
@explicit_fields << [name, [row, first_col], col_range.count] # [name, pos, length]
|
136
|
+
else
|
137
|
+
@implicit_fields_by_row[row] ||= []
|
138
|
+
@implicit_fields_by_row[row] << name
|
139
|
+
col_range.each do |col|
|
140
|
+
grid[row][col] = :field
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
@explicit_fields = nil if @explicit_fields.empty?
|
145
|
+
@implicit_fields_by_row = nil if @implicit_fields_by_row.empty?
|
146
|
+
end
|
147
|
+
|
148
|
+
# Fill in the cursor, possibly overwriting matches (but never fields)
|
149
|
+
if @cursor_pos
|
150
|
+
row, col = @cursor_pos
|
151
|
+
if !grid[row][col]
|
152
|
+
grid[row][col] = :cursor
|
153
|
+
elsif @force_cursor and grid[row][col] != :field and (!@force_cursor.is_a?(Regexp) or @force_cursor =~ grid[row][col])
|
154
|
+
grid[row][col] = :cursor
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
# Walk the grid, checking for nil, :field, and :cursor. We won't
|
159
|
+
# generate characters for ones that aren't present.
|
160
|
+
has_ignore = has_field = has_cursor = false
|
161
|
+
height.times do |row|
|
162
|
+
width.times do |col|
|
163
|
+
if grid[row][col].nil?
|
164
|
+
has_ignore = true
|
165
|
+
elsif grid[row][col] == :field
|
166
|
+
has_field = true
|
167
|
+
elsif grid[row][col] == :cursor
|
168
|
+
has_cursor = true
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
@char_ignore = nil unless has_ignore
|
173
|
+
@char_field = nil unless has_field
|
174
|
+
@char_cursor = nil unless has_cursor
|
175
|
+
|
176
|
+
# Determine which characters were already used
|
177
|
+
@used_chars = Set.new
|
178
|
+
@used_chars << @char_ignore if @char_ignore
|
179
|
+
@used_chars << @char_field if @char_field
|
180
|
+
@used_chars << @char_cursor if @char_cursor
|
181
|
+
height.times do |row|
|
182
|
+
width.times do |col|
|
183
|
+
if grid[row][col] and grid[row][col].is_a?(String)
|
184
|
+
@used_chars << grid[row][col]
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
# Choose a character to represent ignored positions
|
190
|
+
if has_ignore and !@char_ignore
|
191
|
+
IGNORE_CHAR_CHOICES.each do |char|
|
192
|
+
next if @used_chars.include?(char)
|
193
|
+
@char_ignore = char
|
194
|
+
break
|
195
|
+
end
|
196
|
+
raise ArgumentError.new("Could not auto-select char_ignore") unless @char_ignore
|
197
|
+
@used_chars << @char_ignore
|
198
|
+
end
|
199
|
+
|
200
|
+
# Choose a character to represent the cursor
|
201
|
+
if has_cursor and !@char_cursor
|
202
|
+
CURSOR_CHAR_CHOICES.each do |char|
|
203
|
+
next if @used_chars.include?(char)
|
204
|
+
@char_cursor = char
|
205
|
+
break
|
206
|
+
end
|
207
|
+
raise ArgumentError.new("Could not auto-select char_cursor") unless @char_cursor
|
208
|
+
@used_chars << @char_cursor
|
209
|
+
end
|
210
|
+
|
211
|
+
# Choose a character to represent fields
|
212
|
+
if has_field and !@char_field
|
213
|
+
FIELD_CHAR_CHOICES.each do |char|
|
214
|
+
next if @used_chars.include?(char)
|
215
|
+
@char_field = char
|
216
|
+
break
|
217
|
+
end
|
218
|
+
raise ArgumentError.new("Could not auto-select char_field") unless @char_field
|
219
|
+
@used_chars << @char_field
|
220
|
+
end
|
221
|
+
|
222
|
+
# Walk the grid and fill in the placeholders
|
223
|
+
height.times do |row|
|
224
|
+
width.times do |col|
|
225
|
+
if grid[row][col].nil?
|
226
|
+
grid[row][col] = @char_ignore
|
227
|
+
elsif grid[row][col] == :field
|
228
|
+
grid[row][col] = @char_field
|
229
|
+
elsif grid[row][col] == :cursor
|
230
|
+
grid[row][col] = @char_cursor
|
231
|
+
elsif !grid[row][col].is_a?(String)
|
232
|
+
raise "BUG: grid[#{row}][#{col}] #{grid[row][col].inspect}"
|
233
|
+
elsif [@char_cursor, @char_field].include?(grid[row][col])
|
234
|
+
grid[row][col] = @char_ignore
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
@text_lines = []
|
240
|
+
@text_lines << "+" + "-"*width + "+"
|
241
|
+
height.times do |row|
|
242
|
+
grid_row = "|" + grid[row].join + "|"
|
243
|
+
if @implicit_fields_by_row and @implicit_fields_by_row[row]
|
244
|
+
grid_row += " " + encode_tuple(@implicit_fields_by_row[row])
|
245
|
+
end
|
246
|
+
@text_lines << grid_row
|
247
|
+
end
|
248
|
+
@text_lines << "+" + "-"*width + "+"
|
249
|
+
end
|
250
|
+
|
251
|
+
def encode_tuple(t)
|
252
|
+
"(" + t.map{|v|
|
253
|
+
if v.is_a?(Integer)
|
254
|
+
v.to_s
|
255
|
+
elsif v.is_a?(String)
|
256
|
+
encode_string(v)
|
257
|
+
elsif v.is_a?(Array)
|
258
|
+
encode_tuple(v)
|
259
|
+
else
|
260
|
+
raise "BUG: encode_tuple(#{t.inspect}) on #{v.inspect}"
|
261
|
+
end
|
262
|
+
}.join(", ") + ")"
|
263
|
+
end
|
264
|
+
|
265
|
+
def encode_string(v)
|
266
|
+
r = ['"']
|
267
|
+
v.split("").each { |c|
|
268
|
+
if c =~ /\A#{Parser::STR_UNESCAPED}\Z/no
|
269
|
+
r << c
|
270
|
+
else
|
271
|
+
r << sprintf("\\%03o", c.unpack("C*")[0])
|
272
|
+
end
|
273
|
+
}
|
274
|
+
r << '"'
|
275
|
+
r.join
|
276
|
+
end
|
277
|
+
|
278
|
+
def load_spec(name, properties)
|
279
|
+
properties = properties.dup
|
280
|
+
properties.keys.each do |k| # Replace symbol keys with strings, and remove false/nil properties.
|
281
|
+
if properties[k]
|
282
|
+
properties[k.to_s] = properties.delete(k)
|
283
|
+
else
|
284
|
+
properties.delete(k)
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
288
|
+
# Check name
|
289
|
+
raise ArgumentError.new("illegal name") unless name =~ /\A#{Parser::IDENTIFIER}\Z/no
|
290
|
+
@name = name
|
291
|
+
|
292
|
+
# position [row, column]
|
293
|
+
if properties["position"]
|
294
|
+
@position = properties.delete("position").dup
|
295
|
+
unless @position.is_a?(Array) and @position.length == 2 and @position.map{|v| v.is_a?(Integer) and v >= 0}.all?
|
296
|
+
raise ArgumentError.new("bad 'position' entry: #{@position.inspect}")
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
# size [rows, columns]
|
301
|
+
if properties["size"]
|
302
|
+
@size = properties.delete("size").dup
|
303
|
+
unless @size.is_a?(Array) and @size.length == 2 and @size.map{|v| v.is_a?(Integer) and v >= 0}.all?
|
304
|
+
raise ArgumentError.new("bad 'size' entry: #{@size.inspect}")
|
305
|
+
end
|
306
|
+
else
|
307
|
+
raise ArgumentError.new("'size' is required")
|
308
|
+
end
|
309
|
+
|
310
|
+
# cursor_pos [row, column]
|
311
|
+
if properties["cursor_pos"]
|
312
|
+
@cursor_pos = properties.delete("cursor_pos")
|
313
|
+
unless @cursor_pos.is_a?(Array) and @cursor_pos.length == 2 and @cursor_pos.map{|n| n.is_a?(Integer)}.all?
|
314
|
+
raise ArgumentError.new("Illegal cursor_pos")
|
315
|
+
end
|
316
|
+
@cursor_pos = rel_pos(@cursor_pos)
|
317
|
+
unless @cursor_pos[0] >= 0 and @cursor_pos[1] >= 0
|
318
|
+
raise ArgumentError.new("cursor_pos out of range")
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
# fields {"name" => [row, column_range]}
|
323
|
+
if properties["fields"]
|
324
|
+
fields = []
|
325
|
+
pfields = {}
|
326
|
+
properties.delete("fields").each_pair do |name, range|
|
327
|
+
pfields[name.to_s] = range # convert symbols to strings
|
328
|
+
end
|
329
|
+
pfields.each_pair do |name, range|
|
330
|
+
unless range.is_a?(Array) and range.length == 2 and range[0].is_a?(Integer) and range[1].is_a?(Range)
|
331
|
+
raise ArgumentError.new("field #{name.inspect} should be [row, col0..col1], not #{range.inspect}")
|
332
|
+
end
|
333
|
+
row, col_range = range
|
334
|
+
unless col_range.first >= 0 and col_range.last >= 0
|
335
|
+
raise ArgumentError.new("field #{name.inspect} should contain positive column range, not #{col_range.inspect}")
|
336
|
+
end
|
337
|
+
row, col_range = rel_pos([row, col_range])
|
338
|
+
fields << [name, row, col_range]
|
339
|
+
end
|
340
|
+
@fields = fields.sort{|a,b| [a[1], a[2].first] <=> [b[1], b[2].first]} # sort fields in screen order
|
341
|
+
@fields = nil if @fields.empty?
|
342
|
+
end
|
343
|
+
|
344
|
+
# matches [pos, string]
|
345
|
+
if properties["matches"]
|
346
|
+
@matches = []
|
347
|
+
properties.delete("matches").each do |m|
|
348
|
+
unless (m.is_a?(Array) and m.length == 2 and m[0].is_a?(Array) and
|
349
|
+
m[0].length == 2 and m[0].map{|v| v.is_a?(Integer)}.all? and
|
350
|
+
m[1].is_a?(String))
|
351
|
+
raise ArgumentError.new("bad 'matches' entry: #{m.inspect}")
|
352
|
+
end
|
353
|
+
pos, string = m
|
354
|
+
pos = rel_pos(pos)
|
355
|
+
unless pos[0] >= 0 and pos[1] >= 0
|
356
|
+
raise ArgumentError.new("'matches' entry out of range: #{m.inspect}")
|
357
|
+
end
|
358
|
+
@matches << [pos, string]
|
359
|
+
end
|
360
|
+
@matches.sort!{|a,b| a[0] <=> b[0]} # sort matches in screen order
|
361
|
+
end
|
362
|
+
|
363
|
+
if properties['char_cursor']
|
364
|
+
@char_cursor = properties.delete("char_cursor")
|
365
|
+
@char_cursor = Multibyte::Chars.new(@char_cursor).normalize(:c).to_a.join # Unicode Normalization Form C (NFC)
|
366
|
+
raise ArgumentError.new("char_cursor must be 1 character") unless @char_cursor.chars.to_a.length == 1
|
367
|
+
end
|
368
|
+
|
369
|
+
if properties['char_field']
|
370
|
+
@char_field = properties.delete("char_field")
|
371
|
+
@char_field = Multibyte::Chars.new(@char_field).normalize(:c).to_a.join # Unicode Normalization Form C (NFC)
|
372
|
+
raise ArgumentError.new("char_field must be 1 character") unless @char_field.chars.to_a.length == 1
|
373
|
+
raise ArgumentError.new("char_field conflicts with char_cursor") if @char_field == @char_cursor
|
374
|
+
end
|
375
|
+
|
376
|
+
if properties['char_ignore']
|
377
|
+
@char_ignore = properties.delete("char_ignore")
|
378
|
+
@char_ignore = Multibyte::Chars.new(@char_ignore).normalize(:c).to_a.join # Unicode Normalization Form C (NFC)
|
379
|
+
raise ArgumentError.new("char_ignore must be 1 character") unless @char_ignore.chars.to_a.length == 1
|
380
|
+
raise ArgumentError.new("char_ignore conflicts with char_cursor") if @char_ignore == @char_cursor
|
381
|
+
raise ArgumentError.new("char_ignore conflicts with char_field") if @char_ignore == @char_field
|
382
|
+
end
|
383
|
+
|
384
|
+
raise ArgumentError.new("extraneous properties: #{properties.keys.inspect}") unless properties.empty?
|
385
|
+
end
|
386
|
+
|
387
|
+
# Convert an absolute [row,column] or [row, col1..col2] into a relative position or range.
|
388
|
+
def rel_pos(absolute_pos)
|
389
|
+
screen_pos = @position || [0,0]
|
390
|
+
if absolute_pos[1].is_a?(Range)
|
391
|
+
[absolute_pos[0]-screen_pos[0], absolute_pos[1].first-screen_pos[1]..absolute_pos[1].last-screen_pos[1]]
|
392
|
+
else
|
393
|
+
[absolute_pos[0]-screen_pos[0], absolute_pos[1]-screen_pos[1]]
|
394
|
+
end
|
395
|
+
end
|
396
|
+
end
|
397
|
+
end
|
398
|
+
end
|