tweetwine 0.3.2 → 0.4.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.
Files changed (46) hide show
  1. data/.gitignore +6 -0
  2. data/CHANGELOG.rdoc +9 -0
  3. data/Gemfile +5 -13
  4. data/LICENSE.txt +1 -1
  5. data/README.md +3 -2
  6. data/Rakefile +8 -2
  7. data/lib/tweetwine/character_encoding.rb +1 -1
  8. data/lib/tweetwine/cli.rb +9 -3
  9. data/lib/tweetwine/config.rb +3 -3
  10. data/lib/tweetwine/exceptions.rb +54 -0
  11. data/lib/tweetwine/http.rb +1 -1
  12. data/lib/tweetwine/{util.rb → support.rb} +19 -12
  13. data/lib/tweetwine/tweet.rb +69 -0
  14. data/lib/tweetwine/twitter.rb +70 -72
  15. data/lib/tweetwine/ui.rb +36 -41
  16. data/lib/tweetwine/uri.rb +31 -0
  17. data/lib/tweetwine/version.rb +15 -0
  18. data/lib/tweetwine.rb +6 -64
  19. data/man/tweetwine.7 +4 -3
  20. data/man/tweetwine.7.ronn +3 -2
  21. data/release-script.txt +10 -0
  22. data/test/example/authorization_example.rb +40 -0
  23. data/test/example/example_helper.rb +1 -1
  24. data/test/example/global_options_example.rb +64 -0
  25. data/test/example/search_statuses_example.rb +36 -31
  26. data/test/example/show_followers_example.rb +1 -1
  27. data/test/example/show_friends_example.rb +1 -1
  28. data/test/example/show_home_example.rb +17 -29
  29. data/test/example/show_mentions_example.rb +2 -2
  30. data/test/example/show_user_example.rb +14 -12
  31. data/test/example/update_status_example.rb +9 -9
  32. data/test/example/use_http_proxy_example.rb +7 -6
  33. data/test/example/{application_behavior_example.rb → user_help_example.rb} +6 -39
  34. data/test/unit/config_test.rb +1 -1
  35. data/test/unit/http_test.rb +1 -21
  36. data/test/unit/oauth_test.rb +11 -11
  37. data/test/unit/{util_test.rb → support_test.rb} +37 -38
  38. data/test/unit/tweet_helper.rb +83 -0
  39. data/test/unit/tweet_test.rb +153 -0
  40. data/test/unit/twitter_test.rb +240 -248
  41. data/test/unit/ui_test.rb +174 -78
  42. data/test/unit/unit_helper.rb +18 -6
  43. data/test/unit/uri_test.rb +41 -0
  44. data/test/unit/url_shortener_test.rb +7 -7
  45. data/tweetwine.gemspec +12 -22
  46. metadata +52 -73
data/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ /*.gem
2
+ /.bundle/
3
+ /Gemfile.lock
4
+ /coverage/
5
+ /man/
6
+ !/man/*.ronn
data/CHANGELOG.rdoc CHANGED
@@ -1,3 +1,12 @@
1
+ === 0.4.0 released 2011-02-22
2
+
3
+ * Add option <tt>-r</tt> to reverse the order of showing tweets
4
+ * Show retweets as whole tweets
5
+ * Properly display error message on invalid argument to <tt>--page</tt> or
6
+ <tt>--num</tt> options
7
+ * Fix deprecation warning about <tt>URI.escape</tt> on MRI 1.9.2
8
+ * Minor cleanups
9
+
1
10
  === 0.3.2 released 2010-11-17
2
11
 
3
12
  * Drop <tt>json</tt> gem dependency from gemspec in order to allow the user to
data/Gemfile CHANGED
@@ -1,17 +1,9 @@
1
+ # coding: utf-8
2
+
1
3
  source :rubygems
2
4
 
5
+ gemspec
6
+
7
+ # Special handling at runtime.
3
8
  gem 'json', '>= 1.0.0', :platforms => [:ruby_18, :jruby]
4
9
  gem 'nokogiri', '~> 1.4.4'
5
- gem 'oauth', '~> 0.4.4'
6
-
7
- group :test do
8
- gem 'contest', '~> 0.1.2'
9
- gem 'coulda', '~> 0.6.0'
10
- gem 'gem-man', '~> 0.2.0'
11
- gem 'mcmire-matchy', '~> 0.5.2', :require => 'matchy'
12
- gem 'mocha', '= 0.9.8'
13
- gem 'open4', '~> 1.0.1'
14
- gem 'ronn', '~> 0.7.3'
15
- gem 'timecop', '~> 0.3.5'
16
- gem 'webmock', '~> 1.6.1'
17
- end
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2009-2010 Tuomas Kareinen
1
+ Copyright (c) 2009-2011 Tuomas Kareinen
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy
4
4
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -77,6 +77,7 @@ enabled via `shorten_urls` field in the configuration file; for example:
77
77
 
78
78
  username: spoonman
79
79
  colors: true
80
+ show_reverse: true
80
81
  shorten_urls:
81
82
  service_url: http://is.gd/create.php
82
83
  method: post
@@ -124,8 +125,8 @@ snippet to your Bash initialization script (such as `~/.bashrc`):
124
125
 
125
126
  ## COPYRIGHT
126
127
 
127
- Tweetwine is Copyright (c) 2009-2010 Tuomas Kareinen.
128
+ Tweetwine is copyright &copy; 2009-2011 Tuomas Kareinen. See `LICENSE.txt`.
128
129
 
129
130
  ## SEE ALSO
130
131
 
131
- <https://github.com/tuomas/tweetwine>
132
+ <https://github.com/tkareine/tweetwine>
data/Rakefile CHANGED
@@ -4,8 +4,8 @@ require 'rake/clean'
4
4
 
5
5
  $LOAD_PATH.unshift(File.expand_path('lib', File.dirname(__FILE__)))
6
6
  name = 'tweetwine'
7
- require name
8
- version = Tweetwine.version.dup
7
+ require "#{name}/version"
8
+ version = Tweetwine.version
9
9
 
10
10
  namespace :gem do
11
11
  CLOBBER.include "#{name}-*.gem"
@@ -42,6 +42,12 @@ namespace :man do
42
42
  end
43
43
  end
44
44
 
45
+ CLOBBER.include 'rdoc'
46
+ desc "Generate RDoc"
47
+ task :rdoc do
48
+ sh %{rdoc --encoding=UTF-8 --line-numbers --title='#{name} #{version}' --output=rdoc *.rdoc LICENSE.txt lib}
49
+ end
50
+
45
51
  namespace :test do
46
52
  def create_test_task(type, options = {})
47
53
  base_dir = options[:base_dir]
@@ -52,7 +52,7 @@ module Tweetwine
52
52
  def guess_external_encoding_from_env_lang
53
53
  lang = ENV['LANG']
54
54
  return 'UTF-8' if lang =~ /(utf-8|utf8)\z/i
55
- Util.blank?(lang) ? nil : lang
55
+ Support.presence(lang)
56
56
  end
57
57
  end
58
58
  end
data/lib/tweetwine/cli.rb CHANGED
@@ -8,6 +8,9 @@ module Tweetwine
8
8
  :config_file => "#{(ENV['HOME'] || ENV['USERPROFILE'])}/.tweetwine",
9
9
  :env_lookouts => [:http_proxy],
10
10
  :excludes => [:command],
11
+ :num_tweets => 20,
12
+ :page => 1,
13
+ :show_reverse => false,
11
14
  :shorten_urls => {:disable => true},
12
15
  :username => ENV['USER']
13
16
  }.freeze
@@ -84,12 +87,15 @@ module Tweetwine
84
87
  options[:shorten_urls] ||= {}
85
88
  options[:shorten_urls][:disable] = true
86
89
  end
87
- parser.on '-n', '--num <n>', Integer, "Number of statuses per page (default #{Twitter::DEFAULT_NUM_STATUSES})." do |arg|
88
- options[:num_statuses] = arg
90
+ parser.on '-n', '--num <n>', Integer, "Number of tweets per page (default #{DEFAULT_CONFIG[:num_tweets]})." do |arg|
91
+ options[:num_tweets] = arg
89
92
  end
90
- parser.on '-p', '--page <p>', Integer, "Page number for statuses (default #{Twitter::DEFAULT_PAGE_NUM})." do |arg|
93
+ parser.on '-p', '--page <p>', Integer, "Page number for tweets (default #{DEFAULT_CONFIG[:page]})." do |arg|
91
94
  options[:page] = arg
92
95
  end
96
+ parser.on '-r', '--reverse', "Show tweets in reverse order (default #{DEFAULT_CONFIG[:show_reverse]})." do
97
+ options[:show_reverse] = true
98
+ end
93
99
  parser.on '-u', '--username <user>', String, "User to authenticate (default '#{DEFAULT_CONFIG[:username]}')." do |arg|
94
100
  options[:username] = arg
95
101
  end
@@ -27,7 +27,7 @@ module Tweetwine
27
27
  should_set_file_access_to_user_only = !File.exist?(@file)
28
28
  File.open(@file, 'w') do |io|
29
29
  io.chmod(0600) if should_set_file_access_to_user_only
30
- YAML.dump(Util.stringify_hash_keys(to_file), io)
30
+ YAML.dump(Support.stringify_hash_keys(to_file), io)
31
31
  end
32
32
  end
33
33
 
@@ -52,14 +52,14 @@ module Tweetwine
52
52
  def self.parse_env_vars(env_lookouts)
53
53
  env_lookouts.inject({}) do |result, env_var_name|
54
54
  env_option = ENV[env_var_name.to_s]
55
- result[env_var_name.to_sym] = env_option unless Util.blank?(env_option)
55
+ result[env_var_name.to_sym] = env_option if Support.present?(env_option)
56
56
  result
57
57
  end
58
58
  end
59
59
 
60
60
  def self.parse_config_file(config_file)
61
61
  options = File.open(config_file, 'r') { |io| YAML.load(io) }
62
- Util.symbolize_hash_keys(options)
62
+ Support.symbolize_hash_keys(options)
63
63
  end
64
64
  end
65
65
  end
@@ -0,0 +1,54 @@
1
+ # coding: utf-8
2
+
3
+ module Tweetwine
4
+ class Error < StandardError
5
+ @status_code = 42
6
+
7
+ # Idea got from Bundler.
8
+ def self.status_code(code = nil)
9
+ return @status_code unless code
10
+ @status_code = code
11
+ end
12
+
13
+ def status_code
14
+ self.class.status_code
15
+ end
16
+ end
17
+
18
+ class CommandLineError < Error; status_code(13); end
19
+ class UnknownCommandError < Error; status_code(14); end
20
+
21
+ class RequiredOptionError < Error
22
+ status_code(15)
23
+
24
+ attr_reader :key, :owner
25
+
26
+ def initialize(key, owner)
27
+ @key, @owner = key, owner
28
+ end
29
+
30
+ def to_s
31
+ "#{key} is required for #{owner}"
32
+ end
33
+ end
34
+
35
+ class ConnectionError < Error; status_code(21); end
36
+ class TimeoutError < Error; status_code(22); end
37
+
38
+ class HttpError < Error
39
+ status_code(25)
40
+
41
+ attr_reader :http_code, :http_message
42
+
43
+ def initialize(code, message)
44
+ @http_code, @http_message = code.to_i, message
45
+ end
46
+
47
+ def to_s
48
+ "#{http_code} #{http_message}"
49
+ end
50
+ end
51
+
52
+ class TranscodeError < Error; status_code(31); end
53
+ class AuthorizationError < Error; status_code(32); end
54
+ end
@@ -74,7 +74,7 @@ module Tweetwine
74
74
  end
75
75
 
76
76
  def requesting(url)
77
- uri = URI.parse(url)
77
+ uri = Uri.parse(url)
78
78
  connection = @http.new(uri.host, uri.port)
79
79
  configure_for_ssl(connection) if https_scheme?(uri)
80
80
  response = yield connection, uri
@@ -1,15 +1,26 @@
1
1
  # coding: utf-8
2
2
 
3
- require "cgi"
4
- require "time"
5
- require "uri"
3
+ require 'cgi'
4
+ require 'time'
6
5
 
7
6
  module Tweetwine
8
- module Util
7
+ module Support
9
8
  extend self
10
9
 
11
- def blank?(str)
12
- str.nil? || str.empty?
10
+ def blank?(var)
11
+ var.nil? || var.empty?
12
+ end
13
+
14
+ def present?(var)
15
+ !blank?(var)
16
+ end
17
+
18
+ def presence(var)
19
+ if present? var
20
+ block_given? ? yield(var) : var
21
+ else
22
+ nil
23
+ end
13
24
  end
14
25
 
15
26
  def humanize_time_diff(from, to)
@@ -69,10 +80,6 @@ module Tweetwine
69
80
  dup_str
70
81
  end
71
82
 
72
- def percent_encode(str)
73
- URI.escape(str.to_s, /[^#{URI::PATTERN::UNRESERVED}]/)
74
- end
75
-
76
83
  def unescape_html(str)
77
84
  CGI.unescapeHTML(str.gsub('&nbsp;', ' '))
78
85
  end
@@ -99,8 +106,8 @@ module Tweetwine
99
106
  end
100
107
 
101
108
  def pluralize_unit(value, unit)
102
- if ["hour", "day"].include?(unit) && value > 1
103
- unit = unit + "s"
109
+ if %w{hour day}.include?(unit) && value > 1
110
+ unit = unit + 's'
104
111
  end
105
112
  unit
106
113
  end
@@ -0,0 +1,69 @@
1
+ # coding: utf-8
2
+
3
+ module Tweetwine
4
+ class Tweet
5
+ attr_reader :from_user, :to_user, :rt_user, :created_at, :status
6
+
7
+ def initialize(record, paths)
8
+ if field_present? record, paths[:retweet]
9
+ @rt_user = parse_string_field record, paths[:from_user]
10
+ fields = Support.find_hash_path record, paths[:retweet]
11
+ else
12
+ @rt_user = nil
13
+ fields = record
14
+ end
15
+ @to_user = parse_string_field fields, paths[:to_user]
16
+ @from_user = parse_string_field fields, paths[:from_user]
17
+ raise ArgumentError, 'from user record field is required' unless @from_user
18
+ @created_at = parse_time_field fields, paths[:created_at]
19
+ @status = parse_string_field fields, paths[:status]
20
+ end
21
+
22
+ def timestamped?
23
+ !@created_at.nil?
24
+ end
25
+
26
+ def retweet?
27
+ !@rt_user.nil?
28
+ end
29
+
30
+ def status?
31
+ !@status.nil?
32
+ end
33
+
34
+ def reply?
35
+ !@to_user.nil?
36
+ end
37
+
38
+ def ==(other)
39
+ other.is_a?(self.class) &&
40
+ self.rt_user == other.rt_user &&
41
+ self.to_user == other.to_user &&
42
+ self.from_user == other.from_user &&
43
+ self.created_at == other.created_at &&
44
+ self.status == other.status
45
+ end
46
+
47
+ private
48
+
49
+ def field_present?(record, path)
50
+ !!find_field(record, path) { |f| Support.present?(f) }
51
+ end
52
+
53
+ def field_presence(record, path, &block)
54
+ find_field(record, path) { |f| Support.presence(f, &block) }
55
+ end
56
+
57
+ def parse_string_field(record, path)
58
+ field_presence(record, path) { |f| f.to_s }
59
+ end
60
+
61
+ def parse_time_field(record, path)
62
+ field_presence(record, path) { |f| Time.parse(f.to_s) }
63
+ end
64
+
65
+ def find_field(record, path)
66
+ yield(Support.find_hash_path(record, path)) rescue nil
67
+ end
68
+ end
69
+ end
@@ -4,36 +4,56 @@ require "uri"
4
4
 
5
5
  module Tweetwine
6
6
  class Twitter
7
- DEFAULT_NUM_STATUSES = 20
8
- DEFAULT_PAGE_NUM = 1
9
7
  MAX_STATUS_LENGTH = 140
10
8
 
11
- attr_reader :num_statuses, :page, :username
9
+ REST_API_STATUS_PATHS = {
10
+ :from_user => %w{user screen_name},
11
+ :to_user => %w{in_reply_to_screen_name},
12
+ :retweet => %w{retweeted_status},
13
+ :created_at => %w{created_at},
14
+ :status => %w{text}
15
+ }
16
+
17
+ REST_API_USER_PATHS = {
18
+ :from_user => %w{screen_name},
19
+ :to_user => %w{status in_reply_to_screen_name},
20
+ :retweet => %w{retweeted_status},
21
+ :created_at => %w{status created_at},
22
+ :status => %w{status text}
23
+ }
24
+
25
+ SEARCH_API_STATUS_PATHS = {
26
+ :from_user => %w{from_user},
27
+ :to_user => %w{to_user},
28
+ :retweet => %w{retweeted_status},
29
+ :created_at => %w{created_at},
30
+ :status => %w{text}
31
+ }
32
+
33
+ attr_reader :num_tweets, :page, :username
12
34
 
13
35
  def initialize(options = {})
14
- @num_statuses = Util.parse_int_gt(options[:num_statuses], DEFAULT_NUM_STATUSES, 1, "number of statuses to show")
15
- @page = Util.parse_int_gt(options[:page], DEFAULT_PAGE_NUM, 1, "page number")
16
- @username = options[:username].to_s
36
+ @num_tweets = Support.parse_int_gt(options[:num_tweets], CLI::DEFAULT_CONFIG[:num_tweets], 1, "number of tweets to show")
37
+ @page = Support.parse_int_gt(options[:page], CLI::DEFAULT_CONFIG[:page], 1, "page number")
38
+ @username = options[:username].to_s
39
+ rescue ArgumentError => e
40
+ raise CommandLineError, e
17
41
  end
18
42
 
19
43
  def followers
20
- response = get_from_rest_api "statuses/followers"
21
- show_users_from_rest_api(*response)
44
+ show_users_from_rest_api(get_from_rest_api("statuses/followers"))
22
45
  end
23
46
 
24
47
  def friends
25
- response = get_from_rest_api "statuses/friends"
26
- show_users_from_rest_api(*response)
48
+ show_users_from_rest_api(get_from_rest_api("statuses/friends"))
27
49
  end
28
50
 
29
51
  def home
30
- response = get_from_rest_api "statuses/home_timeline"
31
- show_statuses_from_rest_api(*response)
52
+ show_tweets_from_rest_api(get_from_rest_api("statuses/home_timeline"))
32
53
  end
33
54
 
34
55
  def mentions
35
- response = get_from_rest_api "statuses/mentions"
36
- show_statuses_from_rest_api(*response)
56
+ show_tweets_from_rest_api(get_from_rest_api("statuses/mentions"))
37
57
  end
38
58
 
39
59
  def search(words = [], operator = nil)
@@ -41,7 +61,7 @@ module Tweetwine
41
61
  operator = :and unless operator
42
62
  query = operator == :and ? words.join(' ') : words.join(' OR ')
43
63
  response = get_from_search_api query
44
- show_statuses_from_search_api(*response["results"])
64
+ show_tweets_from_search_api(response["results"])
45
65
  end
46
66
 
47
67
  def update(msg = nil)
@@ -53,7 +73,7 @@ module Tweetwine
53
73
  if CLI.ui.confirm("Really send?")
54
74
  response = post_to_rest_api("statuses/update", :status => status_in_utf8)
55
75
  CLI.ui.info "Sent status update.\n\n"
56
- show_statuses_from_rest_api response
76
+ show_tweets_from_rest_api([response])
57
77
  completed = true
58
78
  end
59
79
  end
@@ -61,25 +81,24 @@ module Tweetwine
61
81
  end
62
82
 
63
83
  def user(who = username)
64
- response = get_from_rest_api(
84
+ show_tweets_from_rest_api(get_from_rest_api(
65
85
  "statuses/user_timeline",
66
86
  common_rest_api_query_params.merge!({ :screen_name => who })
67
- )
68
- show_statuses_from_rest_api(*response)
87
+ ))
69
88
  end
70
89
 
71
90
  private
72
91
 
73
92
  def common_rest_api_query_params
74
93
  {
75
- :count => @num_statuses,
94
+ :count => @num_tweets,
76
95
  :page => @page
77
96
  }
78
97
  end
79
98
 
80
99
  def common_search_api_query_params
81
100
  {
82
- :rpp => @num_statuses,
101
+ :rpp => @num_tweets,
83
102
  :page => @page
84
103
  }
85
104
  end
@@ -113,7 +132,7 @@ module Tweetwine
113
132
  end
114
133
 
115
134
  def get_from_search_api(query, params = common_search_api_query_params)
116
- query = "q=#{Util.percent_encode(query)}&" << format_query_params(params)
135
+ query = "q=#{Uri.percent_encode(query)}&" << format_query_params(params)
117
136
  JSON.parse search_api["search.json?#{query}"].get
118
137
  end
119
138
 
@@ -133,55 +152,34 @@ module Tweetwine
133
152
  CLI.config.save
134
153
  end
135
154
 
136
- def show_statuses_from_rest_api(*responses)
137
- show_records(
138
- responses,
139
- {
140
- :from_user => ["user", "screen_name"],
141
- :to_user => "in_reply_to_screen_name",
142
- :created_at => "created_at",
143
- :status => "text"
144
- }
145
- )
146
- end
147
-
148
- def show_users_from_rest_api(*responses)
149
- show_records(
150
- responses,
151
- {
152
- :from_user => "screen_name",
153
- :to_user => ["status", "in_reply_to_screen_name"],
154
- :created_at => ["status", "created_at"],
155
- :status => ["status", "text"]
156
- }
157
- )
158
- end
159
-
160
- def show_statuses_from_search_api(*responses)
161
- show_records(
162
- responses,
163
- {
164
- :from_user => "from_user",
165
- :to_user => "to_user",
166
- :created_at => "created_at",
167
- :status => "text"
168
- }
169
- )
170
- end
171
-
172
- def show_records(twitter_records, paths)
173
- twitter_records.each do |twitter_record|
174
- internal_record = [ :from_user, :to_user, :created_at, :status ].inject({}) do |result, key|
175
- result[key] = Util.find_hash_path(twitter_record, paths[key])
176
- result
155
+ def show_tweets_from_rest_api(records)
156
+ show_tweets(records, REST_API_STATUS_PATHS)
157
+ end
158
+
159
+ def show_users_from_rest_api(records)
160
+ show_tweets(records, REST_API_USER_PATHS)
161
+ end
162
+
163
+ def show_tweets_from_search_api(records)
164
+ show_tweets(records, SEARCH_API_STATUS_PATHS)
165
+ end
166
+
167
+ def show_tweets(records, paths)
168
+ tweets = records.map do |record|
169
+ begin
170
+ Tweet.new(record, paths)
171
+ rescue ArgumentError
172
+ CLI.ui.warn "Invalid tweet. Skipping..."
173
+ nil
177
174
  end
178
- CLI.ui.show_record(internal_record)
179
175
  end
176
+ tweets.reject! { |tweet| tweet.nil? }
177
+ CLI.ui.show_tweets tweets
180
178
  end
181
179
 
182
180
  def create_status_update(status)
183
- status = if Util.blank? status
184
- CLI.ui.prompt("Status update")
181
+ status = if Support.blank? status
182
+ CLI.ui.prompt "Status update"
185
183
  else
186
184
  status.dup
187
185
  end
@@ -192,11 +190,11 @@ module Tweetwine
192
190
  end
193
191
 
194
192
  def shorten_urls_in(status)
195
- url_pairs = URI.
196
- extract(status, %w{http https}).
197
- uniq.
198
- map { |full_url| [full_url, CLI.url_shortener.shorten(full_url)] }.
199
- reject { |(full_url, short_url)| Util.blank? short_url }
193
+ url_pairs = Uri.
194
+ extract(status, %w{http https}).
195
+ uniq.
196
+ map { |full_url| [full_url, CLI.url_shortener.shorten(full_url)] }.
197
+ reject { |(full_url, short_url)| Support.blank? short_url }
200
198
  url_pairs.each { |(full_url, short_url)| status.gsub!(full_url, short_url) }
201
199
  rescue HttpError, LoadError => e
202
200
  CLI.ui.warn "#{e}\nSkipping URL shortening..."
@@ -204,7 +202,7 @@ module Tweetwine
204
202
 
205
203
  def truncate_status(status)
206
204
  status.replace status[0...MAX_STATUS_LENGTH]
207
- CLI.ui.warn("Status will be truncated.")
205
+ CLI.ui.warn "Status will be truncated."
208
206
  end
209
207
  end
210
208
  end