segfault-larch 1.0.2.3
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/HISTORY +44 -0
- data/LICENSE +280 -0
- data/README.rdoc +273 -0
- data/bin/larch +123 -0
- data/lib/larch.rb +254 -0
- data/lib/larch/config.rb +105 -0
- data/lib/larch/db/account.rb +12 -0
- data/lib/larch/db/mailbox.rb +12 -0
- data/lib/larch/db/message.rb +6 -0
- data/lib/larch/db/migrate/001_create_schema.rb +42 -0
- data/lib/larch/errors.rb +14 -0
- data/lib/larch/imap.rb +343 -0
- data/lib/larch/imap/mailbox.rb +505 -0
- data/lib/larch/logger.rb +50 -0
- data/lib/larch/version.rb +9 -0
- metadata +106 -0
data/bin/larch
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'highline/import' # optional dep: termios
|
5
|
+
require 'trollop'
|
6
|
+
|
7
|
+
require 'larch'
|
8
|
+
|
9
|
+
module Larch
|
10
|
+
|
11
|
+
# Parse command-line options.
|
12
|
+
options = Trollop.options do
|
13
|
+
version "Larch #{APP_VERSION}\n" << APP_COPYRIGHT
|
14
|
+
banner <<-EOS
|
15
|
+
Larch syncs messages from one IMAP server to another. Awesomely.
|
16
|
+
|
17
|
+
Usage:
|
18
|
+
larch [config section] [options]
|
19
|
+
larch --from <uri> --to <uri> [options]
|
20
|
+
|
21
|
+
Server Options:
|
22
|
+
EOS
|
23
|
+
opt :from, "URI of the source IMAP server.", :short => '-f', :type => :string
|
24
|
+
opt :from_folder, "Source folder to copy from", :short => '-F', :default => Config::DEFAULT['from-folder']
|
25
|
+
opt :from_pass, "Source server password (default: prompt)", :short => '-p', :type => :string
|
26
|
+
opt :from_user, "Source server username (default: prompt)", :short => '-u', :type => :string
|
27
|
+
opt :to, "URI of the destination IMAP server.", :short => '-t', :type => :string
|
28
|
+
opt :to_folder, "Destination folder to copy to", :short => '-T', :default => Config::DEFAULT['to-folder']
|
29
|
+
opt :to_pass, "Destination server password (default: prompt)", :short => '-P', :type => :string
|
30
|
+
opt :to_user, "Destination server username (default: prompt)", :short => '-U', :type => :string
|
31
|
+
|
32
|
+
text "\nSync Options:"
|
33
|
+
opt :all, "Copy all folders recursively", :short => '-a'
|
34
|
+
opt :all_subscribed, "Copy all subscribed folders recursively", :short => '-s'
|
35
|
+
opt :exclude, "List of mailbox names/patterns that shouldn't be copied", :short => :none, :type => :strings, :multi => true
|
36
|
+
opt :exclude_file, "Filename containing mailbox names/patterns that shouldn't be copied", :short => :none, :type => :string
|
37
|
+
|
38
|
+
text "\nGeneral Options:"
|
39
|
+
opt :config, "Specify a non-default config file to use", :short => '-c', :default => Config::DEFAULT['config']
|
40
|
+
opt :database, "Specify a non-default message database to use", :short => :none, :default => Config::DEFAULT['database']
|
41
|
+
opt :dry_run, "Don't actually make any changes", :short => '-n'
|
42
|
+
opt :max_retries, "Maximum number of times to retry after a recoverable error", :short => :none, :default => Config::DEFAULT['max-retries']
|
43
|
+
opt :no_create_folder, "Don't create destination folders that don't already exist", :short => :none
|
44
|
+
opt :ssl_certs, "Path to a trusted certificate bundle to use to verify server SSL certificates", :short => :none, :type => :string
|
45
|
+
opt :ssl_verify, "Verify server SSL certificates", :short => :none
|
46
|
+
opt :verbosity, "Output verbosity: debug, info, warn, error, or fatal", :short => '-V', :default => Config::DEFAULT['verbosity']
|
47
|
+
end
|
48
|
+
|
49
|
+
# Load config.
|
50
|
+
config = Config.new(ARGV.shift || 'default', options[:config], options)
|
51
|
+
|
52
|
+
if options[:config_given]
|
53
|
+
Trollop.die :config, ": file not found: #{options[:config]}" unless File.exist?(options[:config])
|
54
|
+
end
|
55
|
+
|
56
|
+
# Validate config.
|
57
|
+
begin
|
58
|
+
config.validate
|
59
|
+
rescue Config::Error => e
|
60
|
+
abort "Config error: #{e}"
|
61
|
+
end
|
62
|
+
|
63
|
+
# Create URIs.
|
64
|
+
uri_from = URI(config.from)
|
65
|
+
uri_to = URI(config.to)
|
66
|
+
|
67
|
+
# Use --from-folder and --to-folder unless folders were specified in the URIs.
|
68
|
+
uri_from.path ||= '/' + CGI.escape(config.from_folder.gsub(/^\//, ''))
|
69
|
+
uri_to.path ||= '/' + CGI.escape(config.to_folder.gsub(/^\//, ''))
|
70
|
+
|
71
|
+
# --all and --all-subscribed options override folders
|
72
|
+
if config.all || config.all_subscribed
|
73
|
+
uri_from.path = ''
|
74
|
+
uri_to.path = ''
|
75
|
+
end
|
76
|
+
|
77
|
+
# Usernames and passwords specified as arguments override those in the URIs
|
78
|
+
uri_from.user = CGI.escape(config.from_user) if config.from_user
|
79
|
+
uri_from.password = CGI.escape(config.from_pass) if config.from_pass
|
80
|
+
uri_to.user = CGI.escape(config.to_user) if config.to_user
|
81
|
+
uri_to.password = CGI.escape(config.to_pass) if config.to_pass
|
82
|
+
|
83
|
+
# If usernames/passwords aren't specified in either URIs or config, then prompt.
|
84
|
+
uri_from.user ||= CGI.escape(ask("Source username (#{uri_from.host}): "))
|
85
|
+
uri_from.password ||= CGI.escape(ask("Source password (#{uri_from.host}): ") {|q| q.echo = false })
|
86
|
+
uri_to.user ||= CGI.escape(ask("Destination username (#{uri_to.host}): "))
|
87
|
+
uri_to.password ||= CGI.escape(ask("Destination password (#{uri_to.host}): ") {|q| q.echo = false })
|
88
|
+
|
89
|
+
# Go go go!
|
90
|
+
init(config)
|
91
|
+
|
92
|
+
imap_from = Larch::IMAP.new(uri_from,
|
93
|
+
:dry_run => config[:dry_run],
|
94
|
+
:max_retries => config[:max_retries],
|
95
|
+
:ssl_certs => config[:ssl_certs] || nil,
|
96
|
+
:ssl_verify => config[:ssl_verify]
|
97
|
+
)
|
98
|
+
|
99
|
+
imap_to = Larch::IMAP.new(uri_to,
|
100
|
+
:create_mailbox => !config[:no_create_folder] && !config[:dry_run],
|
101
|
+
:dry_run => config[:dry_run],
|
102
|
+
:max_retries => config[:max_retries],
|
103
|
+
:ssl_certs => config[:ssl_certs] || nil,
|
104
|
+
:ssl_verify => config[:ssl_verify]
|
105
|
+
)
|
106
|
+
|
107
|
+
unless RUBY_PLATFORM =~ /mswin|mingw|bccwin|wince|java/
|
108
|
+
begin
|
109
|
+
for sig in [:SIGINT, :SIGQUIT, :SIGTERM]
|
110
|
+
trap(sig) { @log.fatal "Interrupted (#{sig})"; Kernel.exit }
|
111
|
+
end
|
112
|
+
rescue => e
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
if config.all
|
117
|
+
copy_all(imap_from, imap_to)
|
118
|
+
elsif config.all_subscribed
|
119
|
+
copy_all(imap_from, imap_to, true)
|
120
|
+
else
|
121
|
+
copy_folder(imap_from, imap_to)
|
122
|
+
end
|
123
|
+
end
|
data/lib/larch.rb
ADDED
@@ -0,0 +1,254 @@
|
|
1
|
+
# Prepend this file's directory to the include path if it's not there already.
|
2
|
+
$:.unshift(File.dirname(File.expand_path(__FILE__)))
|
3
|
+
$:.uniq!
|
4
|
+
|
5
|
+
require 'cgi'
|
6
|
+
require 'digest/md5'
|
7
|
+
require 'fileutils'
|
8
|
+
require 'net/imap'
|
9
|
+
require 'time'
|
10
|
+
require 'uri'
|
11
|
+
require 'yaml'
|
12
|
+
|
13
|
+
require 'sequel'
|
14
|
+
require 'sequel/extensions/migration'
|
15
|
+
|
16
|
+
require 'larch/config'
|
17
|
+
require 'larch/errors'
|
18
|
+
require 'larch/imap'
|
19
|
+
require 'larch/imap/mailbox'
|
20
|
+
require 'larch/logger'
|
21
|
+
require 'larch/version'
|
22
|
+
|
23
|
+
module Larch
|
24
|
+
|
25
|
+
class << self
|
26
|
+
attr_reader :config, :db, :log, :exclude
|
27
|
+
|
28
|
+
EXCLUDE_COMMENT = /#.*$/
|
29
|
+
EXCLUDE_REGEX = /^\s*\/(.*)\/\s*/
|
30
|
+
GLOB_PATTERNS = {'*' => '.*', '?' => '.'}
|
31
|
+
LIB_DIR = File.join(File.dirname(File.expand_path(__FILE__)), 'larch')
|
32
|
+
|
33
|
+
def init(config)
|
34
|
+
raise ArgumentError, "config must be a Larch::Config instance" unless config.is_a?(Config)
|
35
|
+
|
36
|
+
@config = config
|
37
|
+
@log = Logger.new(@config[:verbosity])
|
38
|
+
@db = open_db(@config[:database])
|
39
|
+
|
40
|
+
@exclude = @config[:exclude].map do |e|
|
41
|
+
if e =~ EXCLUDE_REGEX
|
42
|
+
Regexp.new($1, Regexp::IGNORECASE)
|
43
|
+
else
|
44
|
+
glob_to_regex(e.strip)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
load_exclude_file(@config[:exclude_file]) if @config[:exclude_file]
|
49
|
+
|
50
|
+
Net::IMAP.debug = true if @log.level == :insane
|
51
|
+
|
52
|
+
# Stats
|
53
|
+
@copied = 0
|
54
|
+
@failed = 0
|
55
|
+
@total = 0
|
56
|
+
end
|
57
|
+
|
58
|
+
# Recursively copies all messages in all folders from the source to the
|
59
|
+
# destination.
|
60
|
+
def copy_all(imap_from, imap_to, subscribed_only = false)
|
61
|
+
raise ArgumentError, "imap_from must be a Larch::IMAP instance" unless imap_from.is_a?(IMAP)
|
62
|
+
raise ArgumentError, "imap_to must be a Larch::IMAP instance" unless imap_to.is_a?(IMAP)
|
63
|
+
|
64
|
+
@copied = 0
|
65
|
+
@failed = 0
|
66
|
+
@total = 0
|
67
|
+
|
68
|
+
imap_from.each_mailbox do |mailbox_from|
|
69
|
+
next if excluded?(mailbox_from.name)
|
70
|
+
next if subscribed_only && !mailbox_from.subscribed?
|
71
|
+
|
72
|
+
mailbox_to = imap_to.mailbox(mailbox_from.name, mailbox_from.delim)
|
73
|
+
mailbox_to.subscribe if mailbox_from.subscribed?
|
74
|
+
|
75
|
+
copy_messages(mailbox_from, mailbox_to)
|
76
|
+
end
|
77
|
+
|
78
|
+
rescue => e
|
79
|
+
@log.fatal e.message
|
80
|
+
|
81
|
+
ensure
|
82
|
+
summary
|
83
|
+
end
|
84
|
+
|
85
|
+
# Copies the messages in a single IMAP folder and all its subfolders
|
86
|
+
# (recursively) from the source to the destination.
|
87
|
+
def copy_folder(imap_from, imap_to)
|
88
|
+
raise ArgumentError, "imap_from must be a Larch::IMAP instance" unless imap_from.is_a?(IMAP)
|
89
|
+
raise ArgumentError, "imap_to must be a Larch::IMAP instance" unless imap_to.is_a?(IMAP)
|
90
|
+
|
91
|
+
@copied = 0
|
92
|
+
@failed = 0
|
93
|
+
@total = 0
|
94
|
+
|
95
|
+
mailbox_from = imap_from.mailbox(imap_from.uri_mailbox || 'INBOX')
|
96
|
+
mailbox_to = imap_to.mailbox(imap_to.uri_mailbox || 'INBOX')
|
97
|
+
|
98
|
+
copy_mailbox(mailbox_from, mailbox_to)
|
99
|
+
|
100
|
+
imap_from.disconnect
|
101
|
+
imap_to.disconnect
|
102
|
+
|
103
|
+
rescue => e
|
104
|
+
@log.fatal e.message
|
105
|
+
|
106
|
+
ensure
|
107
|
+
summary
|
108
|
+
end
|
109
|
+
|
110
|
+
# Opens a connection to the Larch message database, creating it if
|
111
|
+
# necessary.
|
112
|
+
def open_db(database)
|
113
|
+
filename = File.expand_path(database)
|
114
|
+
directory = File.dirname(filename)
|
115
|
+
|
116
|
+
unless File.exist?(directory)
|
117
|
+
FileUtils.mkdir_p(directory)
|
118
|
+
File.chmod(0700, directory)
|
119
|
+
end
|
120
|
+
|
121
|
+
begin
|
122
|
+
db = Sequel.connect("sqlite://#{filename}")
|
123
|
+
db.test_connection
|
124
|
+
rescue => e
|
125
|
+
@log.fatal "unable to open message database: #{e}"
|
126
|
+
abort
|
127
|
+
end
|
128
|
+
|
129
|
+
# Ensure that the database schema is up to date.
|
130
|
+
migration_dir = File.join(LIB_DIR, 'db', 'migrate')
|
131
|
+
|
132
|
+
unless Sequel::Migrator.get_current_migration_version(db) ==
|
133
|
+
Sequel::Migrator.latest_migration_version(migration_dir)
|
134
|
+
begin
|
135
|
+
Sequel::Migrator.apply(db, migration_dir)
|
136
|
+
rescue => e
|
137
|
+
@log.fatal "unable to migrate message database: #{e}"
|
138
|
+
abort
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
require 'larch/db/message'
|
143
|
+
require 'larch/db/mailbox'
|
144
|
+
require 'larch/db/account'
|
145
|
+
|
146
|
+
db
|
147
|
+
end
|
148
|
+
|
149
|
+
def summary
|
150
|
+
@log.info "#{@copied} message(s) copied, #{@failed} failed, #{@total - @copied - @failed} untouched out of #{@total} total"
|
151
|
+
end
|
152
|
+
|
153
|
+
private
|
154
|
+
|
155
|
+
def copy_mailbox(mailbox_from, mailbox_to)
|
156
|
+
raise ArgumentError, "mailbox_from must be a Larch::IMAP::Mailbox instance" unless mailbox_from.is_a?(Larch::IMAP::Mailbox)
|
157
|
+
raise ArgumentError, "mailbox_to must be a Larch::IMAP::Mailbox instance" unless mailbox_to.is_a?(Larch::IMAP::Mailbox)
|
158
|
+
|
159
|
+
return if excluded?(mailbox_from.name) || excluded?(mailbox_to.name)
|
160
|
+
|
161
|
+
mailbox_to.subscribe if mailbox_from.subscribed?
|
162
|
+
copy_messages(mailbox_from, mailbox_to)
|
163
|
+
|
164
|
+
mailbox_from.each_mailbox do |child_from|
|
165
|
+
next if excluded?(child_from.name)
|
166
|
+
child_to = mailbox_to.imap.mailbox(child_from.name, child_from.delim)
|
167
|
+
copy_mailbox(child_from, child_to)
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def copy_messages(mailbox_from, mailbox_to)
|
172
|
+
raise ArgumentError, "mailbox_from must be a Larch::IMAP::Mailbox instance" unless mailbox_from.is_a?(Larch::IMAP::Mailbox)
|
173
|
+
raise ArgumentError, "mailbox_to must be a Larch::IMAP::Mailbox instance" unless mailbox_to.is_a?(Larch::IMAP::Mailbox)
|
174
|
+
|
175
|
+
return if excluded?(mailbox_from.name) || excluded?(mailbox_to.name)
|
176
|
+
|
177
|
+
imap_from = mailbox_from.imap
|
178
|
+
imap_to = mailbox_to.imap
|
179
|
+
|
180
|
+
@log.info "copying messages from #{imap_from.host}/#{mailbox_from.name} to #{imap_to.host}/#{mailbox_to.name}"
|
181
|
+
|
182
|
+
@total += mailbox_from.length
|
183
|
+
|
184
|
+
mailbox_from.each_guid do |guid|
|
185
|
+
next if mailbox_to.has_guid?(guid)
|
186
|
+
|
187
|
+
begin
|
188
|
+
next unless msg = mailbox_from.peek(guid)
|
189
|
+
|
190
|
+
if msg.envelope.from
|
191
|
+
env_from = msg.envelope.from.first
|
192
|
+
from = "#{env_from.mailbox}@#{env_from.host}"
|
193
|
+
else
|
194
|
+
from = '?'
|
195
|
+
end
|
196
|
+
|
197
|
+
@log.info "copying message: #{from} - #{msg.envelope.subject}"
|
198
|
+
|
199
|
+
mailbox_to << msg
|
200
|
+
@copied += 1
|
201
|
+
|
202
|
+
rescue Larch::IMAP::Error => e
|
203
|
+
@failed += 1
|
204
|
+
@log.error e.message
|
205
|
+
next
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def excluded?(name)
|
211
|
+
name = name.downcase
|
212
|
+
|
213
|
+
@exclude.each do |e|
|
214
|
+
return true if (e.is_a?(Regexp) ? !!(name =~ e) : File.fnmatch?(e, name))
|
215
|
+
end
|
216
|
+
|
217
|
+
return false
|
218
|
+
end
|
219
|
+
|
220
|
+
def glob_to_regex(str)
|
221
|
+
str.gsub!(/(.)/) {|c| GLOB_PATTERNS[$1] || Regexp.escape(c) }
|
222
|
+
Regexp.new("^#{str}$", Regexp::IGNORECASE)
|
223
|
+
end
|
224
|
+
|
225
|
+
def load_exclude_file(filename)
|
226
|
+
@exclude ||= []
|
227
|
+
lineno = 0
|
228
|
+
|
229
|
+
File.open(filename, 'rb') do |f|
|
230
|
+
f.each do |line|
|
231
|
+
lineno += 1
|
232
|
+
|
233
|
+
# Strip comments.
|
234
|
+
line.sub!(EXCLUDE_COMMENT, '')
|
235
|
+
line.strip!
|
236
|
+
|
237
|
+
# Skip empty lines.
|
238
|
+
next if line.empty?
|
239
|
+
|
240
|
+
if line =~ EXCLUDE_REGEX
|
241
|
+
@exclude << Regexp.new($1, Regexp::IGNORECASE)
|
242
|
+
else
|
243
|
+
@exclude << glob_to_regex(line)
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
rescue => e
|
249
|
+
raise Larch::IMAP::FatalError, "error in exclude file at line #{lineno}: #{e}"
|
250
|
+
end
|
251
|
+
|
252
|
+
end
|
253
|
+
|
254
|
+
end
|
data/lib/larch/config.rb
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
module Larch
|
2
|
+
|
3
|
+
class Config
|
4
|
+
attr_reader :filename, :section
|
5
|
+
|
6
|
+
DEFAULT = {
|
7
|
+
'all' => false,
|
8
|
+
'all-subscribed' => false,
|
9
|
+
'config' => File.join('~', '.larch', 'config.yaml'),
|
10
|
+
'database' => File.join('~', '.larch', 'larch.db'),
|
11
|
+
'dry-run' => false,
|
12
|
+
'exclude' => [],
|
13
|
+
'exclude-file' => nil,
|
14
|
+
'from' => nil,
|
15
|
+
'from-folder' => 'INBOX',
|
16
|
+
'from-pass' => nil,
|
17
|
+
'from-user' => nil,
|
18
|
+
'max-retries' => 3,
|
19
|
+
'no-create-folder' => false,
|
20
|
+
'ssl-certs' => nil,
|
21
|
+
'ssl-verify' => false,
|
22
|
+
'to' => nil,
|
23
|
+
'to-folder' => 'INBOX',
|
24
|
+
'to-pass' => nil,
|
25
|
+
'to-user' => nil,
|
26
|
+
'verbosity' => 'info'
|
27
|
+
}.freeze
|
28
|
+
|
29
|
+
def initialize(section = 'default', filename = DEFAULT['config'], override = {})
|
30
|
+
@section = section.to_s
|
31
|
+
@override = {}
|
32
|
+
|
33
|
+
override.each do |k, v|
|
34
|
+
k = k.to_s.gsub('_', '-')
|
35
|
+
@override[k] = v if DEFAULT.has_key?(k) && v != DEFAULT[k]
|
36
|
+
end
|
37
|
+
|
38
|
+
load_file(filename)
|
39
|
+
end
|
40
|
+
|
41
|
+
def fetch(name)
|
42
|
+
(@cached || {})[name.to_s.gsub('_', '-')] || nil
|
43
|
+
end
|
44
|
+
alias [] fetch
|
45
|
+
|
46
|
+
def load_file(filename)
|
47
|
+
@filename = File.expand_path(filename)
|
48
|
+
|
49
|
+
config = {}
|
50
|
+
|
51
|
+
if File.exist?(@filename)
|
52
|
+
begin
|
53
|
+
config = YAML.load_file(@filename)
|
54
|
+
rescue => e
|
55
|
+
raise Larch::Config::Error, "config error in #{filename}: #{e}"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
@lookup = [@override, config[@section] || {}, config['default'] || {}, DEFAULT]
|
60
|
+
cache_config
|
61
|
+
end
|
62
|
+
|
63
|
+
def method_missing(name)
|
64
|
+
fetch(name)
|
65
|
+
end
|
66
|
+
|
67
|
+
def validate
|
68
|
+
['from', 'to'].each do |s|
|
69
|
+
raise Error, "'#{s}' must be a valid IMAP URI (e.g. imap://example.com)" unless fetch(s) =~ IMAP::REGEX_URI
|
70
|
+
end
|
71
|
+
|
72
|
+
unless Logger::LEVELS.has_key?(verbosity.to_sym)
|
73
|
+
raise Error, "'verbosity' must be one of: #{Logger::LEVELS.keys.join(', ')}"
|
74
|
+
end
|
75
|
+
|
76
|
+
if exclude_file
|
77
|
+
raise Error, "exclude file not found: #{exclude_file}" unless File.file?(exclude_file)
|
78
|
+
raise Error, "exclude file cannot be read: #{exclude_file}" unless File.readable?(exclude_file)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
# Merges configs such that those earlier in the lookup chain override those
|
85
|
+
# later in the chain.
|
86
|
+
def cache_config
|
87
|
+
@cached = {}
|
88
|
+
|
89
|
+
@lookup.reverse.each do |c|
|
90
|
+
c.each {|k, v| @cached[k] = config_merge(@cached[k] || {}, v) }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def config_merge(master, value)
|
95
|
+
if value.is_a?(Hash)
|
96
|
+
value.each {|k, v| master[k] = config_merge(master[k] || {}, v) }
|
97
|
+
return master
|
98
|
+
end
|
99
|
+
|
100
|
+
value
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|