├── .gitignore ├── CHANGELOG.md ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── client ├── api_client.go ├── cache.go ├── result.go └── syntax │ ├── parser.go │ └── parser_test.go └── cmd └── scrapbox ├── cli.go ├── command ├── .gitignore ├── enums.go ├── link.go ├── link_test.go ├── list.go ├── list_test.go ├── meta.go ├── open.go ├── open_test.go ├── read.go ├── read_test.go └── version.go ├── commands.go ├── main.go └── version.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.test 2 | vendor/ 3 | pkg/ 4 | dist/ 5 | testdata/ 6 | 7 | .idea/ 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.0 (2018-xx-xx) 2 | 3 | ### Added 4 | 5 | - Define environmental variable 6 | - `SCRAPBOX_USER_AGENT` 7 | 8 | ### Changed 9 | 10 | - Restructure go packages 11 | - Locate user home directory correctly -> todo go-homedir 12 | 13 | ## 0.2.3 (2017-04-16) 14 | 15 | ### Added 16 | 17 | - Define environmental variables 18 | - `SCRAPBOX_DEBUG` 19 | - `SCRAPBOX_LONG_RUN_TEST` 20 | 21 | ### Changed 22 | 23 | - Remove exit code 24 | - `ExitCodeTagNotFound` 25 | 26 | ### Fixed 27 | 28 | - Fix test environment problem 29 | 30 | ## 0.2.2 (2017-04-11) 31 | 32 | ### Changed 33 | 34 | - Escape |(pipe) of filename of local cache for Windows 35 | 36 | ## 0.2.1 (2017-04-10) 37 | 38 | ### Changed 39 | 40 | - Escape :(colon) of filename of local cache for Windows 41 | 42 | ## 0.2.0 (2017-02-04) 43 | 44 | ### Added 45 | 46 | - Add local cache expiration option 47 | 48 | ## 0.1.0 (2017-02-01) 49 | 50 | Initial release 51 | 52 | ### Added 53 | 54 | - Add Fundamental features 55 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | name = "github.com/MakeNowJust/heredoc" 7 | packages = ["."] 8 | revision = "e9091a26100e9cfb2b6a8f470085bfa541931a91" 9 | 10 | [[projects]] 11 | branch = "master" 12 | name = "github.com/armon/go-radix" 13 | packages = ["."] 14 | revision = "1fca145dffbcaa8fe914309b1ec0cfc67500fe61" 15 | 16 | [[projects]] 17 | name = "github.com/bgentry/speakeasy" 18 | packages = ["."] 19 | revision = "4aabc24848ce5fd31929f7d1e4ea74d3709c14cd" 20 | version = "v0.1.0" 21 | 22 | [[projects]] 23 | name = "github.com/fatih/color" 24 | packages = ["."] 25 | revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4" 26 | version = "v1.7.0" 27 | 28 | [[projects]] 29 | branch = "master" 30 | name = "github.com/hashicorp/errwrap" 31 | packages = ["."] 32 | revision = "d6c0cd88035724dd42e0f335ae30161c20575ecc" 33 | 34 | [[projects]] 35 | branch = "master" 36 | name = "github.com/hashicorp/go-multierror" 37 | packages = ["."] 38 | revision = "3d5d8f294aa03d8e98859feac328afbdf1ae0703" 39 | 40 | [[projects]] 41 | name = "github.com/mattn/go-colorable" 42 | packages = ["."] 43 | revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" 44 | version = "v0.0.9" 45 | 46 | [[projects]] 47 | name = "github.com/mattn/go-isatty" 48 | packages = ["."] 49 | revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" 50 | version = "v0.0.3" 51 | 52 | [[projects]] 53 | branch = "master" 54 | name = "github.com/mitchellh/cli" 55 | packages = ["."] 56 | revision = "c48282d14eba4b0817ddef3f832ff8d13851aefd" 57 | 58 | [[projects]] 59 | branch = "master" 60 | name = "github.com/mitchellh/go-homedir" 61 | packages = ["."] 62 | revision = "3864e76763d94a6df2f9960b16a20a33da9f9a66" 63 | 64 | [[projects]] 65 | name = "github.com/pkg/errors" 66 | packages = ["."] 67 | revision = "645ef00459ed84a119197bfb8d8205042c6df63d" 68 | version = "v0.8.0" 69 | 70 | [[projects]] 71 | name = "github.com/posener/complete" 72 | packages = [ 73 | ".", 74 | "cmd", 75 | "cmd/install", 76 | "match" 77 | ] 78 | revision = "98eb9847f27ba2008d380a32c98be474dea55bdf" 79 | version = "v1.1.1" 80 | 81 | [[projects]] 82 | branch = "master" 83 | name = "github.com/prataprc/goparsec" 84 | packages = ["."] 85 | revision = "3db61e7995f1cbb8b5caf54557dddd14ce8ed7d1" 86 | 87 | [[projects]] 88 | branch = "master" 89 | name = "golang.org/x/sys" 90 | packages = ["unix"] 91 | revision = "e072cadbbdc8dd3d3ffa82b8b4b9304c261d9311" 92 | 93 | [solve-meta] 94 | analyzer-name = "dep" 95 | analyzer-version = 1 96 | inputs-digest = "e26a0330457dbde1bfa9d79bfa6e24e3e6c5b2ae97dfbd94986947e9a50f6745" 97 | solver-name = "gps-cdcl" 98 | solver-version = 1 99 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | 23 | 24 | [[constraint]] 25 | branch = "master" 26 | name = "github.com/MakeNowJust/heredoc" 27 | 28 | [[constraint]] 29 | branch = "master" 30 | name = "github.com/mitchellh/cli" 31 | 32 | [[constraint]] 33 | name = "github.com/pkg/errors" 34 | version = "0.8.0" 35 | 36 | [[constraint]] 37 | branch = "master" 38 | name = "github.com/mitchellh/go-homedir" 39 | 40 | [[constraint]] 41 | branch = "master" 42 | name = "github.com/prataprc/goparsec" 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Kenichi Ohtomi 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 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAIN_PACKAGE = $(dir $(shell grep -ir -l --exclude-dir vendor --exclude Makefile "func main()" ./*)) 2 | REPO = $(notdir $(CURDIR)) 3 | VERSION = $(shell grep 'Version string' $(MAIN_PACKAGE)/version.go | sed -E 's/.*"(.+)"$$/\1/') 4 | COMMIT = $(shell git describe --always) 5 | PACKAGES = $(shell go list ./... | grep -v '/vendor/') 6 | 7 | GOX_OS = darwin linux windows 8 | GOX_ARCH = amd64 386 9 | 10 | default: test 11 | 12 | build: go-generate 13 | @cd $(MAIN_PACKAGE) ; \ 14 | gox \ 15 | -ldflags "-X main.GitCommit=$(COMMIT)" \ 16 | -os="$(firstword $(GOX_OS))" \ 17 | -arch="$(firstword $(GOX_ARCH))" \ 18 | -output="$(CURDIR)/pkg/{{.OS}}_{{.Arch}}/{{.Dir}}" 19 | 20 | prep: 21 | @rm -fr ./testdata 22 | 23 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go list go-scrapbox 24 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go list go-scrapbox english 25 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go list go-scrapbox english paren 26 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go list go-scrapbox english whitespaces 27 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "title having paren ( ) mark" 28 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "title having plus + mark" 29 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "title having question ? mark" 30 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "title having slash / mark" 31 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "title having whitespaces" 32 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "日本語タイトルのページ" 33 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "HTTPなリンクのあるページ" 34 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "HTTPSなリンクのあるページ" 35 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "地のリンクがあるページ" 36 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "複数のリンクがあるページ" 37 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "文章のなかにリンクがあるページ1" 38 | env SCRAPBOX_HOME="`pwd`/testdata" go run ./*.go read go-scrapbox "文章のなかにリンクがあるページ2" 39 | 40 | test: go-generate 41 | rm -fr ./testdata/query/127.0.0.1 42 | rm -fr ./testdata/page/127.0.0.1 43 | env SCRAPBOX_DEBUG=1 SCRAPBOX_LONG_RUN_TEST=${SCRAPBOX_LONG_RUN_TEST} SCRAPBOX_HOME=`pwd`/testdata SCRAPBOX_EXPIRATION=1 go test ${VERBOSE} -parallel=4 ${PACKAGES} 44 | 45 | test-race: go-generate 46 | go test ${VERBOSE} -race ${PACKAGES} 47 | 48 | vet: go-generate 49 | go vet ${VERBOSE} ${PACKAGES} 50 | 51 | clean: 52 | @rm -fr ./pkg 53 | @rm -fr ./dist/$(VERSION) 54 | 55 | install: clean build 56 | cp "$(CURDIR)/pkg/$(firstword $(GOX_OS))_$(firstword $(GOX_ARCH))/$(REPO)" "${GOPATH}/bin" 57 | 58 | package: clean go-generate 59 | @cd $(MAIN_PACKAGE) ; \ 60 | gox \ 61 | -ldflags "-X main.GitCommit=$(COMMIT)" \ 62 | -parallel=3 \ 63 | -os="$(GOX_OS)" \ 64 | -arch="$(GOX_ARCH)" \ 65 | -output="$(CURDIR)/pkg/{{.OS}}_{{.Arch}}/{{.Dir}}" 66 | 67 | @mkdir -p ./dist/$(VERSION) 68 | 69 | @for platform in $(foreach os,$(GOX_OS),$(foreach arch,$(GOX_ARCH),$(os)_$(arch))) ; do \ 70 | echo "zip ../../dist/$(VERSION)/$(REPO)_$(VERSION)_$$platform.zip ./*" ; \ 71 | (cd ./pkg/$$platform && zip ../../dist/$(VERSION)/$(REPO)_$(VERSION)_$$platform.zip ./*) ; \ 72 | done 73 | 74 | @cd ./dist/$(VERSION) ; \ 75 | echo "shasum -a 256 * > ./$(VERSION)_SHASUMS" ; \ 76 | shasum -a 256 * > ./$(VERSION)_SHASUMS 77 | 78 | release: 79 | ghr $(VERSION) ./dist/$(VERSION) 80 | 81 | fmt: 82 | gofmt -w . 83 | 84 | go-generate: 85 | go generate ${VERBOSE} ${PACKAGES} 86 | 87 | .PHONY: build test test-race vet clean install package release fmt go-generate -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scrapbox 2 | 3 | This tool provides command line interface for scrapbox.io. 4 | 5 | ## Description 6 | 7 | This is a tool to search pages by keywords, to print a content of a page, to print an encoded URL of a page, to print URLs linked by a page. 8 | 9 | ## Usage 10 | 11 | ### List page titles containing specified tags 12 | 13 | ```console 14 | $ scrapbox list -h 15 | usage: scrapbox list [options...] PROJECT [TAGs...] 16 | 17 | Options: 18 | --token, -t Scrapbox connect.sid used to access private project. 19 | --host, -h Scrapbox Host. By default, "https://scrapbox.io". 20 | --expire Local Cache Expiration. By default, 3600 seconds. 21 | --ua User Agent. By default, "ScrapboxGoClient/x.x.x" 22 | 23 | 24 | $ scrapbox list go-scrapbox 25 | title having paren ( ) mark 26 | title having plus + mark 27 | title having question ? mark 28 | title having slash / mark 29 | title having whitespaces 30 | 日本語タイトルのページ 31 | 地のリンクがあるページ 32 | 複数のリンクがあるページ 33 | 文章のなかにリンクがあるページ1 34 | 文章のなかにリンクがあるページ2 35 | 36 | $ scrapbox list go-scrapbox english 37 | title having paren ( ) mark 38 | title having plus + mark 39 | title having question ? mark 40 | title having slash / mark 41 | title having whitespaces 42 | 43 | $ scrapbox list go-scrapbox english paren 44 | title having paren ( ) mark 45 | ``` 46 | 47 | ### Print the content of the scrapbox page 48 | 49 | ```console 50 | $ scrapbox read -h 51 | usage: scrapbox read [options...] PROJECT PAGE 52 | 53 | Options: 54 | --token, -t Scrapbox connect.sid used to access private project. 55 | --host, -h Scrapbox Host. By default, "https://scrapbox.io". 56 | --expire Local Cache Expiration. By default, 3600 seconds. 57 | --ua User Agent. By default, "ScrapboxGoClient/x.x.x" 58 | 59 | 60 | $ scrapbox read go-scrapbox "title having paren ( ) mark" 61 | title having paren ( ) mark 62 | #english #no-url #whitespace #no-slash #paren #no-plus #no-question 63 | ``` 64 | 65 | ### Print the URL of the scrapbox page 66 | 67 | ```console 68 | $ scrapbox open -h 69 | usage: scrapbox open [options...] PROJECT PAGE 70 | 71 | Options: 72 | --host, -h Scrapbox Host. By default, "https://scrapbox.io". 73 | 74 | 75 | $ scrapbox open go-scrapbox "title having paren ( ) mark" 76 | https://scrapbox.io/go-scrapbox/title%20having%20paren%20(%20)%20mark 77 | ``` 78 | 79 | ### Print all URLs in the scrapbox page 80 | 81 | ```console 82 | $ scrapbox link -h 83 | usage: scrapbox link [options...] PROJECT PAGE 84 | 85 | Options: 86 | --token, -t Scrapbox connect.sid used to access private project. 87 | --host, -h Scrapbox Host. By default, "https://scrapbox.io". 88 | --expire Local Cache Expiration. By default, 3600 seconds. 89 | --ua User Agent. By default, "ScrapboxGoClient/x.x.x" 90 | 91 | 92 | $ scrapbox link go-scrapbox "複数のリンクがあるページ" 93 | https://www.google.co.jp 94 | https://www.google.com 95 | ``` 96 | 97 | ### Environment Variables 98 | 99 | - `SCRAPBOX_TOKEN`: specify `token` instead of `--token` option. 100 | - `SCRAPBOX_HOST`: specify `host` instead of `--host` option. 101 | - `SCRAPBOX_EXPIRATION`: specify `expire` instead of `--expire` option. 102 | - `SCRAPBOX_USER_AGENT`: specify `ua`(`user agent`) instead of `--ua` option. 103 | - `SCRAPBOX_HOME`: specify `scrapbox` home directory. By default `~/.scrapbox/` 104 | 105 | ### Private Project 106 | 107 | To access private project, use `--token` option: 108 | 109 | ```console 110 | $ scrapbox --token s%3A... 111 | ``` 112 | 113 | ### Scrapbox Enterprise 114 | 115 | To access Scrapbox Enterprise, use `--host` option: 116 | 117 | ```console 118 | $ scrapbox --host http://host:port 119 | ``` 120 | 121 | ### Local Cache Control 122 | 123 | To ignore local caches, set `expire` to zero: 124 | 125 | ```console 126 | $ scrapbox --expire 127 | ``` 128 | 129 | ## Install 130 | 131 | To install, use `go get`: 132 | 133 | ```console 134 | $ go get github.com/ohtomi/scrapbox/cmd/scrapbox 135 | ``` 136 | 137 | Or get binary from [release page](../../releases/latest). 138 | 139 | ## Contribution 140 | 141 | 1. Fork ([https://github.com/ohtomi/scrapbox/fork](https://github.com/ohtomi/scrapbox/fork)) 142 | 1. Create a feature branch 143 | 1. Commit your changes 144 | 1. Rebase your local changes against the master branch 145 | 1. Run test suite with the `go test ./...` command and confirm that it passes 146 | 1. Run `gofmt -s` 147 | 1. Create a new Pull Request 148 | 149 | ## License 150 | 151 | MIT 152 | 153 | ## Author 154 | 155 | [Kenichi Ohtomi](https://github.com/ohtomi) 156 | -------------------------------------------------------------------------------- /client/api_client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "strings" 13 | "time" 14 | 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | const ( 19 | DefaultHost = "https://scrapbox.io" 20 | DefaultExpiration = 60 * 60 // time.Second 21 | DefaultUserAgent = "ScrapboxGoClient/0.3.0" 22 | ) 23 | 24 | type Client struct { 25 | URL *url.URL 26 | HTTPClient *http.Client 27 | 28 | Token string 29 | Expiration time.Duration 30 | UserAgent string 31 | } 32 | 33 | func NewClient(url *url.URL, token string, expiration int, userAgent string) (*Client, error) { 34 | return &Client{ 35 | URL: url, 36 | HTTPClient: &http.Client{}, 37 | Token: token, 38 | Expiration: time.Duration(expiration) * time.Second, 39 | UserAgent: userAgent, 40 | }, nil 41 | } 42 | 43 | func (c *Client) ExecQuery(ctx context.Context, project string, tags []string, skip, limit int) (*QueryResult, error) { 44 | 45 | var ( 46 | count int 47 | pages []string 48 | 49 | v interface{} 50 | ) 51 | 52 | host := (*c.URL).Host 53 | expiration := c.Expiration 54 | if haveGoodQueryResultFile(host, project, tags, skip, limit, expiration) { 55 | res, err := openQueryResultFile(host, project, tags, skip, limit) 56 | if err != nil { 57 | return nil, err 58 | } 59 | if err := c.decodeFromFile(res, &v); err != nil { 60 | return nil, err 61 | } 62 | } else { 63 | queryPath := buildQueryPath(project, tags, skip, limit) 64 | req, err := c.newRequest(ctx, "GET", queryPath, nil) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | res, err := c.HTTPClient.Do(req) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | if res.StatusCode != 200 { 75 | return nil, errors.New(fmt.Sprintf("http status is %q", res.Status)) 76 | } 77 | 78 | resp, err := createQueryResultFile(host, project, tags, skip, limit) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | if err := c.decodeBody(res, &v, resp); err != nil { 84 | return nil, err 85 | } 86 | } 87 | 88 | for _, p := range v.(interface{}).(map[string]interface{})["pages"].([]interface{}) { 89 | if len(tags) > 0 { 90 | for _, s := range p.(map[string]interface{})["snipet"].([]interface{}) { 91 | all := true 92 | for _, t := range tags { 93 | all = all && 94 | (strings.Contains(strings.ToLower(s.(string)), fmt.Sprintf("%s", strings.ToLower(t))) || 95 | strings.Contains(strings.ToLower(p.(map[string]interface{})["title"].(interface{}).(string)), strings.ToLower(t))) 96 | } 97 | if all { 98 | pages = append(pages, p.(map[string]interface{})["title"].(interface{}).(string)) 99 | break 100 | } 101 | } 102 | } else { 103 | pages = append(pages, p.(map[string]interface{})["title"].(interface{}).(string)) 104 | } 105 | } 106 | 107 | count = int(v.(interface{}).(map[string]interface{})["count"].(float64)) 108 | if count > limit+skip || count == limit { 109 | q, err := c.ExecQuery(context.Background(), project, tags, skip+limit, limit) 110 | if err != nil { 111 | return nil, err 112 | } 113 | pages = append(pages, q.Pages...) 114 | } 115 | 116 | return &QueryResult{ 117 | Count: count, 118 | Pages: pages, 119 | }, nil 120 | } 121 | 122 | func (c *Client) GetPage(ctx context.Context, project, page string) (*Page, error) { 123 | 124 | var ( 125 | v interface{} 126 | ) 127 | 128 | host := (*c.URL).Host 129 | expiration := c.Expiration 130 | if haveGoodPageFile(host, project, page, expiration) { 131 | res, err := openPageFile(host, project, page) 132 | if err != nil { 133 | return nil, err 134 | } 135 | if err := c.decodeFromFile(res, &v); err != nil { 136 | return nil, err 137 | } 138 | } else { 139 | pagePath := buildPagePath(project, page) 140 | req, err := c.newRequest(ctx, "GET", pagePath, nil) 141 | if err != nil { 142 | return nil, err 143 | } 144 | 145 | res, err := c.HTTPClient.Do(req) 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | if res.StatusCode != 200 { 151 | return nil, errors.New(fmt.Sprintf("http status is %q", res.Status)) 152 | } 153 | 154 | resp, err := createPageFile(host, project, page) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | if err := c.decodeBody(res, &v, resp); err != nil { 160 | return nil, err 161 | } 162 | } 163 | 164 | title := v.(interface{}).(map[string]interface{})["title"].(string) 165 | lines := make([]string, len(v.(interface{}).(map[string]interface{})["lines"].([]interface{}))) 166 | for i, l := range v.(interface{}).(map[string]interface{})["lines"].([]interface{}) { 167 | lines[i] = l.(map[string]interface{})["text"].(interface{}).(string) 168 | } 169 | links := make([]string, len(v.(interface{}).(map[string]interface{})["links"].([]interface{}))) 170 | for i, l := range v.(interface{}).(map[string]interface{})["links"].([]interface{}) { 171 | links[i] = l.(string) 172 | } 173 | 174 | return &Page{ 175 | Title: title, 176 | Lines: lines, 177 | Links: links, 178 | }, nil 179 | } 180 | 181 | func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { 182 | 183 | baseURL := *c.URL 184 | u := fmt.Sprintf("%s/%s", baseURL.String(), path) 185 | 186 | req, err := http.NewRequest(method, u, body) 187 | if err != nil { 188 | return nil, errors.Wrap(err, "failed to instantiate http request") 189 | } 190 | 191 | req = req.WithContext(ctx) 192 | 193 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 194 | req.Header.Set("User-Agent", c.UserAgent) 195 | if len(c.Token) != 0 { 196 | req.Header.Set("Cookie", "connect.sid="+c.Token) 197 | } 198 | 199 | return req, nil 200 | } 201 | 202 | func (c *Client) decodeBody(resp *http.Response, out interface{}, f *os.File) error { 203 | defer resp.Body.Close() 204 | if f != nil { 205 | resp.Body = ioutil.NopCloser(io.TeeReader(resp.Body, f)) 206 | defer f.Close() 207 | } 208 | decoder := json.NewDecoder(resp.Body) 209 | return decoder.Decode(out) 210 | } 211 | 212 | func (c *Client) decodeFromFile(resp *os.File, out interface{}) error { 213 | defer resp.Close() 214 | decoder := json.NewDecoder(resp) 215 | return decoder.Decode(out) 216 | } 217 | 218 | func GetURL(host, project, page string) string { 219 | return fmt.Sprintf("%s/%s/%s", host, project, encodeURIComponent(page)) 220 | } 221 | 222 | func encodeURIComponent(component string) string { 223 | regularEscaped := url.QueryEscape(component) 224 | rParenUnescaped := strings.Replace(regularEscaped, "%28", "(", -1) 225 | lParenUnescaped := strings.Replace(rParenUnescaped, "%29", ")", -1) 226 | plusEscaped := strings.Replace(lParenUnescaped, "+", "%20", -1) 227 | return plusEscaped 228 | } 229 | 230 | func buildQueryPath(project string, tags []string, skip, limit int) string { 231 | params := fmt.Sprintf("skip=%d&sort=updated&limit=%d&q=%s", skip, limit, encodeURIComponent(strings.Join(tags, " "))) 232 | if len(tags) == 0 { 233 | return fmt.Sprintf("api/pages/%s?%s", project, params) 234 | } else { 235 | return fmt.Sprintf("api/pages/%s/search/query?%s", project, params) 236 | } 237 | } 238 | 239 | func buildPagePath(project, page string) string { 240 | return fmt.Sprintf("api/pages/%s/%s", project, encodeURIComponent(page)) 241 | } 242 | -------------------------------------------------------------------------------- /client/cache.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mitchellh/go-homedir" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | const ( 15 | EnvHome = "SCRAPBOX_HOME" 16 | ) 17 | 18 | func createQueryResultFile(host, project string, tags []string, skip, limit int) (*os.File, error) { 19 | 20 | baseDir := path.Join(getScrapboxHomeDir(), "query", trimPortFromHost(host), project, path.Join(tags...)) 21 | if err := os.MkdirAll(baseDir, os.ModePerm); err != nil { 22 | return nil, errors.Wrap(err, "failed to make query cache directory") 23 | } 24 | queryResultFilePath := path.Join(baseDir, EncodeFilename(fmt.Sprintf("%d-%d", skip, limit))) 25 | queryResultFile, err := os.Create(queryResultFilePath) 26 | if err != nil { 27 | return nil, errors.Wrap(err, "failed to create query cache file") 28 | } 29 | 30 | return queryResultFile, nil 31 | } 32 | 33 | func haveGoodQueryResultFile(host, project string, tags []string, skip, limit int, expiration time.Duration) bool { 34 | 35 | baseDir := path.Join(getScrapboxHomeDir(), "query", trimPortFromHost(host), project, path.Join(tags...)) 36 | queryResultFilePath := path.Join(baseDir, EncodeFilename(fmt.Sprintf("%d-%d", skip, limit))) 37 | fs, err := os.Stat(queryResultFilePath) 38 | if err != nil { 39 | return false 40 | } 41 | if fs.IsDir() { 42 | return false 43 | } 44 | mod := fs.ModTime() 45 | now := time.Now() 46 | duration := now.Sub(mod) 47 | 48 | return duration <= expiration 49 | } 50 | 51 | func openQueryResultFile(host, project string, tags []string, skip, limit int) (*os.File, error) { 52 | 53 | baseDir := path.Join(getScrapboxHomeDir(), "query", trimPortFromHost(host), project, path.Join(tags...)) 54 | queryResultFilePath := path.Join(baseDir, EncodeFilename(fmt.Sprintf("%d-%d", skip, limit))) 55 | queryResultFile, err := os.Open(queryResultFilePath) 56 | if err != nil { 57 | return nil, errors.Wrap(err, "failed to open query cache file") 58 | } 59 | 60 | return queryResultFile, nil 61 | } 62 | 63 | func createPageFile(host, project, page string) (*os.File, error) { 64 | 65 | baseDir := path.Join(getScrapboxHomeDir(), "page", trimPortFromHost(host), project) 66 | if err := os.MkdirAll(baseDir, os.ModePerm); err != nil { 67 | return nil, errors.Wrap(err, "failed to make page cache directory") 68 | } 69 | pageFilePath := path.Join(baseDir, EncodeFilename(page)) 70 | pageFile, err := os.Create(pageFilePath) 71 | if err != nil { 72 | return nil, errors.Wrap(err, "failed to create page cache file") 73 | } 74 | 75 | return pageFile, nil 76 | } 77 | 78 | func haveGoodPageFile(host, project, page string, expiration time.Duration) bool { 79 | 80 | baseDir := path.Join(getScrapboxHomeDir(), "page", trimPortFromHost(host), project) 81 | pageFilePath := path.Join(baseDir, EncodeFilename(page)) 82 | fs, err := os.Stat(pageFilePath) 83 | if err != nil { 84 | return false 85 | } 86 | if fs.IsDir() { 87 | return false 88 | } 89 | mod := fs.ModTime() 90 | now := time.Now() 91 | duration := now.Sub(mod) 92 | 93 | return duration <= expiration 94 | } 95 | 96 | func openPageFile(host, project, page string) (*os.File, error) { 97 | 98 | baseDir := path.Join(getScrapboxHomeDir(), "page", trimPortFromHost(host), project) 99 | pageFilePath := path.Join(baseDir, EncodeFilename(page)) 100 | pageFile, err := os.Open(pageFilePath) 101 | if err != nil { 102 | return nil, errors.Wrap(err, "failed to open page cache file") 103 | } 104 | 105 | return pageFile, nil 106 | } 107 | 108 | func getScrapboxHomeDir() string { 109 | value := os.Getenv(EnvHome) 110 | if len(value) == 0 { 111 | if userHomeDir, err := homedir.Dir(); err != nil { 112 | value = path.Join(userHomeDir, ".scrapbox") 113 | } else { 114 | value = "./.scrapbox" 115 | } 116 | } else { 117 | if expanded, err := homedir.Expand(value); err != nil { 118 | value = expanded 119 | } 120 | } 121 | return value 122 | } 123 | 124 | func EncodeFilename(filename string) string { 125 | slashEscaped := strings.Replace(filename, "/", "%2F", -1) 126 | colonEscaped := strings.Replace(slashEscaped, ":", "%3A", -1) 127 | pipeEscaped := strings.Replace(colonEscaped, "|", "%7C", -1) 128 | return pipeEscaped 129 | } 130 | 131 | func trimPortFromHost(host string) string { 132 | if index := strings.Index(host, ":"); index == -1 { 133 | return host 134 | } else { 135 | return host[:index] 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /client/result.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | type QueryResult struct { 10 | Count int 11 | Pages []string 12 | } 13 | 14 | type Page struct { 15 | Title string 16 | Lines []string 17 | Links []string 18 | } 19 | 20 | func (p *Page) ExtractExternalLinks() []string { 21 | 22 | includes := []string{"http://", "https://"} 23 | excludes := []string{".png", ".gif", ".jpg", ".jpeg", ".svg"} 24 | whitespace := " " 25 | 26 | match := func(line string, keywords []string) string { 27 | for _, keyword := range keywords { 28 | if strings.Contains(line, keyword) { 29 | return keyword 30 | } 31 | } 32 | return "" 33 | } 34 | 35 | linkURLs := []string{} 36 | 37 | for _, line := range p.Lines { 38 | if matched := match(line, includes); matched != "" { 39 | if match(line, excludes) != "" { 40 | continue 41 | } 42 | foundBracket, _ := regexp.MatchString(fmt.Sprintf("\\[.*%s.*\\]", matched), line) 43 | if strings.Index(line, matched) != -1 { 44 | line = line[strings.Index(line, matched):] 45 | } 46 | if strings.Index(line, whitespace) != -1 { 47 | line = line[:strings.Index(line, whitespace)] 48 | } 49 | if foundBracket && strings.Index(line, "]") == len(line)-1 { 50 | line = line[:len(line)-1] 51 | } 52 | linkURLs = append(linkURLs, line) 53 | } 54 | } 55 | 56 | return linkURLs 57 | } 58 | -------------------------------------------------------------------------------- /client/syntax/parser.go: -------------------------------------------------------------------------------- 1 | package syntax 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/prataprc/goparsec" 7 | ) 8 | 9 | type AST struct { 10 | ast *parsec.AST 11 | parser parsec.Parser 12 | } 13 | 14 | func NewAST() AST { 15 | ast := parsec.NewAST("ast", 1000) 16 | 17 | lf := parsec.Token("\n", "lf") 18 | end := parsec.End() 19 | 20 | ws := parsec.Token("[ \t]+", "ws") 21 | 22 | indent := ast.Maybe("indent", nil, ws) 23 | 24 | quoted := parsec.Atom(">", "quoted") 25 | code := parsec.Atom("code:", "code") 26 | table := parsec.Atom("table:", "table") 27 | 28 | mark := ast.OrdChoice("mark", nil, quoted, code, table) 29 | head := ast.Maybe("head", nil, mark) 30 | 31 | // [$ text] 32 | math := parsec.Token("\\[\\$[ \t]+[^\n]*?\\]", "math") 33 | // [[*/-_]+ url] 34 | styled_url := parsec.Token("\\[[*/\\-_]+[ \t]+https?://[^ \t\n]*?\\]", "styled_url") 35 | // [[*/-_]+ text] 36 | styled_text := parsec.Token("\\[[*/\\-_]+[ \t]+[^\n]*?\\]", "styled_text") 37 | // [/text(/text)*] 38 | project_link := parsec.Token("\\[(/[^/ \n]+)+\\]", "project_link") 39 | // [image url] 40 | image_link1 := parsec.Token("\\[(https://gyazo.com/[^ \t\n]+|https?://[^ \t\n]+(\\.png|\\.gif|\\.jpg|\\.jpeg))[ \t]+https?://[^ \t\n]+\\]", "image_link1") 41 | // [url image] 42 | image_link2 := parsec.Token("\\[https?://[^ \t\n]+[ \t]+(https://gyazo.com/[^ \t\n]+|https?://[^ \t\n]+(\\.png|\\.gif|\\.jpg|\\.jpeg))\\]", "image_link2") 43 | // [text url] 44 | labeled_link1 := parsec.Token("\\[[^[\n]+[ \t]+https?://[^ \t\n]+\\]", "labeled_link1") 45 | // [url text] 46 | labeled_link2 := parsec.Token("\\[https?://[^ \t\n]+[ \t]+[^[\n]+\\]", "labeled_link2") 47 | // [url] 48 | external_link := parsec.Token("\\[https?://[^[ \t\n]+\\]", "external_link") 49 | // [/text(/text)*.icon] 50 | page_icon := parsec.Token("\\[(/[^\n]+)+\\.icon\\]", "page_icon") 51 | // [text.icon] 52 | icon := parsec.Token("\\[[^\n]+\\.icon\\]", "icon") 53 | // [text+] 54 | internal_link := parsec.Token("\\[[^[\n]*?\\]", "internal_link") 55 | // image 56 | image := parsec.Token("(https://gyazo.com/[^ \t\n]+|https?://[^ \t\n]+(\\.png|\\.gif|\\.jpg|\\.jpeg))", "image") 57 | // url 58 | url := parsec.Token("https?://[^ \t\n]+", "url") 59 | // [[image]] 60 | bold_image := parsec.Token("\\[\\[(https://gyazo.com/[^ \t\n]+|https?://[^ \t\n]+(\\.png|\\.gif|\\.jpg|\\.jpeg))?\\]\\]", "bold_image") 61 | // [[text]] 62 | bold_text := parsec.Token("\\[\\[[^\n]*?\\]\\]", "bold_text") 63 | // `text+` 64 | snippet := parsec.Token("`[^`]*?`", "snippet") 65 | // #[text( text)*] | #text 66 | tag := parsec.Token("#(\\[[^[\n]+\\]|[^ \t\n]+)", "tag") 67 | // text 68 | text := parsec.Token("[^\n]+", "text") 69 | 70 | token := ast.OrdChoice("token", nil, 71 | math, 72 | styled_url, 73 | styled_text, 74 | project_link, 75 | image_link1, 76 | image_link2, 77 | labeled_link1, 78 | labeled_link2, 79 | external_link, 80 | page_icon, 81 | icon, 82 | internal_link, 83 | image, 84 | url, 85 | bold_image, 86 | bold_text, 87 | snippet, 88 | tag, 89 | text) 90 | rest := ast.Kleene("rest", nil, token) 91 | 92 | callback := func(name string, s parsec.Scanner, node parsec.Queryable) parsec.Queryable { 93 | indent := node.GetChildren()[0] 94 | attributes := map[string][]string{} 95 | if indent.GetName() == "ws" { 96 | attributes["indent"] = []string{fmt.Sprintf("%d", len(indent.GetValue()))} 97 | } else if indent.GetName() == "missing" { 98 | attributes["indent"] = []string{fmt.Sprintf("%d", 0)} 99 | } 100 | 101 | head := node.GetChildren()[1] 102 | var newName string 103 | switch head.GetName() { 104 | case "quoted": 105 | newName = "quoted_text" 106 | case "code": 107 | newName = "code_block" 108 | case "table": 109 | newName = "table_block" 110 | default: 111 | newName = "simple_text" 112 | } 113 | 114 | rest := node.GetChildren()[2] 115 | children := rest.GetChildren() 116 | 117 | return &parsec.NonTerminal{Name: newName, Children: children, Attributes: attributes} 118 | } 119 | 120 | line := ast.And("line", callback, indent, head, rest) 121 | root := ast.ManyUntil("root", nil, line, lf, end) 122 | 123 | return AST{ast: ast, parser: root} 124 | } 125 | 126 | func Parse(contents []byte, debug bool) parsec.Queryable { 127 | ast := NewAST() 128 | scanner := parsec.NewScanner(contents).SetWSPattern("\r\n") 129 | queryable, _ := ast.ast.Parsewith(ast.parser, scanner) 130 | 131 | if debug { 132 | if queryable != nil { 133 | ast.ast.Prettyprint() 134 | } 135 | } 136 | 137 | return queryable 138 | } 139 | -------------------------------------------------------------------------------- /client/syntax/parser_test.go: -------------------------------------------------------------------------------- 1 | package syntax 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | var ( 11 | enablePrettyPrint = os.Getenv("SCRAPBOX_DEBUG") != "" 12 | ) 13 | 14 | func TestParse__indent_level(t *testing.T) { 15 | for _, fixture := range []struct { 16 | source string 17 | indent int 18 | }{ 19 | {" ", 1}, 20 | {"\t", 1}, 21 | {" \t ", 3}, 22 | {"\t \t", 3}, 23 | } { 24 | queryable := Parse([]byte(fixture.source), enablePrettyPrint) 25 | 26 | if queryable == nil { 27 | t.Fatalf("Failed to parse") 28 | } 29 | 30 | if len(queryable.GetChildren()) > 1 { 31 | t.Fatalf("%d root children found", len(queryable.GetChildren())) 32 | } 33 | node := queryable.GetChildren()[0] 34 | 35 | assertEqualTo(t, node.GetAttribute("indent"), []string{fmt.Sprintf("%d", fixture.indent)}) 36 | } 37 | } 38 | 39 | func TestParse__single_node(t *testing.T) { 40 | for _, fixture := range []struct { 41 | source string 42 | name string 43 | value string 44 | }{ 45 | // link 46 | {"[$ 1+2 = 3]", "math", "[$ 1+2 = 3]"}, 47 | {"[_-/*/-_ https://avatars1.githubusercontent.com/u/1678258#.png]", "styled_url", "[_-/*/-_ https://avatars1.githubusercontent.com/u/1678258#.png]"}, 48 | {"[_-/*/-_ github.com/ohtomi/scrapbox]", "styled_text", "[_-/*/-_ github.com/ohtomi/scrapbox]"}, 49 | {"[/foo/bar/baz]", "project_link", "[/foo/bar/baz]"}, 50 | {"[https://avatars1.githubusercontent.com/u/1678258#.png https://avatars1.githubusercontent.com/u/1678258]", "image_link1", "[https://avatars1.githubusercontent.com/u/1678258#.png https://avatars1.githubusercontent.com/u/1678258]"}, 51 | {"[https://avatars1.githubusercontent.com/u/1678258 https://avatars1.githubusercontent.com/u/1678258#.png]", "image_link2", "[https://avatars1.githubusercontent.com/u/1678258 https://avatars1.githubusercontent.com/u/1678258#.png]"}, 52 | {"[avatar https://avatars1.githubusercontent.com/u/1678258]", "labeled_link1", "[avatar https://avatars1.githubusercontent.com/u/1678258]"}, 53 | {"[https://avatars1.githubusercontent.com/u/1678258 avatar]", "labeled_link2", "[https://avatars1.githubusercontent.com/u/1678258 avatar]"}, 54 | {"[https://avatars1.githubusercontent.com/u/1678258]", "external_link", "[https://avatars1.githubusercontent.com/u/1678258]"}, 55 | {"[ user.icon]", "icon", "[ user.icon]"}, 56 | {"[/foo/bar/baz .icon]", "page_icon", "[/foo/bar/baz .icon]"}, 57 | {"[github.com/ohtomi/scrapbox]", "internal_link", "[github.com/ohtomi/scrapbox]"}, 58 | // image 59 | {"http://avatars1.githubusercontent.com/u/1678258#.png", "image", "http://avatars1.githubusercontent.com/u/1678258#.png"}, 60 | {"http://avatars1.githubusercontent.com/u/1678258#.gif", "image", "http://avatars1.githubusercontent.com/u/1678258#.gif"}, 61 | {"https://avatars1.githubusercontent.com/u/1678258#.jpg", "image", "https://avatars1.githubusercontent.com/u/1678258#.jpg"}, 62 | {"https://avatars1.githubusercontent.com/u/1678258#.jpeg", "image", "https://avatars1.githubusercontent.com/u/1678258#.jpeg"}, 63 | {"https://gyazo.com/1678258/avatar", "image", "https://gyazo.com/1678258/avatar"}, 64 | // url 65 | {"http://avatars1.githubusercontent.com/u/1678258", "url", "http://avatars1.githubusercontent.com/u/1678258"}, 66 | {"https://avatars1.githubusercontent.com/u/1678258", "url", "https://avatars1.githubusercontent.com/u/1678258"}, 67 | // bold 68 | {"[[http://avatars1.githubusercontent.com/u/1678258#.png]]", "bold_image", "[[http://avatars1.githubusercontent.com/u/1678258#.png]]"}, 69 | {"[[github.com/ohtomi/scrapbox]]", "bold_text", "[[github.com/ohtomi/scrapbox]]"}, 70 | {"[[ github.com\t/ohtomi/\tscrapbox ]]", "bold_text", "[[ github.com\t/ohtomi/\tscrapbox ]]"}, 71 | // snippet 72 | {"`github.com\t/ohtomi/\tscrapbox/`", "snippet", "`github.com\t/ohtomi/\tscrapbox/`"}, 73 | // tag 74 | {"#[github.com/ohtomi/scrapbox/]", "tag", "#[github.com/ohtomi/scrapbox/]"}, 75 | {"#[ github.com\t/ohtomi/\tscrapbox/ ]", "tag", "#[ github.com\t/ohtomi/\tscrapbox/ ]"}, 76 | {"#github.com/ohtomi/scrapbox", "tag", "#github.com/ohtomi/scrapbox"}, 77 | // text 78 | {"x github.com\t/ohtomi/\tscrapbox/ x", "text", "x github.com\t/ohtomi/\tscrapbox/ x"}, 79 | } { 80 | queryable := Parse([]byte(fixture.source), enablePrettyPrint) 81 | 82 | if queryable == nil { 83 | t.Fatalf("Failed to parse") 84 | } 85 | 86 | if len(queryable.GetChildren()) > 1 { 87 | t.Fatalf("%d root children found", len(queryable.GetChildren())) 88 | } 89 | node := queryable.GetChildren()[0] 90 | 91 | if len(node.GetChildren()) > 1 { 92 | t.Fatalf("%d children found", len(node.GetChildren())) 93 | } 94 | item := node.GetChildren()[0] 95 | 96 | assertEqualTo(t, item.GetName(), fixture.name) 97 | assertEqualTo(t, item.GetValue(), fixture.value) 98 | } 99 | } 100 | 101 | func TestParse__many_nodes(t *testing.T) { 102 | for _, fixture := range []struct { 103 | source string 104 | names []string 105 | values []string 106 | }{ 107 | { 108 | "[$ 1+2 = 3][_-/*/-_ https://avatars1.githubusercontent.com/u/1678258#.png]", 109 | []string{"math", "styled_url"}, 110 | []string{"[$ 1+2 = 3]", "[_-/*/-_ https://avatars1.githubusercontent.com/u/1678258#.png]"}, 111 | }, 112 | } { 113 | queryable := Parse([]byte(fixture.source), enablePrettyPrint) 114 | 115 | if queryable == nil { 116 | t.Fatalf("Failed to parse") 117 | } 118 | 119 | if len(queryable.GetChildren()) > 1 { 120 | t.Fatalf("%d root children found", len(queryable.GetChildren())) 121 | } 122 | node := queryable.GetChildren()[0] 123 | 124 | if len(node.GetChildren()) != len(fixture.names) { 125 | t.Fatalf("%d children found", len(node.GetChildren())) 126 | } 127 | 128 | for i, item := range node.GetChildren() { 129 | assertEqualTo(t, item.GetName(), fixture.names[i]) 130 | assertEqualTo(t, item.GetValue(), fixture.values[i]) 131 | } 132 | } 133 | } 134 | 135 | func TestParse__quoted_node(t *testing.T) { 136 | for _, fixture := range []struct { 137 | original string 138 | indent []int 139 | expected [][]string 140 | }{ 141 | { 142 | ">https://avatars1.githubusercontent.com/u/1678258", 143 | []int{0}, 144 | [][]string{ 145 | {"https://avatars1.githubusercontent.com/u/1678258"}, 146 | }, 147 | }, 148 | { 149 | " >https://avatars1.githubusercontent.com/u/1678258", 150 | []int{3}, 151 | [][]string{ 152 | {"https://avatars1.githubusercontent.com/u/1678258"}, 153 | }, 154 | }, 155 | { 156 | "\t\t\t>https://avatars1.githubusercontent.com/u/1678258", 157 | []int{3}, 158 | [][]string{ 159 | {"https://avatars1.githubusercontent.com/u/1678258"}, 160 | }, 161 | }, 162 | { 163 | ">https://avatars1.githubusercontent.com/u/1678258#1\n" + 164 | " >https://avatars1.githubusercontent.com/u/1678258#2\n" + 165 | "\t\t\t>https://avatars1.githubusercontent.com/u/1678258#3", 166 | []int{0, 3, 3}, 167 | [][]string{ 168 | {"https://avatars1.githubusercontent.com/u/1678258#1"}, 169 | {"https://avatars1.githubusercontent.com/u/1678258#2"}, 170 | {"https://avatars1.githubusercontent.com/u/1678258#3"}, 171 | }, 172 | }, 173 | } { 174 | queryable := Parse([]byte(fixture.original), enablePrettyPrint) 175 | 176 | if queryable == nil { 177 | t.Fatalf("Failed to parse") 178 | } 179 | if len(queryable.GetChildren()) != len(fixture.expected) { 180 | t.Fatalf("Found %d, but Want %d: %+v", len(queryable.GetChildren()), len(fixture.expected), queryable) 181 | } 182 | 183 | for i, node := range queryable.GetChildren() { 184 | assertEqualTo(t, node.GetName(), "quoted_text") 185 | assertEqualTo(t, node.GetAttribute("indent"), []string{fmt.Sprintf("%d", fixture.indent[i])}) 186 | 187 | for j, expected := range fixture.expected[i] { 188 | assertEqualTo(t, node.GetChildren()[j].GetName(), "url") 189 | assertEqualTo(t, node.GetChildren()[j].GetValue(), expected) 190 | } 191 | } 192 | } 193 | } 194 | 195 | func TestParse__code_directive_node(t *testing.T) { 196 | for _, fixture := range []struct { 197 | original string 198 | indent []int 199 | expected [][]string 200 | }{ 201 | {"code:sample.js", 202 | []int{0}, 203 | [][]string{ 204 | {"sample.js"}, 205 | }, 206 | }, 207 | {" code:sample.js", 208 | []int{3}, 209 | [][]string{ 210 | {"sample.js"}, 211 | }, 212 | }, 213 | {"\t\t\tcode:sample.js", 214 | []int{3}, 215 | [][]string{ 216 | {"sample.js"}, 217 | }, 218 | }, 219 | } { 220 | queryable := Parse([]byte(fixture.original), enablePrettyPrint) 221 | 222 | if queryable == nil { 223 | t.Fatalf("Failed to parse") 224 | } 225 | if len(queryable.GetChildren()) != len(fixture.expected) { 226 | t.Fatalf("Found %d, but Want %d: %+v", len(queryable.GetChildren()), len(fixture.expected), queryable) 227 | } 228 | 229 | for i, node := range queryable.GetChildren() { 230 | assertEqualTo(t, node.GetName(), "code_block") 231 | assertEqualTo(t, node.GetAttribute("indent"), []string{fmt.Sprintf("%d", fixture.indent[i])}) 232 | 233 | for j, expected := range fixture.expected[i] { 234 | assertEqualTo(t, node.GetChildren()[j].GetName(), "text") 235 | assertEqualTo(t, node.GetChildren()[j].GetValue(), expected) 236 | } 237 | } 238 | } 239 | } 240 | 241 | func TestParse__table_directive_node(t *testing.T) { 242 | for _, fixture := range []struct { 243 | original string 244 | indent []int 245 | expected [][]string 246 | }{ 247 | {"table:sample", 248 | []int{0}, 249 | [][]string{ 250 | {"sample"}, 251 | }, 252 | }, 253 | {" table:sample", 254 | []int{3}, 255 | [][]string{ 256 | {"sample"}, 257 | }, 258 | }, 259 | {"\t\t\ttable:sample", 260 | []int{3}, 261 | [][]string{ 262 | {"sample"}, 263 | }, 264 | }, 265 | } { 266 | queryable := Parse([]byte(fixture.original), enablePrettyPrint) 267 | 268 | if queryable == nil { 269 | t.Fatalf("Failed to parse") 270 | } 271 | if len(queryable.GetChildren()) != len(fixture.expected) { 272 | t.Fatalf("Found %d, but Want %d: %+v", len(queryable.GetChildren()), len(fixture.expected), queryable) 273 | } 274 | 275 | for i, node := range queryable.GetChildren() { 276 | assertEqualTo(t, node.GetName(), "table_block") 277 | assertEqualTo(t, node.GetAttribute("indent"), []string{fmt.Sprintf("%d", fixture.indent[i])}) 278 | 279 | for j, expected := range fixture.expected[i] { 280 | assertEqualTo(t, node.GetChildren()[j].GetName(), "text") 281 | assertEqualTo(t, node.GetChildren()[j].GetValue(), expected) 282 | } 283 | } 284 | } 285 | } 286 | 287 | func assertEqualTo(t *testing.T, actual, expected interface{}) { 288 | if !reflect.DeepEqual(actual, expected) { 289 | t.Fatalf("Got %+v, but Want %+v", actual, expected) 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /cmd/scrapbox/cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mitchellh/cli" 8 | "github.com/ohtomi/scrapbox/cmd/scrapbox/command" 9 | ) 10 | 11 | func Run(args []string) int { 12 | 13 | // Meta-option for executables. 14 | // It defines output color and its stdout/stderr stream. 15 | meta := &command.Meta{ 16 | Ui: &cli.ColoredUi{ 17 | OutputColor: cli.UiColorNone, 18 | InfoColor: cli.UiColorBlue, 19 | ErrorColor: cli.UiColorRed, 20 | Ui: &cli.BasicUi{ 21 | Writer: os.Stdout, 22 | ErrorWriter: os.Stderr, 23 | Reader: os.Stdin, 24 | }, 25 | }} 26 | 27 | return RunCustom(args, Commands(meta)) 28 | } 29 | 30 | func RunCustom(args []string, commands map[string]cli.CommandFactory) int { 31 | 32 | // Get the command line args. We shortcut "--version" and "-v" to 33 | // just show the version. 34 | for _, arg := range args { 35 | if arg == "-v" || arg == "-version" || arg == "--version" { 36 | newArgs := make([]string, len(args)+1) 37 | newArgs[0] = "version" 38 | copy(newArgs[1:], args) 39 | args = newArgs 40 | break 41 | } 42 | } 43 | 44 | cli := &cli.CLI{ 45 | Args: args, 46 | Commands: commands, 47 | Version: Version, 48 | HelpFunc: cli.BasicHelpFunc(Name), 49 | HelpWriter: os.Stdout, 50 | } 51 | 52 | exitCode, err := cli.Run() 53 | if err != nil { 54 | fmt.Fprintf(os.Stderr, "Failed to execute: %s\n", err.Error()) 55 | } 56 | 57 | return exitCode 58 | } 59 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/.gitignore: -------------------------------------------------------------------------------- 1 | enums_string.go 2 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/enums.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | //go:generate stringer -type ExitCode -output enums_string.go 4 | type ExitCode int 5 | 6 | const ( 7 | ExitCodeOK ExitCode = iota 8 | ExitCodeError 9 | ExitCodeParseFlagsError 10 | ExitCodeBadArgs 11 | ExitCodeInvalidURL 12 | ExitCodeProjectNotFound 13 | ExitCodePageNotFound 14 | ExitCodeFetchFailure 15 | ) 16 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/link.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "strings" 10 | 11 | "github.com/ohtomi/scrapbox/client" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type LinkCommand struct { 16 | Meta 17 | } 18 | 19 | func (c *LinkCommand) FetchAllLinks(client *client.Client, project, page string) ([]string, error) { 20 | 21 | p, err := client.GetPage(context.Background(), project, page) 22 | if err != nil { 23 | return nil, errors.Wrap(err, "failed to get page") 24 | } 25 | 26 | return p.ExtractExternalLinks(), nil 27 | } 28 | 29 | func (c *LinkCommand) Run(args []string) int { 30 | 31 | var ( 32 | project string 33 | page string 34 | 35 | token string 36 | host string 37 | expiration int 38 | userAgent string 39 | ) 40 | 41 | flags := flag.NewFlagSet("open", flag.ContinueOnError) 42 | flags.Usage = func() { 43 | c.Ui.Error(c.Help()) 44 | } 45 | 46 | flags.StringVar(&token, "token", os.Getenv(EnvScrapboxToken), "") 47 | flags.StringVar(&token, "t", os.Getenv(EnvScrapboxToken), "") 48 | flags.StringVar(&host, "host", os.Getenv(EnvScrapboxHost), "") 49 | flags.StringVar(&host, "h", os.Getenv(EnvScrapboxHost), "") 50 | flags.IntVar(&expiration, "expire", EnvToInt(EnvExpiration, client.DefaultExpiration), "") 51 | flags.StringVar(&userAgent, "ua", os.Getenv(EnvUserAgent), "") 52 | 53 | if err := flags.Parse(args); err != nil { 54 | return int(ExitCodeParseFlagsError) 55 | } 56 | 57 | parsedArgs := flags.Args() 58 | if len(parsedArgs) != 2 { 59 | c.Ui.Error("you must set PROJECT and PAGE.") 60 | return int(ExitCodeBadArgs) 61 | } 62 | project, page = parsedArgs[0], parsedArgs[1] 63 | 64 | if len(project) == 0 { 65 | c.Ui.Error("missing PROJECT.") 66 | return int(ExitCodeProjectNotFound) 67 | } 68 | if len(page) == 0 { 69 | c.Ui.Error("missing PAGE.") 70 | return int(ExitCodePageNotFound) 71 | } 72 | 73 | if len(host) == 0 { 74 | host = client.DefaultHost 75 | } 76 | 77 | parsedURL, err := url.ParseRequestURI(host) 78 | if err != nil { 79 | c.Ui.Error(fmt.Sprintf("failed to parse the url. host: %s, cause: %s", host, err)) 80 | return int(ExitCodeInvalidURL) 81 | } 82 | 83 | if len(userAgent) == 0 { 84 | userAgent = client.DefaultUserAgent 85 | } 86 | 87 | // process 88 | 89 | client, err := client.NewClient(parsedURL, token, expiration, userAgent) 90 | if err != nil { 91 | c.Ui.Error(fmt.Sprintf("failed to initialize api client. cause: %s", err)) 92 | return int(ExitCodeError) 93 | } 94 | 95 | linkURLs, err := c.FetchAllLinks(client, project, page) 96 | if err != nil { 97 | c.Ui.Error(fmt.Sprintf("failed to fetch the scrapbox page. cause: %s", err)) 98 | return int(ExitCodeFetchFailure) 99 | } 100 | 101 | for _, u := range linkURLs { 102 | c.Ui.Output(u) 103 | } 104 | 105 | return int(ExitCodeOK) 106 | } 107 | 108 | func (c *LinkCommand) Synopsis() string { 109 | return "Print all URLs in the scrapbox page" 110 | } 111 | 112 | func (c *LinkCommand) Help() string { 113 | helpText := `usage: scrapbox link [options...] PROJECT PAGE 114 | 115 | Options: 116 | --token, -t Scrapbox connect.sid used to access private project. 117 | --host, -h Scrapbox Host. By default, "https://scrapbox.io". 118 | --expire Local Cache Expiration. By default, 3600 seconds. 119 | --ua User Agent. By default, "ScrapboxGoClient/x.x.x" 120 | ` 121 | return strings.TrimSpace(helpText) 122 | } 123 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/link_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | _ "github.com/mitchellh/cli" 9 | ) 10 | 11 | func TestLinkCommand__print_http_link(t *testing.T) { 12 | 13 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 14 | meta := NewTestMeta(outStream, errStream, inStream) 15 | command := &LinkCommand{ 16 | Meta: *meta, 17 | } 18 | 19 | testAPIServer := RunAPIServer() 20 | defer testAPIServer.Close() 21 | 22 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "HTTPなリンクのあるページ"} 23 | exitStatus := command.Run(args) 24 | 25 | if DebugMode { 26 | t.Log(outStream.String()) 27 | t.Log(errStream.String()) 28 | } 29 | 30 | if ExitCode(exitStatus) != ExitCodeOK { 31 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 32 | } 33 | 34 | expected := "http://www.sphinx-doc.org/en/stable/" 35 | if !strings.Contains(outStream.String(), expected) { 36 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 37 | } 38 | } 39 | 40 | func TestLinkCommand__print_https_link(t *testing.T) { 41 | 42 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 43 | meta := NewTestMeta(outStream, errStream, inStream) 44 | command := &LinkCommand{ 45 | Meta: *meta, 46 | } 47 | 48 | testAPIServer := RunAPIServer() 49 | defer testAPIServer.Close() 50 | 51 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "HTTPSなリンクのあるページ"} 52 | exitStatus := command.Run(args) 53 | 54 | if DebugMode { 55 | t.Log(outStream.String()) 56 | t.Log(errStream.String()) 57 | } 58 | 59 | if ExitCode(exitStatus) != ExitCodeOK { 60 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 61 | } 62 | 63 | expected := "https://www.google.co.jp" 64 | if !strings.Contains(outStream.String(), expected) { 65 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 66 | } 67 | } 68 | 69 | func TestLinkCommand__print_link_with_name_1(t *testing.T) { 70 | 71 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 72 | meta := NewTestMeta(outStream, errStream, inStream) 73 | command := &LinkCommand{ 74 | Meta: *meta, 75 | } 76 | 77 | testAPIServer := RunAPIServer() 78 | defer testAPIServer.Close() 79 | 80 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "文章のなかにリンクがあるページ1"} 81 | exitStatus := command.Run(args) 82 | 83 | if DebugMode { 84 | t.Log(outStream.String()) 85 | t.Log(errStream.String()) 86 | } 87 | 88 | if ExitCode(exitStatus) != ExitCodeOK { 89 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 90 | } 91 | 92 | expected := "https://www.google.co.jp" 93 | if !strings.Contains(outStream.String(), expected) { 94 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 95 | } 96 | } 97 | 98 | func TestLinkCommand__print_link_with_name_2(t *testing.T) { 99 | 100 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 101 | meta := NewTestMeta(outStream, errStream, inStream) 102 | command := &LinkCommand{ 103 | Meta: *meta, 104 | } 105 | 106 | testAPIServer := RunAPIServer() 107 | defer testAPIServer.Close() 108 | 109 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "文章のなかにリンクがあるページ2"} 110 | exitStatus := command.Run(args) 111 | 112 | if DebugMode { 113 | t.Log(outStream.String()) 114 | t.Log(errStream.String()) 115 | } 116 | 117 | if ExitCode(exitStatus) != ExitCodeOK { 118 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 119 | } 120 | 121 | expected := "https://www.google.com" 122 | if !strings.Contains(outStream.String(), expected) { 123 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 124 | } 125 | } 126 | 127 | func TestLinkCommand__print_multiple_links(t *testing.T) { 128 | 129 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 130 | meta := NewTestMeta(outStream, errStream, inStream) 131 | command := &LinkCommand{ 132 | Meta: *meta, 133 | } 134 | 135 | testAPIServer := RunAPIServer() 136 | defer testAPIServer.Close() 137 | 138 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "複数のリンクがあるページ"} 139 | exitStatus := command.Run(args) 140 | 141 | if DebugMode { 142 | t.Log(outStream.String()) 143 | t.Log(errStream.String()) 144 | } 145 | 146 | if ExitCode(exitStatus) != ExitCodeOK { 147 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 148 | } 149 | 150 | expected := "https://www.google.co.jp\nhttps://www.google.com" 151 | if !strings.Contains(outStream.String(), expected) { 152 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 153 | } 154 | } 155 | 156 | func TestLinkCommand__print_no_links(t *testing.T) { 157 | 158 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 159 | meta := NewTestMeta(outStream, errStream, inStream) 160 | command := &LinkCommand{ 161 | Meta: *meta, 162 | } 163 | 164 | testAPIServer := RunAPIServer() 165 | defer testAPIServer.Close() 166 | 167 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "日本語タイトルのページ"} 168 | exitStatus := command.Run(args) 169 | 170 | if DebugMode { 171 | t.Log(outStream.String()) 172 | t.Log(errStream.String()) 173 | } 174 | 175 | if ExitCode(exitStatus) != ExitCodeOK { 176 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 177 | } 178 | 179 | expected := "" 180 | if !strings.Contains(outStream.String(), expected) { 181 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/list.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "strings" 10 | 11 | "github.com/ohtomi/scrapbox/client" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type ListCommand struct { 16 | Meta 17 | } 18 | 19 | func (c *ListCommand) FetchRelatedPages(client *client.Client, project string, tags []string) ([]string, error) { 20 | 21 | q, err := client.ExecQuery(context.Background(), project, tags, 0, 100) 22 | if err != nil { 23 | return nil, errors.Wrap(err, "failed to execute query") 24 | } 25 | 26 | return q.Pages, nil 27 | } 28 | 29 | func (c *ListCommand) Run(args []string) int { 30 | 31 | var ( 32 | project string 33 | tags []string 34 | 35 | token string 36 | host string 37 | expiration int 38 | userAgent string 39 | ) 40 | 41 | flags := flag.NewFlagSet("list", flag.ContinueOnError) 42 | flags.Usage = func() { 43 | c.Ui.Error(c.Help()) 44 | } 45 | 46 | flags.StringVar(&token, "token", os.Getenv(EnvScrapboxToken), "") 47 | flags.StringVar(&token, "t", os.Getenv(EnvScrapboxToken), "") 48 | flags.StringVar(&host, "host", os.Getenv(EnvScrapboxHost), "") 49 | flags.StringVar(&host, "h", os.Getenv(EnvScrapboxHost), "") 50 | flags.IntVar(&expiration, "expire", EnvToInt(EnvExpiration, client.DefaultExpiration), "") 51 | flags.StringVar(&userAgent, "ua", os.Getenv(EnvUserAgent), "") 52 | 53 | if err := flags.Parse(args); err != nil { 54 | return int(ExitCodeParseFlagsError) 55 | } 56 | 57 | parsedArgs := flags.Args() 58 | if len(parsedArgs) < 1 { 59 | c.Ui.Error("you must set PROJECT.") 60 | return int(ExitCodeBadArgs) 61 | } 62 | project, tags = parsedArgs[0], parsedArgs[1:] 63 | 64 | if len(project) == 0 { 65 | c.Ui.Error("missing PROJECT.") 66 | return int(ExitCodeProjectNotFound) 67 | } 68 | 69 | if len(host) == 0 { 70 | host = client.DefaultHost 71 | } 72 | 73 | parsedURL, err := url.ParseRequestURI(host) 74 | if err != nil { 75 | c.Ui.Error(fmt.Sprintf("failed to parse the url. host: %s, cause: %s", host, err)) 76 | return int(ExitCodeInvalidURL) 77 | } 78 | 79 | if len(userAgent) == 0 { 80 | userAgent = client.DefaultUserAgent 81 | } 82 | 83 | // process 84 | 85 | client, err := client.NewClient(parsedURL, token, expiration, userAgent) 86 | if err != nil { 87 | c.Ui.Error(fmt.Sprintf("failed to initialize api client. cause: %s", err)) 88 | return int(ExitCodeError) 89 | } 90 | 91 | relatedPages, err := c.FetchRelatedPages(client, project, tags) 92 | if err != nil { 93 | c.Ui.Error(fmt.Sprintf("failed to fetch the scrapbox page. cause: %s", err)) 94 | return int(ExitCodeFetchFailure) 95 | } 96 | 97 | for _, p := range relatedPages { 98 | c.Ui.Output(p) 99 | } 100 | 101 | return int(ExitCodeOK) 102 | } 103 | 104 | func (c *ListCommand) Synopsis() string { 105 | return "List page titles containing specified tags" 106 | } 107 | 108 | func (c *ListCommand) Help() string { 109 | helpText := `usage: scrapbox list [options...] PROJECT [TAGs...] 110 | 111 | Options: 112 | --token, -t Scrapbox connect.sid used to access private project. 113 | --host, -h Scrapbox Host. By default, "https://scrapbox.io". 114 | --expire Local Cache Expiration. By default, 3600 seconds. 115 | --ua User Agent. By default, "ScrapboxGoClient/x.x.x" 116 | ` 117 | return strings.TrimSpace(helpText) 118 | } 119 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/list_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "path" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/MakeNowJust/heredoc" 15 | "github.com/mitchellh/cli" 16 | "github.com/ohtomi/scrapbox/client" 17 | ) 18 | 19 | func SetTestEnv(key, newValue string) func() { 20 | 21 | prevValue := os.Getenv(key) 22 | os.Setenv(key, newValue) 23 | reverter := func() { 24 | os.Setenv(key, prevValue) 25 | } 26 | return reverter 27 | } 28 | 29 | func NewTestMeta(outStream, errStream io.Writer, inStream io.Reader) *Meta { 30 | 31 | return &Meta{ 32 | Ui: &cli.BasicUi{ 33 | Writer: outStream, 34 | ErrorWriter: errStream, 35 | Reader: inStream, 36 | }} 37 | } 38 | 39 | func RunAPIServer() *httptest.Server { 40 | 41 | muxAPI := http.NewServeMux() 42 | testAPIServer := httptest.NewServer(muxAPI) 43 | 44 | muxAPI.HandleFunc("/api/pages/go-scrapbox/search/query", func(w http.ResponseWriter, r *http.Request) { 45 | query := r.URL.Query() 46 | skip := query.Get("skip") 47 | limit := query.Get("limit") 48 | tags := strings.Split(query.Get("q"), " ") 49 | 50 | filename := fmt.Sprintf("%s-%s", skip, limit) 51 | directory := path.Join("../../../testdata/query/scrapbox.io/go-scrapbox", path.Join(tags...)) 52 | filepath := path.Join(directory, client.EncodeFilename(filename)) 53 | http.ServeFile(w, r, filepath) 54 | }) 55 | 56 | muxAPI.HandleFunc("/api/pages/go-scrapbox", func(w http.ResponseWriter, r *http.Request) { 57 | query := r.URL.Query() 58 | skip := query.Get("skip") 59 | limit := query.Get("limit") 60 | 61 | filename := fmt.Sprintf("%s-%s", skip, limit) 62 | directory := path.Join("../../../testdata/query/scrapbox.io/go-scrapbox") 63 | filepath := path.Join(directory, client.EncodeFilename(filename)) 64 | http.ServeFile(w, r, filepath) 65 | }) 66 | 67 | muxAPI.HandleFunc("/api/pages/go-scrapbox/", func(w http.ResponseWriter, r *http.Request) { 68 | urlPath := r.URL.Path 69 | 70 | filename := strings.Replace(urlPath, "/api/pages/go-scrapbox/", "", -1) 71 | directory := "../../../testdata/page/scrapbox.io/go-scrapbox" 72 | filepath := path.Join(directory, client.EncodeFilename(filename)) 73 | http.ServeFile(w, r, filepath) 74 | }) 75 | 76 | return testAPIServer 77 | } 78 | 79 | func TestListCommand__find_by_project_only(t *testing.T) { 80 | 81 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 82 | meta := NewTestMeta(outStream, errStream, inStream) 83 | command := &ListCommand{ 84 | Meta: *meta, 85 | } 86 | 87 | testAPIServer := RunAPIServer() 88 | defer testAPIServer.Close() 89 | 90 | args := []string{"--host", testAPIServer.URL, "go-scrapbox"} 91 | exitStatus := command.Run(args) 92 | 93 | if DebugMode { 94 | t.Log(outStream.String()) 95 | t.Log(errStream.String()) 96 | } 97 | 98 | if ExitCode(exitStatus) != ExitCodeOK { 99 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 100 | } 101 | 102 | expected := heredoc.Doc(` 103 | HTTPなリンクのあるページ 104 | HTTPSなリンクのあるページ 105 | title having question ? mark 106 | title having plus + mark 107 | title having paren ( ) mark 108 | title having slash / mark 109 | 文章のなかにリンクがあるページ2 110 | 文章のなかにリンクがあるページ1 111 | 複数のリンクがあるページ 112 | title having whitespaces 113 | 日本語タイトルのページ 114 | `) 115 | if !strings.Contains(outStream.String(), expected) { 116 | t.Fatalf("Output is \n%s\n, but want \n%s", outStream.String(), expected) 117 | } 118 | } 119 | 120 | func TestListCommand__find_by_project_and_one_keyword(t *testing.T) { 121 | 122 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 123 | meta := NewTestMeta(outStream, errStream, inStream) 124 | command := &ListCommand{ 125 | Meta: *meta, 126 | } 127 | 128 | testAPIServer := RunAPIServer() 129 | defer testAPIServer.Close() 130 | 131 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "english"} 132 | exitStatus := command.Run(args) 133 | 134 | if DebugMode { 135 | t.Log(outStream.String()) 136 | t.Log(errStream.String()) 137 | } 138 | 139 | if ExitCode(exitStatus) != ExitCodeOK { 140 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 141 | } 142 | 143 | expected := heredoc.Doc(` 144 | title having question ? mark 145 | title having plus + mark 146 | title having paren ( ) mark 147 | title having slash / mark 148 | title having whitespaces 149 | `) 150 | if !strings.Contains(outStream.String(), expected) { 151 | t.Fatalf("Output is \n%s\n, but want \n%s", outStream.String(), expected) 152 | } 153 | } 154 | 155 | func TestListCommand__find_by_project_and_many_keywords(t *testing.T) { 156 | 157 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 158 | meta := NewTestMeta(outStream, errStream, inStream) 159 | command := &ListCommand{ 160 | Meta: *meta, 161 | } 162 | 163 | testAPIServer := RunAPIServer() 164 | defer testAPIServer.Close() 165 | 166 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "english", "paren"} 167 | exitStatus := command.Run(args) 168 | 169 | if DebugMode { 170 | t.Log(outStream.String()) 171 | t.Log(errStream.String()) 172 | } 173 | 174 | if ExitCode(exitStatus) != ExitCodeOK { 175 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 176 | } 177 | 178 | expected := "title having paren ( ) mark" 179 | if !strings.Contains(outStream.String(), expected) { 180 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 181 | } 182 | } 183 | 184 | func TestListCommand__find_by_project_and_non_tag_keyword(t *testing.T) { 185 | 186 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 187 | meta := NewTestMeta(outStream, errStream, inStream) 188 | command := &ListCommand{ 189 | Meta: *meta, 190 | } 191 | 192 | testAPIServer := RunAPIServer() 193 | defer testAPIServer.Close() 194 | 195 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "english", "whitespaces"} 196 | exitStatus := command.Run(args) 197 | 198 | if DebugMode { 199 | t.Log(outStream.String()) 200 | t.Log(errStream.String()) 201 | } 202 | 203 | if ExitCode(exitStatus) != ExitCodeOK { 204 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 205 | } 206 | 207 | expected := "title having whitespaces" 208 | if !strings.Contains(outStream.String(), expected) { 209 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/meta.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | 7 | "github.com/mitchellh/cli" 8 | ) 9 | 10 | const ( 11 | EnvScrapboxToken = "SCRAPBOX_TOKEN" 12 | EnvScrapboxHost = "SCRAPBOX_HOST" 13 | EnvExpiration = "SCRAPBOX_EXPIRATION" 14 | EnvUserAgent = "SCRAPBOX_USER_AGENT" 15 | ) 16 | 17 | const ( 18 | EnvDebug = "SCRAPBOX_DEBUG" 19 | EnvLongRunTest = "SCRAPBOX_LONG_RUN_TEST" 20 | ) 21 | 22 | var ( 23 | DebugMode = os.Getenv(EnvDebug) != "" 24 | LongRunTestMode = os.Getenv(EnvLongRunTest) != "" 25 | ) 26 | 27 | func EnvToInt(name string, value int) int { 28 | parsedInt, err := strconv.Atoi(os.Getenv(name)) 29 | if err != nil { 30 | return value 31 | } 32 | return parsedInt 33 | } 34 | 35 | // Meta contain the meta-option that nearly all subcommand inherited. 36 | type Meta struct { 37 | Ui cli.Ui 38 | } 39 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/open.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "strings" 9 | 10 | "github.com/ohtomi/scrapbox/client" 11 | ) 12 | 13 | type OpenCommand struct { 14 | Meta 15 | } 16 | 17 | func (c *OpenCommand) BuildPageURL(host, project, page string) string { 18 | return client.GetURL(host, project, page) 19 | } 20 | 21 | func (c *OpenCommand) Run(args []string) int { 22 | 23 | var ( 24 | project string 25 | page string 26 | 27 | host string 28 | ) 29 | 30 | flags := flag.NewFlagSet("open", flag.ContinueOnError) 31 | flags.Usage = func() { 32 | c.Ui.Error(c.Help()) 33 | } 34 | 35 | flags.StringVar(&host, "host", os.Getenv(EnvScrapboxHost), "") 36 | flags.StringVar(&host, "h", os.Getenv(EnvScrapboxHost), "") 37 | 38 | if err := flags.Parse(args); err != nil { 39 | return int(ExitCodeParseFlagsError) 40 | } 41 | 42 | parsedArgs := flags.Args() 43 | if len(parsedArgs) != 2 { 44 | c.Ui.Error("you must set PROJECT and PAGE.") 45 | return int(ExitCodeBadArgs) 46 | } 47 | project, page = parsedArgs[0], parsedArgs[1] 48 | 49 | if len(project) == 0 { 50 | c.Ui.Error("missing PROJECT.") 51 | return int(ExitCodeProjectNotFound) 52 | } 53 | if len(page) == 0 { 54 | c.Ui.Error("missing PAGE.") 55 | return int(ExitCodePageNotFound) 56 | } 57 | 58 | if len(host) == 0 { 59 | host = client.DefaultHost 60 | } 61 | 62 | _, err := url.ParseRequestURI(host) 63 | if err != nil { 64 | c.Ui.Error(fmt.Sprintf("failed to parse the url. host: %s, cause: %s", host, err)) 65 | return int(ExitCodeInvalidURL) 66 | } 67 | 68 | // process 69 | 70 | pageURL := c.BuildPageURL(host, project, page) 71 | c.Ui.Output(pageURL) 72 | 73 | return int(ExitCodeOK) 74 | } 75 | 76 | func (c *OpenCommand) Synopsis() string { 77 | return "Print the URL of the scrapbox page" 78 | } 79 | 80 | func (c *OpenCommand) Help() string { 81 | helpText := `usage: scrapbox open [options...] PROJECT PAGE 82 | 83 | Options: 84 | --host, -h Scrapbox Host. By default, "https://scrapbox.io". 85 | ` 86 | return strings.TrimSpace(helpText) 87 | } 88 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/open_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | _ "github.com/mitchellh/cli" 9 | ) 10 | 11 | func TestOpenCommand__print_url_having_paren(t *testing.T) { 12 | 13 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 14 | meta := NewTestMeta(outStream, errStream, inStream) 15 | command := &OpenCommand{ 16 | Meta: *meta, 17 | } 18 | 19 | args := []string{"go-scrapbox", "title having paren ( ) mark"} 20 | exitStatus := command.Run(args) 21 | 22 | if DebugMode { 23 | t.Log(outStream.String()) 24 | t.Log(errStream.String()) 25 | } 26 | 27 | if ExitCode(exitStatus) != ExitCodeOK { 28 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 29 | } 30 | 31 | expected := "https://scrapbox.io/go-scrapbox/title%20having%20paren%20(%20)%20mark" 32 | if !strings.Contains(outStream.String(), expected) { 33 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 34 | } 35 | } 36 | 37 | func TestOpenCommand__print_url_having_plus(t *testing.T) { 38 | 39 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 40 | meta := NewTestMeta(outStream, errStream, inStream) 41 | command := &OpenCommand{ 42 | Meta: *meta, 43 | } 44 | 45 | args := []string{"go-scrapbox", "title having plus + mark"} 46 | exitStatus := command.Run(args) 47 | 48 | if DebugMode { 49 | t.Log(outStream.String()) 50 | t.Log(errStream.String()) 51 | } 52 | 53 | if ExitCode(exitStatus) != ExitCodeOK { 54 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 55 | } 56 | 57 | expected := "https://scrapbox.io/go-scrapbox/title%20having%20plus%20%2B%20mark" 58 | if !strings.Contains(outStream.String(), expected) { 59 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 60 | } 61 | } 62 | 63 | func TestOpenCommand__print_url_having_question(t *testing.T) { 64 | 65 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 66 | meta := NewTestMeta(outStream, errStream, inStream) 67 | command := &OpenCommand{ 68 | Meta: *meta, 69 | } 70 | 71 | args := []string{"go-scrapbox", "title having question ? mark"} 72 | exitStatus := command.Run(args) 73 | 74 | if DebugMode { 75 | t.Log(outStream.String()) 76 | t.Log(errStream.String()) 77 | } 78 | 79 | if ExitCode(exitStatus) != ExitCodeOK { 80 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 81 | } 82 | 83 | expected := "https://scrapbox.io/go-scrapbox/title%20having%20question%20%3F%20mark" 84 | if !strings.Contains(outStream.String(), expected) { 85 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 86 | } 87 | } 88 | 89 | func TestOpenCommand__print_url_having_slash(t *testing.T) { 90 | 91 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 92 | meta := NewTestMeta(outStream, errStream, inStream) 93 | command := &OpenCommand{ 94 | Meta: *meta, 95 | } 96 | 97 | args := []string{"go-scrapbox", "title having slash / mark"} 98 | exitStatus := command.Run(args) 99 | 100 | if DebugMode { 101 | t.Log(outStream.String()) 102 | t.Log(errStream.String()) 103 | } 104 | 105 | if ExitCode(exitStatus) != ExitCodeOK { 106 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 107 | } 108 | 109 | expected := "https://scrapbox.io/go-scrapbox/title%20having%20slash%20%2F%20mark" 110 | if !strings.Contains(outStream.String(), expected) { 111 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 112 | } 113 | } 114 | 115 | func TestOpenCommand__print_url_having_whitespace(t *testing.T) { 116 | 117 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 118 | meta := NewTestMeta(outStream, errStream, inStream) 119 | command := &OpenCommand{ 120 | Meta: *meta, 121 | } 122 | 123 | args := []string{"go-scrapbox", "title having whitespaces"} 124 | exitStatus := command.Run(args) 125 | 126 | if DebugMode { 127 | t.Log(outStream.String()) 128 | t.Log(errStream.String()) 129 | } 130 | 131 | if ExitCode(exitStatus) != ExitCodeOK { 132 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 133 | } 134 | 135 | expected := "https://scrapbox.io/go-scrapbox/title%20having%20whitespaces" 136 | if !strings.Contains(outStream.String(), expected) { 137 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 138 | } 139 | } 140 | 141 | func TestOpenCommand__print_url_having_japanese(t *testing.T) { 142 | 143 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 144 | meta := NewTestMeta(outStream, errStream, inStream) 145 | command := &OpenCommand{ 146 | Meta: *meta, 147 | } 148 | 149 | args := []string{"go-scrapbox", "日本語タイトルのページ"} 150 | exitStatus := command.Run(args) 151 | 152 | if DebugMode { 153 | t.Log(outStream.String()) 154 | t.Log(errStream.String()) 155 | } 156 | 157 | if ExitCode(exitStatus) != ExitCodeOK { 158 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 159 | } 160 | 161 | expected := "https://scrapbox.io/go-scrapbox/%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB%E3%81%AE%E3%83%9A%E3%83%BC%E3%82%B8" 162 | if !strings.Contains(outStream.String(), expected) { 163 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/read.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "strings" 10 | 11 | "github.com/ohtomi/scrapbox/client" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type ReadCommand struct { 16 | Meta 17 | } 18 | 19 | func (c *ReadCommand) FetchContent(client *client.Client, project, page string) ([]string, error) { 20 | 21 | p, err := client.GetPage(context.Background(), project, page) 22 | if err != nil { 23 | return nil, errors.Wrap(err, "failed to get page") 24 | } 25 | 26 | return p.Lines, nil 27 | } 28 | 29 | func (c *ReadCommand) Run(args []string) int { 30 | 31 | var ( 32 | project string 33 | page string 34 | 35 | token string 36 | host string 37 | expiration int 38 | userAgent string 39 | ) 40 | 41 | flags := flag.NewFlagSet("read", flag.ContinueOnError) 42 | flags.Usage = func() { 43 | c.Ui.Error(c.Help()) 44 | } 45 | 46 | flags.StringVar(&token, "token", os.Getenv(EnvScrapboxToken), "") 47 | flags.StringVar(&token, "t", os.Getenv(EnvScrapboxToken), "") 48 | flags.StringVar(&host, "host", os.Getenv(EnvScrapboxHost), "") 49 | flags.StringVar(&host, "h", os.Getenv(EnvScrapboxHost), "") 50 | flags.IntVar(&expiration, "expire", EnvToInt(EnvExpiration, client.DefaultExpiration), "") 51 | flags.StringVar(&userAgent, "ua", os.Getenv(EnvUserAgent), "") 52 | 53 | if err := flags.Parse(args); err != nil { 54 | return int(ExitCodeParseFlagsError) 55 | } 56 | 57 | parsedArgs := flags.Args() 58 | if len(parsedArgs) != 2 { 59 | c.Ui.Error("you must set PROJECT and PAGE.") 60 | return int(ExitCodeBadArgs) 61 | } 62 | project, page = parsedArgs[0], parsedArgs[1] 63 | 64 | if len(project) == 0 { 65 | c.Ui.Error("missing PROJECT.") 66 | return int(ExitCodeProjectNotFound) 67 | } 68 | if len(page) == 0 { 69 | c.Ui.Error("missing PAGE.") 70 | return int(ExitCodePageNotFound) 71 | } 72 | 73 | if len(host) == 0 { 74 | host = client.DefaultHost 75 | } 76 | 77 | parsedURL, err := url.ParseRequestURI(host) 78 | if err != nil { 79 | c.Ui.Error(fmt.Sprintf("failed to parse the url. host: %s, cause: %s", host, err)) 80 | return int(ExitCodeInvalidURL) 81 | } 82 | 83 | if len(userAgent) == 0 { 84 | userAgent = client.DefaultUserAgent 85 | } 86 | 87 | // process 88 | 89 | client, err := client.NewClient(parsedURL, token, expiration, userAgent) 90 | if err != nil { 91 | c.Ui.Error(fmt.Sprintf("failed to initialize api client. cause: %s", err)) 92 | return int(ExitCodeError) 93 | } 94 | 95 | lines, err := c.FetchContent(client, project, page) 96 | if err != nil { 97 | c.Ui.Error(fmt.Sprintf("failed to fetch the scrapbox page. cause: %s", err)) 98 | return int(ExitCodeFetchFailure) 99 | } 100 | 101 | for _, l := range lines { 102 | c.Ui.Output(l) 103 | } 104 | 105 | return int(ExitCodeOK) 106 | } 107 | 108 | func (c *ReadCommand) Synopsis() string { 109 | return "Print the content of the scrapbox page" 110 | } 111 | 112 | func (c *ReadCommand) Help() string { 113 | helpText := `usage: scrapbox read [options...] PROJECT PAGE 114 | 115 | Options: 116 | --token, -t Scrapbox connect.sid used to access private project. 117 | --host, -h Scrapbox Host. By default, "https://scrapbox.io". 118 | --expire Local Cache Expiration. By default, 3600 seconds. 119 | --ua User Agent. By default, "ScrapboxGoClient/x.x.x" 120 | ` 121 | return strings.TrimSpace(helpText) 122 | } 123 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/read_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | _ "github.com/mitchellh/cli" 9 | ) 10 | 11 | func TestReadCommand__print_url_having_paren(t *testing.T) { 12 | 13 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 14 | meta := NewTestMeta(outStream, errStream, inStream) 15 | command := &ReadCommand{ 16 | Meta: *meta, 17 | } 18 | 19 | testAPIServer := RunAPIServer() 20 | defer testAPIServer.Close() 21 | 22 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "title having paren ( ) mark"} 23 | exitStatus := command.Run(args) 24 | 25 | if DebugMode { 26 | t.Log(outStream.String()) 27 | t.Log(errStream.String()) 28 | } 29 | 30 | if ExitCode(exitStatus) != ExitCodeOK { 31 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 32 | } 33 | 34 | expected := "title having paren ( ) mark\n#english #no-url #whitespace #no-slash #paren #no-plus #no-question" 35 | if !strings.Contains(outStream.String(), expected) { 36 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 37 | } 38 | } 39 | 40 | func TestReadCommand__print_url_having_plus(t *testing.T) { 41 | 42 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 43 | meta := NewTestMeta(outStream, errStream, inStream) 44 | command := &ReadCommand{ 45 | Meta: *meta, 46 | } 47 | 48 | testAPIServer := RunAPIServer() 49 | defer testAPIServer.Close() 50 | 51 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "title having plus + mark"} 52 | exitStatus := command.Run(args) 53 | 54 | if DebugMode { 55 | t.Log(outStream.String()) 56 | t.Log(errStream.String()) 57 | } 58 | 59 | if ExitCode(exitStatus) != ExitCodeOK { 60 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 61 | } 62 | 63 | expected := "title having plus + mark\n#english #no-url #whitespace #no-slash #no-paren #plus #no-question" 64 | if !strings.Contains(outStream.String(), expected) { 65 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 66 | } 67 | } 68 | 69 | func TestReadCommand__print_url_having_question(t *testing.T) { 70 | 71 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 72 | meta := NewTestMeta(outStream, errStream, inStream) 73 | command := &ReadCommand{ 74 | Meta: *meta, 75 | } 76 | 77 | testAPIServer := RunAPIServer() 78 | defer testAPIServer.Close() 79 | 80 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "title having question ? mark"} 81 | exitStatus := command.Run(args) 82 | 83 | if DebugMode { 84 | t.Log(outStream.String()) 85 | t.Log(errStream.String()) 86 | } 87 | 88 | if ExitCode(exitStatus) != ExitCodeOK { 89 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 90 | } 91 | 92 | expected := "title having question ? mark\n#english #no-url #whitespace #no-slash #no-paren #no-plus #question" 93 | if !strings.Contains(outStream.String(), expected) { 94 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 95 | } 96 | } 97 | 98 | func TestReadCommand__print_url_having_slash(t *testing.T) { 99 | 100 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 101 | meta := NewTestMeta(outStream, errStream, inStream) 102 | command := &ReadCommand{ 103 | Meta: *meta, 104 | } 105 | 106 | testAPIServer := RunAPIServer() 107 | defer testAPIServer.Close() 108 | 109 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "title having slash / mark"} 110 | exitStatus := command.Run(args) 111 | 112 | if DebugMode { 113 | t.Log(outStream.String()) 114 | t.Log(errStream.String()) 115 | } 116 | 117 | if ExitCode(exitStatus) != ExitCodeOK { 118 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 119 | } 120 | 121 | expected := "title having slash / mark\n#english #no-url #whitespace #slash #no-paren #no-plus #no-question" 122 | if !strings.Contains(outStream.String(), expected) { 123 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 124 | } 125 | } 126 | 127 | func TestReadCommand__print_url_having_whitespace(t *testing.T) { 128 | 129 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 130 | meta := NewTestMeta(outStream, errStream, inStream) 131 | command := &ReadCommand{ 132 | Meta: *meta, 133 | } 134 | 135 | testAPIServer := RunAPIServer() 136 | defer testAPIServer.Close() 137 | 138 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "title having whitespaces"} 139 | exitStatus := command.Run(args) 140 | 141 | if DebugMode { 142 | t.Log(outStream.String()) 143 | t.Log(errStream.String()) 144 | } 145 | 146 | if ExitCode(exitStatus) != ExitCodeOK { 147 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 148 | } 149 | 150 | expected := "title having whitespaces\n#english #no-url #whitespace #no-slash #no-paren #no-plus #no-question" 151 | if !strings.Contains(outStream.String(), expected) { 152 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 153 | } 154 | } 155 | 156 | func TestReadCommand__print_url_having_japanese(t *testing.T) { 157 | 158 | outStream, errStream, inStream := new(bytes.Buffer), new(bytes.Buffer), strings.NewReader("") 159 | meta := NewTestMeta(outStream, errStream, inStream) 160 | command := &ReadCommand{ 161 | Meta: *meta, 162 | } 163 | 164 | testAPIServer := RunAPIServer() 165 | defer testAPIServer.Close() 166 | 167 | args := []string{"--host", testAPIServer.URL, "go-scrapbox", "日本語タイトルのページ"} 168 | exitStatus := command.Run(args) 169 | 170 | if DebugMode { 171 | t.Log(outStream.String()) 172 | t.Log(errStream.String()) 173 | } 174 | 175 | if ExitCode(exitStatus) != ExitCodeOK { 176 | t.Fatalf("ExitStatus is %s, but want %s", ExitCode(exitStatus), ExitCodeOK) 177 | } 178 | 179 | expected := "日本語タイトルのページ\n#japanese #no-url #no-whitespace #no-slash #no-paren #no-plus #no-question" 180 | if !strings.Contains(outStream.String(), expected) { 181 | t.Fatalf("Output is %q, but want %q", outStream.String(), expected) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /cmd/scrapbox/command/version.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | type VersionCommand struct { 9 | Meta 10 | 11 | Name string 12 | Version string 13 | Revision string 14 | } 15 | 16 | func (c *VersionCommand) Run(args []string) int { 17 | var versionString bytes.Buffer 18 | 19 | fmt.Fprintf(&versionString, "%s version %s", c.Name, c.Version) 20 | if c.Revision != "" { 21 | fmt.Fprintf(&versionString, " (%s)", c.Revision) 22 | } 23 | 24 | c.Ui.Output(versionString.String()) 25 | return 0 26 | } 27 | 28 | func (c *VersionCommand) Synopsis() string { 29 | return fmt.Sprintf("Print %s version and quit", c.Name) 30 | } 31 | 32 | func (c *VersionCommand) Help() string { 33 | return "" 34 | } 35 | -------------------------------------------------------------------------------- /cmd/scrapbox/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mitchellh/cli" 5 | "github.com/ohtomi/scrapbox/cmd/scrapbox/command" 6 | ) 7 | 8 | func Commands(meta *command.Meta) map[string]cli.CommandFactory { 9 | return map[string]cli.CommandFactory{ 10 | "list": func() (cli.Command, error) { 11 | return &command.ListCommand{ 12 | Meta: *meta, 13 | }, nil 14 | }, 15 | "read": func() (cli.Command, error) { 16 | return &command.ReadCommand{ 17 | Meta: *meta, 18 | }, nil 19 | }, 20 | "link": func() (cli.Command, error) { 21 | return &command.LinkCommand{ 22 | Meta: *meta, 23 | }, nil 24 | }, 25 | "open": func() (cli.Command, error) { 26 | return &command.OpenCommand{ 27 | Meta: *meta, 28 | }, nil 29 | }, 30 | 31 | "version": func() (cli.Command, error) { 32 | return &command.VersionCommand{ 33 | Meta: *meta, 34 | Version: Version, 35 | Revision: GitCommit, 36 | Name: Name, 37 | }, nil 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /cmd/scrapbox/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func main() { 8 | os.Exit(Run(os.Args[1:])) 9 | } 10 | -------------------------------------------------------------------------------- /cmd/scrapbox/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const Name string = "scrapbox" 4 | const Version string = "0.2.3" 5 | 6 | // GitCommit describes latest commit hash. 7 | // This value is extracted by git command when building. 8 | // To set this from outside, use go build -ldflags "-X main.GitCommit \"$(COMMIT)\"" 9 | var GitCommit string 10 | --------------------------------------------------------------------------------