hacker_term 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in hacker_term.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,24 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ hacker_term (0.0.1)
5
+ clipboard
6
+ launchy
7
+ rest-client
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ addressable (2.3.2)
13
+ clipboard (1.0.1)
14
+ launchy (2.1.2)
15
+ addressable (~> 2.3)
16
+ mime-types (1.19)
17
+ rest-client (1.6.7)
18
+ mime-types (>= 1.16)
19
+
20
+ PLATFORMS
21
+ ruby
22
+
23
+ DEPENDENCIES
24
+ hacker_term!
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Ciaran Archer
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,18 @@
1
+ hacker_term
2
+ ==========
3
+
4
+ Hacker News on the Terminal.
5
+
6
+ See the front page of HN, use the arrow keys to browse and open particular items in the default system browser.
7
+
8
+ * Uses the Ruby `curses` library to create a terminal UI.
9
+ * Captures keyboard events to allow browsing of the HN front page from the terminal.
10
+ * Tested (and looks colourful) on OSX Mountain Lion, but some functionality may be lost on other flavours of Linux.
11
+ * Ditto the above point if using something other than the basic OSX terminal application.
12
+ * Uses the HN feed available at http://hndroidapi.appspot.com - without that resource this project would not exist.
13
+ * Sorting options included.
14
+ * Some stats included.
15
+
16
+ This project was created to allow me to scratch a particular programming itch after reading about https://github.com/etsy/mctop. It brought me back to my days in college coding in C where everything was a terminal program!
17
+
18
+ Please enjoy/contribute/ignore as you see fit.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/bin/hacker_term ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
3
+ require 'hacker_term'
4
+
5
+ app = HackerTerm::TerminalApp.new
6
+ exit app.run!
data/data/data.json ADDED
@@ -0,0 +1,304 @@
1
+ {"items":[
2
+ {
3
+ "title":"PowWow - Collaborative Screen Sharing",
4
+ "url":"http://powwow.cc/",
5
+ "score":"124 points",
6
+ "user":"siong1987",
7
+ "comments":"43 comments",
8
+ "time":"7 hours ago",
9
+ "item_id":"4924763",
10
+ "description":"124 points by siong1987 7 hours ago | 43 comments"
11
+ },
12
+ {
13
+ "title":"Ray Kurzweil joins Google",
14
+ "url":"http://www.kurzweilai.net/kurzweil-joins-google-to-work-on-new-projects-involving-machine-learning-and-language-processing?utm_source=twitterfeed&utm_medium=twitter",
15
+ "score":"260 points",
16
+ "user":"dumitrue",
17
+ "comments":"122 comments",
18
+ "time":"14 hours ago",
19
+ "item_id":"4923914",
20
+ "description":"260 points by dumitrue 14 hours ago | 122 comments"
21
+ },
22
+ {
23
+ "title":"The Pinboard Investment Co-Prosperity Cloud",
24
+ "url":"http://static.pinboard.in/prosperity_cloud.htm",
25
+ "score":"381 points",
26
+ "user":"adulau",
27
+ "comments":"136 comments",
28
+ "time":"17 hours ago",
29
+ "item_id":"4923136",
30
+ "description":"381 points by adulau 17 hours ago | 136 comments"
31
+ },
32
+ {
33
+ "title":"Web Revenue Models - 9 Categories, 80+ variations",
34
+ "url":"https://hackpad.com/EgXuEtSibE7#Web-And-Mobile-Revenue-Models-%28final%29",
35
+ "score":"84 points",
36
+ "user":"stickhandle",
37
+ "comments":"6 comments",
38
+ "time":"8 hours ago",
39
+ "item_id":"4924647",
40
+ "description":"84 points by stickhandle 8 hours ago | 6 comments"
41
+ },
42
+ {
43
+ "title":"The 2012 Perl 6 Coding Contest",
44
+ "url":"http://strangelyconsistent.org/blog/the-2012-perl-6-coding-contest",
45
+ "score":"20 points",
46
+ "user":"draegtun",
47
+ "comments":"discuss",
48
+ "time":"4 hours ago",
49
+ "item_id":"4925036",
50
+ "description":"20 points by draegtun 4 hours ago | discuss"
51
+ },
52
+ {
53
+ "title":"Brython - Python to Javascript translator",
54
+ "url":"http://www.brython.info/index_en.html",
55
+ "score":"222 points",
56
+ "user":"toni",
57
+ "comments":"55 comments",
58
+ "time":"16 hours ago",
59
+ "item_id":"4923530",
60
+ "description":"222 points by toni 16 hours ago | 55 comments"
61
+ },
62
+ {
63
+ "title":"Dear Open Source Project Leader: Quit Being A Jerk",
64
+ "url":"http://lostechies.com/derickbailey/2012/12/14/dear-open-source-project-leader-quit-being-a-jerk/",
65
+ "score":"362 points",
66
+ "user":"derickbailey",
67
+ "comments":"158 comments",
68
+ "time":"22 hours ago",
69
+ "item_id":"4921152",
70
+ "description":"362 points by derickbailey 22 hours ago | 158 comments"
71
+ },
72
+ {
73
+ "title":"Google Disabling Exchange Sync for Free Accounts",
74
+ "url":"http://support.google.com/a/bin/answer.py?hl=en&answer=135937",
75
+ "score":"145 points",
76
+ "user":"HaloZero",
77
+ "comments":"115 comments",
78
+ "time":"14 hours ago",
79
+ "item_id":"4923832",
80
+ "description":"145 points by HaloZero 14 hours ago | 115 comments"
81
+ },
82
+ {
83
+ "title":"Want to learn to code? Don't copy and paste, type out other people's code",
84
+ "url":"http://www.shockoe.com/blog/typingcodeout/",
85
+ "score":"283 points",
86
+ "user":"tomasien",
87
+ "comments":"167 comments",
88
+ "time":"22 hours ago",
89
+ "item_id":"4921258",
90
+ "description":"283 points by tomasien 22 hours ago | 167 comments"
91
+ },
92
+ {
93
+ "title":"Moravec's paradox",
94
+ "url":"https://en.wikipedia.org/wiki/Moravec%27s_paradox",
95
+ "score":"144 points",
96
+ "user":"kristiandupont",
97
+ "comments":"34 comments",
98
+ "time":"16 hours ago",
99
+ "item_id":"4923299",
100
+ "description":"144 points by kristiandupont 16 hours ago | 34 comments"
101
+ },
102
+ {
103
+ "title":"A Bad UI Pattern",
104
+ "url":"http://www.kapilkale.com/blog/the-worst-ui-pattern-in-existence/",
105
+ "score":"91 points",
106
+ "user":"kapilkale",
107
+ "comments":"45 comments",
108
+ "time":"13 hours ago",
109
+ "item_id":"4923971",
110
+ "description":"91 points by kapilkale 13 hours ago | 45 comments"
111
+ },
112
+ {
113
+ "title":"I love you, dad",
114
+ "url":"http://notch.tumblr.com/post/37823268132/i-love-you-dad",
115
+ "score":"987 points",
116
+ "user":"kjackson2012",
117
+ "comments":"204 comments",
118
+ "time":"1 day ago",
119
+ "item_id":"4916629",
120
+ "description":"987 points by kjackson2012 1 day ago | 204 comments"
121
+ },
122
+ {
123
+ "title":"What euros Facebooktrades responsibility when the nation seeks to lynch someone?",
124
+ "url":"http://pandodaily.com/2012/12/14/whats-facebooks-responsibility-when-the-nation-seeks-to-lynch-someone-on-only-a-name/",
125
+ "score":"81 points",
126
+ "user":"muratmutlu",
127
+ "comments":"71 comments",
128
+ "time":"11 hours ago",
129
+ "item_id":"4924361",
130
+ "description":"81 points by muratmutlu 11 hours ago | 71 comments"
131
+ },
132
+ {
133
+ "title":"A vim interface for gmail: Vmail",
134
+ "url":"http://www.danielchoi.com/software/vmail.html",
135
+ "score":"128 points",
136
+ "user":"ezl",
137
+ "comments":"67 comments",
138
+ "time":"18 hours ago",
139
+ "item_id":"4922542",
140
+ "description":"128 points by ezl 18 hours ago | 67 comments"
141
+ },
142
+ {
143
+ "title":"E Online has left a Gist url on the top of their site",
144
+ "url":"http://www.eonline.com/news/371827/newtown-school-shooting-jack-reacher-u-s-premiere-postponed-out-of-respect-for-victims",
145
+ "score":"39 points",
146
+ "user":"Moto7451",
147
+ "comments":"14 comments",
148
+ "time":"8 hours ago",
149
+ "item_id":"4924624",
150
+ "description":"39 points by Moto7451 8 hours ago | 14 comments"
151
+ },
152
+ {
153
+ "title":"The Web We Lost",
154
+ "url":"http://dashes.com/anil/2012/12/the-web-we-lost.html",
155
+ "score":"593 points",
156
+ "user":"kzasada",
157
+ "comments":"151 comments",
158
+ "time":"1 day ago",
159
+ "item_id":"4917828",
160
+ "description":"593 points by kzasada 1 day ago | 151 comments"
161
+ },
162
+ {
163
+ "title":"NASA Eyes Mission To Icy Jupiter Moon Europa To Gauge Habitability",
164
+ "url":"http://www.space.com/18901-nasa-mission-jupiter-moon-europa.html",
165
+ "score":"26 points",
166
+ "user":"rpm4321",
167
+ "comments":"5 comments",
168
+ "time":"9 hours ago",
169
+ "item_id":"4924607",
170
+ "description":"26 points by rpm4321 9 hours ago | 5 comments"
171
+ },
172
+ {
173
+ "title":"Tcl the misunderstood (2006)",
174
+ "url":"http://antirez.com/articoli/tclmisunderstood.html?",
175
+ "score":"170 points",
176
+ "user":"zeitg3ist",
177
+ "comments":"96 comments",
178
+ "time":"1 day ago",
179
+ "item_id":"4920831",
180
+ "description":"170 points by zeitg3ist 1 day ago | 96 comments"
181
+ },
182
+ {
183
+ "title":"EFF: Stop Congress from Reauthorizing the Warrantless Spying Bill",
184
+ "url":"https://www.eff.org/deeplinks/2012/12/congress-poised-reauthorize-fisa-amendment-act-warrantless-spying-bill",
185
+ "score":"191 points",
186
+ "user":"mtgx",
187
+ "comments":"38 comments",
188
+ "time":"1 day ago",
189
+ "item_id":"4920542",
190
+ "description":"191 points by mtgx 1 day ago | 38 comments"
191
+ },
192
+ {
193
+ "title":"Thank HN: You helped the FreeBSD Foundation raise over $43K in three days",
194
+ "url":"http://freebsdfoundation.blogspot.com/2012/12/stunning-news-website-fundraising.html#",
195
+ "score":"154 points",
196
+ "user":"profquail",
197
+ "comments":"23 comments",
198
+ "time":"23 hours ago",
199
+ "item_id":"4920891",
200
+ "description":"154 points by profquail 23 hours ago | 23 comments"
201
+ },
202
+ {
203
+ "title":"Meet the World trades Cheapest Venture Capitalist",
204
+ "url":"http://www.wired.com/business/2012/12/worlds-cheapest-venture-capitalist/",
205
+ "score":"34 points",
206
+ "user":"wyclif",
207
+ "comments":"9 comments",
208
+ "time":"8 hours ago",
209
+ "item_id":"4924651",
210
+ "description":"34 points by wyclif 8 hours ago | 9 comments"
211
+ },
212
+ {
213
+ "title":"Programmer creates 800,000 books algorithmically, starts selling them on Amazon",
214
+ "url":"http://www.extremetech.com/extreme/143382-programmer-creates-800000-books-algorithmically-starts-selling-them-on-amazon?utm_source=rss&utm_medium=rss&utm_campaign=programmer-creates-800000-books-algorithmically-starts-selling-them-on-amazon",
215
+ "score":"131 points",
216
+ "user":"Libertatea",
217
+ "comments":"119 comments",
218
+ "time":"17 hours ago",
219
+ "item_id":"4923208",
220
+ "description":"131 points by Libertatea 17 hours ago | 119 comments"
221
+ },
222
+ {
223
+ "title":"Internet porn: Automatic block rejected (UK)",
224
+ "url":"http://www.bbc.co.uk/news/uk-politics-20738746",
225
+ "score":"6 points",
226
+ "user":"andrewaylett",
227
+ "comments":"4 comments",
228
+ "time":"3 hours ago",
229
+ "item_id":"4925047",
230
+ "description":"6 points by andrewaylett 3 hours ago | 4 comments"
231
+ },
232
+ {
233
+ "title":"Working alone sucks",
234
+ "url":"http://fleetadmiral.tumblr.com/post/37907736486/working-alone-sucks",
235
+ "score":"139 points",
236
+ "user":"labaraka",
237
+ "comments":"108 comments",
238
+ "time":"23 hours ago",
239
+ "item_id":"4921047",
240
+ "description":"139 points by labaraka 23 hours ago | 108 comments"
241
+ },
242
+ {
243
+ "title":"Ole Roemer and the Speed of Light",
244
+ "url":"http://www.amnh.org/education/resources/rfl/web/essaybooks/cosmic/p_roemer.html",
245
+ "score":"307 points",
246
+ "user":"tmoretti",
247
+ "comments":"66 comments",
248
+ "time":"1 day ago",
249
+ "item_id":"4919594",
250
+ "description":"307 points by tmoretti 1 day ago | 66 comments"
251
+ },
252
+ {
253
+ "title":"Show HN: Introducing KA Lite, an offline version of Khan Academy",
254
+ "url":"http://jamiealexandre.com/blog/2012/12/12/ka-lite-offline-khan-academy/",
255
+ "score":"49 points",
256
+ "user":"jamalex",
257
+ "comments":"3 comments",
258
+ "time":"14 hours ago",
259
+ "item_id":"4923821",
260
+ "description":"49 points by jamalex 14 hours ago | 3 comments"
261
+ },
262
+ {
263
+ "title":"Google Maps for iOS",
264
+ "url":"https://itunes.apple.com/us/app/google-maps/id585027354?mt=8",
265
+ "score":"808 points",
266
+ "user":"zacharytamas",
267
+ "comments":"440 comments",
268
+ "time":"2 days ago",
269
+ "item_id":"4914089",
270
+ "description":"808 points by zacharytamas 2 days ago | 440 comments"
271
+ },
272
+ {
273
+ "title":"Perfect Audience (YC S11) seeks full-stack engineer",
274
+ "url":"item?id=4924955",
275
+ "time":"5 hours ago",
276
+ "description":"5 hours ago"
277
+ },
278
+ {
279
+ "title":"AngelList Raising A Big Round, To Be Valued at $150 Million Or More",
280
+ "url":"http://techcrunch.com/2012/12/14/angellist-to-be-valued-at-150-million-or-more/",
281
+ "score":"33 points",
282
+ "user":"zosegal",
283
+ "comments":"12 comments",
284
+ "time":"12 hours ago",
285
+ "item_id":"4924134",
286
+ "description":"33 points by zosegal 12 hours ago | 12 comments"
287
+ },
288
+ {
289
+ "title":"Data crunching to find the cheapest airline in the world",
290
+ "url":"http://www.tnooz.com/2012/12/14/news/data-crunching-to-find-the-cheapest-airline-in-the-world/",
291
+ "score":"28 points",
292
+ "user":"tomhoward",
293
+ "comments":"2 comments",
294
+ "time":"11 hours ago",
295
+ "item_id":"4924319",
296
+ "description":"28 points by tomhoward 11 hours ago | 2 comments"
297
+ },
298
+ {
299
+ "title":"NextId",
300
+ "url":"/news2",
301
+ "description":"hn next id news2 "
302
+ }
303
+ ]
304
+ }
@@ -0,0 +1,22 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'hacker_term/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "hacker_term"
8
+ gem.version = HackerTerm::VERSION
9
+ gem.authors = ["Ciaran Archer"]
10
+ gem.email = ["ciaran.archer@gmail.com"]
11
+ gem.description = %q{Read Hacker News on the Terminal}
12
+ gem.summary = %q{Allows the reading, sorting and opening of HN articles from the terminal.}
13
+ gem.homepage = "https://github.com/ciaranarcher/hacker_term"
14
+ gem.add_dependency('rest-client')
15
+ gem.add_dependency('launchy')
16
+ gem.add_dependency('clipboard')
17
+
18
+ gem.files = `git ls-files`.split($/)
19
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
20
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
21
+ gem.require_paths = ["lib"]
22
+ end
@@ -0,0 +1,112 @@
1
+ require 'json'
2
+
3
+ # Controversial monkeypatch of String class so it can tell us if a string is a number
4
+ class String
5
+ def is_num?
6
+ self =~ /^[-+]?[0-9]*\.?[0-9]+$/
7
+ end
8
+ end
9
+
10
+ module HackerTerm
11
+ class PageData
12
+ attr_reader :data, :mean_score, :median_score, :mode_score, :sorted_by, :line_pos
13
+
14
+ def initialize(data)
15
+ @data = JSON.parse(data)['items']
16
+
17
+ add_missing_keys!
18
+ format_numbers!
19
+
20
+ calculate_mean_score
21
+ calculate_median_score
22
+ calculate_mode_score
23
+
24
+ @sorted_by = 'RANK'
25
+ @line_pos = 1
26
+ end
27
+
28
+ def sort_on!(mode)
29
+ case mode
30
+ when :score
31
+ @data = @data.sort { |a, b| a['score'].to_f <=> b['score'].to_f }
32
+ when :comments
33
+ @data = @data.sort { |a, b| a['comments'].to_f <=> b['comments'].to_f }
34
+ when :rank
35
+ @data = @data.sort { |a, b| a['rank'].to_f <=> b['rank'].to_f }
36
+ when :title
37
+ @data = @data.sort { |a, b| a['title'].upcase <=> b['title'].upcase } # Convert all to upper case when comparing
38
+ else
39
+ throw "sorting mode #{mode} not supported"
40
+ end
41
+
42
+ @sorted_by = mode.to_s.upcase
43
+ end
44
+
45
+ def change_line_pos(direction)
46
+ if direction == :up
47
+ @line_pos += 1 unless @line_pos == @data.length
48
+ elsif direction == :down
49
+ @line_pos -= 1 unless @line_pos == 1
50
+ elsif direction == :reset
51
+ @line_pos = 1
52
+ end
53
+ end
54
+
55
+ def selected_url
56
+ @data[@line_pos - 1]['url']
57
+ end
58
+
59
+ private
60
+
61
+ def calculate_mode_score
62
+ freq = @data.inject(Hash.new(0)) { |h,v| h[v['score'].to_f] += 1; h }
63
+ # Call sort_by on hash to create an array which each contains two elements, the key and value
64
+ # So we grab the last item, and return the 'key' from our original hash
65
+ @mode_score = freq.sort_by { |k, v| v }.last.first
66
+ end
67
+
68
+ def calculate_mean_score
69
+ @mean_score = @data.inject(0.0) { |sum, el| sum + el['score'].to_f } / @data.size
70
+ end
71
+
72
+ def calculate_median_score
73
+ # Read our numbers and sort them first
74
+ sorted_scores = @data.map { |el| el['score'].to_f }.sort
75
+ len = sorted_scores.length
76
+ @median_score = len % 2 == 1 ? sorted_scores[len / 2] : (sorted_scores[len / 2 - 1] + sorted_scores[len / 2]).to_f / 2
77
+ end
78
+
79
+ def add_missing_keys!
80
+ # Here we're looking to fix nodes with missing/incorrect data
81
+ counter = 1
82
+ @data.each do |item|
83
+
84
+ # Add rank (so we can re-sort in 'natural' order)
85
+ unless item.has_key? 'rank'
86
+ item['rank'] = counter.to_s
87
+ end
88
+
89
+ unless item.has_key? 'score'
90
+ item['score'] = '0'
91
+ end
92
+
93
+ unless item.has_key? 'comments'
94
+ item['comments'] = '0'
95
+ end
96
+
97
+ counter += 1
98
+ end
99
+ end
100
+
101
+ def format_numbers!
102
+ # Assumption here is a format like '10 comments' or '35 points'
103
+ # Also chucks anything left over that isn't a number
104
+ @data.each do |item|
105
+ item['comments'] = item['comments'].split(' ').first if item['comments'].include? ' '
106
+ item['comments'] = '0' unless item['comments'].is_num?
107
+ item['score'] = item['score'].split(' ').first if item['score'].include? ' '
108
+ item['score'] = '0' unless item['score'].is_num?
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,152 @@
1
+ require 'curses'
2
+
3
+ module HackerTerm
4
+ class UI
5
+ include Curses
6
+
7
+ def initialize(opts={})
8
+
9
+ opts = defaults.merge(opts) # Ununsed for now
10
+
11
+ raw # Intercept everything
12
+ noecho # Do not echo user input to stdout
13
+ stdscr.keypad(true) # Enable arrows
14
+
15
+ if can_change_color?
16
+ start_color
17
+ # foreground / background colours
18
+ init_pair(0, COLOR_WHITE, COLOR_BLACK)
19
+ init_pair(1, COLOR_WHITE, COLOR_BLUE)
20
+ init_pair(2, COLOR_WHITE, COLOR_RED)
21
+ init_pair(3, COLOR_BLACK, COLOR_GREEN)
22
+ end
23
+
24
+ @total_width = cols
25
+ @total_height = lines
26
+ @padding_left = 2
27
+ @title_width = 0
28
+ @cols = ['rank', 'title', 'score', 'comments']
29
+ @line_num = -1
30
+
31
+ clear!
32
+ end
33
+
34
+ def next_line_num
35
+ @line_num += 1
36
+ end
37
+
38
+ def output_line(line_num, data)
39
+ setpos(line_num, 0)
40
+ padding_right = @total_width - data.length - @padding_left
41
+ padding_right = 0 if padding_right < 0
42
+ addstr((" " * @padding_left) + data + (" " * padding_right))
43
+ end
44
+
45
+ def output_divider(line_num)
46
+ setpos(line_num, 0)
47
+ attrset color_pair(0)
48
+ addstr('-' * @total_width)
49
+ end
50
+
51
+ def draw_header
52
+ output_divider(next_line_num)
53
+ attrset color_pair(1)
54
+ output_line(next_line_num, "HACKER NEWS TERMINAL - thanks to http://hndroidapi.appspot.com")
55
+ output_line(next_line_num, "COMMANDS: Select (Arrows), Open (O), Refresh (A) | Sort by Rank (R), Score (S), Comments (C), Title (T) | Quit (Q)")
56
+ output_divider(next_line_num)
57
+
58
+ # Get width_excl_title, i.e. width of all columns + some extra for |'s and spacing.
59
+ # Once obtained, pad out the title column with the any width remaining
60
+ # A nicer way to do this is always put the title last, and assume last column gets
61
+ # remaining width. That way we can just loop through our cols, rather than hardcoding
62
+ # them as per example below. I'm sticking to this because I want the title listed second.
63
+ width_excl_title = @cols.inject(0) do |width, col|
64
+ width += (3 + col.length)
65
+ end
66
+ attrset color_pair(2)
67
+ @title_width = @total_width - width_excl_title + 'title'.length
68
+ output_line(next_line_num, "RANK | TITLE " + " " * (@total_width - width_excl_title) + "| SCORE | COMMENTS")
69
+ output_divider(next_line_num)
70
+ end
71
+
72
+ def draw_footer(sorted_by, mean, median, mode)
73
+ output_divider(next_line_num)
74
+ attrset color_pair(1)
75
+ formatted = sprintf("Sorted by: %7s | Scores: Mean: %4.2f | Median: %4.2f | Mode: %4.2f",
76
+ sorted_by, mean, median, mode)
77
+ output_line(next_line_num, formatted)
78
+ output_divider(next_line_num)
79
+ end
80
+
81
+ def draw_item_line(rank, data, selected)
82
+
83
+ begin
84
+ # Truncate if too long
85
+ title = truncate_line! data
86
+
87
+ # Format and output
88
+ if selected
89
+ rank = '> ' + rank
90
+ attrset color_pair(3)
91
+ else
92
+ attrset color_pair(0)
93
+ end
94
+
95
+ formatted = sprintf("%4s | %-#{@title_width}s | %5s | %8s", rank, title, data['score'], data['comments'])
96
+ output_line(next_line_num, formatted)
97
+ rescue => ex
98
+ p "error: #{ex.to_s}"
99
+ end
100
+ end
101
+
102
+ def truncate_line!(data)
103
+ return data['title'][0, @title_width - 3] + '...' if data['title'].length >= @title_width
104
+ data['title']
105
+ end
106
+
107
+ def show(page_data)
108
+ draw_header
109
+
110
+ page_data.data.each_index do |i|
111
+ line_data = page_data.data.fetch(i)
112
+ draw_item_line(line_data['rank'], line_data, page_data.line_pos == i + 1)
113
+ end
114
+
115
+ draw_footer(page_data.sorted_by,
116
+ page_data.mean_score,
117
+ page_data.median_score,
118
+ page_data.mode_score
119
+ )
120
+ end
121
+
122
+ def get_char
123
+ interpret_char(getch)
124
+ end
125
+
126
+ def interpret_char(c)
127
+ case c
128
+ when Curses::Key::UP
129
+ 'up'
130
+ when Curses::Key::DOWN
131
+ 'down'
132
+ else
133
+ c
134
+ end
135
+ end
136
+
137
+ def close
138
+ close_screen
139
+ end
140
+
141
+ def clear!
142
+ setpos(0, 0)
143
+ clear
144
+ end
145
+
146
+ private
147
+
148
+ def defaults
149
+ @options ||= {}
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,3 @@
1
+ module HackerTerm
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,93 @@
1
+ require 'hacker_term/page_data'
2
+ require 'hacker_term/ui'
3
+ require 'rest_client'
4
+ require 'launchy'
5
+ require 'clipboard'
6
+
7
+ module HackerTerm
8
+ class TerminalApp
9
+ def initialize
10
+ @raw_json = read_json
11
+ load
12
+ end
13
+
14
+ def run!
15
+ clear_and_show
16
+
17
+ begin
18
+ char = @ui.get_char
19
+
20
+ case char.to_s.upcase.chomp
21
+ when "Q"
22
+ @ui.close
23
+ exit
24
+
25
+ when "UP"
26
+ @page.change_line_pos :down
27
+
28
+ when "DOWN"
29
+ @page.change_line_pos :up
30
+
31
+ when "O"
32
+ launch
33
+
34
+ when "A"
35
+ load
36
+ @page.change_line_pos :reset
37
+
38
+ when "S"
39
+ @page.sort_on!(:score)
40
+
41
+ when "R"
42
+ @page.sort_on!(:rank)
43
+
44
+ when "T"
45
+ @page.sort_on!(:title)
46
+
47
+ when "C"
48
+ @page.sort_on!(:comments)
49
+ end
50
+
51
+ clear_and_show
52
+
53
+ end while true
54
+
55
+ 0 # Zero exit code means everything was OK...
56
+ end
57
+
58
+ private
59
+
60
+ def launch
61
+ # Attempts to launch a browser; writes URL to clipboard in any case
62
+ begin
63
+ Launchy.open @page.selected_url # May not work in some Linux flavors
64
+ rescue
65
+ ensure
66
+ Clipboard.copy @page.selected_url
67
+ end
68
+ end
69
+
70
+ def load
71
+ @page = PageData.new @raw_json
72
+ @ui = UI.new
73
+ end
74
+
75
+ def read_json
76
+ local_proxy = get_local_proxy
77
+ RestClient.proxy = local_proxy unless local_proxy.nil?
78
+ RestClient.get 'http://hndroidapi.appspot.com/news/format/json/page/'
79
+ end
80
+
81
+ def get_local_proxy
82
+ # Cater for both upper and lower case env variables
83
+ local_proxy = ENV['HTTP_PROXY']
84
+ return local_proxy unless local_proxy.nil?
85
+ ENV['http_proxy']
86
+ end
87
+
88
+ def clear_and_show
89
+ @ui.clear!
90
+ @ui.show @page
91
+ end
92
+ end
93
+ end
data/run.rb ADDED
@@ -0,0 +1,5 @@
1
+ $:.unshift(File.dirname(__FILE__) + '/lib')
2
+
3
+ require 'hacker_term'
4
+ app = HackerTerm::TerminalApp.new
5
+ app.run!
@@ -0,0 +1,147 @@
1
+ require 'hacker_term/page_data'
2
+
3
+ module HackerTerm
4
+ describe PageData do
5
+ describe 'replace missing nodes and format numbers' do
6
+ before(:each) do
7
+ @data =
8
+ '{"items":[
9
+ {
10
+ "title":"NextId",
11
+ "url":"/news2",
12
+ "description":"hn next id news2 "
13
+ },
14
+ {
15
+ "title":"Ray Kurzweil joins Google",
16
+ "url":"http://www.kurzweilai.net/kurzweil-joins-google-to-work-on-new-projects-involving-machine-learning-and-language-processing?utm_source=twitterfeed&utm_medium=twitter",
17
+ "score":"260 points",
18
+ "user":"dumitrue",
19
+ "comments":"122 comments",
20
+ "time":"14 hours ago",
21
+ "item_id":"4923914",
22
+ "description":"260 points by dumitrue 14 hours ago | 122 comments"
23
+ }
24
+ ]}'
25
+ @pd = PageData.new @data
26
+ end
27
+
28
+ it 'adds score node' do
29
+ @pd.data.first.should have_key 'score'
30
+ end
31
+
32
+ it 'adds comments node' do
33
+ @pd.data.first.should have_key 'comments'
34
+ end
35
+
36
+ it 'formats score node as a number when the node didn\'t exist' do
37
+ @pd.data.first['score'].should == '0'
38
+ end
39
+
40
+ it 'formats score node as a number when text is present' do
41
+ @pd.data.last['score'].should == '260'
42
+ end
43
+
44
+ it 'formats comments node as a number when the node didn\'t exist' do
45
+ @pd.data.first['comments'].should == '0'
46
+ end
47
+
48
+ it 'formats comments node as a number when text is present' do
49
+ @pd.data.last['comments'].should == '122'
50
+ end
51
+
52
+ end
53
+
54
+ describe 'calculating stats' do
55
+ before(:each) do
56
+ @page_data = HackerTerm::PageData.new File.read './data/data.json'
57
+ end
58
+
59
+ it 'provides a mean' do
60
+ @page_data.mean_score.should == 194.19354838709677
61
+ end
62
+
63
+ it 'provides a median' do
64
+ @page_data.median_score.should == 131
65
+ end
66
+
67
+ it 'provides a mode' do
68
+ @page_data.mode_score.should == 0
69
+ end
70
+ end
71
+
72
+ describe 'sorting' do
73
+ before(:each) do
74
+ @data =
75
+ '{"items":[
76
+ {
77
+ "title":"First Article",
78
+ "url":"http://google.com",
79
+ "score":"0 points",
80
+ "user":"dumitrue",
81
+ "comments":"100 comments",
82
+ "time":"14 hours ago",
83
+ "item_id":"4923914",
84
+ "description":"260 points by dumitrue 14 hours ago | 122 comments"
85
+ },
86
+ {
87
+ "title":"Second Article",
88
+ "url":"http://google.com",
89
+ "score":"50 points",
90
+ "user":"dumitrue",
91
+ "comments":"5 comments",
92
+ "time":"14 hours ago",
93
+ "item_id":"4923914",
94
+ "description":"260 points by dumitrue 14 hours ago | 122 comments"
95
+ },
96
+ {
97
+ "title":"Third Article",
98
+ "url":"http://google.com",
99
+ "score":"25 points",
100
+ "user":"dumitrue",
101
+ "comments":"0 comments",
102
+ "time":"14 hours ago",
103
+ "item_id":"4923914",
104
+ "description":"260 points by dumitrue 14 hours ago | 122 comments"
105
+ }
106
+ ]}'
107
+ @pd = PageData.new @data
108
+ end
109
+
110
+ it 'preserves natural ordering as default' do
111
+ @pd.data.first['title'].should == 'First Article'
112
+ @pd.data.last['title'].should == 'Third Article'
113
+ end
114
+
115
+ it 'sorts by score when requested' do
116
+ @pd.sort_on!(:score)
117
+ @pd.data.first['title'].should == 'First Article'
118
+ @pd.data.last['title'].should == 'Second Article'
119
+ end
120
+
121
+ it 'sorts by number of comments when requested' do
122
+ @pd.sort_on!(:comments)
123
+ @pd.data.first['title'].should == 'Third Article'
124
+ @pd.data.last['title'].should == 'First Article'
125
+ end
126
+
127
+ it 'sorts by rank when requested' do
128
+ @pd.sort_on!(:rank)
129
+ @pd.data.first['title'].should == 'First Article'
130
+ @pd.data.last['title'].should == 'Third Article'
131
+ end
132
+
133
+ it 'sorts by title when requested' do
134
+ @pd.sort_on!(:title)
135
+ @pd.data.first['title'].should == 'First Article'
136
+ @pd.data.last['title'].should == 'Third Article'
137
+ end
138
+
139
+ it 're-sorts by rank when requested' do
140
+ @pd.sort_on!(:comments)
141
+ @pd.sort_on!(:rank)
142
+ @pd.data.first['title'].should == 'First Article'
143
+ @pd.data.last['title'].should == 'Third Article'
144
+ end
145
+ end
146
+ end
147
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hacker_term
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.0.1
6
+ platform: ruby
7
+ authors:
8
+ - Ciaran Archer
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2013-01-01 00:00:00 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: rest-client
17
+ prerelease: false
18
+ requirement: &id001 !ruby/object:Gem::Requirement
19
+ none: false
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ type: :runtime
25
+ version_requirements: *id001
26
+ - !ruby/object:Gem::Dependency
27
+ name: launchy
28
+ prerelease: false
29
+ requirement: &id002 !ruby/object:Gem::Requirement
30
+ none: false
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: "0"
35
+ type: :runtime
36
+ version_requirements: *id002
37
+ - !ruby/object:Gem::Dependency
38
+ name: clipboard
39
+ prerelease: false
40
+ requirement: &id003 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: "0"
46
+ type: :runtime
47
+ version_requirements: *id003
48
+ description: Read Hacker News on the Terminal
49
+ email:
50
+ - ciaran.archer@gmail.com
51
+ executables:
52
+ - hacker_term
53
+ extensions: []
54
+
55
+ extra_rdoc_files: []
56
+
57
+ files:
58
+ - .gitignore
59
+ - Gemfile
60
+ - Gemfile.lock
61
+ - LICENSE.txt
62
+ - README.md
63
+ - Rakefile
64
+ - bin/hacker_term
65
+ - data/data.json
66
+ - hacker_term.gemspec
67
+ - lib/hacker_term.rb
68
+ - lib/hacker_term/page_data.rb
69
+ - lib/hacker_term/ui.rb
70
+ - lib/hacker_term/version.rb
71
+ - run.rb
72
+ - spec/page_data_spec.rb
73
+ homepage: https://github.com/ciaranarcher/hacker_term
74
+ licenses: []
75
+
76
+ post_install_message:
77
+ rdoc_options: []
78
+
79
+ require_paths:
80
+ - lib
81
+ required_ruby_version: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: "0"
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ none: false
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: "0"
93
+ requirements: []
94
+
95
+ rubyforge_project:
96
+ rubygems_version: 1.8.10
97
+ signing_key:
98
+ specification_version: 3
99
+ summary: Allows the reading, sorting and opening of HN articles from the terminal.
100
+ test_files:
101
+ - spec/page_data_spec.rb