bulletin 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (4) hide show
  1. data/bulletin +28 -0
  2. data/bulletin.rb +238 -0
  3. data/bulletin_test.rb +54 -0
  4. metadata +162 -0
data/bulletin ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ require File.expand_path('bulletin', File.dirname(__FILE__))
4
+
5
+ ARGV.options do |o|
6
+ Bulletin::App.setup_db
7
+ bulletin = Bulletin::App.new(true)
8
+ bulletin.load_config
9
+ o.set_summary_indent(' ')
10
+ o.banner = "Usage: #{File.basename($0)} [OPTIONS]"
11
+ o.define_head 'Personalized news client'
12
+ o.on('-a', '--all', 'show all links') { bulletin.run(0); exit }
13
+ o.on('-f', '--filter uri', 'filter news') { |f| bulletin.filter(f) }
14
+ o.on('-o', '--open id', 'open link') { |id| bulletin.open_item(id); exit }
15
+ o.on('-p', '--page page', 'show page') { |page| bulletin.run(page); exit }
16
+ o.on('-r', '--read id', 'read a link') { |id| bulletin.read(id); exit }
17
+ o.on('-R', '--refresh', 'refresh and ranks stories') { bulletin.refresh; exit }
18
+ o.on('-s', '--save id', 'save link') { |id| bulletin.save(id); exit }
19
+ o.on('-S', '--saved', 'show all saved') { |id| bulletin.saved; exit }
20
+ o.on('-u', '--unsave id', 'undo save') { |id| bulletin.unsave(id); exit }
21
+ o.on('-v', '--version', 'show version') do
22
+ puts "bulletin version #{Bulletin::VERSION}"
23
+ exit
24
+ end
25
+ o.on('-h', '--help', 'show this help message') { puts o; exit }
26
+ o.parse!
27
+ ARGV.empty? ? bulletin.run : puts(o)
28
+ end
data/bulletin.rb ADDED
@@ -0,0 +1,238 @@
1
+ require 'rubygems'
2
+ require 'uri'
3
+ require 'data_mapper'
4
+ require 'dm-types'
5
+ require 'colorize'
6
+ require 'feedzirra'
7
+ require 'nokogiri'
8
+ require 'date'
9
+
10
+ module Bulletin
11
+ VERSION = '0.0.4'
12
+
13
+ class App
14
+ attr_reader :options, :feeds
15
+
16
+ def initialize(production = true)
17
+ @production = production
18
+ @options = {}
19
+ @feeds = []
20
+ @term_width = `tput cols`.to_i
21
+ @term_height = `tput lines`.to_i
22
+ end
23
+
24
+ def filter(site)
25
+ @filter = site
26
+ end
27
+
28
+ def run(page=1)
29
+ total = Item.count
30
+ per_page = options[:per_page]
31
+ page = page.to_i - 1
32
+ options = {:order => [:rank]}
33
+ if page > -1
34
+ options.merge!(
35
+ :rank.gt => (per_page * page),
36
+ :rank.lte => (per_page * (page + 1)))
37
+ end
38
+ if @filter
39
+ options.merge!(
40
+ :uri.like => "%#{@filter}%"
41
+ )
42
+ end
43
+ items = Item.all(options)
44
+ return if items.empty?
45
+ puts table(items)
46
+ end
47
+
48
+ def read(id)
49
+ item = Item.first(:rank => id)
50
+ print wrap_line(item.title).colorize(:light_cyan)
51
+ puts (item.is_saved ? ' (S)'.colorize(:light_red) : '')
52
+ print item.uri.host.to_s.colorize(:light_green) + ' on '
53
+ puts item.published_at.strftime('%m/%d/%Y')
54
+ puts ('-----').colorize(:light_red)
55
+ puts html_to_text(item.body)
56
+ end
57
+
58
+ def open_item(id)
59
+ item = Item.first(:rank => id)
60
+ `#{options[:browser]} #{item.uri}` if item
61
+ end
62
+
63
+ def save(id)
64
+ item = Item.first(:rank => id)
65
+ if item
66
+ item.is_saved = true
67
+ item.save
68
+ end
69
+ end
70
+
71
+ def unsave(id)
72
+ return if id !~ /^\d+$/
73
+ item = Item.first(:is_saved => true, :rank => id)
74
+ if item
75
+ item.is_saved = false
76
+ item.save
77
+ end
78
+ end
79
+
80
+ def saved
81
+ items = Item.all(:is_saved => true, :order => [:rank])
82
+ return if items.empty?
83
+ num_width = items.last.rank.to_s.size
84
+ puts table(items)
85
+ end
86
+
87
+ def refresh
88
+ items = []
89
+ Feedzirra::Feed.fetch_and_parse(@feeds).each do |uri, rss|
90
+ if rss.is_a?(Fixnum)
91
+ puts "Unable to fetch #{uri}"
92
+ else
93
+ items += rss.entries.map { |entry| Item.from_rss(rss, entry) }
94
+ end
95
+ end
96
+ expired = Date.today - options[:expire]
97
+ all_uris = Item.all.map(&:uri)
98
+ items.reject! { |i| all_uris.include?(i.uri) || i.published_at < expired }
99
+ Item.all(:published_at.lt => (Date.today - options[:expire])).destroy
100
+ items = items + Item.all
101
+ items.sort_by!(&:published_at)
102
+ items.reverse!
103
+ items.each_with_index do |item, index|
104
+ item.update(:rank => index + 1)
105
+ end
106
+ end
107
+
108
+ def set(option, value)
109
+ @options[option] = value
110
+ end
111
+
112
+ def feed(uri)
113
+ @feeds << uri
114
+ end
115
+
116
+ def load_config
117
+ config = File.join(ENV['HOME'], '.bulletinrc')
118
+ if File.exists?(config)
119
+ app = self
120
+ Object.class_eval do
121
+ define_method(:set) { |opt, val| app.set(opt, val) }
122
+ define_method(:feed) { |uri| app.feed(uri) }
123
+ end
124
+ load(config, true)
125
+ @options[:browser] ||= 'firefox'
126
+ @options[:per_page] ||= (@term_height - 2)
127
+ @options[:expire] ||= 30
128
+ end
129
+ end
130
+
131
+ private
132
+ def truncate(str)
133
+ str.size <= @term_width ?
134
+ str :
135
+ "#{str[0..@term_width-4]}..."
136
+ end
137
+
138
+ def production?
139
+ !!@production
140
+ end
141
+
142
+ def table(items)
143
+ id_width = items.map { |i| i.rank.to_s.size }.max
144
+ host_width = items.map { |i| i.host.to_s.size }.max
145
+ items.map do |item|
146
+ id_pad = ' ' * (id_width - item.rank.to_s.size)
147
+ host_pad = ' ' * (host_width - item.host.to_s.size)
148
+ flag = item.is_saved ? 'S' : '*'
149
+ id = "#{id_pad}#{item.rank}"
150
+ host = "#{item.host}#{host_pad}"
151
+ prefix_width = id_width + host_width + 4
152
+ title = if (prefix_width + item.title.size) > @term_width
153
+ "#{item.title[0..@term_width-(4 + prefix_width)]}..."
154
+ else
155
+ item.title
156
+ end
157
+ flag = flag == 'S' ? flag.colorize(:light_red) : flag
158
+ "#{id.colorize(:light_blue)} #{host.colorize(:light_green)} #{flag} #{title}"
159
+ end.join("\n")
160
+ end
161
+
162
+ def html_to_text(html)
163
+ blocks = %w(p div ul ol h1 h2 h3 h4 h5 h6)
164
+ lists = %w(li)
165
+ swaps = {'br'=>"\n", 'hr'=>"\n"}
166
+ node = Nokogiri::HTML(html)
167
+ node.xpath('.//text()').each { |t| t.content = t.text.gsub(/\s+/,' ') }
168
+ node.css(swaps.keys.join(',')).each { |n| n.replace(swaps[n.name]) }
169
+ node.css(blocks.join(',')).each { |n| n.after("\n\n") }
170
+ node.css(lists.join(',')).each { |n| n.after("\n").before("* ") }
171
+ text = node.text.split(/\n\n+/).map do |paragraph|
172
+ paragraph.split(/\n/).map do |line|
173
+ wrap_line(line.strip)
174
+ end.join("\n")
175
+ end.join("\n\n").gsub(/\n\n+/, "\n\n").strip
176
+ end
177
+
178
+ def wrap_line(line)
179
+ buffer = []
180
+ width = @term_width - 2
181
+ line.split(/\s+/).each do |word|
182
+ if buffer.empty? || (buffer.last.length + word.length + 1) > width
183
+ if buffer.first && buffer.first =~ /^\*/
184
+ buffer << " #{word}"
185
+ else
186
+ buffer << word
187
+ end
188
+ else
189
+ last = buffer.pop
190
+ buffer << "#{last} #{word}"
191
+ end
192
+ end
193
+ buffer.join("\n")
194
+ end
195
+
196
+ def self.setup_db(production = true)
197
+ DataMapper.setup(:default, production ?
198
+ "sqlite://#{File.expand_path('~/.bulletindb')}" :
199
+ "sqlite3::memory:")
200
+ if !DataMapper.repository(:default).adapter.storage_exists?('bulletin_items')
201
+ DataMapper.auto_migrate!
202
+ end
203
+ end
204
+ end
205
+
206
+ class Item
207
+ include DataMapper::Resource
208
+
209
+ property :id, Serial
210
+ property :created_at, DateTime
211
+ property :published_at, DateTime
212
+ property :title, String, :length => 255
213
+ property :uri, URI
214
+ property :is_saved, Boolean, :default => false
215
+ property :rank, Integer, :default => 0, :key => true
216
+ property :body, Text
217
+
218
+ def self.from_rss(rss, entry)
219
+ return nil if entry.url.nil?
220
+ Item.new(:published_at => (entry.published || Time.now),
221
+ :title => entry.title.to_s.strip,
222
+ :uri => entry.url,
223
+ :body => entry.content)
224
+ end
225
+
226
+ def full_title
227
+ code = self.is_saved ? 'S'.colorize(:light_red) : '*'
228
+ "#{host.colorize(:light_green)} #{code} #{title}"
229
+ end
230
+
231
+ def host
232
+ uri.host.sub(/www\./, '').sub(/\.[^.]+$/, '')[0..14]
233
+ end
234
+ end
235
+
236
+ DataMapper.finalize
237
+ DataMapper::Model.raise_on_save_failure = true
238
+ end
data/bulletin_test.rb ADDED
@@ -0,0 +1,54 @@
1
+ if ENV['COVERAGE']
2
+ require 'simplecov'
3
+ SimpleCov.start
4
+ SimpleCov.command_name 'Unit Tests'
5
+ end
6
+
7
+ require File.join(File.dirname(__FILE__), 'bulletin')
8
+ require 'minitest/autorun'
9
+
10
+ class BulletinTest < MiniTest::Unit::TestCase
11
+ Bulletin::App.setup_db(false)
12
+
13
+ def setup
14
+ @bulletin = Bulletin::App.new(false)
15
+ Bulletin::Item.all.destroy
16
+ end
17
+
18
+ def test_refresh
19
+ assert_equal(0, Bulletin::Item.count)
20
+ @bulletin.feed sample_uri
21
+ @bulletin.refresh
22
+ assert_equal(3, Bulletin::Item.count)
23
+
24
+ item = Bulletin::Item.first
25
+ assert(item.title)
26
+ assert(item.created_at)
27
+ assert(item.published_at)
28
+ assert(item.uri)
29
+ assert(item.rank)
30
+
31
+ @bulletin.refresh
32
+ assert_equal(3, Bulletin::Item.count)
33
+ end
34
+
35
+ def test_configure
36
+ @bulletin.set :browser, 'firefox'
37
+ @bulletin.set :per_page, 20
38
+ @bulletin.feed 'http://liftoff.msfc.nasa.gov/rss.xml'
39
+
40
+ assert_equal('firefox', @bulletin.send(:options)[:browser])
41
+ assert_equal(20, @bulletin.send(:options)[:per_page])
42
+ assert_equal('http://liftoff.msfc.nasa.gov/rss.xml',
43
+ @bulletin.send(:feeds)[0])
44
+ end
45
+
46
+ def test_environment
47
+ refute(@bulletin.send(:production?))
48
+ end
49
+
50
+ private
51
+ def sample_uri
52
+ File.join(File.expand_path(File.dirname(__FILE__)), 'sample.xml')
53
+ end
54
+ end
metadata ADDED
@@ -0,0 +1,162 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bulletin
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.4
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Hugh Bien
9
+ autorequire:
10
+ bindir: .
11
+ cert_chain: []
12
+ date: 2012-06-02 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: dm-sqlite-adapter
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: data_mapper
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: colorize
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: feedzirra
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: nokogiri
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :runtime
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: minitest
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: simplecov
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ description: Command line RSS reader
127
+ email:
128
+ - hugh@hughbien.com
129
+ executables:
130
+ - bulletin
131
+ extensions: []
132
+ extra_rdoc_files: []
133
+ files:
134
+ - bulletin.rb
135
+ - bulletin_test.rb
136
+ - bulletin
137
+ - ./bulletin
138
+ homepage: https://github.com/hughbien/bulletin
139
+ licenses: []
140
+ post_install_message:
141
+ rdoc_options: []
142
+ require_paths:
143
+ - lib
144
+ required_ruby_version: !ruby/object:Gem::Requirement
145
+ none: false
146
+ requirements:
147
+ - - ! '>='
148
+ - !ruby/object:Gem::Version
149
+ version: '0'
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ none: false
152
+ requirements:
153
+ - - ! '>='
154
+ - !ruby/object:Gem::Version
155
+ version: 1.3.6
156
+ requirements: []
157
+ rubyforge_project:
158
+ rubygems_version: 1.8.23
159
+ signing_key:
160
+ specification_version: 3
161
+ summary: Command line RSS reader
162
+ test_files: []