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 +7 -0
- data/README.md +75 -0
- data/app/components/app_frame_component.rb +95 -0
- data/app/components/story_list_component.rb +70 -0
- data/app/controllers/application_controller.rb +42 -0
- data/app/controllers/home_controller.rb +257 -0
- data/app/state/application_state.rb +6 -0
- data/app/state/home_state.rb +73 -0
- data/app/views/home/show_view.rb +11 -0
- data/app/views/layouts/application_layout.rb +127 -0
- data/config/routes.rb +10 -0
- data/exe/hackernews +7 -0
- data/lib/hackernews/application.rb +13 -0
- data/lib/hackernews/article_extractor.rb +34 -0
- data/lib/hackernews/client.rb +92 -0
- data/lib/hackernews/story.rb +48 -0
- data/lib/hackernews/version.rb +5 -0
- data/lib/hackernews.rb +19 -0
- metadata +98 -0
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,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,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,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
|
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: []
|