├── .github
└── workflows
│ ├── golangci-lint.yml
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .goreleaser.yaml
├── LICENSE
├── Makefile
├── README.md
├── client
└── client.go
├── go.mod
├── go.sum
├── main.go
└── tui
├── fuzzyfinder.go
└── pager.go
/.github/workflows/golangci-lint.yml:
--------------------------------------------------------------------------------
1 | name: golangci-lint
2 | on:
3 | push:
4 | tags:
5 | - v*
6 | branches:
7 | - master
8 | - main
9 | pull_request:
10 | permissions:
11 | contents: read
12 | # Optional: allow read access to pull request. Use with `only-new-issues` option.
13 | # pull-requests: read
14 | jobs:
15 | golangci:
16 | name: lint
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: actions/setup-go@v3
20 | with:
21 | go-version: 1.17
22 | - uses: actions/checkout@v3
23 | - name: golangci-lint
24 | uses: golangci/golangci-lint-action@v3
25 | with:
26 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
27 | version: v1.29
28 |
29 | # Optional: working directory, useful for monorepos
30 | # working-directory: somedir
31 |
32 | # Optional: golangci-lint command line arguments.
33 | # args: --issues-exit-code=0
34 |
35 | # Optional: show only new issues if it's a pull request. The default value is `false`.
36 | # only-new-issues: true
37 |
38 | # Optional: if set to true then the all caching functionality will be complete disabled,
39 | # takes precedence over all other caching options.
40 | # skip-cache: true
41 |
42 | # Optional: if set to true then the action don't cache or restore ~/go/pkg.
43 | # skip-pkg-cache: true
44 |
45 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
46 | # skip-build-cache: true
47 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | release:
10 | name: Release
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout code
14 | uses: actions/checkout@v2
15 | with:
16 | fetch-depth: 0
17 | - name: Setup Go
18 | uses: actions/setup-go@v2
19 | with:
20 | go-version: 1.18.x
21 | - name: Run GoReleaser
22 | uses: goreleaser/goreleaser-action@v2
23 | with:
24 | version: latest
25 | args: release --rm-dist
26 | env:
27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: test
3 |
4 | on:
5 | push:
6 | paths-ignore:
7 | - LICENSE
8 | - README.*
9 | pull_request:
10 | paths-ignore:
11 | - LICENSE
12 | - README.*
13 |
14 | jobs:
15 | test:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout code
19 | uses: actions/checkout@v2
20 | - name: Setup Go
21 | uses: actions/setup-go@v2
22 | with:
23 | go-version: 1.18.x
24 | - name: Format
25 | run: go fmt
26 | - name: Vet
27 | run: go vet
28 | - name: Build
29 | run: go build
30 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
17 | #############################################################
18 | bin/
19 | dist/
20 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | # This is an example .goreleaser.yml file with some sensible defaults.
2 | # Make sure to check the documentation at https://goreleaser.com
3 | before:
4 | hooks:
5 | # You may remove this if you don't use go modules.
6 | - go mod tidy
7 | # you may remove this if you don't need go generate
8 | - go generate ./...
9 | builds:
10 | - env:
11 | - CGO_ENABLED=0
12 | goos:
13 | - linux
14 | - windows
15 | - darwin
16 | ldflags:
17 | - -w -s -X main.appVersion={{.Version}} -X main.appRevision={{.ShortCommit}}
18 | archives:
19 | - replacements:
20 | darwin: Darwin
21 | linux: Linux
22 | windows: Windows
23 | 386: i386
24 | amd64: x86_64
25 | format_overrides:
26 | - goos: windows
27 | format: zip
28 | checksum:
29 | name_template: 'checksums.txt'
30 | snapshot:
31 | name_template: "{{ incpatch .Version }}-next"
32 | changelog:
33 | sort: asc
34 | filters:
35 | exclude:
36 | - '^docs:'
37 | - '^test:'
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 sheepla
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | NAME = qiitaz
2 | BIN := bin/$(NAME)
3 |
4 | # version e.g. v0.0.1
5 | VERSION := $(shell git describe --tags --abbrev=0 | tr -d "v")
6 | # commit hash of HEAD e.g. 3a913f
7 | REVISION := $(shell git rev-parse --short HEAD)
8 |
9 | LDFLAGS := -w \
10 | -s \
11 | -X "main.appVersion=$(VERSION)" \
12 | -X "main.appRevision=$(REVISION)"
13 |
14 | COVERAGE_OUT := .test/cover.out
15 | COVERAGE_HTML := .test/cover.html
16 |
17 | .PHONY: fmt
18 | fmt:
19 | go fmt
20 |
21 | .PHONY: lint
22 | lint:
23 | staticcheck ./...
24 |
25 | .PHONY: build
26 | build:
27 | go build -ldflags "$(LDFLAGS)" -o $(BIN)
28 |
29 | .PHONY: test
30 | test:
31 | go test -coverprofile=$(COVERAGE_OUT) ./...
32 |
33 | .PHONY: coverage
34 | coverage:
35 | go tool cover -html=$(COVERAGE_OUT) -o $(COVERAGE_HTML)
36 |
37 | .PHONY: clean
38 | clean:
39 | rm $(BIN)
40 | rm $(COVERAGE_OUT)
41 | rm $(COVERAGE_HTML)
42 |
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | # 📝 qiitaz
5 |
6 |
7 |
8 |
9 |
10 | [Qiita](https://qiita.com)の記事を素早く検索し、ターミナル上で閲覧できるコマンドラインツール
11 |
12 | 
13 | 
14 | 
15 | [](https://github.com/sheepla/qiitaz/releases/latest)
16 |
17 |
18 |
19 | ## 使い方
20 |
21 | ```
22 | Usage:
23 | qiitaz [OPTIONS] QUERY...
24 |
25 | Application Options:
26 | -V, --version Show version
27 | -s, --sort= Sort key to search e.g. "created", "like", "stock", "rel",
28 | (default: "rel")
29 | -o, --open Open URL in your web browser
30 | -p, --preview Preview page on your terminal
31 | -n, --pageno= Max page number of search page (default: 1)
32 | -j, --json Output result in JSON format
33 |
34 | Help Options:
35 | -h, --help Show this help message
36 | ```
37 |
38 | 1. 引数に検索したいキーワードを指定してコマンドを実行します。
39 | 1. 検索結果をfuzzyfinderで絞り込みます。`Ctrl-N`, `Ctrl-P` または `Ctrl-J`, `Ctrl-K` でフォーカスを移動します。 `Tab`キーで選択し `Enter` キーで確定します。
40 | 1. 選択した記事のURLが出力されます。また、`--open`, `--json`, `--preview` などのオプションを指定することで、選択した記事をブラウザで開いたりターミナル上で閲覧したりすることができます。
41 |
42 | ### ブラウザで記事のページを開く
43 |
44 | `-o`, `--open`オプションを付けるとデフォルトのブラウザが起動し、選択した記事のページが開きます。
45 |
46 | ### 記事を閲覧する
47 |
48 | `-p`, `--preview` オプションを付けると、ターミナルに記事を色付きでレンダリングします。
49 |
50 | ```bash
51 | qiitaz -p QUERY...
52 | ```
53 |
54 | lessページャのような感覚でスクロールして、記事を閲覧することができます。
55 |
56 | |キー |説明 |
57 | |-----------|---------------------------------------|
58 | |`j` / `k` |上下スクロール |
59 | |`d` / `u` |半ページスクロール |
60 | |`f` / `b` |1ページスクロール |
61 | |`g` / `G` |ページの先頭へ移動 / ページの末尾へ移動|
62 | |`q` / `Esc`|終了 |
63 |
64 | ### 高度な検索
65 |
66 | クエリ引数に次のオプションや演算子を指定することで、条件を詳細に指定して検索することができます。
67 |
68 | ```
69 | qiitaz title:Go created:\>2022-03-01
70 | ```
71 |
72 | |オプション |説明 |
73 | |-------------------------|----------------------------------|
74 | |`title:{{タイトル}}` |タイトルにそのキーワードが含まれる|
75 | |`body:{{キーワード}}` |本文にそのキーワードが含まれる |
76 | |`code:{{コードの一部}}` |コードにそのキーワードが含まれる |
77 | |`tag:{{タグ}}` |記事に付けられているタグ |
78 | |`-tag:{{タグ}}` |除外するタグ |
79 | |`user:{{ユーザー名}}` |ユーザー名 |
80 | |`stocks:>{{数値}}` |ストック数 |
81 | |`created:>{{YYYY-MM-DD}}`|作成日がその日以降 |
82 | |`updated:>{{YYYY-MM-DD}}`|更新日がその日以降 |
83 |
84 | **注**: bash, fish, zsh等のシェルでは `>` がファイル上書きのリダイレクトの記号と認識されてしまいます。そのため`\>` のようにエスケープするか、シングルクォートないしはダブルクォートで引数を囲む必要があります。
85 |
86 | どちらかの条件にマッチするものを検索したい場合は `OR` 演算子を使います。
87 |
88 | |演算子 |説明 |
89 | |----------------------|------|
90 | |`{{条件}} OR {{条件}}`|OR条件|
91 |
92 | ### ソート条件の変更
93 |
94 | `-s`, `--sort` オプションを指定することで、ソート条件を変更することができます。
95 |
96 | **例**:
97 |
98 | ```
99 | qiitaz -s like Go
100 | ```
101 |
102 | |値 |説明 |
103 | |---------|------------------|
104 | |`rel` |関連度順 |
105 | |`like` |LGTM数の多い順 |
106 | |`stock` |ストック数の多い順|
107 | |`created`|作成日順 |
108 |
109 | ### JSON形式で出力
110 |
111 | `-j`, `--json` オプションを指定すると、検索結果をJSON形式で出力することができます。
112 |
113 | ```
114 | qiitaz -j QUERY...
115 | ```
116 |
117 | ## インストール
118 |
119 | リリースページから実行可能なバイナリをダウンロードしてください。
120 |
121 | > [](https://github.com/sheepla/qiitaz/releases/latest)
122 |
123 | ソースからビルドする場合は、このリポジトリをクローンして `go install` を実行してください。
124 | `v1.18.1 linux/amd64`にて開発しています。
125 |
126 | ## ライセンス
127 |
128 | [MIT](LICENSE)
129 |
130 | ## 関連
131 |
132 | - [sheepla/fzwiki](https://github.com/sheepla/fzwiki)
133 | - [sheepla/fzenn](https://github.com/sheepla/fzenn)
134 |
135 |
--------------------------------------------------------------------------------
/client/client.go:
--------------------------------------------------------------------------------
1 | package client
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "net/http"
7 | "net/url"
8 | "strconv"
9 |
10 | "github.com/PuerkitoBio/goquery"
11 | )
12 |
13 | type Result struct {
14 | Title string `json:"title"`
15 | Link string `json:"link"`
16 | Snippet string `json:"snippet"`
17 | Tags []string `json:"tags"`
18 | }
19 |
20 | type SortBy string
21 |
22 | func (s SortBy) validate() bool {
23 | switch s {
24 | case "like":
25 | return true
26 | case "stock":
27 | return true
28 | case "rel":
29 | return true
30 | case "created":
31 | return true
32 | default:
33 | return false
34 | }
35 | }
36 |
37 | func NewSearchURL(query string, sortby SortBy, pageno int) (string, error) {
38 | if sortby == "" {
39 | sortby = "rel"
40 | }
41 |
42 | // nolint:goerr113
43 | if !sortby.validate() {
44 | return "", fmt.Errorf("invalid sort key: %s", sortby)
45 | }
46 |
47 | // nolint:exhaustivestruct,exhaustruct,varnamelen
48 | u := &url.URL{
49 | Scheme: "https",
50 | Host: "qiita.com",
51 | Path: "search",
52 | }
53 | q := u.Query()
54 | q.Set("q", query)
55 | q.Set("sort", string(sortby))
56 | q.Set("page", strconv.Itoa(pageno))
57 | u.RawQuery = q.Encode()
58 |
59 | return u.String(), nil
60 | }
61 |
62 | // nolint:gosec,noctx
63 | func Search(url string) ([]Result, error) {
64 | res, err := http.Get(url)
65 | if err != nil {
66 | return nil, fmt.Errorf("failed to fetch the page %s: %w", url, err)
67 | }
68 | defer res.Body.Close()
69 |
70 | doc, err := goquery.NewDocumentFromReader(res.Body)
71 | if err != nil {
72 | return nil, fmt.Errorf("failed to parse HTML: %w", err)
73 | }
74 |
75 | var (
76 | results []Result
77 | result Result
78 | )
79 |
80 | doc.Find("article").Each(func(i int, article *goquery.Selection) {
81 | result.Title = article.Find("h3").Text()
82 | result.Link = article.Find("a").First().AttrOr("href", "")
83 | result.Snippet = article.Find("p").Text()
84 |
85 | article.Find("li").Each(func(i int, a *goquery.Selection) {
86 | result.Tags = append(result.Tags, a.Text())
87 | })
88 | results = append(results, result)
89 | })
90 |
91 | return results, nil
92 | }
93 |
94 | // nolint:exhaustivestruct,exhaustruct,varnamelen
95 | func NewPageURL(path string) string {
96 | u := &url.URL{
97 | Scheme: "https",
98 | Host: "qiita.com",
99 | Path: path,
100 | }
101 |
102 | return u.String()
103 | }
104 |
105 | func newPageMarkdownURL(path string) string {
106 | return NewPageURL(path) + ".md"
107 | }
108 |
109 | func FetchArticle(path string) (io.ReadCloser, error) {
110 | url := newPageMarkdownURL(path)
111 | // nolint:gosec,noctx
112 | res, err := http.Get(url)
113 | if err != nil {
114 | return nil, fmt.Errorf("failed to fetch the page (%s): %w", url, err)
115 | }
116 |
117 | if res.StatusCode != http.StatusOK {
118 | // nolint:goerr113
119 | return nil, fmt.Errorf("HTTP status error: %d %s", res.StatusCode, res.Status)
120 | }
121 |
122 | return res.Body, nil
123 | }
124 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/sheepla/qiitaz
2 |
3 | go 1.17
4 |
5 | require (
6 | github.com/PuerkitoBio/goquery v1.8.0
7 | github.com/charmbracelet/bubbles v0.10.3
8 | github.com/charmbracelet/bubbletea v0.20.0
9 | github.com/charmbracelet/glamour v0.5.0
10 | github.com/charmbracelet/lipgloss v0.5.0
11 | github.com/jessevdk/go-flags v1.5.0
12 | github.com/ktr0731/go-fuzzyfinder v0.6.0
13 | github.com/mattn/go-runewidth v0.0.13
14 | github.com/toqueteos/webbrowser v1.2.0
15 | )
16 |
17 | require (
18 | github.com/alecthomas/chroma v0.10.0 // indirect
19 | github.com/andybalholm/cascadia v1.3.1 // indirect
20 | github.com/aymerick/douceur v0.2.0 // indirect
21 | github.com/containerd/console v1.0.3 // indirect
22 | github.com/dlclark/regexp2 v1.4.0 // indirect
23 | github.com/gdamore/encoding v1.0.0 // indirect
24 | github.com/gdamore/tcell/v2 v2.4.0 // indirect
25 | github.com/gorilla/css v1.0.0 // indirect
26 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
27 | github.com/mattn/go-isatty v0.0.14 // indirect
28 | github.com/microcosm-cc/bluemonday v1.0.17 // indirect
29 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
30 | github.com/muesli/reflow v0.3.0 // indirect
31 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect
32 | github.com/nsf/termbox-go v0.0.0-20201124104050-ed494de23a00 // indirect
33 | github.com/olekukonko/tablewriter v0.0.5 // indirect
34 | github.com/pkg/errors v0.9.1 // indirect
35 | github.com/rivo/uniseg v0.2.0 // indirect
36 | github.com/yuin/goldmark v1.4.4 // indirect
37 | github.com/yuin/goldmark-emoji v1.0.1 // indirect
38 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 // indirect
39 | golang.org/x/sys v0.0.0-20220325203850-36772127a21f // indirect
40 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed // indirect
41 | golang.org/x/text v0.3.6 // indirect
42 | )
43 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
2 | github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
3 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
4 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
5 | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
6 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
7 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
8 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
9 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
10 | github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho=
11 | github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
12 | github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
13 | github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc=
14 | github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM=
15 | github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
16 | github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
17 | github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
18 | github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
19 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
20 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
21 | github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
22 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
23 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
27 | github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
28 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
29 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
30 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
31 | github.com/gdamore/tcell/v2 v2.4.0 h1:W6dxJEmaxYvhICFoTY3WrLLEXsQ11SaFnKGVEXW57KM=
32 | github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
33 | github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
34 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
35 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
36 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
37 | github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
38 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
39 | github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
40 | github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
41 | github.com/ktr0731/go-fuzzyfinder v0.6.0 h1:lP6B3B8CjqbKGf/K5f1X5kdpxiSkCH0+9AzgA3Cm+VU=
42 | github.com/ktr0731/go-fuzzyfinder v0.6.0/go.mod h1:QrbU5RFMEFBbPZnlJBqctX6028IV8qW/yCX3DCAzi1Y=
43 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
44 | github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
45 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
46 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
47 | github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
48 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
49 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
50 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
51 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
52 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
53 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
54 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
55 | github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
56 | github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
57 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
58 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
59 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
60 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
61 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
62 | github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
63 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
64 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
65 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
66 | github.com/nsf/termbox-go v0.0.0-20201124104050-ed494de23a00 h1:Rl8NelBe+n7SuLbJyw13ho7CGWUt2BjGGKIoreCWQ/c=
67 | github.com/nsf/termbox-go v0.0.0-20201124104050-ed494de23a00/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
68 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
69 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
70 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
71 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
72 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
73 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
74 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
75 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
76 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
77 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
78 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
79 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
80 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
81 | github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ=
82 | github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM=
83 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
84 | github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs=
85 | github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
86 | github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
87 | github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
88 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
89 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 h1:/6y1LfuqNuQdHAm0jjtPtgRcxIxjVZgm5OTu8/QhZvk=
90 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
91 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
92 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
93 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
94 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
95 | golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
96 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
97 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
98 | golang.org/x/sys v0.0.0-20220325203850-36772127a21f h1:TrmogKRsSOxRMJbLYGrB4SBbW+LJcEllYBLME5Zk5pU=
99 | golang.org/x/sys v0.0.0-20220325203850-36772127a21f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
100 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
101 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
102 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0=
103 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
104 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
105 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
106 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
107 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
108 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
109 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
110 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
111 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
112 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
113 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "os"
9 | "strings"
10 |
11 | "github.com/jessevdk/go-flags"
12 | "github.com/ktr0731/go-fuzzyfinder"
13 | "github.com/sheepla/qiitaz/client"
14 | "github.com/sheepla/qiitaz/tui"
15 | "github.com/toqueteos/webbrowser"
16 | )
17 |
18 | //nolint:gochecknoglobals
19 | var (
20 | appName = "qiitaz"
21 | appVersion = "unknown"
22 | appRevision = "unknown"
23 | appUsage = "[OPTIONS] QUERY..."
24 | )
25 |
26 | type exitCode int
27 |
28 | //nolint:maligned
29 | type options struct {
30 | Version bool `short:"V" long:"version" description:"Show version"`
31 | Sort string `short:"s" long:"sort" description:"Sort key to search e.g. \"created\", \"like\", \"stock\", \"rel\", (default: \"rel\")" `
32 | Open bool `short:"o" long:"open" description:"Open URL in your web browser"`
33 | Preview bool `short:"p" long:"preview" description:"Preview page on your terminal"`
34 | PageNo int `short:"n" long:"pageno" description:"Max page number of search page" default:"1"`
35 | JSON bool `short:"j" long:"json" description:"Output result in JSON format"`
36 | }
37 |
38 | const (
39 | exitCodeOK exitCode = iota
40 | exitCodeErrArgs
41 | exitCodeErrRequest
42 | exitCodeErrFuzzyFinder
43 | exitCodeErrWebbrowser
44 | exitCodeErrJSON
45 | exitCodeErrPreview
46 | )
47 |
48 | func main() {
49 | exitCode, err := Main(os.Args[1:])
50 | if err != nil {
51 | fmt.Fprintln(os.Stderr, err)
52 | }
53 |
54 | os.Exit(int(exitCode))
55 | }
56 |
57 | //nolint:funlen,golint,cyclop
58 | func Main(cliArgs []string) (exitCode, error) {
59 | var opts options
60 | parser := flags.NewParser(&opts, flags.Default)
61 | parser.Name = appName
62 | parser.Usage = appUsage
63 |
64 | args, err := parser.ParseArgs(cliArgs)
65 | // The content of the go-flags error is already output, so ignore it.
66 | if err != nil {
67 | if flags.WroteHelp(err) {
68 | return exitCodeOK, nil
69 | }
70 |
71 | return exitCodeErrArgs, nil
72 | }
73 |
74 | if opts.Version {
75 | //nolint:forbidigo
76 | fmt.Printf("%s: v%s-%s\n", appName, appVersion, appRevision)
77 |
78 | return exitCodeOK, nil
79 | }
80 |
81 | if len(args) == 0 {
82 | //nolint:goerr113
83 | return exitCodeErrArgs, errors.New("must require argument (s)")
84 | }
85 |
86 | if opts.PageNo <= 0 {
87 | fmt.Fprintln(os.Stderr)
88 | //nolint:goerr113
89 | return exitCodeErrArgs, errors.New("the page number must be a positive value")
90 | }
91 |
92 | var urls []string
93 |
94 | for i := 1; i <= opts.PageNo; i++ {
95 | u, err := client.NewSearchURL(strings.Join(args, " "), client.SortBy(opts.Sort), i)
96 | if err != nil {
97 | return exitCodeErrArgs, fmt.Errorf("failed to create search URL %s: %w", u, err)
98 | }
99 |
100 | urls = append(urls, u)
101 | }
102 |
103 | var results []client.Result
104 |
105 | for _, u := range urls {
106 | r, err := client.Search(u)
107 | if err != nil {
108 | return exitCodeErrRequest, fmt.Errorf("failed to search articles: %w", err)
109 | }
110 |
111 | results = append(results, r...)
112 | }
113 |
114 | if len(results) == 0 {
115 | //nolint:goerr113
116 | return exitCodeOK, errors.New("no results found")
117 | }
118 |
119 | if opts.JSON {
120 | bytes, err := json.Marshal(&results)
121 | if err != nil {
122 | return exitCodeErrJSON, fmt.Errorf("failed to marshalling JSON: %w", err)
123 | }
124 |
125 | stdout := bufio.NewWriter(os.Stdout)
126 | fmt.Fprintln(stdout, string(bytes))
127 | stdout.Flush()
128 |
129 | return exitCodeOK, nil
130 | }
131 |
132 | if opts.Preview {
133 | if err := startPreviewMode(results); err != nil {
134 | return exitCodeErrPreview, fmt.Errorf("an error occurred on preview mode: %w", err)
135 | }
136 |
137 | return exitCodeOK, nil
138 | }
139 |
140 | choices, err := tui.FindMulti(results)
141 | if err != nil {
142 | return exitCodeErrFuzzyFinder, fmt.Errorf("an error occurred on fuzzyfinder: %w", err)
143 | }
144 |
145 | if len(choices) == 0 {
146 | return exitCodeOK, nil
147 | }
148 |
149 | if opts.Open {
150 | for _, idx := range choices {
151 | url := client.NewPageURL(results[idx].Link)
152 | if err := webbrowser.Open(url); err != nil {
153 | return exitCodeErrWebbrowser, fmt.Errorf("failed to open the URL %s: %w", url, err)
154 | }
155 | }
156 | }
157 |
158 | for _, idx := range choices {
159 | //nolint:forbidigo
160 | fmt.Println(client.NewPageURL(results[idx].Link))
161 | }
162 |
163 | return exitCodeOK, nil
164 | }
165 |
166 | func startPreviewMode(result []client.Result) error {
167 | for {
168 | idx, err := tui.Find(result)
169 | if err != nil {
170 | if errors.Is(fuzzyfinder.ErrAbort, err) {
171 | // normal termination
172 | return nil
173 | }
174 |
175 | return fmt.Errorf("an error occurred on fuzzyfinder: %w", err)
176 | }
177 |
178 | title := result[idx].Title
179 | path := result[idx].Link
180 |
181 | pager, err := tui.NewPagerProgram(path, title)
182 | if err != nil {
183 | return fmt.Errorf("failed to init pager program: %w", err)
184 | }
185 |
186 | if err := pager.Start(); err != nil {
187 | return fmt.Errorf("an error occurred on pager: %w", err)
188 | }
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/tui/fuzzyfinder.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/ktr0731/go-fuzzyfinder"
8 | "github.com/mattn/go-runewidth"
9 | "github.com/sheepla/qiitaz/client"
10 | )
11 |
12 | // nolint:wrapcheck,gomnd
13 | func FindMulti(result []client.Result) ([]int, error) {
14 | return fuzzyfinder.FindMulti(
15 | result,
16 | func(i int) string {
17 | return result[i].Title
18 | },
19 | fuzzyfinder.WithPreviewWindow(func(idx, width, height int) string {
20 | if idx == -1 {
21 | return ""
22 | }
23 |
24 | wrapedWidth := width/2 - 5
25 |
26 | return runewidth.Wrap(renderPreviewWindow(&result[idx]), wrapedWidth)
27 | }),
28 | )
29 | }
30 |
31 | // nolint:wrapcheck,gomnd
32 | func Find(result []client.Result) (int, error) {
33 | return fuzzyfinder.Find(
34 | result,
35 | func(i int) string {
36 | return result[i].Title
37 | },
38 | fuzzyfinder.WithPreviewWindow(func(idx, width, height int) string {
39 | if idx == -1 {
40 | return ""
41 | }
42 |
43 | wrapedWidth := width/2 - 5
44 |
45 | return runewidth.Wrap(renderPreviewWindow(&result[idx]), wrapedWidth)
46 | }),
47 | )
48 | }
49 |
50 | func renderPreviewWindow(result *client.Result) string {
51 | return fmt.Sprintf("%s\n\n%s\n\n%s",
52 | result.Title,
53 | result.Snippet,
54 | strings.Join(result.Tags, " "),
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/tui/pager.go:
--------------------------------------------------------------------------------
1 | package tui
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "strings"
7 |
8 | "github.com/charmbracelet/bubbles/viewport"
9 | tea "github.com/charmbracelet/bubbletea"
10 | "github.com/charmbracelet/glamour"
11 | lip "github.com/charmbracelet/lipgloss"
12 | "github.com/sheepla/qiitaz/client"
13 | )
14 |
15 | const (
16 | useHighPerformanceRenderer = true
17 | glamourTheme = "dark"
18 | )
19 |
20 | // nolint:gochecknoglobals
21 | var (
22 | titleStyle = func() lip.Style {
23 | b := lip.NormalBorder()
24 | b.Right = "├"
25 |
26 | return lip.NewStyle().BorderStyle(b).Padding(0, 1)
27 | }()
28 |
29 | infoStyle = func() lip.Style {
30 | b := lip.NormalBorder()
31 | b.Left = "┤"
32 |
33 | return titleStyle.Copy().BorderStyle(b)
34 | }()
35 | )
36 |
37 | type model struct {
38 | content string
39 | ready bool
40 | viewport viewport.Model
41 | title string
42 | }
43 |
44 | func (m *model) Init() tea.Cmd {
45 | return nil
46 | }
47 |
48 | // nolint:ireturn
49 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50 | var (
51 | cmd tea.Cmd
52 | cmds []tea.Cmd
53 | )
54 |
55 | switch msg := msg.(type) {
56 | case tea.KeyMsg:
57 | if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" {
58 | return m, tea.Quit
59 | }
60 |
61 | if msg.String() == "g" {
62 | m.viewport.GotoTop()
63 | cmds = append(cmds, viewport.Sync(m.viewport))
64 | }
65 |
66 | if msg.String() == "G" {
67 | m.viewport.GotoBottom()
68 | cmds = append(cmds, viewport.Sync(m.viewport))
69 | }
70 | case tea.WindowSizeMsg:
71 | headerHeight := lip.Height(m.headerView())
72 | footerHeight := lip.Height(m.footerView())
73 | verticalMarginHeight := headerHeight + footerHeight
74 |
75 | if !m.ready {
76 | m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight)
77 | m.viewport.YPosition = headerHeight
78 | m.viewport.HighPerformanceRendering = useHighPerformanceRenderer
79 | m.viewport.SetContent(m.content)
80 | m.ready = true
81 |
82 | m.viewport.YPosition = headerHeight + 1
83 | } else {
84 | m.viewport.Width = msg.Width
85 | m.viewport.Height = msg.Height - verticalMarginHeight
86 | }
87 |
88 | cmds = append(cmds, viewport.Sync(m.viewport))
89 | }
90 |
91 | m.viewport, cmd = m.viewport.Update(msg)
92 | cmds = append(cmds, cmd)
93 |
94 | return m, tea.Batch(cmds...)
95 | }
96 |
97 | func (m *model) View() string {
98 | if !m.ready {
99 | return "\n Initializing..."
100 | }
101 |
102 | return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())
103 | }
104 |
105 | func (m *model) headerView() string {
106 | title := titleStyle.Render(m.title)
107 | line := strings.Repeat("─", larger(0, m.viewport.Width-lip.Width(title)))
108 |
109 | return lip.JoinHorizontal(lip.Center, title, line)
110 | }
111 |
112 | func (m *model) footerView() string {
113 | info := infoStyle.Render(scrollPercent(m.viewport.ScrollPercent()))
114 | line := strings.Repeat("─", larger(0, m.viewport.Width-lip.Width(info)))
115 |
116 | return lip.JoinHorizontal(lip.Center, line, info)
117 | }
118 |
119 | func scrollPercent(p float64) string {
120 | // nolint:gomnd
121 | return fmt.Sprintf("%3.f%%", p*100)
122 | }
123 |
124 | func larger(a, b int) int {
125 | if a > b {
126 | return a
127 | }
128 |
129 | return b
130 | }
131 |
132 | func NewPagerProgram(path string, title string) (*tea.Program, error) {
133 | body, err := client.FetchArticle(path)
134 | if err != nil {
135 | return nil, fmt.Errorf("failed to fetch the article page: %w", err)
136 | }
137 | defer body.Close()
138 |
139 | bytes, err := io.ReadAll(body)
140 | if err != nil {
141 | return nil, fmt.Errorf("failed to read response body: %w", err)
142 | }
143 |
144 | content, err := glamour.RenderBytes(bytes, glamourTheme)
145 | if err != nil {
146 | return nil, fmt.Errorf("failed to render markdown: %w", err)
147 | }
148 |
149 | pager := tea.NewProgram(
150 | // nolint:exhaustivestruct,exhaustruct
151 | &model{
152 | title: title,
153 | content: string(content),
154 | },
155 | tea.WithAltScreen(),
156 | tea.WithMouseCellMotion(),
157 | )
158 |
159 | return pager, nil
160 | }
161 |
--------------------------------------------------------------------------------