ruby-msg 1.3.1 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,169 @@
1
+ require 'stringio'
2
+ require 'strscan'
3
+ require 'rtf'
4
+
5
+ module Mapi
6
+ #
7
+ # = Introduction
8
+ #
9
+ # The +RTF+ module contains a few helper functions for dealing with rtf
10
+ # in mapi messages: +rtfdecompr+, and <tt>rtf2html</tt>.
11
+ #
12
+ # Both were ported from their original C versions for simplicity's sake.
13
+ #
14
+ module RTF
15
+ RTF_PREBUF =
16
+ "{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}" \
17
+ "{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript " \
18
+ "\\fdecor MS Sans SerifSymbolArialTimes New RomanCourier" \
19
+ "{\\colortbl\\red0\\green0\\blue0\n\r\\par " \
20
+ "\\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx"
21
+
22
+ # Decompresses compressed rtf +data+, as found in the mapi property
23
+ # +PR_RTF_COMPRESSED+. Code converted from my C version, which in turn
24
+ # I wrote from a Java source, in JTNEF I believe.
25
+ #
26
+ # C version was modified to use circular buffer for back references,
27
+ # instead of the optimization of the Java version to index directly into
28
+ # output buffer. This was in preparation to support streaming in a
29
+ # read/write neutral fashion.
30
+ def rtfdecompr data
31
+ io = StringIO.new data
32
+ buf = RTF_PREBUF + "\x00" * (4096 - RTF_PREBUF.length)
33
+ wp = RTF_PREBUF.length
34
+ rtf = ''
35
+
36
+ # get header fields (as defined in RTFLIB.H)
37
+ compr_size, uncompr_size, magic, crc32 = io.read(16).unpack 'V*'
38
+ #warn "compressed-RTF data size mismatch" unless io.size == data.compr_size + 4
39
+
40
+ # process the data
41
+ case magic
42
+ when 0x414c454d # "MELA" magic number that identifies the stream as a uncompressed stream
43
+ rtf = io.read uncompr_size
44
+ when 0x75465a4c # "LZFu" magic number that identifies the stream as a compressed stream
45
+ flag_count = -1
46
+ flags = nil
47
+ while rtf.length < uncompr_size and !io.eof?
48
+ # each flag byte flags 8 literals/references, 1 per bit
49
+ flags = ((flag_count += 1) % 8 == 0) ? io.getc : flags >> 1
50
+ if 1 == (flags & 1) # each flag bit is 1 for reference, 0 for literal
51
+ rp, l = io.getc, io.getc
52
+ # offset is a 12 byte number. 2^12 is 4096, so thats fine
53
+ rp = (rp << 4) | (l >> 4) # the offset relative to block start
54
+ l = (l & 0xf) + 2 # the number of bytes to copy
55
+ l.times do
56
+ rtf << buf[wp] = buf[rp]
57
+ wp = (wp + 1) % 4096
58
+ rp = (rp + 1) % 4096
59
+ end
60
+ else
61
+ rtf << buf[wp] = io.getc
62
+ wp = (wp + 1) % 4096
63
+ end
64
+ end
65
+ else # unknown magic number
66
+ raise "Unknown compression type (magic number 0x%08x)" % magic
67
+ end
68
+
69
+ # not sure if its due to a bug in the above code. doesn't seem to be
70
+ # in my tests, but sometimes there's a trailing null. we chomp it here,
71
+ # which actually makes the resultant rtf smaller than its advertised
72
+ # size (+uncompr_size+).
73
+ rtf.chomp! 0.chr
74
+ rtf
75
+ end
76
+
77
+ # Note, this is a conversion of the original C code. Not great - needs tests and
78
+ # some refactoring, and an attempt to correct some inaccuracies. Hacky but works.
79
+ #
80
+ # Returns +nil+ if it doesn't look like an rtf encapsulated rtf.
81
+ #
82
+ # Some cases that the original didn't deal with have been patched up, eg from
83
+ # this chunk, where there are tags outside of the htmlrtf ignore block.
84
+ #
85
+ # "{\\*\\htmltag116 <br />}\\htmlrtf \\line \\htmlrtf0 \\line {\\*\\htmltag84 <a href..."
86
+ #
87
+ # We take the approach of ignoring all rtf tags not explicitly handled. A proper
88
+ # parse tree would be nicer to work with. will need to look for ruby rtf library
89
+ #
90
+ # Some of the original comment to the c code is excerpted here:
91
+ #
92
+ # Sometimes in MAPI, the PR_BODY_HTML property contains the HTML of a message.
93
+ # But more usually, the HTML is encoded inside the RTF body (which you get in the
94
+ # PR_RTF_COMPRESSED property). These routines concern the decoding of the HTML
95
+ # from this RTF body.
96
+ #
97
+ # An encoded htmlrtf file is a valid RTF document, but which contains additional
98
+ # html markup information in its comments, and sometimes contains the equivalent
99
+ # rtf markup outside the comments. Therefore, when it is displayed by a plain
100
+ # simple RTF reader, the html comments are ignored and only the rtf markup has
101
+ # effect. Typically, this rtf markup is not as rich as the html markup would have been.
102
+ # But for an html-aware reader (such as the code below), we can ignore all the
103
+ # rtf markup, and extract the html markup out of the comments, and get a valid
104
+ # html document.
105
+ #
106
+ # There are actually two kinds of html markup in comments. Most of them are
107
+ # prefixed by "\*\htmltagNNN", for some number NNN. But sometimes there's one
108
+ # prefixed by "\*\mhtmltagNNN" followed by "\*\htmltagNNN". In this case,
109
+ # the two are equivalent, but the m-tag is for a MIME Multipart/Mixed Message
110
+ # and contains tags that refer to content-ids (e.g. img src="cid:072344a7")
111
+ # while the normal tag just refers to a name (e.g. img src="fred.jpg")
112
+ # The code below keeps the m-tag and discards the normal tag.
113
+ # If there are any m-tags like this, then the message also contains an
114
+ # attachment with a PR_CONTENT_ID property e.g. "072344a7". Actually,
115
+ # sometimes the m-tag is e.g. img src="http://outlook/welcome.html" and the
116
+ # attachment has a PR_CONTENT_LOCATION "http://outlook/welcome.html" instead
117
+ # of a PR_CONTENT_ID.
118
+ #
119
+ def rtf2html rtf
120
+ scan = StringScanner.new rtf
121
+ # require \fromhtml. is this worth keeping? apparently you see \\fromtext if it
122
+ # was converted from plain text.
123
+ return nil unless rtf["\\fromhtml"]
124
+ html = ''
125
+ ignore_tag = nil
126
+ # skip up to the first htmltag. return nil if we don't ever find one
127
+ return nil unless scan.scan_until /(?=\{\\\*\\htmltag)/
128
+ until scan.empty?
129
+ if scan.scan /\{/
130
+ elsif scan.scan /\}/
131
+ elsif scan.scan /\\\*\\htmltag(\d+) ?/
132
+ #p scan[1]
133
+ if ignore_tag == scan[1]
134
+ scan.scan_until /\}/
135
+ ignore_tag = nil
136
+ end
137
+ elsif scan.scan /\\\*\\mhtmltag(\d+) ?/
138
+ ignore_tag = scan[1]
139
+ elsif scan.scan /\\par ?/
140
+ html << "\r\n"
141
+ elsif scan.scan /\\tab ?/
142
+ html << "\t"
143
+ elsif scan.scan /\\'([0-9A-Za-z]{2})/
144
+ html << scan[1].hex.chr
145
+ elsif scan.scan /\\pntext/
146
+ scan.scan_until /\}/
147
+ elsif scan.scan /\\htmlrtf/
148
+ scan.scan_until /\\htmlrtf0 ?/
149
+ # a generic throw away unknown tags thing.
150
+ # the above 2 however, are handled specially
151
+ elsif scan.scan /\\[a-z-]+(\d+)? ?/
152
+ #elsif scan.scan /\\li(\d+) ?/
153
+ #elsif scan.scan /\\fi-(\d+) ?/
154
+ elsif scan.scan /[\r\n]/
155
+ elsif scan.scan /\\([{}\\])/
156
+ html << scan[1]
157
+ elsif scan.scan /(.)/
158
+ html << scan[1]
159
+ else
160
+ p :wtf
161
+ end
162
+ end
163
+ html.strip.empty? ? nil : html
164
+ end
165
+
166
+ module_function :rtf2html, :rtfdecompr
167
+ end
168
+ end
169
+
@@ -0,0 +1,51 @@
1
+ require 'rubygems'
2
+ require 'ole/types'
3
+
4
+ module Mapi
5
+ Log = Logger.new_with_callstack
6
+
7
+ module Types
8
+ #
9
+ # Mapi property types, taken from http://msdn2.microsoft.com/en-us/library/bb147591.aspx.
10
+ #
11
+ # The fields are [mapi name, variant name, description]. Maybe I should just make it a
12
+ # struct.
13
+ #
14
+ # seen some synonyms here, like PT_I8 vs PT_LONG. seen stuff like PT_SRESTRICTION, not
15
+ # sure what that is. look at `grep ' PT_' data/mapitags.yaml | sort -u`
16
+ # also, it has stuff like PT_MV_BINARY, where _MV_ probably means multi value, and is
17
+ # likely just defined to | in 0x1000.
18
+ #
19
+ # Note that the last 2 are the only ones where the Mapi value differs from the Variant value
20
+ # for the corresponding variant type. Odd. Also, the last 2 are currently commented out here
21
+ # because of the clash.
22
+ #
23
+ # Note 2 - the strings here say VT_BSTR, but I don't have that defined in Ole::Types. Should
24
+ # maybe change them to match. I've also seen reference to PT_TSTRING, which is defined as some
25
+ # sort of get unicode first, and fallback to ansii or something.
26
+ #
27
+ DATA = {
28
+ 0x0001 => ['PT_NULL', 'VT_NULL', 'Null (no valid data)'],
29
+ 0x0002 => ['PT_SHORT', 'VT_I2', '2-byte integer (signed)'],
30
+ 0x0003 => ['PT_LONG', 'VT_I4', '4-byte integer (signed)'],
31
+ 0x0004 => ['PT_FLOAT', 'VT_R4', '4-byte real (floating point)'],
32
+ 0x0005 => ['PT_DOUBLE', 'VT_R8', '8-byte real (floating point)'],
33
+ 0x0006 => ['PT_CURRENCY', 'VT_CY', '8-byte integer (scaled by 10,000)'],
34
+ 0x000a => ['PT_ERROR', 'VT_ERROR', 'SCODE value; 32-bit unsigned integer'],
35
+ 0x000b => ['PT_BOOLEAN', 'VT_BOOL', 'Boolean'],
36
+ 0x000d => ['PT_OBJECT', 'VT_UNKNOWN', 'Data object'],
37
+ 0x001e => ['PT_STRING8', 'VT_BSTR', 'String'],
38
+ 0x001f => ['PT_UNICODE', 'VT_BSTR', 'String'],
39
+ 0x0040 => ['PT_SYSTIME', 'VT_DATE', '8-byte real (date in integer, time in fraction)'],
40
+ #0x0102 => ['PT_BINARY', 'VT_BLOB', 'Binary (unknown format)'],
41
+ #0x0102 => ['PT_CLSID', 'VT_CLSID', 'OLE GUID']
42
+ }
43
+
44
+ module Constants
45
+ DATA.each { |num, (mapi_name, variant_name, desc)| const_set mapi_name, num }
46
+ end
47
+
48
+ include Constants
49
+ end
50
+ end
51
+
data/lib/rtf.rb CHANGED
@@ -1,5 +1,3 @@
1
- #! /usr/bin/ruby -w
2
-
3
1
  require 'stringio'
4
2
 
5
3
  # this file is pretty crap, its just to ensure there is always something readable if
@@ -109,10 +107,3 @@ module RTF
109
107
  end
110
108
  end
111
109
 
112
- if $0 == __FILE__
113
- #str = File.read('test.rtf')
114
- str = YAML.load(open('rtfs.yaml'))[2]
115
- #puts str
116
- puts text
117
- end
118
-
@@ -0,0 +1,60 @@
1
+ require 'test/unit'
2
+
3
+ $:.unshift File.dirname(__FILE__) + '/../lib'
4
+ require 'mapi'
5
+ require 'mapi/convert'
6
+
7
+ class TestMapiPropertySet < Test::Unit::TestCase
8
+ include Mapi
9
+
10
+ def test_contact_from_property_hash
11
+ make_key1 = proc { |id| PropertySet::Key.new id }
12
+ make_key2 = proc { |id| PropertySet::Key.new id, PropertySet::PSETID_Address }
13
+ store = {
14
+ make_key1[0x001a] => 'IPM.Contact',
15
+ make_key1[0x0037] => 'full name',
16
+ make_key1[0x3a06] => 'given name',
17
+ make_key1[0x3a08] => 'business telephone number',
18
+ make_key1[0x3a11] => 'surname',
19
+ make_key1[0x3a15] => 'postal address',
20
+ make_key1[0x3a16] => 'company name',
21
+ make_key1[0x3a17] => 'title',
22
+ make_key1[0x3a18] => 'department name',
23
+ make_key1[0x3a19] => 'office location',
24
+ make_key2[0x8005] => 'file under',
25
+ make_key2[0x801b] => 'business address',
26
+ make_key2[0x802b] => 'web page',
27
+ make_key2[0x8045] => 'business address street',
28
+ make_key2[0x8046] => 'business address city',
29
+ make_key2[0x8047] => 'business address state',
30
+ make_key2[0x8048] => 'business address postal code',
31
+ make_key2[0x8049] => 'business address country',
32
+ make_key2[0x804a] => 'business address post office box',
33
+ make_key2[0x8062] => 'im address',
34
+ make_key2[0x8082] => 'SMTP',
35
+ make_key2[0x8083] => 'email@address.com'
36
+ }
37
+ props = PropertySet.new store
38
+ message = Message.new props
39
+ assert_equal 'text/x-vcard', message.mime_type
40
+ vcard = message.to_vcard
41
+ assert_equal Vpim::Vcard, vcard.class
42
+ assert_equal <<-'end', vcard.to_s
43
+ BEGIN:VCARD
44
+ VERSION:3.0
45
+ N:surname;given name;;;
46
+ FN:full name
47
+ ADR;TYPE=work:;;business address street;business address city\, business ad
48
+ dress state;;;
49
+ X-EVOLUTION-FILE-AS:file under
50
+ EMAIL:email@address.com
51
+ ORG:company name
52
+ END:VCARD
53
+ end
54
+ end
55
+
56
+ def test_contact_from_msg
57
+ # load some msg contacts and convert them...
58
+ end
59
+ end
60
+
@@ -0,0 +1,66 @@
1
+ require 'test/unit'
2
+
3
+ $:.unshift File.dirname(__FILE__) + '/../lib'
4
+ require 'mapi'
5
+ require 'mapi/convert'
6
+
7
+ class TestMapiPropertySet < Test::Unit::TestCase
8
+ include Mapi
9
+
10
+ def test_using_pseudo_properties
11
+ # load some compressed rtf data
12
+ data = File.read File.dirname(__FILE__) + '/test_rtf.data'
13
+ store = {
14
+ PropertySet::Key.new(0x0037) => 'Subject',
15
+ PropertySet::Key.new(0x0c1e) => 'SMTP',
16
+ PropertySet::Key.new(0x0c1f) => 'sender@email.com',
17
+ PropertySet::Key.new(0x1009) => StringIO.new(data)
18
+ }
19
+ props = PropertySet.new store
20
+ msg = Message.new props
21
+ def msg.attachments
22
+ []
23
+ end
24
+ def msg.recipients
25
+ []
26
+ end
27
+ # the ignoring of \r here should change. its actually not output consistently currently.
28
+ assert_equal((<<-end), msg.to_mime.to_s.gsub(/NextPart[_0-9a-z\.]+/, 'NextPart_XXX').delete("\r"))
29
+ From: sender@email.com
30
+ Subject: Subject
31
+ Content-Type: multipart/alternative; boundary="----_=_NextPart_XXX"
32
+
33
+ This is a multi-part message in MIME format.
34
+
35
+ ------_=_NextPart_XXX
36
+ Content-Type: text/plain
37
+
38
+
39
+ I will be out of the office starting 15.02.2007 and will not return until
40
+ 27.02.2007.
41
+
42
+ I will respond to your message when I return. For urgent enquiries please
43
+ contact Motherine Jacson.
44
+
45
+
46
+
47
+ ------_=_NextPart_XXX
48
+ Content-Type: text/html
49
+
50
+ <html>
51
+ <body>
52
+ <br>I will be out of the office starting 15.02.2007 and will not return until
53
+ <br>27.02.2007.
54
+ <br>
55
+ <br>I will respond to your message when I return. For urgent enquiries please
56
+ <br>contact Motherine Jacson.
57
+ <br>
58
+ <br></body>
59
+ </html>
60
+
61
+
62
+ ------_=_NextPart_XXX--
63
+ end
64
+ end
65
+ end
66
+
@@ -14,9 +14,11 @@ class TestMime < Test::Unit::TestCase
14
14
  assert_equal 'Body text.', mime.body
15
15
  assert_equal false, mime.multipart?
16
16
  assert_equal nil, mime.parts
17
- # we get round trip conversion. this is mostly fluke, as orderedhash hasn't been
18
- # added yet
19
17
  assert_equal "Header1: Value1\r\nHeader2: Value2\r\n\r\nBody text.", mime.to_s
20
18
  end
19
+
20
+ def test_boundaries
21
+ assert_match(/^----_=_NextPart_001_/, Mime.make_boundary(1))
22
+ end
21
23
  end
22
24
 
@@ -0,0 +1,29 @@
1
+ #! /usr/bin/ruby
2
+
3
+ TEST_DIR = File.dirname __FILE__
4
+ $: << "#{TEST_DIR}/../lib"
5
+
6
+ require 'test/unit'
7
+ require 'mapi/msg'
8
+
9
+ class TestMsg < Test::Unit::TestCase
10
+ def test_blammo
11
+ Mapi::Msg.open "#{TEST_DIR}/test_Blammo.msg" do |msg|
12
+ assert_equal '"TripleNickel" <TripleNickel@mapi32.net>', msg.from
13
+ assert_equal 'BlammoBlammo', msg.subject
14
+ assert_equal 0, msg.recipients.length
15
+ assert_equal 0, msg.attachments.length
16
+ # this is all properties
17
+ assert_equal 66, msg.properties.raw.length
18
+ # this is unique named properties
19
+ assert_equal 48, msg.properties.to_h.length
20
+ # get the named property keys
21
+ keys = msg.properties.raw.keys.select { |key| String === key.code }
22
+ assert_equal '55555555-5555-5555-c000-000000000046', keys[0].guid.format
23
+ assert_equal 'Yippee555', msg.properties[keys[0]]
24
+ assert_equal '66666666-6666-6666-c000-000000000046', keys[1].guid.format
25
+ assert_equal 'Yippee666', msg.properties[keys[1]]
26
+ end
27
+ end
28
+ end
29
+
@@ -0,0 +1,116 @@
1
+ require 'test/unit'
2
+
3
+ $:.unshift File.dirname(__FILE__) + '/../lib'
4
+ require 'mapi/property_set'
5
+
6
+ class TestMapiPropertySet < Test::Unit::TestCase
7
+ include Mapi
8
+
9
+ def test_constants
10
+ assert_equal '00020328-0000-0000-c000-000000000046', PropertySet::PS_MAPI.format
11
+ end
12
+
13
+ def test_lookup
14
+ guid = Ole::Types::Clsid.parse '00020328-0000-0000-c000-000000000046'
15
+ assert_equal 'PS_MAPI', PropertySet::NAMES[guid]
16
+ end
17
+
18
+ def test_simple_key
19
+ key = PropertySet::Key.new 0x0037
20
+ assert_equal PropertySet::PS_MAPI, key.guid
21
+ hash = {key => 'hash lookup'}
22
+ assert_equal 'hash lookup', hash[PropertySet::Key.new(0x0037)]
23
+ assert_equal '0x0037', key.inspect
24
+ assert_equal :subject, key.to_sym
25
+ end
26
+
27
+ def test_complex_keys
28
+ key = PropertySet::Key.new 'Keywords', PropertySet::PS_PUBLIC_STRINGS
29
+ # note that the inspect string now uses symbolic guids
30
+ assert_equal '#<Key PS_PUBLIC_STRINGS/"Keywords">', key.inspect
31
+ # note that this isn't categories
32
+ assert_equal :keywords, key.to_sym
33
+ custom_guid = '00020328-0000-0000-c000-deadbeefcafe'
34
+ key = PropertySet::Key.new 0x8000, Ole::Types::Clsid.parse(custom_guid)
35
+ assert_equal "#<Key {#{custom_guid}}/0x8000>", key.inspect
36
+ key = PropertySet::Key.new 0x8005, PropertySet::PSETID_Address
37
+ assert_equal 'file_under', key.to_s
38
+ end
39
+
40
+ def test_property_set_basics
41
+ # the propertystore can be mocked with a hash:
42
+ store = {
43
+ PropertySet::Key.new(0x0037) => 'the subject',
44
+ PropertySet::Key.new('Keywords', PropertySet::PS_PUBLIC_STRINGS) => ['some keywords'],
45
+ PropertySet::Key.new(0x8888) => 'un-mapped value'
46
+ }
47
+ props = PropertySet.new store
48
+ # can resolve subject
49
+ assert_equal PropertySet::Key.new(0x0037), props.resolve('subject')
50
+ # note that the way things are set up, you can't resolve body though. ie, only
51
+ # existent (not all-known) properties resolve. maybe this should be changed. it'll
52
+ # need to be, for <tt>props.body=</tt> to work as it should.
53
+ assert_equal nil, props.resolve('body')
54
+ assert_equal 'the subject', props.subject
55
+ assert_equal ['some keywords'], props.keywords
56
+ # other access methods
57
+ assert_equal 'the subject', props['subject']
58
+ assert_equal 'the subject', props[0x0037]
59
+ assert_equal 'the subject', props[0x0037, PropertySet::PS_MAPI]
60
+ # note that the store is accessible directly, as #raw currently (maybe i should rename)
61
+ assert_equal store, props.raw
62
+ # note that currently, props.each / props.to_h works with the symbolically
63
+ # mapped properties, so the above un-mapped value won't be in the list:
64
+ assert_equal({:subject => 'the subject', :keywords => ['some keywords']}, props.to_h)
65
+ assert_equal [:keywords, :subject], props.keys.sort_by(&:to_s)
66
+ assert_equal [['some keywords'], 'the subject'], props.values.sort_by(&:to_s)
67
+ end
68
+
69
+ # other things we could test - write support. duplicate key handling
70
+
71
+ def test_pseudo_properties
72
+ # load some compressed rtf data
73
+ data = File.read File.dirname(__FILE__) + '/test_rtf.data'
74
+ props = PropertySet.new PropertySet::Key.new(0x1009) => StringIO.new(data)
75
+ # all these get generated from the rtf. still need tests for the way the priorities work
76
+ # here, and also the html embedded in rtf stuff....
77
+ assert_equal((<<-'end').chomp.gsub(/\n/, "\n\r"), props.body_rtf)
78
+ {\rtf1\ansi\ansicpg1252\fromtext \deff0{\fonttbl
79
+ {\f0\fswiss Arial;}
80
+ {\f1\fmodern Courier New;}
81
+ {\f2\fnil\fcharset2 Symbol;}
82
+ {\f3\fmodern\fcharset0 Courier New;}}
83
+ {\colortbl\red0\green0\blue0;\red0\green0\blue255;}
84
+ \uc1\pard\plain\deftab360 \f0\fs20 \par
85
+ I will be out of the office starting 15.02.2007 and will not return until\par
86
+ 27.02.2007.\par
87
+ \par
88
+ I will respond to your message when I return. For urgent enquiries please\par
89
+ contact Motherine Jacson.\par
90
+ \par
91
+ }
92
+ end
93
+ assert_equal <<-'end', props.body_html
94
+ <html>
95
+ <body>
96
+ <br>I will be out of the office starting 15.02.2007 and will not return until
97
+ <br>27.02.2007.
98
+ <br>
99
+ <br>I will respond to your message when I return. For urgent enquiries please
100
+ <br>contact Motherine Jacson.
101
+ <br>
102
+ <br></body>
103
+ </html>
104
+ end
105
+ assert_equal <<-'end', props.body
106
+
107
+ I will be out of the office starting 15.02.2007 and will not return until
108
+ 27.02.2007.
109
+
110
+ I will respond to your message when I return. For urgent enquiries please
111
+ contact Motherine Jacson.
112
+
113
+ end
114
+ end
115
+ end
116
+