feed2email 0.8.0 → 0.9.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.
@@ -1,126 +1,155 @@
1
1
  require 'thor'
2
- require 'feed2email'
3
- require 'feed2email/feed_autodiscoverer'
4
- require 'feed2email/redirection_checker'
5
2
 
6
3
  module Feed2Email
7
4
  class Cli < Thor
8
- desc 'add URL', 'subscribe to feed at URL'
5
+ desc 'add URL', 'Subscribe to feed at URL'
9
6
  def add(uri)
10
- uri = handle_permanent_redirection(uri)
11
- uri = perform_feed_autodiscovery(uri)
7
+ require 'feed2email/feed'
8
+ require 'feed2email/feed_autodiscoverer'
12
9
 
13
- begin
14
- feed_list << uri
15
- rescue FeedList::DuplicateFeedError => e
16
- abort e.message
10
+ uri = autodiscover_feeds(uri)
11
+
12
+ if feed = Feed[uri: uri]
13
+ abort "Feed already exists: #{feed}"
17
14
  end
18
15
 
19
- if feed_list.sync
20
- puts "Added feed #{uri} at index #{feed_list.size - 1}"
16
+ feed = Feed.new(uri: uri)
17
+
18
+ if feed.save(raise_on_failure: false)
19
+ puts "Added feed: #{feed}"
21
20
  else
22
21
  abort 'Failed to add feed'
23
22
  end
24
23
  end
25
24
 
26
- desc 'fetch FEED', 'clear fetch cache for feed at index FEED'
27
- def fetch(index)
28
- index = check_feed_index(index, in: (0...feed_list.size))
29
- feed_list.clear_fetch_cache(index)
25
+ desc 'backend', 'Open an SQLite console to the database'
26
+ def backend
27
+ require 'feed2email/database'
28
+ exec('sqlite3', Feed2Email.database_path)
29
+ end
30
30
 
31
- if feed_list.sync
32
- puts "Cleared fetch cache for feed at index #{index}"
31
+ desc 'config', 'Open configuration file with $EDITOR'
32
+ def config
33
+ if ENV['EDITOR']
34
+ require 'feed2email'
35
+ exec(ENV['EDITOR'], Feed2Email.config_path)
33
36
  else
34
- abort "Failed to clear fetch cache for feed at index #{index}"
37
+ abort 'EDITOR not set'
35
38
  end
36
39
  end
37
40
 
38
- desc 'history FEED', 'edit history file of feed at index FEED with $EDITOR'
39
- def history(index)
40
- abort '$EDITOR not set' unless ENV['EDITOR']
41
+ desc 'export PATH', 'Export feed subscriptions as OPML to PATH'
42
+ def export(path)
43
+ require 'feed2email/feed'
41
44
 
42
- index = check_feed_index(index, in: (0...feed_list.size))
43
- require 'feed2email/feed_history'
44
- history_path = FeedHistory.new(feed_list[index][:uri]).path
45
- exec(ENV['EDITOR'], history_path)
46
- end
45
+ if Feed.empty?
46
+ abort 'No feeds to export'
47
+ end
47
48
 
48
- desc 'remove FEED', 'unsubscribe from feed at index FEED'
49
- def remove(index)
50
- index = check_feed_index(index, in: (0...feed_list.size))
51
- deleted = feed_list.delete_at(index)
49
+ unless File.exist?(path)
50
+ require 'feed2email/opml_exporter'
52
51
 
53
- if deleted && feed_list.sync
54
- puts "Removed feed at index #{index}"
52
+ puts 'This may take a bit. Please wait...'
55
53
 
56
- if feed_list.size != index # feed was not the last
57
- puts 'Warning: Feed list indices have changed!'
54
+ if n = OPMLExporter.export(path)
55
+ puts "Exported #{'feed subscription'.pluralize(n)} to #{path}"
56
+ else
57
+ abort 'Failed to export feed subscriptions'
58
58
  end
59
59
  else
60
- abort "Failed to remove feed at index #{index}"
60
+ abort 'File already exists'
61
61
  end
62
62
  end
63
63
 
64
- desc 'toggle FEED', 'enable/disable feed at index FEED'
65
- def toggle(index)
66
- index = check_feed_index(index, in: (0...feed_list.size))
67
- toggled = feed_list.toggle(index)
68
- enabled = feed_list[index][:enabled]
64
+ desc 'import PATH', 'Import feed subscriptions as OPML from PATH'
65
+ def import(path)
66
+ if File.exist?(path)
67
+ require 'feed2email/opml_importer'
69
68
 
70
- if toggled && feed_list.sync
71
- puts "#{enabled ? 'En' : 'Dis'}abled feed at index #{index}"
69
+ puts 'Importing...'
70
+
71
+ if n = OPMLImporter.import(path)
72
+ if n > 0
73
+ puts "Imported #{'feed subscription'.pluralize(n)} from #{path}"
74
+ end
75
+ else
76
+ abort 'Failed to import feed subscriptions'
77
+ end
72
78
  else
73
- abort "Failed to #{enabled ? 'en' : 'dis'}able feed at index #{index}"
79
+ abort 'File does not exist'
74
80
  end
75
81
  end
76
82
 
77
- desc 'list', 'list feed subscriptions'
83
+ desc 'list', 'List feed subscriptions'
78
84
  def list
79
- puts feed_list
85
+ require 'feed2email/feed'
86
+
87
+ if Feed.any?
88
+ puts Feed.by_smallest_id.to_a
89
+ else
90
+ puts 'No feeds'
91
+ end
80
92
  end
81
93
 
82
- desc 'process', 'process feed subscriptions'
94
+ desc 'process', 'Process feed subscriptions'
83
95
  def process
84
- feed_list.process
96
+ require 'feed2email/feed'
97
+ Feed.enabled.by_smallest_id.each(&:process)
85
98
  end
86
99
 
87
- desc 'version', 'show feed2email version'
88
- def version
89
- require 'feed2email/version'
90
- puts "feed2email #{Feed2Email::VERSION}"
91
- end
100
+ desc 'remove ID', 'Unsubscribe from feed with id ID'
101
+ def remove(id)
102
+ require 'feed2email/feed'
92
103
 
93
- no_commands do
94
- def check_feed_index(index, options = {})
95
- if index.to_i.to_s != index ||
96
- (options[:in] && !options[:in].include?(index.to_i))
97
- puts if index.nil? # Ctrl-D
98
- abort 'Invalid index'
99
- end
104
+ feed = Feed[id]
100
105
 
101
- index.to_i
106
+ if feed && feed.delete
107
+ puts "Removed feed: #{feed}"
108
+ else
109
+ abort "Failed to remove feed. Is #{id} a valid id?"
102
110
  end
111
+ end
112
+
113
+ desc 'toggle ID', 'Enable/disable feed with id ID'
114
+ def toggle(id)
115
+ require 'feed2email/feed'
116
+
117
+ feed = Feed[id]
103
118
 
104
- def feed_list
105
- Feed2Email.feed_list # delegate
119
+ if feed && feed.toggle
120
+ puts "Toggled feed: #{feed}"
121
+ else
122
+ abort "Failed to toggle feed. Is #{id} a valid id?"
106
123
  end
124
+ end
107
125
 
108
- def handle_permanent_redirection(uri)
109
- checker = RedirectionChecker.new(uri)
126
+ desc 'uncache ID', 'Clear fetch cache for feed with id ID'
127
+ def uncache(id)
128
+ require 'feed2email/feed'
110
129
 
111
- if checker.permanently_redirected?
112
- puts "Got permanently redirected to #{checker.location}"
113
- checker.location
114
- else
115
- uri
116
- end
130
+ feed = Feed[id]
131
+
132
+ if feed && feed.uncache
133
+ puts "Uncached feed: #{feed}"
134
+ else
135
+ abort "Failed to uncache feed. Is #{id} a valid id?"
117
136
  end
137
+ end
118
138
 
119
- def perform_feed_autodiscovery(uri)
139
+ desc 'version', 'Show feed2email version'
140
+ def version
141
+ require 'feed2email/version'
142
+ puts "feed2email #{Feed2Email::VERSION}"
143
+ end
144
+
145
+ no_commands do
146
+ def autodiscover_feeds(uri)
120
147
  discoverer = FeedAutodiscoverer.new(uri)
121
148
 
149
+ # Exclude already subscribed feeds from results
150
+ subscribed_feed_uris = Feed.select_map(:uri)
122
151
  discovered_feeds = discoverer.feeds.reject {|feed|
123
- feed_list.include?(feed[:uri])
152
+ subscribed_feed_uris.include?(feed[:uri])
124
153
  }
125
154
 
126
155
  if discovered_feeds.empty?
@@ -150,8 +179,16 @@ module Feed2Email
150
179
  exit
151
180
  end
152
181
 
153
- index = check_feed_index(response, in: (0...discovered_feeds.size))
154
- discovered_feeds[index][:uri]
182
+ unless response.numeric? &&
183
+ (0...discovered_feeds.size).include?(response.to_i)
184
+ abort 'Invalid index'
185
+ end
186
+
187
+ feed = discovered_feeds[response.to_i]
188
+
189
+ abort 'Invalid index' unless feed && feed[:uri]
190
+
191
+ feed[:uri]
155
192
  end
156
193
  end
157
194
  end
@@ -1,52 +1,62 @@
1
+ require 'forwardable'
1
2
  require 'yaml'
2
3
 
3
4
  module Feed2Email
4
5
  class Config
5
- class MissingConfigError < StandardError; end
6
- class InvalidConfigPermissionsError < StandardError; end
7
- class InvalidConfigSyntaxError < StandardError; end
8
- class InvalidConfigDataTypeError < StandardError; end
9
- class MissingConfigOptionError < StandardError; end
6
+ class ConfigError < StandardError; end
7
+ class MissingConfigError < ConfigError; end
8
+ class InvalidConfigPermissionsError < ConfigError; end
9
+ class InvalidConfigSyntaxError < ConfigError; end
10
+ class InvalidConfigDataTypeError < ConfigError; end
11
+ class MissingConfigOptionError < ConfigError; end
12
+ class InvalidConfigOptionError < ConfigError; end
10
13
 
11
- def initialize(path)
12
- @path = File.expand_path(path)
13
- check
14
- end
14
+ SEND_METHODS = %w{file sendmail smtp}
15
15
 
16
- def [](option)
17
- config[option] # delegate
18
- end
16
+ extend Forwardable
17
+
18
+ delegate [:[], :keys, :slice] => :config
19
19
 
20
- def smtp_configured?
21
- config['smtp_host'] && config['smtp_port'] && config['smtp_user'] &&
22
- config['smtp_pass']
20
+ def initialize(path)
21
+ @path = path
23
22
  end
24
23
 
25
24
  private
26
25
 
27
- def path
28
- @path
26
+ def check_data_type
27
+ if !data.is_a?(Hash)
28
+ raise InvalidConfigDataTypeError,
29
+ "Invalid data type (not a Hash) for config file #{path}"
30
+ end
29
31
  end
30
32
 
31
- def data
32
- @data
33
+ def check_existence
34
+ if !File.exist?(path)
35
+ raise MissingConfigError, "Missing config file #{path}"
36
+ end
33
37
  end
34
38
 
35
- def check
39
+ def check_file
36
40
  check_existence
37
41
  check_permissions
38
42
  check_syntax
39
43
  check_data_type
40
- check_recipient_existence
41
- check_sender_existence
42
44
  end
43
45
 
44
- def check_existence
45
- if !File.exist?(path)
46
- raise MissingConfigError, "Missing config file #{path}"
46
+ def check_option(option)
47
+ if config[option].nil?
48
+ raise MissingConfigOptionError,
49
+ "Option #{option} missing from config file #{path}"
47
50
  end
48
51
  end
49
52
 
53
+ def check_options
54
+ check_recipient
55
+ check_sender
56
+ check_send_method
57
+ check_smtp_options if config['send_method'] == 'smtp'
58
+ end
59
+
50
60
  def check_permissions
51
61
  if '%o' % (File.stat(path).mode & 0777) != '600'
52
62
  raise InvalidConfigPermissionsError,
@@ -54,40 +64,53 @@ module Feed2Email
54
64
  end
55
65
  end
56
66
 
57
- def check_syntax
58
- begin
59
- load_yaml
60
- rescue Psych::SyntaxError
61
- raise InvalidConfigSyntaxError,
62
- "Invalid YAML syntax for config file #{path}"
63
- end
67
+ def check_recipient
68
+ check_option('recipient')
64
69
  end
65
70
 
66
- def check_data_type
67
- if !data.is_a?(Hash)
68
- raise InvalidConfigDataTypeError,
69
- "Invalid data type (not a Hash) for config file #{path}"
71
+ def check_send_method
72
+ unless SEND_METHODS.include?(config['send_method'])
73
+ raise InvalidConfigOptionError,
74
+ "Option send_method not one of: #{SEND_METHODS.join(' ')}"
70
75
  end
71
76
  end
72
77
 
73
- def check_recipient_existence
74
- check_option_existence('recipient')
78
+ def check_sender
79
+ check_option('sender')
75
80
  end
76
81
 
77
- def check_sender_existence
78
- check_option_existence('sender')
82
+ def check_smtp_options
83
+ check_option('smtp_host')
84
+ check_option('smtp_port')
85
+ check_option('smtp_user')
86
+ check_option('smtp_pass')
79
87
  end
80
88
 
81
- def load_yaml
82
- @data = YAML.load(read_file)
89
+ def check_syntax
90
+ begin
91
+ data
92
+ rescue Psych::SyntaxError
93
+ raise InvalidConfigSyntaxError,
94
+ "Invalid YAML syntax for config file #{path}"
95
+ end
83
96
  end
84
97
 
85
- def read_file
86
- File.read(path)
98
+ def config
99
+ return @config if @config
100
+
101
+ begin
102
+ check_file
103
+ @config = defaults.merge(data)
104
+ check_options
105
+ rescue ConfigError => e
106
+ abort e.message
107
+ end
108
+
109
+ @config
87
110
  end
88
111
 
89
- def config
90
- @config ||= defaults.merge(data)
112
+ def data
113
+ @data ||= YAML.load(File.read(path))
91
114
  end
92
115
 
93
116
  def defaults
@@ -96,19 +119,16 @@ module Feed2Email
96
119
  'log_path' => true,
97
120
  'log_shift_age' => 0,
98
121
  'log_shift_size' => 1, # megabyte
122
+ 'mail_path' => File.join(ENV['HOME'], 'Mail'),
99
123
  'max_entries' => 20,
100
124
  'send_delay' => 10,
125
+ 'send_method' => 'file',
101
126
  'sendmail_path' => '/usr/sbin/sendmail',
102
127
  'smtp_auth' => 'login',
103
128
  'smtp_starttls' => true,
104
129
  }
105
130
  end
106
131
 
107
- def check_option_existence(option)
108
- if data[option].nil?
109
- raise MissingConfigOptionError,
110
- "Option #{option} missing from config file #{path}"
111
- end
112
- end
132
+ def path; @path end
113
133
  end
114
134
  end
@@ -2,6 +2,12 @@ require 'cgi'
2
2
  require 'reverse_markdown'
3
3
  require 'sanitize'
4
4
 
5
+ class Hash
6
+ def slice(*keys)
7
+ Hash[values_at(*keys).each_with_index.map {|v, i| [keys[i], v] }]
8
+ end
9
+ end
10
+
5
11
  class Numeric
6
12
  def megabytes
7
13
  self * 1024 * 2014
@@ -13,6 +19,10 @@ class String
13
19
  CGI.escapeHTML(self)
14
20
  end
15
21
 
22
+ def numeric?
23
+ to_i.to_s == self
24
+ end
25
+
16
26
  def pluralize(count, plural = self + 's')
17
27
  "#{count} #{count == 1 ? self : plural}"
18
28
  end