commit cde359806d79df5d864b49ff278421b12b88896d Author: Edi Piqoni Date: Sun Jun 2 18:55:22 2024 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b8a5b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vscode/ +.DS_STORE +hn-text diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a50a765 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module hn-text + +go 1.21.1 + +require ( + github.com/PuerkitoBio/goquery v1.9.2 + github.com/gdamore/tcell/v2 v2.7.4 + github.com/gelembjuk/articletext v0.0.0-20231013143648-bc7a97ba132a + github.com/k3a/html2text v1.2.1 + github.com/rivo/tview v0.0.0-20240524063012-037df494fb76 +) + +require ( + github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/gdamore/encoding v1.0.0 // indirect + github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/term v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/neurosnap/sentences.v1 v1.0.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2aa4a12 --- /dev/null +++ b/go.sum @@ -0,0 +1,87 @@ +github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= +github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= +github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= +github.com/gelembjuk/articletext v0.0.0-20231013143648-bc7a97ba132a h1:qiro+IlH6Wj1YAEnLGYYGNuqEEQUyrDDWThnHL5Xgzo= +github.com/gelembjuk/articletext v0.0.0-20231013143648-bc7a97ba132a/go.mod h1:MEAzbitBZyN3cjFntWZJnfeForJTU+VNDLR69SdHesA= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= +github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY= +github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/neurosnap/sentences v1.1.2 h1:iphYOzx/XckXeBiLIUBkPu2EKMJ+6jDbz/sLJZ7ZoUw= +github.com/neurosnap/sentences v1.1.2/go.mod h1:/pwU4E9XNL21ygMIkOIllv/SMy2ujHwpf8GQPu1YPbQ= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/rivo/tview v0.0.0-20240524063012-037df494fb76 h1:iqvDlgyjmqleATtFbA7c14djmPh2n4mCYUv7JlD/ruA= +github.com/rivo/tview v0.0.0-20240524063012-037df494fb76/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/neurosnap/sentences.v1 v1.0.7 h1:gpTUYnqthem4+o8kyTLiYIB05W+IvdQFYR29erfe8uU= +gopkg.in/neurosnap/sentences.v1 v1.0.7/go.mod h1:YlK+SN+fLQZj+kY3r8DkGDhDr91+S3JmTb5LSxFRQo0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..691a374 --- /dev/null +++ b/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "log" + "os" + + "github.com/rivo/tview" +) + +var hackerNewsURL = "https://news.ycombinator.com/" + +func main() { + app := tview.NewApplication() + // TODO: rewrite this for other options + if len(os.Args) > 1 && os.Args[1] == "best" { + hackerNewsURL = "https://news.ycombinator.com/best" + } + + htmlContent, err := fetchWebpage(hackerNewsURL) + if err != nil { + log.Fatal(err) + } + + articles, err := parseArticles(htmlContent) + if err != nil { + log.Fatal(err) + } + + list := createArticleList(articles) + pages := tview.NewPages() + pages.AddPage("homepage", list, true, false) + + app.SetInputCapture(createInputHandler(app, list, articles, pages)) + + if err := app.SetRoot(list, true).Run(); err != nil { + log.Fatal(err) + } +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..1231161 --- /dev/null +++ b/parser.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/PuerkitoBio/goquery" +) + +type Article struct { + Title string + Link string + Comments int + CommentsLink string +} + +func parseArticles(htmlContent string) ([]Article, error) { + var articles []Article + + doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent)) + if err != nil { + return nil, err + } + + doc.Find("tr.athing").Each(func(i int, s *goquery.Selection) { + title := s.Find("td.title > span.titleline > a").Text() + link, _ := s.Find("td.title > span.titleline > a").Attr("href") + commentText := s.Next().Find("a[href^='item']").Last().Text() + commentsCount, err := extractNumberFromString(commentText) + commentsLink := s.Next().Find("a[href^='item']").Last().AttrOr("href", "") + if err != nil { + commentsCount = 0 + } + + article := Article{ + Title: title, + Link: link, + Comments: commentsCount, + CommentsLink: commentsLink, + } + + articles = append(articles, article) + }) + + return articles, nil +} + +func extractCommentsCount(s *goquery.Selection) (int, error) { + // find the second a[href^='item'] element + + commentText := s.Next().Find("a[href^='item']").Last().Text() + + return extractNumberFromString(commentText) +} + +func extractNumberFromString(input string) (int, error) { + + input = strings.TrimSpace(input) + re := regexp.MustCompile(`\d+`) + matches := re.FindString(input) + if matches == "" { + return 0, fmt.Errorf("no numbers found in input") + } + + number, err := strconv.Atoi(matches) + if err != nil { + return 0, err + } + + return number, nil +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..ba7c58f --- /dev/null +++ b/parser_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "testing" +) + +func TestParseArticles(t *testing.T) { + htmlContent := ` + + + + + + + + + + + + + + + + + + + +
1.Majorana, the search for the most elusive neutrino of all (lbl.gov)
+ 23 points by bilsbie 2 hours ago | hide | 1 comment + +
2.Abusing Go's Infrastructure (put.as)
+ 298 points by efge 10 + hours ago | hide | 62 comments + +
` + + expectedArticles := []Article{ + {Title: "Majorana, the search for the most elusive neutrino of all", Link: "https://newscenter.lbl.gov/2012/05/16/majorana-demonstrator/", Comments: 1, CommentsLink: "item?id=40477653"}, + {Title: "Abusing Go's Infrastructure", Link: "https://reverse.put.as/2024/05/24/abusing-go-infrastructure/", Comments: 62, CommentsLink: "item?id=40474712"}, + } + + articles, err := parseArticles(htmlContent) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(articles) != len(expectedArticles) { + t.Fatalf("Expected %d articles, got %d", len(expectedArticles), len(articles)) + } + + for i, article := range articles { + if article.Title != expectedArticles[i].Title { + t.Errorf("Expected title %q, got %q", expectedArticles[i].Title, article.Title) + } + if article.Link != expectedArticles[i].Link { + t.Errorf("Expected link %q, got %q", expectedArticles[i].Link, article.Link) + } + if article.Comments != expectedArticles[i].Comments { + t.Errorf("Expected comments %d, got %d", expectedArticles[i].Comments, article.Comments) + } + if article.CommentsLink != expectedArticles[i].CommentsLink { + t.Errorf("Expected comments link %q, got %q", expectedArticles[i].CommentsLink, article.CommentsLink) + } + } +} + +// func TestParseArticles(t *testing.T) { +// t.Run("Empty HTML", func(t *testing.T) { +// articles, err := parseArticles("") +// assert.NoError(t, err) +// assert.Empty(t, articles) +// }) + +// t.Run("Valid HTML", func(t *testing.T) { +// html := ` +// +// +// +// Article 1 +// +// +// +// +// +// +// Article 2 +// +// +// +// ` +// articles, err := parseArticles(html) +// assert.NoError(t, err) +// assert.Len(t, articles, 2) +// assert.Equal(t, "Article 1", articles[0].Title) +// assert.Equal(t, "https://example.com", articles[0].Link) +// assert.Equal(t, "Article 2", articles[1].Title) +// assert.Equal(t, "https://example.com/2", articles[1].Link) +// }) + +// t.Run("Error in goquery", func(t *testing.T) { +// doc := &goquery.Document{} +// // doc.SetError(fmt.Errorf("test error")) +// articles, err := parseArticlesFromDocument(doc) +// assert.Error(t, err) +// assert.Nil(t, articles) +// }) +// } + +// func parseArticlesFromDocument(doc *goquery.Document) ([]Article, error) { +// var articles []Article + +// doc.Find("tr.athing").Each(func(i int, s *goquery.Selection) { +// title := s.Find("td.title > span.titleline > a").Text() +// link, _ := s.Find("td.title > span.titleline > a").Attr("href") +// commentsCount, err := extractCommentsCount(s) +// if err != nil { +// commentsCount = 0 +// } + +// article := Article{ +// Title: title, +// Link: link, +// Comments: commentsCount, +// } + +// articles = append(articles, article) +// }) + +// return articles, nil +// } diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..9094691 --- /dev/null +++ b/ui.go @@ -0,0 +1,169 @@ +package main + +import ( + "fmt" + "log" + "net/url" + "os/exec" + "runtime" + "strconv" + + "github.com/gdamore/tcell/v2" + "github.com/gelembjuk/articletext" + "github.com/rivo/tview" +) + +func createArticleList(articles []Article) *tview.List { + list := tview.NewList().ShowSecondaryText(true).SetSecondaryTextColor(tcell.ColorGray) + for _, article := range articles { + title := article.Title + if article.Comments > 50 { // TODO: configurable + title = "🔥 " + title + } + + commentsText := strconv.Itoa(article.Comments) + " comments" + list.AddItem(title, extractDomain(article.Link)+" "+commentsText, 0, nil) + } + + return list +} + +func extractDomain(link string) string { + u, err := url.Parse(link) + if err != nil { + log.Fatal(err) + } + return u.Host +} + +func createInputHandler(app *tview.Application, list *tview.List, articles []Article, pages *tview.Pages) func(event *tcell.EventKey) *tcell.EventKey { + + return func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlC: + app.Stop() + return nil + case tcell.KeyRight: + nextPage(pages, app, articles, list) + return nil + case tcell.KeyLeft: + backPage(pages) + return nil + case tcell.KeyRune: + switch event.Rune() { + case 'q': + app.Stop() + return nil + case 'j': + return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) + case 'k': + return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) + case 'l': + nextPage(pages, app, articles, list) + return nil + case 'h': + backPage(pages) + return nil + case ' ': + openURL(articles[list.GetCurrentItem()].Link) + return nil + case 'c': + openURL(hackerNewsURL + articles[list.GetCurrentItem()].CommentsLink) + return nil + + } + } + + return event + } +} + +func backPage(pages *tview.Pages) { + // TODO: navigation flow will become configurable + currentPage, _ := pages.GetFrontPage() + if currentPage == "comments" { + pages.SwitchToPage("homepage") + } + if currentPage == "article" { + pages.SwitchToPage("comments") + } +} + +func nextPage(pages *tview.Pages, app *tview.Application, articles []Article, list *tview.List) { + currentPage, _ := pages.GetFrontPage() + if currentPage == "comments" { + openArticle(app, articles[list.GetCurrentItem()].Link, pages) + } else { + openComments(app, articles[list.GetCurrentItem()].CommentsLink, pages) + } +} + +func openComments(app *tview.Application, commentsLink string, pages *tview.Pages) { + u, err := url.Parse(commentsLink) + if err != nil { + fmt.Println("Error parsing URL:", err) // TODO maybe alert dialogbox + return + } + story_id := u.Query().Get("id") + + articleStringList := fetchComments(story_id) + commentsText := "" + for _, articleString := range articleStringList { + commentsText += articleString + "\n" + } + displayComments(app, pages, commentsText) +} + +func openArticle(app *tview.Application, articleLink string, pages *tview.Pages) { + articleText := getArticleTextFromLink(articleLink) + displayArticle(app, pages, articleText) +} + +func getArticleTextFromLink(url string) string { + article, err := articletext.GetArticleTextFromUrl(url) + if err != nil { + fmt.Printf("Failed to parse %s, %v\n", url, err) + } + return article +} + +func displayArticle(app *tview.Application, pages *tview.Pages, text string) { + articleTextView := tview.NewTextView(). + SetText(text). + SetDynamicColors(true). + SetScrollable(true) + + pages.AddPage("article", articleTextView, true, true) + if err := app.SetRoot(pages, true).Run(); err != nil { + log.Fatal(err) + } +} + +func displayComments(app *tview.Application, pages *tview.Pages, text string) { + commentsTextView := tview.NewTextView(). + SetText(text). + SetDynamicColors(true). + SetScrollable(true) + + pages.AddPage("comments", commentsTextView, true, true) + if err := app.SetRoot(pages, true).Run(); err != nil { + log.Fatal(err) + } +} + +func openURL(url string) { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + } + args = append(args, url) + exec.Command(cmd, args...).Start() +} diff --git a/web.go b/web.go new file mode 100644 index 0000000..322c23d --- /dev/null +++ b/web.go @@ -0,0 +1,125 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/k3a/html2text" +) + +func fetchWebpage(url string) (string, error) { + res, err := http.Get(url) + if err != nil { + return "", err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + return string(body), nil +} + +const HN_SEARCH_URL = "https://hn.algolia.com/api/v1/" + +type Comment struct { + Author string `json:"author"` + Text string `json:"text"` + Children []Comment `json:"children"` +} + +func sanitize(input string) string { + return html2text.HTML2Text(input) +} + +func safeRequest(url string) *http.Response { + resp, err := http.Get(url) + if err != nil { + fmt.Printf("Failed to fetch URL: %s\n", url) + return nil + } + return resp +} + +func fetchComments(storyID string) []string { + resp := safeRequest(HN_SEARCH_URL + "items/" + storyID) + if resp == nil { + return nil + } + defer resp.Body.Close() + + var comments map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { + fmt.Printf("Failed to decode JSON response\n") + return nil + } + + var lines []string + lines = append(lines, " ") + + if children, ok := comments["children"].([]interface{}); ok { + for _, child := range children { + childComment := child.(map[string]interface{}) + appendComment(childComment, &lines, 0) + } + } + + return lines +} + +func appendComment(comment map[string]interface{}, lines *[]string, level int) { + indent := "" + strings.Repeat(" ", min(level, 4)*2) + "| " + + if author, ok := comment["author"].(string); ok { + *lines = append(*lines, indent+sanitize(author)+" wrote:") + + text := sanitize(comment["text"].(string)) + + paragraphs := strings.Split(text, "\n\n") + for _, paragraph := range paragraphs { + textLines := wrapText(paragraph, indent) + *lines = append(*lines, textLines...) + *lines = append(*lines, indent) + } + *lines = (*lines)[:len(*lines)-1] // Drop the blank line after the last paragraph + } else { + *lines = append(*lines, indent+"[deleted]") + } + + *lines = append(*lines, " ") + + if children, ok := comment["children"].([]interface{}); ok { + for _, child := range children { + appendComment(child.(map[string]interface{}), lines, level+1) + } + } +} + +func wrapText(text, indent string) []string { + words := strings.Fields(text) + var lines []string + var sb strings.Builder + + maxWidth := 80 + + for _, word := range words { + if sb.Len()+len(word)+1 > maxWidth { + lines = append(lines, indent+sb.String()) + sb.Reset() + } + if sb.Len() > 0 { + sb.WriteString(" ") + } + sb.WriteString(word) + } + if sb.Len() > 0 { + lines = append(lines, indent+sb.String()) + } + + return lines +}