hackernews-tui 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5ee2d5fd17591b63f01af5c2afcd4444a2ba60a634e5416bbd81d086869afa70
4
+ data.tar.gz: 948b7b8bd56b8e634c04921b5137724cbc017a2ffc4594c4d27be8d17f9e9711
5
+ SHA512:
6
+ metadata.gz: 44ef23cc5c9c021a430b60702cd829f9b6e248a45a027341f47a47221638017ae0d69124f02935447620322af0d3f771ae351df9426143f3b19ce21880363e85
7
+ data.tar.gz: d17b3d34810941fd7d306e78d8e99d2159117dc6de6785645ddade5218fe4d59c84348c7b8c6fc4250cdb1d0c0afc1189c892a8df7b60b47351cb3cdd09e709b
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # Hackernews
2
+
3
+ A terminal-based UI for reading Hacker News, built with Charming.
4
+
5
+ Browse top, new, best, ask HN, show HN, and jobs feeds from your terminal. Open articles inline, navigate with a familiar Vim-inspired keyboard layout, and style it with any of the built-in Charming themes.
6
+
7
+ ## Features
8
+
9
+ - **6 feed tabs** — Top, New, Best, Ask HN, Show HN, Jobs
10
+ - **Story cards** with rank, score, domain, author, and comment count
11
+ - **Inline article reading** — fetches full article text via trafilatura for CLI-based extraction
12
+ - **Async background loading** — feeds load in parallel threads without blocking the interface
13
+ - **Vim-inspired navigation** — j/k keys mapped to down/up, page up/down for big jumps
14
+ - **Theme support** — all Charming built-in themes are available out of the box, Phosphor by default
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ gem install hackernews-tui
20
+ ```
21
+
22
+ Or use it locally:
23
+
24
+ ```sh
25
+ git clone https://github.com/lbpdevcodes/hackernews.git
26
+ cd hackernews
27
+ bundle install
28
+ bundle exec hackernews
29
+ ```
30
+
31
+ ### System dependency
32
+
33
+ Article extraction requires [trafilatura](https://github.com/adverb-xyz/trafilatura) to be installed and on your PATH.
34
+
35
+ ## Usage
36
+
37
+ ```sh
38
+ hackernews
39
+ # or
40
+ bundle exec hackernews
41
+ ```
42
+
43
+ ### Keyboard shortcuts
44
+
45
+ | Key | Action |
46
+ |-------------|-------------------------|
47
+ | j / Down | Move cursor down |
48
+ | k / Up | Move cursor up |
49
+ | Page up | Jump 10 items up |
50
+ | Page down | Jump 10 items down |
51
+ | n / Right | Next page of stories |
52
+ | Left | Previous page |
53
+ | Enter | Open selected article |
54
+ | Escape | Close article, return to feed |
55
+ | r | Refresh current feed |
56
+
57
+ ## Architecture
58
+
59
+ - **State** — `HomeState` manages local state: stories by page, feed index, article cache, loading indicators
60
+ - **Controllers** — `HomeController` coordinates data fetching from the Firebase API and article extraction
61
+ - **Views** — Ruby view classes under `app/views`
62
+ - **Components** — reusable widgets like `StoryListComponent` that handle their own rendering
63
+
64
+ Key libraries: Charming (UI framework), HTTParty (HTTP client), Zeitwerk (auto-loading).
65
+
66
+ ## Development
67
+
68
+ ```sh
69
+ rspec # run tests
70
+ rake # all gem tasks
71
+ ```
72
+
73
+ ## License
74
+
75
+ TODO: Add license information.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ class AppFrameComponent < Charming::Component
5
+ def render
6
+ column(*lines, gap: 1)
7
+ end
8
+
9
+ private
10
+
11
+ def lines
12
+ output = [title_line]
13
+ output << status_line if home.error.to_s.strip != ""
14
+ output << activity_line if home.working?
15
+ output << content_line
16
+ output << help_line
17
+ output
18
+ end
19
+
20
+ def title_line
21
+ text "#{home.title} / #{feed_title}", style: theme.title
22
+ end
23
+
24
+ def status_line
25
+ text home.error, style: theme.warn
26
+ end
27
+
28
+ def activity_line
29
+ render_component(Charming::Components::ActivityIndicator.new(
30
+ width: 24,
31
+ label: home.loading_label.to_s,
32
+ index: home.activity_index,
33
+ seed: "hackernews-loading",
34
+ label_style: theme.muted,
35
+ max_width: content_width,
36
+ fallback_label: "Working"
37
+ ))
38
+ end
39
+
40
+ def content_line
41
+ if home.reading?
42
+ article_view
43
+ else
44
+ render_component(StoryListComponent.new(home: home, width: content_width, height: content_height, theme: theme))
45
+ end
46
+ end
47
+
48
+ def article_view
49
+ article = home.article || {}
50
+ header = text(article.fetch(:url, ""), style: theme.muted)
51
+ markdown = Charming::Components::Markdown.new(
52
+ content: article.fetch(:markdown, ""),
53
+ width: content_width,
54
+ theme: theme,
55
+ base_url: article.fetch(:url, nil)
56
+ )
57
+ body = render_component(Charming::Components::Viewport.new(
58
+ content: markdown,
59
+ width: content_width,
60
+ height: [content_height - 2, 1].max,
61
+ offset: home.article_scroll,
62
+ wrap: true
63
+ ))
64
+ column(header, body)
65
+ end
66
+
67
+ def help_line
68
+ text help_text, style: theme.muted
69
+ end
70
+
71
+ def help_text
72
+ if home.reading?
73
+ "j/k scroll, pgup/pgdn jump, esc back. p commands, q quit."
74
+ else
75
+ "j/k move, enter read, r refresh, left/right page. p commands, q quit."
76
+ end
77
+ end
78
+
79
+ def feed_title
80
+ HomeController::FEEDS.fetch(home.feed).fetch(:title)
81
+ end
82
+
83
+ def content_width
84
+ if screen.narrow?(below: 72, min_height: 20)
85
+ [screen.width - 12, 20].max
86
+ else
87
+ [screen.width - 42, 20].max
88
+ end
89
+ end
90
+
91
+ def content_height
92
+ [screen.height - 12, 5].max
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ class StoryListComponent < Charming::Component
5
+ def render
6
+ return empty_state if home.current_stories.empty?
7
+
8
+ column(*story_lines, footer_line, gap: 1)
9
+ end
10
+
11
+ private
12
+
13
+ def empty_state
14
+ message = if home.loading
15
+ ""
16
+ elsif home.error.to_s.strip != ""
17
+ "Press r to retry."
18
+ else
19
+ "No stories loaded. Press r to refresh."
20
+ end
21
+
22
+ text message, style: theme.muted
23
+ end
24
+
25
+ def story_lines
26
+ visible_stories.each_with_index.map do |story, index|
27
+ absolute_index = viewport_start + index
28
+ rank = (home.page * Client::DEFAULT_PER_PAGE) + absolute_index + 1
29
+ title = "#{rank}. #{story.title}"
30
+ title += " (#{story.domain})" unless story.domain.empty?
31
+ meta = " #{story.score} points by #{story.by} | #{story.descendants} comments"
32
+ line = "#{title}\n#{meta}"
33
+ absolute_index == home.selected_index ? text(line, style: theme.selected) : text(line)
34
+ end
35
+ end
36
+
37
+ def footer_line
38
+ text footer_text, style: theme.muted
39
+ end
40
+
41
+ def footer_text
42
+ total = home.current_story_ids.length
43
+ loaded = home.current_stories.length
44
+ page = home.page + 1
45
+ return "Page #{page} | #{loaded} loaded" if total.zero?
46
+
47
+ "Page #{page}/#{page_count(total)} | #{loaded} loaded on this page | #{total} ids cached"
48
+ end
49
+
50
+ def visible_stories
51
+ home.current_stories.slice(viewport_start, viewport_height) || []
52
+ end
53
+
54
+ def viewport_start
55
+ (home.selected_index - viewport_height + 1).clamp(0, max_viewport_start)
56
+ end
57
+
58
+ def viewport_height
59
+ [height.to_i / 3, 1].max
60
+ end
61
+
62
+ def max_viewport_start
63
+ [home.current_stories.length - viewport_height, 0].max
64
+ end
65
+
66
+ def page_count(total)
67
+ (total.to_f / Client::DEFAULT_PER_PAGE).ceil
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ class ApplicationController < Charming::Controller
5
+ include Charming::Shell::Sidebar
6
+ include Charming::Shell::Palette
7
+
8
+ layout Layouts::ApplicationLayout
9
+ focus_ring :content, :sidebar
10
+
11
+ key "p", :open_command_palette, scope: :global
12
+ key "q", :quit, scope: :global
13
+
14
+ command "Top" do
15
+ navigate :root
16
+ end
17
+
18
+ command "New" do
19
+ navigate :new
20
+ end
21
+
22
+ command "Best" do
23
+ navigate :best
24
+ end
25
+
26
+ command "Ask HN" do
27
+ navigate :ask
28
+ end
29
+
30
+ command "Show HN" do
31
+ navigate :show
32
+ end
33
+
34
+ command "Jobs" do
35
+ navigate :jobs
36
+ end
37
+
38
+ command "Theme", :open_theme_palette
39
+ command "Close palette", :close_command_palette
40
+ command "Quit app", :quit
41
+ end
42
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ class HomeController < ApplicationController
5
+ FEEDS = {
6
+ "top" => {title: "Top Stories", screen: :root},
7
+ "new" => {title: "New", screen: :new},
8
+ "best" => {title: "Best", screen: :best},
9
+ "ask" => {title: "Ask HN", screen: :ask},
10
+ "show" => {title: "Show HN", screen: :show},
11
+ "jobs" => {title: "Jobs", screen: :jobs}
12
+ }.freeze
13
+
14
+ key "r", :refresh
15
+ key "up", :move_up
16
+ key "k", :move_up
17
+ key "down", :move_down
18
+ key "j", :move_down
19
+ key "page_up", :page_up
20
+ key "page_down", :page_down
21
+ key "left", :previous_page
22
+ key "right", :next_page
23
+ key "n", :next_page
24
+ key "enter", :open_selected_story
25
+ key "escape", :close_article
26
+
27
+ timer :activity, every: 0.12, action: :tick_activity
28
+ on_task :load_feed, action: :load_feed_done
29
+ on_task :extract_article, action: :extract_article_done
30
+
31
+ def show
32
+ ensure_feed_loaded
33
+ render :show, home: home, palette: command_palette
34
+ end
35
+
36
+ def new
37
+ switch_feed("new")
38
+ end
39
+
40
+ def best
41
+ switch_feed("best")
42
+ end
43
+
44
+ def ask
45
+ switch_feed("ask")
46
+ end
47
+
48
+ def show_hn
49
+ switch_feed("show")
50
+ end
51
+
52
+ def jobs
53
+ switch_feed("jobs")
54
+ end
55
+
56
+ def refresh
57
+ key = home.page_key
58
+ home.stories_by_page.delete(key)
59
+ home.story_ids_by_feed.delete(home.feed)
60
+ home.failed_pages.delete(key)
61
+ start_feed_load(force: true)
62
+ render :show, home: home, palette: command_palette
63
+ end
64
+
65
+ def tick_activity
66
+ home.activity_index += 1 if home.working?
67
+ show
68
+ end
69
+
70
+ def load_feed_done
71
+ value = event.value || {}
72
+ feed = value[:feed] || home.feed
73
+ page = value[:page] || home.page
74
+ key = home.page_key(feed, page)
75
+ current_page = feed == home.feed && page == home.page
76
+
77
+ if value[:error]
78
+ home.error = value[:error]
79
+ home.failed_pages[key] = true
80
+ else
81
+ home.error = ""
82
+ home.failed_pages.delete(key)
83
+ home.story_ids_by_feed[value.fetch(:feed)] = value.fetch(:ids)
84
+ home.stories_by_page[key] = value.fetch(:stories)
85
+ home.selected_index = 0 if current_page
86
+ end
87
+
88
+ if home.loading_key == key || current_page
89
+ home.loading = false
90
+ home.loading_key = nil
91
+ end
92
+
93
+ show
94
+ end
95
+
96
+ def extract_article_done
97
+ value = event.value || {}
98
+ story_id = value[:story_id].to_i
99
+ home.extracting_story_id = nil if home.extracting_story_id.to_i == story_id
100
+
101
+ if value[:error]
102
+ home.error = value[:error]
103
+ else
104
+ home.error = ""
105
+ home.articles_by_story_id[story_id] = value.fetch(:article)
106
+ home.reading_story_id = story_id
107
+ home.article_scroll = 0
108
+ end
109
+
110
+ show
111
+ end
112
+
113
+ def move_up
114
+ if home.reading?
115
+ home.article_scroll = [home.article_scroll - 1, 0].max
116
+ else
117
+ home.selected_index = [home.selected_index - 1, 0].max
118
+ end
119
+ show
120
+ end
121
+
122
+ def move_down
123
+ if home.reading?
124
+ home.article_scroll += 1
125
+ else
126
+ max = [home.current_stories.length - 1, 0].max
127
+ home.selected_index = [home.selected_index + 1, max].min
128
+ end
129
+ show
130
+ end
131
+
132
+ def page_up
133
+ if home.reading?
134
+ home.article_scroll = [home.article_scroll - 10, 0].max
135
+ else
136
+ home.selected_index = [home.selected_index - 10, 0].max
137
+ end
138
+ show
139
+ end
140
+
141
+ def page_down
142
+ if home.reading?
143
+ home.article_scroll += 10
144
+ else
145
+ max = [home.current_stories.length - 1, 0].max
146
+ home.selected_index = [home.selected_index + 10, max].min
147
+ end
148
+ show
149
+ end
150
+
151
+ def previous_page
152
+ return show if home.reading?
153
+ return show if home.page.to_i <= 0
154
+
155
+ home.page -= 1
156
+ home.reset_list_position
157
+ show
158
+ end
159
+
160
+ def next_page
161
+ return show if home.reading?
162
+ return show unless more_pages?
163
+
164
+ home.page += 1
165
+ home.reset_list_position
166
+ show
167
+ end
168
+
169
+ def open_selected_story
170
+ story = home.selected_story
171
+ return show unless story
172
+
173
+ if home.articles_by_story_id.key?(story.id)
174
+ home.reading_story_id = story.id
175
+ home.article_scroll = 0
176
+ elsif story.url.to_s.strip.empty?
177
+ home.articles_by_story_id[story.id] = {url: story.article_url, markdown: story.hn_markdown}
178
+ home.reading_story_id = story.id
179
+ home.article_scroll = 0
180
+ else
181
+ start_article_extract(story)
182
+ end
183
+
184
+ show
185
+ end
186
+
187
+ def close_article
188
+ home.reading_story_id = nil
189
+ home.article_scroll = 0
190
+ show
191
+ end
192
+
193
+ def current_route?(route)
194
+ route.name == FEEDS.fetch(home.feed).fetch(:screen)
195
+ end
196
+
197
+ private
198
+
199
+ def home
200
+ state(:home, HomeState)
201
+ end
202
+
203
+ def switch_feed(feed)
204
+ home.feed = feed
205
+ home.page = 0
206
+ home.error = ""
207
+ home.reset_list_position
208
+ show
209
+ end
210
+
211
+ def ensure_feed_loaded
212
+ return if home.cached_page? || home.failed_page?
213
+
214
+ start_feed_load
215
+ end
216
+
217
+ def start_feed_load(force: false)
218
+ key = home.page_key
219
+ return if home.loading_key == key
220
+ return if home.cached_page? && !force
221
+
222
+ feed = home.feed
223
+ page = home.page
224
+ home.loading = true
225
+ home.loading_key = key
226
+ home.loading_label = "Loading #{FEEDS.fetch(feed).fetch(:title)}"
227
+ home.error = ""
228
+
229
+ run_task(:load_feed, with: {feed: feed, page: page}) do |ctx|
230
+ Client.new.stories(feed: ctx[:feed], page: ctx[:page])
231
+ rescue StandardError => e
232
+ {feed: ctx[:feed], page: ctx[:page], error: e.message}
233
+ end
234
+ end
235
+
236
+ def start_article_extract(story)
237
+ return if home.extracting_story_id.to_i == story.id
238
+
239
+ home.extracting_story_id = story.id
240
+ home.loading_label = "Extracting article"
241
+ home.error = ""
242
+
243
+ run_task(:extract_article, with: {story: story}) do |ctx|
244
+ {story_id: ctx[:story].id, article: ArticleExtractor.new.extract(ctx[:story].url)}
245
+ rescue StandardError => e
246
+ {story_id: ctx[:story].id, error: e.message}
247
+ end
248
+ end
249
+
250
+ def more_pages?
251
+ ids = home.current_story_ids
252
+ return true if ids.empty?
253
+
254
+ (home.page + 1) * Client::DEFAULT_PER_PAGE < ids.length
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ class ApplicationState < Charming::ApplicationState
5
+ end
6
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ class HomeState < ApplicationState
5
+ attribute :title, :string, default: "Hackernews"
6
+ attribute :feed, :string, default: "top"
7
+ attribute :page, :integer, default: 0
8
+ attribute :selected_index, :integer, default: 0
9
+ attribute :loading, :boolean, default: false
10
+ attribute :loading_label, :string, default: ""
11
+ attribute :error, :string, default: ""
12
+ attribute :activity_index, :integer, default: 0
13
+ attribute :reading_story_id, :integer
14
+ attribute :article_scroll, :integer, default: 0
15
+
16
+ attr_accessor :story_ids_by_feed, :stories_by_page, :articles_by_story_id,
17
+ :failed_pages, :loading_key, :extracting_story_id
18
+
19
+ def initialize(**attributes)
20
+ super
21
+ @story_ids_by_feed = {}
22
+ @stories_by_page = {}
23
+ @articles_by_story_id = {}
24
+ @failed_pages = {}
25
+ end
26
+
27
+ def page_key(feed_name = feed, page_number = page)
28
+ "#{feed_name}:#{page_number.to_i}"
29
+ end
30
+
31
+ def current_stories
32
+ stories_by_page.fetch(page_key, [])
33
+ end
34
+
35
+ def current_story_ids
36
+ story_ids_by_feed.fetch(feed, [])
37
+ end
38
+
39
+ def cached_page?
40
+ stories_by_page.key?(page_key)
41
+ end
42
+
43
+ def failed_page?
44
+ failed_pages[page_key]
45
+ end
46
+
47
+ def selected_story
48
+ current_stories[selected_index]
49
+ end
50
+
51
+ def selected_story=(story)
52
+ self.selected_index = current_stories.index(story) || 0
53
+ end
54
+
55
+ def article
56
+ articles_by_story_id[reading_story_id]
57
+ end
58
+
59
+ def reading?
60
+ reading_story_id.to_i.positive?
61
+ end
62
+
63
+ def working?
64
+ (loading && !cached_page?) || extracting_story_id.to_i.positive?
65
+ end
66
+
67
+ def reset_list_position
68
+ self.selected_index = 0
69
+ self.article_scroll = 0
70
+ self.reading_story_id = nil
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ module Home
5
+ class ShowView < Charming::View
6
+ def render
7
+ render_component AppFrameComponent.new(home: home, screen: screen, theme: theme)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ module Layouts
5
+ class ApplicationLayout < Charming::View
6
+ def render
7
+ modal = command_palette_modal
8
+
9
+ screen_layout(background: theme.background) do
10
+ split(narrow? ? :vertical : :horizontal, gap: 1) do
11
+ pane(:sidebar, **sidebar_options, border: :rounded, padding: [1, 2], style: sidebar_style) do
12
+ column(app_title, navigation, shortcuts, gap: 1)
13
+ end
14
+
15
+ pane(:content, grow: 1, border: :rounded, padding: [1, 2], style: content_style) do
16
+ yield_content
17
+ end
18
+ end
19
+
20
+ overlay modal if modal
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def palette_component
27
+ assigns.fetch(:palette, nil)
28
+ end
29
+
30
+ def narrow?
31
+ screen.narrow?(below: 72, min_height: 20)
32
+ end
33
+
34
+ def sidebar_options
35
+ narrow? ? {height: sidebar_outer_height} : {width: sidebar_outer_width}
36
+ end
37
+
38
+ def sidebar_outer_width
39
+ sidebar_inner_width + 6
40
+ end
41
+
42
+ def sidebar_outer_height
43
+ sidebar_routes.length + 10
44
+ end
45
+
46
+ def sidebar_inner_width
47
+ narrow? ? [screen.width - 6, 20].max : 22
48
+ end
49
+
50
+ def app_title
51
+ text "Hackernews", style: theme.header_accent.align(:center).width(sidebar_inner_width)
52
+ end
53
+
54
+ def navigation
55
+ column(*nav_items)
56
+ end
57
+
58
+ def nav_items
59
+ sidebar_routes.each_with_index.map do |route, index|
60
+ text nav_item_label(route, index), style: nav_item_style(route, index)
61
+ end
62
+ end
63
+
64
+ def nav_item_label(route, index)
65
+ cursor = (sidebar_focused? && index == sidebar_index) ? ">" : " "
66
+ active = current_route?(route) ? "●" : " "
67
+ "#{cursor} #{active} #{route.title}"
68
+ end
69
+
70
+ def nav_item_style(route, index)
71
+ if sidebar_focused? && index == sidebar_index
72
+ theme.selected
73
+ elsif current_route?(route)
74
+ theme.title
75
+ else
76
+ theme.muted
77
+ end
78
+ end
79
+
80
+ def shortcuts
81
+ text "tab focus\np commands\nq quit", style: theme.muted
82
+ end
83
+
84
+ def sidebar_style
85
+ panel_style(sidebar_focused?)
86
+ end
87
+
88
+ def content_style
89
+ panel_style(content_focused?)
90
+ end
91
+
92
+ def panel_style(focused)
93
+ style = focused ? theme.title : theme.border
94
+ palette_component ? style.faint : style
95
+ end
96
+
97
+ def command_palette_modal
98
+ return unless palette_component
99
+
100
+ render_component Charming::Components::CommandPaletteModal.new(
101
+ content: palette_component,
102
+ theme: theme
103
+ )
104
+ end
105
+
106
+ def sidebar_focused?
107
+ controller.sidebar_focused?
108
+ end
109
+
110
+ def content_focused?
111
+ controller.content_focused?
112
+ end
113
+
114
+ def sidebar_index
115
+ controller.sidebar_index
116
+ end
117
+
118
+ def sidebar_routes
119
+ controller.sidebar_routes
120
+ end
121
+
122
+ def current_route?(route)
123
+ controller.current_route?(route)
124
+ end
125
+ end
126
+ end
127
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ Hackernews::Application.routes do
4
+ root "home#show", title: "Top"
5
+ screen :new, "home#new", title: "New"
6
+ screen :best, "home#best", title: "Best"
7
+ screen :ask, "home#ask", title: "Ask HN"
8
+ screen :show, "home#show_hn", title: "Show HN"
9
+ screen :jobs, "home#jobs", title: "Jobs"
10
+ end
data/exe/hackernews ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "hackernews"
6
+
7
+ Charming.run(Hackernews::Application.new)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ class Application < Charming::Application
5
+ root File.expand_path("../..", __dir__)
6
+
7
+ Charming::UI::Theme.built_in_names.each do |theme_name|
8
+ theme theme_name.to_sym, built_in: theme_name
9
+ end
10
+
11
+ default_theme :phosphor
12
+ end
13
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module Hackernews
7
+ class ArticleExtractor
8
+ COMMAND = "trafilatura"
9
+ TIMEOUT = 30
10
+
11
+ def extract(url)
12
+ url = url.to_s.strip
13
+ raise ArgumentError, "story has no article URL" if url.empty?
14
+
15
+ stdout, stderr, status = Timeout.timeout(TIMEOUT) do
16
+ Open3.capture3(COMMAND, "--markdown", "--images", "--no-comments", "--no-tables", "-u", url)
17
+ end
18
+
19
+ unless status.success?
20
+ message = stderr.strip.empty? ? stdout.strip : stderr.strip
21
+ raise "trafilatura extraction failed: #{message}"
22
+ end
23
+
24
+ markdown = stdout.strip
25
+ raise "trafilatura did not find readable article content" if markdown.empty?
26
+
27
+ {url: url, markdown: markdown}
28
+ rescue Errno::ENOENT
29
+ raise "trafilatura is required. Install it separately and ensure `trafilatura` is on PATH."
30
+ rescue Timeout::Error
31
+ raise "article extraction timed out"
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "thread"
5
+
6
+ module Hackernews
7
+ class Client
8
+ include HTTParty
9
+
10
+ FEEDS = {
11
+ "top" => "topstories",
12
+ "new" => "newstories",
13
+ "best" => "beststories",
14
+ "ask" => "askstories",
15
+ "show" => "showstories",
16
+ "jobs" => "jobstories"
17
+ }.freeze
18
+
19
+ DEFAULT_LIMIT = 500
20
+ DEFAULT_PER_PAGE = 30
21
+ DEFAULT_CONCURRENCY = 8
22
+
23
+ base_uri "https://hacker-news.firebaseio.com/v0"
24
+
25
+ def initialize(per_page: DEFAULT_PER_PAGE, limit: DEFAULT_LIMIT, concurrency: DEFAULT_CONCURRENCY)
26
+ @per_page = per_page
27
+ @limit = limit
28
+ @concurrency = concurrency
29
+ end
30
+
31
+ def stories(feed:, page: 0)
32
+ feed = feed.to_s
33
+ ids = story_ids(feed).first(limit)
34
+ page_ids = ids.slice(page.to_i * per_page, per_page) || []
35
+
36
+ {
37
+ feed: feed,
38
+ page: page.to_i,
39
+ ids: ids,
40
+ stories: stories_for_ids(page_ids)
41
+ }
42
+ end
43
+
44
+ def story_ids(feed)
45
+ endpoint = FEEDS.fetch(feed.to_s) { FEEDS.fetch("top") }
46
+ fetch_json("/#{endpoint}.json")
47
+ end
48
+
49
+ def item(id)
50
+ Story.from_hash(fetch_json("/item/#{id}.json"))
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :per_page, :limit, :concurrency
56
+
57
+ def stories_for_ids(ids)
58
+ return [] if ids.empty?
59
+
60
+ work = Queue.new
61
+ ids.each_with_index { |id, index| work << [index, id] }
62
+ stories = Array.new(ids.length)
63
+ errors = Queue.new
64
+
65
+ [concurrency, ids.length].min.times.map do
66
+ Thread.new do
67
+ loop do
68
+ index, id = work.pop(true)
69
+ story = item(id)
70
+ stories[index] = story if story&.readable?
71
+ rescue ThreadError
72
+ break
73
+ rescue StandardError => e
74
+ errors << e
75
+ break
76
+ end
77
+ end
78
+ end.each(&:join)
79
+
80
+ raise errors.pop unless errors.empty?
81
+
82
+ stories.compact
83
+ end
84
+
85
+ def fetch_json(path)
86
+ response = self.class.get(path, timeout: 10)
87
+ raise "Hacker News API returned #{response.code}" unless response.success?
88
+
89
+ response.parsed_response
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "uri"
5
+
6
+ module Hackernews
7
+ Story = Data.define(:id, :type, :by, :time, :title, :url, :score, :descendants, :text) do
8
+ def self.from_hash(hash)
9
+ return unless hash
10
+
11
+ new(
12
+ id: hash.fetch("id", nil),
13
+ type: hash.fetch("type", nil),
14
+ by: hash.fetch("by", ""),
15
+ time: hash.fetch("time", 0).to_i,
16
+ title: hash.fetch("title", "Untitled"),
17
+ url: hash.fetch("url", ""),
18
+ score: hash.fetch("score", 0).to_i,
19
+ descendants: hash.fetch("descendants", 0).to_i,
20
+ text: hash.fetch("text", "")
21
+ )
22
+ end
23
+
24
+ def readable?
25
+ %w[story job poll].include?(type)
26
+ end
27
+
28
+ def article_url
29
+ url.to_s.strip.empty? ? "https://news.ycombinator.com/item?id=#{id}" : url
30
+ end
31
+
32
+ def domain
33
+ return "news.ycombinator.com" if url.to_s.strip.empty?
34
+
35
+ URI.parse(url).hostname.to_s.delete_prefix("www.")
36
+ rescue URI::InvalidURIError
37
+ ""
38
+ end
39
+
40
+ def hn_markdown
41
+ body = CGI.unescapeHTML(text.to_s)
42
+ body = body.gsub("<p>", "\n\n")
43
+ body = body.gsub("<pre><code>", "\n\n```")
44
+ body = body.gsub("</code></pre>", "```\n\n")
45
+ "# #{title}\n\n#{body}"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hackernews
4
+ VERSION = "0.1.1"
5
+ end
data/lib/hackernews.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "charming"
4
+ require "zeitwerk"
5
+
6
+ module Hackernews
7
+ end
8
+
9
+ loader = Zeitwerk::Loader.new
10
+ loader.tag = "hackernews"
11
+ loader.inflector.inflect("version" => "VERSION")
12
+ loader.push_dir(File.expand_path("hackernews", __dir__), namespace: Hackernews)
13
+ loader.push_dir(File.expand_path("../app/state", __dir__), namespace: Hackernews)
14
+ loader.push_dir(File.expand_path("../app/components", __dir__), namespace: Hackernews)
15
+ loader.push_dir(File.expand_path("../app/views", __dir__), namespace: Hackernews)
16
+ loader.push_dir(File.expand_path("../app/controllers", __dir__), namespace: Hackernews)
17
+ loader.setup
18
+
19
+ require_relative "../config/routes"
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hackernews-tui
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - lbpdevcodes
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: charming
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.4.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.4.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: httparty
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ email:
55
+ - info@lbp.dev
56
+ executables:
57
+ - hackernews
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - README.md
62
+ - app/components/app_frame_component.rb
63
+ - app/components/story_list_component.rb
64
+ - app/controllers/application_controller.rb
65
+ - app/controllers/home_controller.rb
66
+ - app/state/application_state.rb
67
+ - app/state/home_state.rb
68
+ - app/views/home/show_view.rb
69
+ - app/views/layouts/application_layout.rb
70
+ - config/routes.rb
71
+ - exe/hackernews
72
+ - lib/hackernews.rb
73
+ - lib/hackernews/application.rb
74
+ - lib/hackernews/article_extractor.rb
75
+ - lib/hackernews/client.rb
76
+ - lib/hackernews/story.rb
77
+ - lib/hackernews/version.rb
78
+ licenses: []
79
+ metadata:
80
+ rubygems_mfa_required: 'true'
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 4.0.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 4.0.16
96
+ specification_version: 4
97
+ summary: A Charming terminal user interface.
98
+ test_files: []