bulletin 0.0.4
Sign up to get free protection for your applications and to get access to all the features.
- data/bulletin +28 -0
- data/bulletin.rb +238 -0
- data/bulletin_test.rb +54 -0
- 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: []
|