├── .gitignore ├── test └── no_op_editor.sh ├── go.mod ├── scripts └── bindata.sh ├── cmd └── gurnel │ └── main.go ├── .github ├── workflows │ ├── main.yml │ └── release.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── README.md ├── Makefile ├── LICENSE ├── go.sum └── internal ├── test └── test.go └── gurnel ├── config.go ├── beeminder.go ├── stats_test.go ├── command_test.go ├── start_test.go ├── config_test.go ├── command.go ├── journalentry.go ├── start.go ├── stats.go └── beeminder_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | .envrc 3 | -------------------------------------------------------------------------------- /test/no_op_editor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | sleep 1 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mikeraimondi/gurnel 2 | 3 | go 1.13 4 | 5 | require github.com/mikeraimondi/frontmatter/v2 v2.0.1 6 | -------------------------------------------------------------------------------- /scripts/bindata.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | go-bindata -o internal/bindata/bindata.go -pkg bindata -prefix assets assets 8 | -------------------------------------------------------------------------------- /cmd/gurnel/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/mikeraimondi/gurnel/internal/gurnel" 8 | ) 9 | 10 | func main() { 11 | var conf gurnel.Config 12 | if err := conf.Load("gurnel", "gurnel.json"); err != nil { 13 | fmt.Fprintf(os.Stderr, "Error loading config: %s\n", err) 14 | os.Exit(2) 15 | } 16 | 17 | if err := gurnel.Do(os.Stdin, os.Stdout, &conf); err != nil { 18 | fmt.Fprintf(os.Stderr, "Error: %s\n", err) 19 | os.Exit(2) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | types: [assigned, opened, synchronize, reopened] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-18.04 12 | container: golang:1.13.1 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Run Tests 16 | run: make test 17 | - name: Build 18 | run: make build 19 | lint: 20 | runs-on: ubuntu-18.04 21 | container: golangci/golangci-lint:v1.21 22 | steps: 23 | - uses: actions/checkout@v1 24 | - name: Run Linters 25 | run: make lint 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-18.04 11 | container: golang:1.13.1 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Run Tests 15 | run: make test 16 | - name: Go Tidy 17 | run: make tidy 18 | - name: Run GoReleaser 19 | uses: goreleaser/goreleaser-action@v1 20 | with: 21 | version: v0.119.0 22 | args: release --config=build/package/.goreleaser.yml 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_PERSONAL_ACCESS_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: mikeraimondi 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Run command '...' 16 | 2. Enter data '....' 17 | 4. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Desktop (please complete the following information):** 26 | - OS: [e.g. iOS] 27 | - Version [e.g. 22] 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gurnel 2 | 3 | ![Build Status](https://github.com/mikeraimondi/gurnel/workflows/CI/badge.svg) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/mikeraimondi/gurnel)](https://goreportcard.com/report/github.com/mikeraimondi/gurnel) 5 | 6 | Daily journaling for hackers 7 | 8 | ## Features 9 | 10 | * ### BYOE (Bring your own editor) 11 | 12 | Use the editor you're already comfortable with. Gurnel speaks Markdown for your entries. 13 | 14 | * ### Works with the tools of the trade 15 | 16 | Gurnel lives on the command line. By default, Gurnel uses Git, keeping your journal version-controlled and backed up. 17 | 18 | * ### Blog-aware 19 | 20 | Works with Jekyll right out of the box, no configuration required. 21 | 22 | ## Getting Started 23 | 24 | ### Install (Homebrew) 25 | 26 | ```sh 27 | brew tap mikeraimondi/tap 28 | brew install gurnel 29 | ``` 30 | 31 | ### Usage 32 | 33 | ```sh 34 | gurnel 35 | ``` 36 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: check 2 | check: tidy test lint 3 | 4 | .PHONY: build 5 | build: pre 6 | mkdir -p dist 7 | go build -o dist/gurnel cmd/gurnel/main.go 8 | 9 | .PHONY: pre 10 | pre: 11 | go mod download 12 | 13 | .PHONY: lint 14 | lint: 15 | golangci-lint run -c build/.golangci.yml 16 | 17 | .PHONY: test 18 | test: 19 | go test ./internal/gurnel/... -v -race 20 | 21 | .PHONY: clean 22 | clean: 23 | rm -rf dist 24 | 25 | .PHONY: tidy 26 | tidy: 27 | go mod tidy 28 | 29 | .PHONY: release 30 | release: check clean 31 | @:$(call check_defined, VERSION, version to release) 32 | git tag $(VERSION) 33 | goreleaser release --config=build/package/.goreleaser.yml 34 | 35 | .PHONY: publish 36 | publish: pre release clean 37 | 38 | check_defined = \ 39 | $(strip $(foreach 1,$1, \ 40 | $(call __check_defined,$1,$(strip $(value 2))))) 41 | __check_defined = \ 42 | $(if $(value $1),, \ 43 | $(error Undefined $1$(if $2, ($2)))) 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Michael Raimondi 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 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 2 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 3 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 4 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 5 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 6 | github.com/mikeraimondi/frontmatter/v2 v2.0.1 h1:IdqXJSqj7PXos2PfW219FBU3q0Oc8vVWhpU4hc311Tc= 7 | github.com/mikeraimondi/frontmatter/v2 v2.0.1/go.mod h1:8fo2D92nQbz/afceRhd1OCtx0i0WhXpB+QPnfAfVuFs= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 11 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 12 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 13 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 14 | -------------------------------------------------------------------------------- /internal/test/test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strings" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func SetupTestDir(t *testing.T) (dir string, cleanup func()) { 12 | dir, err := ioutil.TempDir("", "gurnel_test") 13 | if err != nil { 14 | t.Fatalf("creating test dir: %s", err) 15 | } 16 | if err = os.Chdir(dir); err != nil { 17 | t.Fatalf("changing to test dir: %s", err) 18 | } 19 | 20 | cleanup = func() { 21 | if err := os.RemoveAll(dir); err != nil { 22 | t.Fatalf("removing test dir: %s", err) 23 | } 24 | } 25 | return dir, cleanup 26 | } 27 | 28 | func WriteFile(t *testing.T, path, contents string) func() { 29 | f, err := os.OpenFile(path, os.O_RDWR|os.O_APPEND|os.O_SYNC, 0600) 30 | if err != nil { 31 | t.Fatalf("opening file: %s", err) 32 | } 33 | 34 | if _, err = f.WriteString(contents); err != nil { 35 | t.Fatalf("writing file: %s", err) 36 | } 37 | 38 | return func() { 39 | if err := f.Close(); err != nil { 40 | t.Fatalf("closing file: %s", err) 41 | } 42 | } 43 | } 44 | 45 | func CheckErr(t *testing.T, expected string, actual error) { 46 | if expected == "" { 47 | if actual != nil { 48 | t.Fatalf("expected no error. got %s", actual) 49 | } 50 | } else { 51 | if !strings.Contains(actual.Error(), expected) { 52 | t.Fatalf("expected an error containing %s. got %s", expected, actual) 53 | } 54 | } 55 | } 56 | 57 | func CheckOutput(t *testing.T, expected []string, actual string) { 58 | for _, expectedOut := range expected { 59 | if !strings.Contains(strings.ToLower(actual), strings.ToLower(expectedOut)) { 60 | t.Fatalf("expected output containing %s. got %q", expectedOut, actual) 61 | } 62 | } 63 | } 64 | 65 | type FixedClock struct{} 66 | 67 | func (c *FixedClock) Now() time.Time { 68 | return time.Date(2008, time.April, 12, 16, 0, 0, 0, time.UTC) 69 | } 70 | -------------------------------------------------------------------------------- /internal/gurnel/config.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | ) 11 | 12 | type dirProvider interface { 13 | getConfigDir() (string, error) 14 | } 15 | 16 | type clock interface { 17 | Now() time.Time 18 | } 19 | 20 | type Config struct { 21 | BeeminderEnabled bool 22 | BeeminderUser string 23 | BeeminderTokenFile string 24 | BeeminderGoal string 25 | MinimumWordCount int 26 | Editor string 27 | dp dirProvider 28 | subcommands []subcommand 29 | clock clock 30 | } 31 | 32 | type defaultDirProvider struct{} 33 | 34 | func (dp *defaultDirProvider) getConfigDir() (string, error) { 35 | return os.UserConfigDir() 36 | } 37 | 38 | type defaultClock struct{} 39 | 40 | func (c *defaultClock) Now() time.Time { return time.Now() } 41 | 42 | func (c *Config) Load(path ...string) error { 43 | c.setupSubcommands() 44 | c.MinimumWordCount = 750 45 | 46 | if c.clock == nil { 47 | c.clock = &defaultClock{} 48 | } 49 | 50 | dir, err := c.getConfigDir() 51 | if err != nil { 52 | return fmt.Errorf("getting config directory: %w", err) 53 | } 54 | 55 | path = append([]string{dir}, path...) 56 | configData, err := ioutil.ReadFile(filepath.Join(path...)) 57 | if err != nil { 58 | if os.IsNotExist(err) { 59 | return nil 60 | } 61 | return fmt.Errorf("opening file: %w", err) 62 | } 63 | return json.Unmarshal(configData, c) 64 | } 65 | 66 | func (c *Config) getConfigDir() (string, error) { 67 | if c.dp == nil { 68 | c.dp = &defaultDirProvider{} 69 | } 70 | 71 | dir, err := c.dp.getConfigDir() 72 | if err != nil { 73 | return "", err 74 | } 75 | return dir, nil 76 | } 77 | 78 | func (c *Config) setupSubcommands() { 79 | if len(c.subcommands) == 0 { 80 | c.subcommands = []subcommand{ 81 | &startCmd{}, 82 | &statsCmd{}, 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/gurnel/beeminder.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | type beeminderClient struct { 14 | Token []byte 15 | User string 16 | c http.Client 17 | serverURL string 18 | } 19 | 20 | func newBeeminderClient(user string, token []byte) (*beeminderClient, error) { 21 | if user == "" { 22 | return nil, fmt.Errorf("user must not be blank") 23 | } 24 | if token != nil && len(token) == 0 { 25 | return nil, fmt.Errorf("token must not be blank") 26 | } 27 | 28 | return &beeminderClient{ 29 | Token: bytes.TrimSpace(token), 30 | User: user, 31 | serverURL: "https://www.beeminder.com", 32 | }, nil 33 | } 34 | 35 | func (client *beeminderClient) postDatapoint( 36 | goal string, 37 | count int, 38 | t time.Time, 39 | ) error { 40 | if goal == "" { 41 | return fmt.Errorf("goal must not be blank") 42 | } 43 | if count < 0 { 44 | return fmt.Errorf("count must be nonnegative") 45 | } 46 | 47 | postURL, err := url.Parse(client.serverURL) 48 | if err != nil { 49 | return fmt.Errorf("internal URL error: %w", err) 50 | } 51 | postURL.Path = fmt.Sprintf("api/v1/users/%s/goals/%s/datapoints.json", 52 | client.User, goal) 53 | 54 | v := url.Values{} 55 | v.Set("auth_token", string(client.Token)) 56 | v.Set("value", strconv.Itoa(count)) 57 | v.Set("comment", "via Gurnel at "+t.Format("15:04:05 MST")) 58 | 59 | resp, err := client.c.PostForm(postURL.String(), v) 60 | if err != nil { 61 | return fmt.Errorf("making request: %w", err) 62 | } 63 | defer resp.Body.Close() 64 | 65 | if resp.StatusCode < 200 || resp.StatusCode > 299 { 66 | respData, err := ioutil.ReadAll(resp.Body) 67 | if err != nil || len(respData) == 0 { 68 | respData = []byte("no further info") 69 | } 70 | return fmt.Errorf("server returned %s: %s", resp.Status, respData) 71 | } 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /internal/gurnel/stats_test.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | 8 | "github.com/mikeraimondi/gurnel/internal/test" 9 | ) 10 | 11 | func TestStats(t *testing.T) { 12 | const noentry = "NOENTRY" 13 | 14 | testCases := []struct { 15 | desc string 16 | entryWords []string 17 | out []string 18 | }{ 19 | { 20 | desc: "with no entries", 21 | entryWords: []string{}, 22 | out: []string{"no entries found"}, 23 | }, 24 | { 25 | desc: "with an empty entry", 26 | entryWords: []string{""}, 27 | out: []string{ 28 | "word count: 0", 29 | `100.00% of days`, 30 | }, 31 | }, 32 | { 33 | desc: "with one populated entry", 34 | entryWords: []string{"foo bar baz"}, 35 | out: []string{ 36 | "word count: 3", 37 | `100.00% of days`, 38 | }, 39 | }, 40 | { 41 | desc: "with two populated entries", 42 | entryWords: []string{"foo bar", "baz"}, 43 | out: []string{ 44 | "word count: 3", 45 | `100.00% of days`, 46 | }, 47 | }, 48 | { 49 | desc: "with one entry and one day missed", 50 | entryWords: []string{noentry, "baz"}, 51 | out: []string{ 52 | "word count: 1", 53 | `50.00% of days`, 54 | }, 55 | }, 56 | } 57 | for _, tC := range testCases { 58 | t.Run(tC.desc, func(t *testing.T) { 59 | dir, cleanup := test.SetupTestDir(t) 60 | defer cleanup() 61 | testClock := test.FixedClock{} 62 | 63 | for i, words := range tC.entryWords { 64 | if words == noentry { 65 | continue 66 | } 67 | 68 | entryTime := testClock.Now().Add(-time.Duration(24*i) * time.Hour) 69 | t.Log(entryTime) 70 | entry, err := NewEntry(dir, entryTime) 71 | if err != nil { 72 | t.Fatalf("saving entry: %s", err) 73 | } 74 | 75 | defer test.WriteFile(t, entry.Path, words)() 76 | } 77 | 78 | cmd := statsCmd{} 79 | out := bytes.Buffer{} 80 | conf := Config{clock: &testClock} 81 | if err := cmd.Run(&bytes.Buffer{}, &out, []string{}, &conf); err != nil { 82 | t.Fatalf("expected no error. got %s", err) 83 | } 84 | 85 | test.CheckOutput(t, tC.out, out.String()) 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /internal/gurnel/command_test.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "io" 7 | "testing" 8 | 9 | "github.com/mikeraimondi/gurnel/internal/test" 10 | ) 11 | 12 | type testCmd struct { 13 | runFn func(io.Reader, io.Writer, []string, *Config) error 14 | helpFn func() string 15 | } 16 | 17 | func (t *testCmd) Name() string { return "testing" } 18 | func (t *testCmd) ShortHelp() string { return "" } 19 | func (t *testCmd) Flag() flag.FlagSet { return flag.FlagSet{} } 20 | func (t *testCmd) LongHelp() string { return t.helpFn() } 21 | 22 | func (t *testCmd) Run(r io.Reader, w io.Writer, args []string, conf *Config) error { 23 | return t.runFn(r, w, args, conf) 24 | } 25 | 26 | func TestRun(t *testing.T) { 27 | testCases := []struct { 28 | desc string 29 | args []string 30 | conf Config 31 | err string 32 | out []string 33 | }{ 34 | { 35 | desc: "with no subcommand", 36 | args: []string{}, 37 | conf: Config{}, 38 | err: "no subcommand", 39 | out: []string{}, 40 | }, 41 | { 42 | desc: "with a nonexistent subcommand", 43 | args: []string{"foobar"}, 44 | conf: Config{}, 45 | err: "unknown subcommand", 46 | out: []string{}, 47 | }, 48 | { 49 | desc: "with the help subcommand", 50 | args: []string{"help"}, 51 | conf: Config{}, 52 | err: "", 53 | out: []string{ 54 | "usage", 55 | "commands are", 56 | }, 57 | }, 58 | { 59 | desc: "when invoking a subcommand", 60 | args: []string{"testing"}, 61 | conf: Config{ 62 | subcommands: []subcommand{ 63 | &testCmd{ 64 | runFn: func(r io.Reader, w io.Writer, _ []string, _ *Config) error { 65 | io.WriteString(w, "foo bar baz") 66 | return nil 67 | }, 68 | }, 69 | }, 70 | }, 71 | err: "", 72 | out: []string{"foo bar baz"}, 73 | }, 74 | { 75 | desc: "when invoking a subcommand's help", 76 | args: []string{"help", "testing"}, 77 | conf: Config{ 78 | subcommands: []subcommand{ 79 | &testCmd{ 80 | helpFn: func() string { 81 | return "help for testing" 82 | }, 83 | }, 84 | }, 85 | }, 86 | err: "", 87 | out: []string{"help for testing"}, 88 | }, 89 | } 90 | for _, tC := range testCases { 91 | t.Run(tC.desc, func(t *testing.T) { 92 | out := bytes.Buffer{} 93 | err := run(&bytes.Buffer{}, &out, tC.args, &tC.conf) 94 | 95 | test.CheckErr(t, tC.err, err) 96 | test.CheckOutput(t, tC.out, out.String()) 97 | }) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /internal/gurnel/start_test.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/mikeraimondi/gurnel/internal/test" 14 | ) 15 | 16 | type testReader struct { 17 | t *testing.T 18 | input []string 19 | i int 20 | done bool 21 | } 22 | 23 | func (tr *testReader) Read(p []byte) (int, error) { 24 | if tr.done || tr.i >= len(tr.input) { 25 | tr.done = false 26 | return 0, io.EOF 27 | } 28 | 29 | n, err := strings.NewReader(tr.input[tr.i]).Read(p) 30 | tr.i++ 31 | tr.done = true 32 | return n, err 33 | } 34 | 35 | func TestStart(t *testing.T) { 36 | testCases := []struct { 37 | desc string 38 | input string 39 | conf Config 40 | err string 41 | out []string 42 | }{ 43 | { 44 | desc: "with input exceeding the minimum length", 45 | input: "foo bar baz", 46 | conf: Config{ 47 | MinimumWordCount: 3, 48 | }, 49 | out: []string{"begin entry preview", "foo bar baz", "exiting"}, 50 | }, 51 | { 52 | desc: "with input less than the minimum length", 53 | input: "foo bar", 54 | conf: Config{ 55 | MinimumWordCount: 3, 56 | }, 57 | out: []string{"2 words", "Insufficient word count"}, 58 | }, 59 | } 60 | for _, tC := range testCases { 61 | t.Run(tC.desc, func(t *testing.T) { 62 | tC.conf.BeeminderEnabled = false 63 | _, filename, _, _ := runtime.Caller(0) 64 | dir := filepath.Dir(filepath.Dir(filepath.Dir(filename))) 65 | tC.conf.Editor = filepath.Join(dir, "test", "no_op_editor.sh") 66 | tC.conf.clock = &test.FixedClock{} 67 | 68 | dir, cleanup := test.SetupTestDir(t) 69 | defer cleanup() 70 | 71 | cmd := startCmd{} 72 | inReader := testReader{ 73 | t: t, 74 | input: []string{"1\n", "1\n", "1\n", "1\n", "n\n"}, 75 | } 76 | out := bytes.Buffer{} 77 | errC := make(chan error) 78 | 79 | go func() { 80 | errC <- cmd.Run(&inReader, &out, []string{}, &tC.conf) 81 | }() 82 | 83 | files, _ := ioutil.ReadDir(dir) 84 | var file os.FileInfo 85 | for { 86 | if len(files) == 1 { 87 | file = files[0] 88 | break 89 | } else if len(files) > 1 { 90 | t.Fatalf("expected 1 file in directory. got %d", len(files)) 91 | } 92 | files, _ = ioutil.ReadDir(dir) 93 | } 94 | 95 | defer test.WriteFile(t, filepath.Join(dir, file.Name()), tC.input)() 96 | 97 | err := <-errC 98 | test.CheckErr(t, tC.err, err) 99 | 100 | test.CheckOutput(t, tC.out, out.String()) 101 | }) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /internal/gurnel/config_test.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "path/filepath" 7 | "reflect" 8 | "testing" 9 | ) 10 | 11 | type testDirProvider struct { 12 | configDir string 13 | } 14 | 15 | func (tdp *testDirProvider) getConfigDir() (string, error) { 16 | return tdp.configDir, nil 17 | } 18 | 19 | func TestLoadConfig(t *testing.T) { 20 | testCases := []struct { 21 | desc string 22 | preLoadConfFn func(string) Config 23 | postLoadConf Config 24 | }{ 25 | { 26 | desc: "with an existing valid file", 27 | preLoadConfFn: func(dir string) Config { 28 | return Config{ 29 | dp: &testDirProvider{ 30 | configDir: dir, 31 | }, 32 | } 33 | }, 34 | postLoadConf: Config{ 35 | BeeminderEnabled: true, 36 | }, 37 | }, 38 | { 39 | desc: "with no existing file", 40 | preLoadConfFn: func(_ string) Config { 41 | return Config{ 42 | dp: &testDirProvider{ 43 | configDir: "/not/a/real/directory942310", 44 | }, 45 | } 46 | }, 47 | postLoadConf: Config{ 48 | BeeminderEnabled: false, 49 | }, 50 | }, 51 | } 52 | for _, tC := range testCases { 53 | t.Run(tC.desc, func(t *testing.T) { 54 | file, err := ioutil.TempFile("", "testconf") 55 | if err != nil { 56 | t.Fatalf("creating temp file: %s", err) 57 | } 58 | defer file.Close() 59 | if err := json.NewEncoder(file).Encode(&tC.postLoadConf); err != nil { 60 | t.Fatalf("writing to temp file: %s", err) 61 | } 62 | 63 | c := tC.preLoadConfFn(filepath.Dir(file.Name())) 64 | if err := c.Load(filepath.Base(file.Name())); err != nil { 65 | t.Fatalf("expected no error loading config. got %s", err) 66 | } 67 | 68 | if tC.postLoadConf.BeeminderEnabled != c.BeeminderEnabled { 69 | t.Fatalf("wrong value for BeeminderEnabled. expected %t. got %t", 70 | tC.postLoadConf.BeeminderEnabled, c.BeeminderEnabled) 71 | } 72 | }) 73 | } 74 | } 75 | 76 | func TestGetConfigDir(t *testing.T) { 77 | tdp := &testDirProvider{ 78 | configDir: filepath.Dir("/tmp"), 79 | } 80 | testCases := []struct { 81 | desc string 82 | pre Config 83 | post Config 84 | }{ 85 | { 86 | desc: "with no provider", 87 | pre: Config{}, 88 | post: Config{ 89 | dp: &defaultDirProvider{}, 90 | }, 91 | }, 92 | { 93 | desc: "with a provider", 94 | pre: Config{ 95 | dp: tdp, 96 | }, 97 | post: Config{ 98 | dp: tdp, 99 | }, 100 | }, 101 | } 102 | for _, tC := range testCases { 103 | t.Run(tC.desc, func(t *testing.T) { 104 | tC.pre.getConfigDir() 105 | if !reflect.DeepEqual(tC.pre, tC.post) { 106 | t.Fatalf("wrong config after load.\nexpected: %+v\ngot: %+v", 107 | tC.post, tC.pre) 108 | } 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/gurnel/command.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "strings" 10 | "text/template" 11 | ) 12 | 13 | type subcommand interface { 14 | Run(io.Reader, io.Writer, []string, *Config) error 15 | Name() string 16 | ShortHelp() string 17 | LongHelp() string 18 | Flag() flag.FlagSet 19 | } 20 | 21 | func Do(r io.Reader, w io.Writer, conf *Config) error { 22 | flag.Usage = func() { 23 | printUsage(w, conf.subcommands) 24 | } 25 | flag.Parse() 26 | 27 | args := flag.Args() 28 | return run(r, w, args, conf) 29 | } 30 | 31 | func run(r io.Reader, w io.Writer, args []string, conf *Config) error { 32 | if len(args) < 1 { 33 | printUsage(w, conf.subcommands) 34 | return fmt.Errorf("no subcommand supplied. Did you mean 'gurnel start'?") 35 | } 36 | 37 | if args[0] == "help" { 38 | return help(w, conf.subcommands, args[1:]) 39 | } 40 | 41 | for _, cmd := range conf.subcommands { 42 | if cmd.Name() != args[0] { 43 | continue 44 | } 45 | 46 | flagSet := cmd.Flag() 47 | name := cmd.Name() 48 | flagSet.Usage = func() { 49 | fmt.Fprintf(w, "usage: %s\n\n", name) 50 | } 51 | if err := flagSet.Parse(args[1:]); err != nil { 52 | return fmt.Errorf("parsing flags: %w", err) 53 | } 54 | args = flagSet.Args() 55 | if err := cmd.Run(r, w, args, conf); err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | return fmt.Errorf( 62 | "unknown subcommand %q\n Run 'gurnel help' for usage", 63 | args[0], 64 | ) 65 | } 66 | 67 | func printUsage(w io.Writer, commands []subcommand) { 68 | bw := bufio.NewWriter(w) 69 | usageTemplate := `Gurnel is a simple journal manager. 70 | 71 | Usage: 72 | 73 | gurnel command [arguments] 74 | 75 | The commands are: 76 | {{range .}} 77 | {{.Name | printf "%-11s"}} {{.ShortHelp}}{{end}} 78 | Use "gurnel help [command]" for more information about a command. 79 | ` 80 | tmpl(bw, usageTemplate, commands) 81 | bw.Flush() 82 | } 83 | 84 | func help(w io.Writer, commands []subcommand, args []string) error { 85 | if len(args) == 0 { 86 | printUsage(w, commands) 87 | return nil 88 | } 89 | if len(args) != 1 { 90 | return errors.New("too many arguments given") 91 | } 92 | 93 | arg := args[0] 94 | 95 | helpTemplate := "usage: gurnel {{.Name}}\n{{.LongHelp | trim}}" 96 | for _, cmd := range commands { 97 | if cmd.Name() == arg { 98 | tmpl(w, helpTemplate, cmd) 99 | return nil 100 | } 101 | } 102 | 103 | return errors.New("unknown help topic %#q. Run 'gurnel help'") 104 | } 105 | 106 | func tmpl(w io.Writer, text string, data interface{}) { 107 | t := template.New("top") 108 | t.Funcs(template.FuncMap{"trim": strings.TrimSpace}) 109 | template.Must(t.Parse(text)) 110 | if err := t.Execute(w, data); err != nil { 111 | panic(err) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /internal/gurnel/journalentry.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "time" 12 | 13 | "github.com/mikeraimondi/frontmatter/v2" 14 | ) 15 | 16 | const ( 17 | entryFormat = "2006-01-02-Journal-Entry-for-Jan-2" + ".md" 18 | entryRegex = `\d{4}-\d{2}-\d{2}-Journal-Entry-for-\D{3}-\d{1,2}` + ".md" 19 | wordRegex = `\S+` 20 | ) 21 | 22 | // Entry represents a single journal entry. 23 | type Entry struct { 24 | Seconds uint16 25 | LowMood uint8 26 | HighMood uint8 27 | AverageMood uint8 28 | Body []byte `yaml:"-"` 29 | Path string `yaml:"-"` 30 | ModTime time.Time `yaml:"-"` 31 | } 32 | 33 | // NewEntry reads the directory named by dir and either returns an existing 34 | // Entry in that directory, or creates a new one if none exist. 35 | func NewEntry(dir string, t time.Time) (*Entry, error) { 36 | info, err := os.Stat(dir) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if !info.IsDir() { 41 | return nil, errors.New("must be a directory") 42 | } 43 | p := &Entry{Path: dir + string(filepath.Separator) + t.Format(entryFormat)} 44 | _, err = os.Stat(p.Path) 45 | if os.IsNotExist(err) { 46 | p.ModTime = t 47 | err = p.Save() 48 | } else if err == nil { 49 | _, err = p.Load() 50 | } 51 | return p, err 52 | } 53 | 54 | // Load reads the file named by p.Path and populates the Entry 55 | func (p *Entry) Load() (modified bool, err error) { 56 | f, err := os.Open(p.Path) 57 | if err != nil { 58 | return false, err 59 | } 60 | defer f.Close() 61 | data, err := ioutil.ReadAll(f) 62 | if err != nil { 63 | return false, err 64 | } 65 | info, err := f.Stat() 66 | if err != nil { 67 | return false, err 68 | } 69 | modified = info.ModTime() != p.ModTime 70 | p.ModTime = info.ModTime() 71 | p.Body, err = frontmatter.Unmarshal(data, p) 72 | return modified, err 73 | } 74 | 75 | // Save writes the Entry to the file named by p.Path 76 | func (p *Entry) Save() (err error) { 77 | fm, err := frontmatter.Marshal(&p) 78 | if err != nil { 79 | return err 80 | } 81 | var perm os.FileMode = 0666 82 | if err = ioutil.WriteFile(p.Path, append(fm, p.Body...), perm); err != nil { 83 | fmt.Println("Dump:") 84 | fmt.Println(string(fm)) 85 | fmt.Println(string(p.Body)) 86 | } 87 | return err 88 | } 89 | 90 | // Date returns the date of the entry 91 | func (p *Entry) Date() (time.Time, error) { 92 | return time.Parse(entryFormat, filepath.Base(p.Path)) 93 | } 94 | 95 | // Words returns the number of words in p.body 96 | func (p *Entry) Words() [][]byte { 97 | return regexp.MustCompile(wordRegex).FindAll(p.Body, -1) 98 | } 99 | 100 | // PromptForMetadata prints questions to w and sets the values of p based on values read from reader. 101 | func (p *Entry) PromptForMetadata(reader io.Reader, w io.Writer) error { 102 | for prompt, setter := range p.prompts() { 103 | var input uint8 104 | for input == 0 { 105 | fmt.Fprint(w, prompt) 106 | if _, err := fmt.Fscanf(reader, "%d\n", &input); err != nil { 107 | fmt.Fprintln(w, "Unrecognized input") 108 | continue 109 | } 110 | setter(input) 111 | } 112 | } 113 | return nil 114 | } 115 | 116 | // IsEntry returns true if path refers to a file with an Entry-like name, false otherwise. 117 | func IsEntry(path string) bool { 118 | return regexp.MustCompile(entryRegex).MatchString(path) 119 | } 120 | 121 | func (p *Entry) setLowMood(rating uint8) { 122 | p.LowMood = rating 123 | } 124 | 125 | func (p *Entry) setHighMood(rating uint8) { 126 | p.HighMood = rating 127 | } 128 | 129 | func (p *Entry) setAvgMood(rating uint8) { 130 | p.AverageMood = rating 131 | } 132 | 133 | func (p *Entry) prompts() (pr map[string]func(uint8)) { 134 | pr = make(map[string]func(uint8)) 135 | if p.HighMood == 0 { 136 | pr["High mood for the day? (1-5) "] = p.setHighMood 137 | } 138 | if p.LowMood == 0 { 139 | pr["Low mood for the day? (1-5) "] = p.setLowMood 140 | } 141 | if p.AverageMood == 0 { 142 | pr["Average mood for the day? (1-5) "] = p.setAvgMood 143 | } 144 | return pr 145 | } 146 | -------------------------------------------------------------------------------- /internal/gurnel/start.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | type startCmd struct{} 18 | 19 | func (*startCmd) Name() string { return "start" } 20 | func (*startCmd) ShortHelp() string { return "Begin journal entry for today" } 21 | func (*startCmd) Flag() flag.FlagSet { return flag.FlagSet{} } 22 | 23 | func (*startCmd) LongHelp() string { 24 | return "If you don't like the editor this uses, set $EDITOR to something else." 25 | } 26 | 27 | func (*startCmd) Run(r io.Reader, w io.Writer, args []string, conf *Config) error { 28 | // Create or open entry at working directory 29 | wd, err := os.Getwd() 30 | if err != nil { 31 | return errors.New("getting working directory " + err.Error()) 32 | } 33 | wd, err = filepath.EvalSymlinks(wd) 34 | if err != nil { 35 | return errors.New("evaluating symlinks " + err.Error()) 36 | } 37 | p, err := NewEntry(wd, conf.clock.Now()) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | // Open file for editing 43 | editor := conf.Editor 44 | if editor == "" { 45 | editor = os.Getenv("EDITOR") 46 | } 47 | editCmd := strings.Split(editor, " ") 48 | editCmd = append(editCmd, p.Path) 49 | startTime := conf.clock.Now() 50 | // #nosec 51 | cmd := exec.Command(editCmd[0], editCmd[1:]...) 52 | cmd.Stdin = r 53 | cmd.Stdout = w 54 | cmd.Stderr = w 55 | if err = cmd.Run(); err != nil { 56 | return errors.New("opening editor " + err.Error()) 57 | } 58 | elapsed := time.Since(startTime) 59 | 60 | // Abort if file is untouched 61 | if modified, modErr := p.Load(); modErr != nil { 62 | return errors.New("loading file " + modErr.Error()) 63 | } else if !modified { 64 | fmt.Fprintln(w, "Aborting due to unchanged file") 65 | return nil 66 | } 67 | 68 | // Check word count before proceeding to metadata collection 69 | wordCount := len(p.Words()) 70 | fmt.Fprintf(w, "%v words in entry\n", wordCount) 71 | if wordCount < conf.MinimumWordCount { 72 | fmt.Fprintf(w, "Minimum word count is %v. Insufficient word count to commit\n", conf.MinimumWordCount) 73 | } else { 74 | fmt.Fprintf(w, "---begin entry preview---\n%v\n--end entry preview---\n", string(p.Body)) 75 | 76 | // Collect & set metadata 77 | if promptErr := p.PromptForMetadata(r, w); promptErr != nil { 78 | return errors.New("collecting metadata " + promptErr.Error()) 79 | } 80 | } 81 | p.Seconds += uint16(elapsed.Seconds()) 82 | if saveErr := p.Save(); saveErr != nil { 83 | return errors.New("saving file " + saveErr.Error()) 84 | } 85 | 86 | if wordCount < conf.MinimumWordCount { 87 | return nil 88 | } 89 | 90 | // Prompt for commit 91 | fmt.Fprint(w, "Commit? (y/n) ") 92 | scanner := bufio.NewScanner(r) 93 | for scanner.Scan() { 94 | input := scanner.Text() 95 | input = strings.TrimSpace(input) 96 | switch input { 97 | case "y": 98 | // Commit the changes 99 | // #nosec 100 | err = exec.Command("git", "add", p.Path).Run() 101 | if err != nil { 102 | return errors.New("adding file to version control " + err.Error()) 103 | } 104 | err = exec.Command("git", "commit", "-m", "Done").Run() 105 | if err != nil { 106 | return errors.New("committing file " + err.Error()) 107 | } 108 | fmt.Fprintln(w, "Committed") 109 | 110 | token, err := ioutil.ReadFile(conf.BeeminderTokenFile) 111 | if err != nil { 112 | return fmt.Errorf("reading token: %w", err) 113 | } 114 | client, err := newBeeminderClient(conf.BeeminderUser, token) 115 | if err != nil { 116 | return fmt.Errorf("setting up client: %w", err) 117 | } 118 | err = client.postDatapoint(conf.BeeminderGoal, wordCount, conf.clock.Now()) 119 | if err != nil { 120 | return fmt.Errorf("posting to Beeminder: %w", err) 121 | } 122 | 123 | return nil 124 | case "n": 125 | fmt.Fprintln(w, "Exiting") 126 | return nil 127 | default: 128 | fmt.Fprintln(w, "Unrecognized input") 129 | fmt.Fprint(w, "Commit? (y/n) ") 130 | } 131 | } 132 | return scanner.Err() 133 | } 134 | -------------------------------------------------------------------------------- /internal/gurnel/stats.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "errors" 7 | "flag" 8 | "fmt" 9 | "io" 10 | "math" 11 | "os" 12 | "path/filepath" 13 | "sort" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | "text/tabwriter" 18 | "time" 19 | 20 | "github.com/mikeraimondi/gurnel/internal/bindata" 21 | ) 22 | 23 | type statsCmd struct{} 24 | 25 | func (*statsCmd) Name() string { return "stats" } 26 | func (*statsCmd) ShortHelp() string { return "View journal statistics" } 27 | func (*statsCmd) Flag() flag.FlagSet { return flag.FlagSet{} } 28 | 29 | func (*statsCmd) LongHelp() string { 30 | return "Unusually frequent/infrequent words are relative " + 31 | "to a Google Ngram corpus of scanned literature" 32 | } 33 | 34 | func (*statsCmd) Run(_ io.Reader, w io.Writer, args []string, conf *Config) error { 35 | refFreqsCSV, err := bindata.Asset("eng-us-10000-1960.csv") 36 | if err != nil { 37 | return fmt.Errorf("loading asset: %w", err) 38 | } 39 | csvReader := csv.NewReader(bytes.NewReader(refFreqsCSV)) 40 | csvReader.FieldsPerRecord = 2 41 | 42 | refFreqs := make(map[string]float64) 43 | for { 44 | record, csvErr := csvReader.Read() 45 | if csvErr == io.EOF { 46 | break 47 | } 48 | if csvErr != nil { 49 | return csvErr 50 | } 51 | 52 | if record[0] == "" || record[1] == "" { 53 | return fmt.Errorf("invalid input") 54 | } 55 | freq, csvErr := strconv.ParseFloat(record[1], 64) 56 | if csvErr != nil { 57 | return fmt.Errorf("invalid frequency: %w", csvErr) 58 | } 59 | refFreqs[strings.ReplaceAll(record[0], `"`, `\"`)] = freq 60 | } 61 | 62 | wd, err := os.Getwd() 63 | if err != nil { 64 | return errors.New("getting working directory " + err.Error()) 65 | } 66 | wd, err = filepath.EvalSymlinks(wd) 67 | if err != nil { 68 | return errors.New("evaluating symlinks " + err.Error()) 69 | } 70 | 71 | done := make(chan struct{}) 72 | defer close(done) 73 | paths, errc := walkFiles(done, wd) 74 | c := make(chan result) 75 | var wg sync.WaitGroup 76 | const numScanners = 32 77 | wg.Add(numScanners) 78 | for i := 0; i < numScanners; i++ { 79 | go func() { 80 | entryScanner(done, paths, c) 81 | wg.Done() 82 | }() 83 | } 84 | go func() { 85 | wg.Wait() 86 | close(c) 87 | }() 88 | var entryCount float64 89 | wordMap := make(map[string]uint64) 90 | t := conf.clock.Now() 91 | minDate := t 92 | for r := range c { 93 | if r.err != nil { 94 | return r.err 95 | } 96 | entryCount++ 97 | for word, count := range r.wordMap { 98 | wordMap[word] += count 99 | } 100 | if minDate.After(r.date) { 101 | minDate = r.date 102 | } 103 | } 104 | // Check whether the Walk failed. 105 | if err := <-errc; err != nil { 106 | return err 107 | } 108 | 109 | if entryCount == 0 { 110 | fmt.Fprint(w, "no entries found! why not try writing one with 'gurnel start'?") 111 | return nil 112 | } 113 | 114 | percent := entryCount / math.Ceil(t.Sub(minDate).Hours()/24) 115 | const outFormat = "Jan 2 2006" 116 | fmt.Fprintf(w, "%.2f%% of days journaled since %v\n", percent*100, minDate.Format(outFormat)) 117 | var wordCount uint64 118 | for _, count := range wordMap { 119 | wordCount += count 120 | } 121 | fmt.Fprintf(w, "Total word count: %v\n", wordCount) 122 | avgCount := float64(wordCount) / entryCount 123 | fmt.Fprintf(w, "Average word count: %.1f\n", avgCount) 124 | fmt.Fprint(w, "\n") 125 | 126 | wordStats := make([]*wordStat, len(wordMap)) 127 | i := 0 128 | for word, count := range wordMap { 129 | frequency := float64(count) / float64(wordCount) 130 | var relFrequency float64 131 | refFrequency := refFreqs[word] 132 | if frequency > refFrequency { 133 | if refFrequency > 0 { 134 | relFrequency = frequency / refFrequency 135 | } 136 | } else { 137 | relFrequency = (refFrequency / frequency) * -1 138 | } 139 | wordStats[i] = &wordStat{word: word, occurrences: count, frequency: relFrequency} 140 | i++ 141 | } 142 | 143 | sort.Slice(wordStats, func(i, j int) bool { 144 | return wordStats[i].frequency > wordStats[j].frequency 145 | }) 146 | 147 | var topUnusualWordCount uint64 148 | topUnusualWordCount = 100 149 | if topUnusualWordCount > wordCount { 150 | topUnusualWordCount = wordCount 151 | } 152 | out := tabwriter.NewWriter(w, 0, 0, 1, ' ', 0) 153 | fmt.Fprintf(out, "Top %v unusually frequent words:\n", topUnusualWordCount) 154 | for _, ws := range wordStats[:topUnusualWordCount] { 155 | fmt.Fprintf(out, "%v\t%.1fX\n", ws.word, ws.frequency) 156 | } 157 | out.Flush() 158 | fmt.Fprint(out, "\n") 159 | fmt.Fprintf(out, "Top %v unusually infrequent words:\n", topUnusualWordCount) 160 | for i := 1; i <= int(topUnusualWordCount); i++ { 161 | ws := wordStats[len(wordStats)-i] 162 | fmt.Fprintf(out, "%v\t%.1fX\n", ws.word, ws.frequency) 163 | } 164 | out.Flush() 165 | 166 | return nil 167 | } 168 | 169 | type result struct { 170 | wordMap map[string]uint64 171 | date time.Time 172 | err error 173 | } 174 | 175 | type wordStat struct { 176 | word string 177 | occurrences uint64 178 | frequency float64 179 | } 180 | 181 | func walkFiles( 182 | done <-chan struct{}, 183 | root string, 184 | ) (paths chan string, errc chan error) { 185 | paths = make(chan string) 186 | errc = make(chan error, 1) 187 | visited := make(map[string]bool) 188 | go func() { 189 | // Close the paths channel after Walk returns. 190 | defer close(paths) 191 | // No select needed for this send, since errc is buffered. 192 | errc <- filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 193 | if err != nil { 194 | return err 195 | } 196 | if !info.Mode().IsRegular() || visited[info.Name()] || !IsEntry(path) { 197 | return nil 198 | } 199 | visited[info.Name()] = true 200 | select { 201 | case paths <- path: 202 | case <-done: 203 | return errors.New("walk canceled") 204 | } 205 | return nil 206 | }) 207 | }() 208 | return paths, errc 209 | } 210 | 211 | func entryScanner(done <-chan struct{}, paths <-chan string, c chan<- result) { 212 | for path := range paths { 213 | p := &Entry{Path: path} 214 | m := make(map[string]uint64) 215 | _, err := p.Load() 216 | if err == nil { 217 | for _, word := range p.Words() { 218 | m[strings.ToLower(string(word))]++ 219 | } 220 | } 221 | date, _ := p.Date() 222 | select { 223 | case c <- result{date: date, wordMap: m, err: err}: 224 | case <-done: 225 | return 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /internal/gurnel/beeminder_test.go: -------------------------------------------------------------------------------- 1 | package gurnel 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/mikeraimondi/gurnel/internal/test" 14 | ) 15 | 16 | func TestBeeminderPostServerErrors(t *testing.T) { 17 | tests := []struct { 18 | returnCode int 19 | returnBody string 20 | }{ 21 | { 22 | 200, 23 | "", 24 | }, 25 | { 26 | 404, 27 | "server not found", 28 | }, 29 | { 30 | 500, 31 | "server on fire", 32 | }, 33 | { 34 | 503, 35 | "", 36 | }, 37 | } 38 | 39 | for _, tt := range tests { 40 | t.Run(fmt.Sprintf("server return HTTP code %d", 41 | tt.returnCode), func(t *testing.T) { 42 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 | w.WriteHeader(tt.returnCode) 44 | w.Write([]byte(tt.returnBody)) 45 | }) 46 | server := httptest.NewTLSServer(handler) 47 | defer server.Close() 48 | 49 | client := beeminderClient{ 50 | Token: []byte("test"), 51 | User: "test", 52 | c: *server.Client(), 53 | serverURL: server.URL, 54 | } 55 | 56 | now := (&test.FixedClock{}).Now() 57 | result := make(chan error) 58 | go func() { 59 | result <- client.postDatapoint("foo", 1, now) 60 | }() 61 | err := <-result 62 | 63 | if tt.returnCode == 200 { 64 | if err != nil { 65 | t.Fatalf("expected no error. got %q", err) 66 | } 67 | } else { 68 | if c := strconv.Itoa(tt.returnCode); !strings.Contains(err.Error(), c) { 69 | t.Fatalf("wrong error code. expected an error containing %q. got %q", 70 | c, err) 71 | } 72 | if !strings.Contains(err.Error(), tt.returnBody) { 73 | t.Fatalf("wrong error message. expected an error containing %q. got %q", 74 | tt.returnBody, err) 75 | } 76 | if expectedMsg := "no further info"; tt.returnBody == "" && 77 | !strings.Contains(err.Error(), expectedMsg) { 78 | t.Fatalf("wrong error message. expected an error containing %q. got %q", 79 | expectedMsg, err) 80 | } 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func TestBeeminderPostParameters(t *testing.T) { 87 | tests := []struct { 88 | goal string 89 | count int 90 | valid bool 91 | }{ 92 | { 93 | "testGoal", 94 | 10, 95 | true, 96 | }, 97 | { 98 | "testGoal", 99 | 0, 100 | true, 101 | }, 102 | { 103 | "testGoal", 104 | -10, 105 | false, 106 | }, 107 | { 108 | "", 109 | 10, 110 | false, 111 | }, 112 | { 113 | "", 114 | -10, 115 | false, 116 | }, 117 | } 118 | 119 | for _, tt := range tests { 120 | t.Run(fmt.Sprintf("with goal %q and count %d", 121 | tt.goal, tt.count), func(t *testing.T) { 122 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 123 | if !tt.valid { 124 | t.Error("expected no API call with invalid parameters passed") 125 | } 126 | if v := r.FormValue("value"); v != strconv.Itoa(tt.count) { 127 | t.Errorf("wrong value. expected %d. got %s", tt.count, v) 128 | } 129 | if !strings.Contains(r.RequestURI, tt.goal) { 130 | t.Errorf("goal not in URL. expected a string containing %q. got %s", 131 | tt.goal, r.RequestURI) 132 | } 133 | w.Write([]byte("OK")) 134 | }) 135 | server := httptest.NewTLSServer(handler) 136 | defer server.Close() 137 | 138 | client := beeminderClient{ 139 | Token: []byte("test"), 140 | User: "test", 141 | c: *server.Client(), 142 | serverURL: server.URL, 143 | } 144 | 145 | now := (&test.FixedClock{}).Now() 146 | result := make(chan error) 147 | go func() { 148 | result <- client.postDatapoint(tt.goal, tt.count, now) 149 | }() 150 | err := <-result 151 | if !tt.valid { 152 | if err == nil { 153 | t.Fatal("expected an error with invalid parameters passed") 154 | } 155 | } 156 | }) 157 | } 158 | } 159 | 160 | func TestNewBeeminderClient(t *testing.T) { 161 | tests := []struct { 162 | user string 163 | token string 164 | valid bool 165 | }{ 166 | { 167 | "testUser", 168 | "testToken", 169 | true, 170 | }, 171 | { 172 | "testUser", 173 | " testToken ", 174 | true, 175 | }, 176 | { 177 | "", 178 | " testToken ", 179 | false, 180 | }, 181 | { 182 | "testUser", 183 | "", 184 | false, 185 | }, 186 | } 187 | 188 | for _, tt := range tests { 189 | t.Run(fmt.Sprintf("with user %q and token %q", 190 | tt.user, tt.token), func(t *testing.T) { 191 | client, err := newBeeminderClient(tt.user, []byte(tt.token)) 192 | if !tt.valid { 193 | if err == nil { 194 | t.Fatal("expected an error with invalid parameters passed") 195 | } 196 | if client != nil { 197 | t.Fatal("expected a nil client with invalid parameters passed") 198 | } 199 | return 200 | } 201 | if tt.user != client.User { 202 | t.Fatalf("wrong user. expected %q. got %q", tt.user, client.User) 203 | } 204 | if s := strings.TrimSpace(tt.token); s != string(client.Token) { 205 | t.Fatalf("wrong token. expected %q. got %q", s, client.Token) 206 | } 207 | }) 208 | } 209 | } 210 | 211 | type testTransport struct { 212 | err error 213 | } 214 | 215 | func (t *testTransport) RoundTrip(*http.Request) (*http.Response, error) { 216 | return &http.Response{ 217 | StatusCode: 200, 218 | Body: ioutil.NopCloser(&bytes.Buffer{}), 219 | }, t.err 220 | } 221 | 222 | func TestBeeminderClient(t *testing.T) { 223 | tests := []struct { 224 | serverURL string 225 | transport http.RoundTripper 226 | expectedErr string 227 | }{ 228 | { 229 | "http://test.com", 230 | &testTransport{}, 231 | "", 232 | }, 233 | { 234 | "http:// not a url", 235 | &testTransport{}, 236 | "URL error", 237 | }, 238 | { 239 | "http://test.com", 240 | &testTransport{err: fmt.Errorf("test error")}, 241 | "making request", 242 | }, 243 | } 244 | 245 | for _, tt := range tests { 246 | t.Run(fmt.Sprintf("with expected error %q", 247 | tt.expectedErr), func(t *testing.T) { 248 | client := beeminderClient{ 249 | serverURL: tt.serverURL, 250 | c: http.Client{Transport: tt.transport}, 251 | } 252 | 253 | now := (&test.FixedClock{}).Now() 254 | err := client.postDatapoint("test", 10, now) 255 | if tt.expectedErr == "" { 256 | if err != nil { 257 | t.Fatalf("expected no error. got %q", err) 258 | } 259 | } else { 260 | if err == nil || !strings.Contains(err.Error(), tt.expectedErr) { 261 | t.Fatalf("wrong error. expected %q. got %v", tt.expectedErr, err) 262 | } 263 | } 264 | }) 265 | } 266 | } 267 | --------------------------------------------------------------------------------