├── .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 | ![Relase](https://github.com/sheepla/qiitaz/actions/workflows/release.yml/badge.svg) 13 | ![Relase](https://github.com/sheepla/qiitaz/actions/workflows/test.yml/badge.svg) 14 | ![Relase](https://github.com/sheepla/qiitaz/actions/workflows/golangci-lint.yml/badge.svg) 15 | [![Latest Release](https://img.shields.io/github/v/release/sheepla/qiitaz?style=flat-square)](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 | > [![Latest Release](https://img.shields.io/github/v/release/sheepla/qiitaz?style=flat-square)](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 | --------------------------------------------------------------------------------