pipio 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +27 -0
- data/.rspec +2 -0
- data/.simplecov +5 -0
- data/.travis.yml +12 -0
- data/Gemfile +3 -0
- data/LICENSE +20 -0
- data/NEWS.md +10 -0
- data/README.md +88 -0
- data/Rakefile +13 -0
- data/lib/pipio.rb +34 -0
- data/lib/pipio/alias_registry.rb +26 -0
- data/lib/pipio/chat.rb +39 -0
- data/lib/pipio/cleaners/html_cleaner.rb +95 -0
- data/lib/pipio/cleaners/text_cleaner.rb +15 -0
- data/lib/pipio/file_reader.rb +29 -0
- data/lib/pipio/message_creators/auto_or_xml_message_creator.rb +25 -0
- data/lib/pipio/message_creators/event_message_creator.rb +47 -0
- data/lib/pipio/message_creators/status_message_creator.rb +19 -0
- data/lib/pipio/messages/auto_reply_message.rb +7 -0
- data/lib/pipio/messages/event.rb +67 -0
- data/lib/pipio/messages/message.rb +23 -0
- data/lib/pipio/messages/status_message.rb +26 -0
- data/lib/pipio/messages/xml_message.rb +43 -0
- data/lib/pipio/metadata.rb +34 -0
- data/lib/pipio/metadata_parser.rb +55 -0
- data/lib/pipio/parser_factory.rb +32 -0
- data/lib/pipio/parsers/basic_parser.rb +83 -0
- data/lib/pipio/parsers/html_log_parser.rb +22 -0
- data/lib/pipio/parsers/null_parser.rb +9 -0
- data/lib/pipio/parsers/text_log_parser.rb +21 -0
- data/lib/pipio/tag_balancer.rb +163 -0
- data/lib/pipio/time_parser.rb +36 -0
- data/lib/pipio/version.rb +3 -0
- data/pipio.gemspec +27 -0
- data/spec/pipio/alias_registry_spec.rb +37 -0
- data/spec/pipio/chat_spec.rb +66 -0
- data/spec/pipio/cleaners/html_cleaner_spec.rb +102 -0
- data/spec/pipio/cleaners/text_cleaner_spec.rb +29 -0
- data/spec/pipio/file_reader_spec.rb +130 -0
- data/spec/pipio/messages/auto_reply_message_spec.rb +40 -0
- data/spec/pipio/messages/event_spec.rb +41 -0
- data/spec/pipio/messages/status_message_spec.rb +43 -0
- data/spec/pipio/messages/xml_message_spec.rb +55 -0
- data/spec/pipio/metadata_parser_spec.rb +81 -0
- data/spec/pipio/metadata_spec.rb +72 -0
- data/spec/pipio/parser_factory_spec.rb +31 -0
- data/spec/pipio/parsers/html_log_parser_spec.rb +160 -0
- data/spec/pipio/parsers/null_parser_spec.rb +13 -0
- data/spec/pipio/parsers/text_log_parser_spec.rb +37 -0
- data/spec/pipio/tag_balancer_spec.rb +16 -0
- data/spec/pipio/time_parser_spec.rb +66 -0
- data/spec/pipio_spec.rb +63 -0
- data/spec/spec_helper.rb +18 -0
- data/spec/support/chat_builder.rb +29 -0
- data/spec/support/chat_builder_helpers.rb +41 -0
- data/spec/support/file_builder.rb +22 -0
- data/spec/support/html_chat_builder.rb +67 -0
- data/spec/support/logfiles/2006-12-21.223606.txt +3 -0
- data/spec/support/logfiles/2008-01-15.071445-0500PST.htm +5 -0
- data/spec/support/logfiles/2008-01-15.071445-0500PST.html +5 -0
- data/spec/support/text_chat_builder.rb +21 -0
- data/spec/test-output/README.md +1 -0
- data/spec/test-output/html_log_output.xml +6 -0
- data/spec/test-output/text_log_output.xml +4 -0
- metadata +193 -0
@@ -0,0 +1,25 @@
|
|
1
|
+
module Pipio
|
2
|
+
class AutoOrXmlMessageCreator
|
3
|
+
def initialize(text, time, sender_screen_name, sender_alias, is_auto_reply)
|
4
|
+
@text = text
|
5
|
+
@time = time
|
6
|
+
@sender_screen_name = sender_screen_name
|
7
|
+
@sender_alias = sender_alias
|
8
|
+
@is_auto_reply = is_auto_reply
|
9
|
+
end
|
10
|
+
|
11
|
+
def create
|
12
|
+
if auto_reply?
|
13
|
+
AutoReplyMessage.new(@sender_screen_name, @time, @sender_alias, @text)
|
14
|
+
else
|
15
|
+
XMLMessage.new(@sender_screen_name, @time, @sender_alias, @text)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def auto_reply?
|
22
|
+
!! @is_auto_reply
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Pipio
|
2
|
+
class EventMessageCreator
|
3
|
+
def initialize(text, time, sender_alias, sender_screen_name, alias_registry)
|
4
|
+
@text = text
|
5
|
+
@time = time
|
6
|
+
@sender_alias = sender_alias
|
7
|
+
@sender_screen_name = sender_screen_name
|
8
|
+
@alias_registry = alias_registry
|
9
|
+
end
|
10
|
+
|
11
|
+
def create
|
12
|
+
create_lib_purple_event_message ||
|
13
|
+
create_non_lib_purple_event_message
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def create_lib_purple_event_message
|
19
|
+
regex = Event::LIB_PURPLE.detect { |rxp| @text =~ rxp }
|
20
|
+
if regex
|
21
|
+
event_type = 'libpurpleEvent'
|
22
|
+
create_event_message_from(regex, event_type)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def create_non_lib_purple_event_message
|
27
|
+
regex, event_type = Event::MAP.detect { |rxp,ev_type| @text =~ rxp }
|
28
|
+
if regex && event_type
|
29
|
+
create_event_message_from(regex, event_type)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def create_event_message_from(regex, event_type)
|
34
|
+
regex_matches = regex.match(@text)
|
35
|
+
if regex_matches.size == 1
|
36
|
+
# No alias - this means it's the user
|
37
|
+
sender_alias = @sender_alias
|
38
|
+
sender_screen_name = @sender_screen_name
|
39
|
+
else
|
40
|
+
sender_alias = regex_matches[1]
|
41
|
+
sender_screen_name = @alias_registry[sender_alias]
|
42
|
+
end
|
43
|
+
|
44
|
+
Event.new(sender_screen_name, @time, sender_alias, @text, event_type)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Pipio
|
2
|
+
class StatusMessageCreator
|
3
|
+
def initialize(text, time, alias_registry)
|
4
|
+
@text = text
|
5
|
+
@time = time
|
6
|
+
@alias_registry = alias_registry
|
7
|
+
end
|
8
|
+
|
9
|
+
def create
|
10
|
+
regex, status = StatusMessage::MAP.detect { |rxp, stat| @text =~ rxp }
|
11
|
+
|
12
|
+
if regex && status
|
13
|
+
sender_alias = regex.match(@text)[1]
|
14
|
+
sender_screen_name = @alias_registry[sender_alias]
|
15
|
+
StatusMessage.new(sender_screen_name, @time, sender_alias, status)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module Pipio
|
2
|
+
# Pidgin does not have Events, but Adium does. Pidgin mostly uses system
|
3
|
+
# messages to display what Adium calls events. These include sending a file,
|
4
|
+
# starting a Direct IM connection, or an error in chat.
|
5
|
+
class Event < XMLMessage
|
6
|
+
# All of event_type libPurple.
|
7
|
+
LIB_PURPLE = [
|
8
|
+
# file transfer
|
9
|
+
/Starting transfer of .+ from (.+)/,
|
10
|
+
/^Offering to send .+ to (.+)$/,
|
11
|
+
/(.+) is offering to send file/,
|
12
|
+
/^Transfer of file .+ complete$/,
|
13
|
+
/Error reading|writing|accessing .+: .+/,
|
14
|
+
/You cancell?ed the transfer of/,
|
15
|
+
/File transfer cancelled/,
|
16
|
+
/(.+?) cancell?ed the transfer of/,
|
17
|
+
/(.+?) cancelled the file transfer/,
|
18
|
+
# Direct IM - actual (dis)connect events are their own types
|
19
|
+
/^Attempting to connect to (.+) at .+ for Direct IM\./,
|
20
|
+
/^Asking (.+) to connect to us at .+ for Direct IM\./,
|
21
|
+
/^Attempting to connect via proxy server\.$/,
|
22
|
+
/^Direct IM with (.+) failed/,
|
23
|
+
# encryption
|
24
|
+
/Received message encrypted with wrong key/,
|
25
|
+
/^Requesting key\.\.\.$/,
|
26
|
+
/^Outgoing message lost\.$/,
|
27
|
+
/^Conflicting Key Received!$/,
|
28
|
+
/^Error in decryption- asking for resend\.\.\.$/,
|
29
|
+
/^Making new key pair\.\.\.$/,
|
30
|
+
# sending errors
|
31
|
+
/^Last outgoing message not received properly- resetting$/,
|
32
|
+
/Resending\.\.\./,
|
33
|
+
# connection errors
|
34
|
+
/Lost connection with the remote user:.+/,
|
35
|
+
# chats
|
36
|
+
/^.+ entered the room\.$/,
|
37
|
+
/^.+ left the room\.$/
|
38
|
+
]
|
39
|
+
|
40
|
+
# Adium ignores SN/alias changes.
|
41
|
+
IGNORE = [/^.+? is now known as .+?\.<br\/?>$/]
|
42
|
+
|
43
|
+
# Each key maps to an event_type string. The keys will be matched against
|
44
|
+
# a line of chat and the partner's alias will be in regex group 1, IF the
|
45
|
+
# alias is matched.
|
46
|
+
MAP = {
|
47
|
+
# .+ is not an alias, it's a proxy server so no grouping
|
48
|
+
/^Attempting to connect to .+\.$/ => 'direct-im-connect',
|
49
|
+
# NB: pidgin doesn't track when Direct IM is disconnected, AFAIK
|
50
|
+
/^Direct IM established$/ => 'directIMConnected',
|
51
|
+
/Unable to send message/ => 'chat-error',
|
52
|
+
/You missed .+ messages from (.+) because they were too large/ => 'chat-error',
|
53
|
+
/User information not available/ => 'chat-error'
|
54
|
+
}
|
55
|
+
|
56
|
+
def initialize(sender_screen_name, time, sender_alias, body, event_type)
|
57
|
+
super(sender_screen_name, time, sender_alias, body)
|
58
|
+
@event_type = event_type
|
59
|
+
end
|
60
|
+
|
61
|
+
attr_reader :event_type
|
62
|
+
|
63
|
+
def to_s
|
64
|
+
%(<event type="#{@event_type}" sender="#{@sender_screen_name}" time="#{adium_formatted_time}" alias="#{@sender_alias}">#{@styled_body}</event>)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Pipio
|
2
|
+
# A holding object for each line of the chat. It is subclassed as
|
3
|
+
# appropriate (eg AutoReplyMessage). Each subclass (but not Message
|
4
|
+
# itself) has its own to_s which prints out its information in a format
|
5
|
+
# appropriate for putting in an Adium log file.
|
6
|
+
class Message
|
7
|
+
include Comparable
|
8
|
+
|
9
|
+
def initialize(sender_screen_name, time, sender_alias)
|
10
|
+
@sender_screen_name = sender_screen_name
|
11
|
+
@time = time
|
12
|
+
@sender_alias = sender_alias
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :sender_screen_name, :time, :sender_alias
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def adium_formatted_time
|
20
|
+
@time.xmlschema
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Pipio
|
2
|
+
# A message saying e.g. "Blahblah has gone away."
|
3
|
+
class StatusMessage < Message
|
4
|
+
MAP = {
|
5
|
+
/(.+) logged in\.$/ => 'online',
|
6
|
+
/(.+) logged out\.$/ => 'offline',
|
7
|
+
/(.+) has signed on\.$/ => 'online',
|
8
|
+
/(.+) has signed off\.$/ => 'offline',
|
9
|
+
/(.+) has gone away\.$/ => 'away',
|
10
|
+
/(.+) is no longer away\.$/ => 'available',
|
11
|
+
/(.+) has become idle\.$/ => 'idle',
|
12
|
+
/(.+) is no longer idle\.$/ => 'available'
|
13
|
+
}
|
14
|
+
|
15
|
+
def initialize(sender_screen_name, time, sender_alias, status)
|
16
|
+
super(sender_screen_name, time, sender_alias)
|
17
|
+
@status = status
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :status
|
21
|
+
|
22
|
+
def to_s
|
23
|
+
%(<status type="#{@status}" sender="#{@sender_screen_name}" time="#{adium_formatted_time}" alias="#{@sender_alias}"/>\n)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Pipio
|
2
|
+
# Basic message with body text (as opposed to pure status messages, which
|
3
|
+
# have no body).
|
4
|
+
class XMLMessage < Message
|
5
|
+
def initialize(sender_screen_name, time, sender_alias, body)
|
6
|
+
super(sender_screen_name, time, sender_alias)
|
7
|
+
@body = normalize(body)
|
8
|
+
@styled_body = %(<div><span style="font-family: Helvetica; font-size: 12pt;">#{@body}</span></div>)
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :body
|
12
|
+
|
13
|
+
def to_s
|
14
|
+
%(<message sender="#{@sender_screen_name}" time="#{adium_formatted_time}" alias="#{@sender_alias}">#{@styled_body}</message>\n)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
# Balances mismatched tags, normalizes body style, and fixes actions
|
20
|
+
# so they are in Adium style (Pidgin uses "***Buddy waves at you", Adium uses
|
21
|
+
# "*Buddy waves at you*").
|
22
|
+
def normalize(string)
|
23
|
+
new_body = normalize_entities(string)
|
24
|
+
# Fix mismatched tags. Yes, it's faster to do it per-message
|
25
|
+
# than all at once.
|
26
|
+
new_body = Pipio::TagBalancer.new(new_body).balance
|
27
|
+
if @sender_alias[0,3] == '***'
|
28
|
+
# "***<alias>" is what pidgin sets as the alias for a /me action
|
29
|
+
@sender_alias.slice!(0,3)
|
30
|
+
new_body = "*#{new_body}*"
|
31
|
+
end
|
32
|
+
|
33
|
+
new_body
|
34
|
+
end
|
35
|
+
|
36
|
+
# Escapes all entities in string except for "<", ">", "&", """,
|
37
|
+
# and "'".
|
38
|
+
def normalize_entities(string)
|
39
|
+
# Convert '&' to '&' only if it's not followed by an entity.
|
40
|
+
string.gsub(/&(?!lt|gt|amp|quot|apos)/, '&')
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Pipio
|
2
|
+
class Metadata
|
3
|
+
def initialize(metadata_hash)
|
4
|
+
@service = metadata_hash[:service]
|
5
|
+
@my_screen_name = normalize_screen_name(metadata_hash[:my_screen_name])
|
6
|
+
@their_screen_name = metadata_hash[:their_screen_name]
|
7
|
+
@start_time = metadata_hash[:start_time]
|
8
|
+
end
|
9
|
+
|
10
|
+
attr_reader :my_screen_name, :their_screen_name, :start_time, :service
|
11
|
+
|
12
|
+
def valid?
|
13
|
+
[@their_screen_name, @my_screen_name, @start_time, @service].all?
|
14
|
+
end
|
15
|
+
|
16
|
+
def start_year
|
17
|
+
@start_time.year
|
18
|
+
end
|
19
|
+
|
20
|
+
def start_month
|
21
|
+
@start_time.mon
|
22
|
+
end
|
23
|
+
|
24
|
+
def start_mday
|
25
|
+
@start_time.mday
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def normalize_screen_name(screen_name)
|
31
|
+
screen_name && screen_name.downcase.gsub(' ', '')
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module Pipio
|
2
|
+
class MetadataParser
|
3
|
+
def initialize(first_line)
|
4
|
+
@first_line = first_line || ''
|
5
|
+
end
|
6
|
+
|
7
|
+
def parse
|
8
|
+
{
|
9
|
+
my_screen_name: my_screen_name,
|
10
|
+
their_screen_name: their_screen_name,
|
11
|
+
start_time: start_time,
|
12
|
+
service: service
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def service
|
19
|
+
match = @first_line.match(/\(([a-z]+)\)/)
|
20
|
+
if match
|
21
|
+
match[1]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def their_screen_name
|
26
|
+
match = @first_line.match(/Conversation with (.+?) at/)
|
27
|
+
if match
|
28
|
+
match[1]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def my_screen_name
|
33
|
+
match = @first_line.match(/ on ([^()]+) /)
|
34
|
+
if match
|
35
|
+
match[1]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def start_time
|
40
|
+
match = @first_line.match(%r{ at ([-\d/APM: ]+) on})
|
41
|
+
if match
|
42
|
+
timestamp = match[1]
|
43
|
+
parse_time(timestamp)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def parse_time(timestamp)
|
48
|
+
begin
|
49
|
+
Time.parse(timestamp)
|
50
|
+
rescue ArgumentError
|
51
|
+
TimeParser.new(nil, nil, nil).parse(timestamp)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Pipio
|
2
|
+
class ParserFactory
|
3
|
+
PARSER_FOR_EXTENSION = {
|
4
|
+
"html" => HtmlLogParser,
|
5
|
+
"htm" => HtmlLogParser,
|
6
|
+
"txt" => TextLogParser
|
7
|
+
}
|
8
|
+
|
9
|
+
def initialize(logfile_path, aliases)
|
10
|
+
@logfile_path = logfile_path
|
11
|
+
@aliases = aliases
|
12
|
+
end
|
13
|
+
|
14
|
+
def parser
|
15
|
+
parser_class.new(@logfile_path, @aliases)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def parser_class
|
21
|
+
PARSER_FOR_EXTENSION.fetch(extension, NullParser)
|
22
|
+
end
|
23
|
+
|
24
|
+
def extension
|
25
|
+
extension_with_leading_period[1..-1]
|
26
|
+
end
|
27
|
+
|
28
|
+
def extension_with_leading_period
|
29
|
+
File.extname(@logfile_path).downcase
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module Pipio
|
2
|
+
class BasicParser
|
3
|
+
def initialize(source_file_path, my_aliases, line_regex, line_regex_status, cleaner)
|
4
|
+
@my_aliases = my_aliases.split(',')
|
5
|
+
@line_regex = line_regex
|
6
|
+
@line_regex_status = line_regex_status
|
7
|
+
@my_alias = @my_aliases.first
|
8
|
+
|
9
|
+
@file_reader = FileReader.new(source_file_path, cleaner)
|
10
|
+
end
|
11
|
+
|
12
|
+
# This method returns a Chat instance, or false if it could not parse the
|
13
|
+
# file.
|
14
|
+
def parse
|
15
|
+
if pre_parse
|
16
|
+
messages = @file_reader.other_lines.map do |line|
|
17
|
+
basic_message_match = @line_regex.match(line)
|
18
|
+
meta_message_match = @line_regex_status.match(line)
|
19
|
+
if basic_message_match
|
20
|
+
create_message(basic_message_match)
|
21
|
+
elsif meta_message_match
|
22
|
+
create_status_or_event_message(meta_message_match)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
Chat.new(messages, @metadata)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Extract required data from the file. Run by parse.
|
31
|
+
def pre_parse
|
32
|
+
@file_reader.read
|
33
|
+
metadata = Metadata.new(MetadataParser.new(@file_reader.first_line).parse)
|
34
|
+
if metadata.valid?
|
35
|
+
@metadata = metadata
|
36
|
+
@alias_registry = AliasRegistry.new(@metadata.their_screen_name)
|
37
|
+
@my_aliases.each do |my_alias|
|
38
|
+
@alias_registry[my_alias] = @metadata.my_screen_name
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def create_message(match_data)
|
44
|
+
# Either a regular message line or an auto-reply/away message.
|
45
|
+
time = time_parser.parse(match_data[:timestamp])
|
46
|
+
if time
|
47
|
+
my_alias = match_data[:sn_or_alias]
|
48
|
+
my_screen_name = @alias_registry[my_alias]
|
49
|
+
body = match_data[:body]
|
50
|
+
is_auto_reply = match_data[:auto_reply]
|
51
|
+
|
52
|
+
AutoOrXmlMessageCreator.new(body, time, my_screen_name, my_alias, is_auto_reply).create
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def create_status_or_event_message(match_data)
|
57
|
+
time = time_parser.parse(match_data[:timestamp])
|
58
|
+
str = match_data[:body]
|
59
|
+
|
60
|
+
if time && event_we_care_about?(str)
|
61
|
+
create_status_message(str, time) || create_event_message(str, time)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def time_parser
|
66
|
+
@time_parser ||= TimeParser.new(@metadata.start_year, @metadata.start_month, @metadata.start_mday)
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def event_we_care_about?(str)
|
72
|
+
Event::IGNORE.none? { |regex| str =~ regex }
|
73
|
+
end
|
74
|
+
|
75
|
+
def create_event_message(text, time)
|
76
|
+
EventMessageCreator.new(text, time, @my_alias, @metadata.my_screen_name, @alias_registry).create
|
77
|
+
end
|
78
|
+
|
79
|
+
def create_status_message(text, time)
|
80
|
+
StatusMessageCreator.new(text, time, @alias_registry).create
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|