tweetwine 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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