hacker_term 0.0.1

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.
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