├── .gitpod.yml ├── .mailmap ├── .gitattributes ├── Procfile ├── internal ├── tool │ ├── notes │ │ ├── now │ │ │ ├── templates │ │ │ │ ├── footer.html │ │ │ │ ├── simple.html │ │ │ │ ├── q.html │ │ │ │ ├── article.html │ │ │ │ ├── list.html │ │ │ │ ├── sup.html │ │ │ │ ├── header.html │ │ │ │ └── update.html │ │ │ ├── testdata │ │ │ │ └── add_item │ │ │ │ │ ├── xyzzy.txt │ │ │ │ │ └── create-section.txt │ │ │ ├── notes_other.go │ │ │ ├── add_item_test.go │ │ │ ├── add_item.go │ │ │ ├── notes.go │ │ │ └── template.go │ │ ├── zine │ │ │ ├── zine.md │ │ │ ├── testdata │ │ │ │ ├── cats.md │ │ │ │ └── index.tmpl │ │ │ ├── run_other.go │ │ │ ├── zine.go │ │ │ └── zine_test.go │ │ ├── brainstem │ │ │ ├── brainstem.md │ │ │ └── brainstem.go │ │ ├── amygdala │ │ │ └── amygdala.md │ │ └── proj │ │ │ ├── proj.go │ │ │ └── proj.md │ ├── misc │ │ ├── steamsrv │ │ │ ├── templates │ │ │ │ ├── zzfooter.html │ │ │ │ ├── logs.html │ │ │ │ ├── apps.html │ │ │ │ ├── zzheader.html │ │ │ │ └── shots.html │ │ │ ├── steamsrv.md │ │ │ └── screenshots.go │ │ ├── bamboo │ │ │ ├── export-bamboohr.md │ │ │ ├── export-bamboohr.go │ │ │ └── client.go │ │ ├── prependhist │ │ │ ├── testdata │ │ │ │ └── hist.txt │ │ │ ├── prepend-hist.md │ │ │ ├── prependemojihist_test.go │ │ │ └── prepend-hist.go │ │ ├── backlight │ │ │ ├── backlight.md │ │ │ ├── backlight_test.go │ │ │ └── backlight.go │ │ ├── status │ │ │ ├── vpn.go │ │ │ ├── cam.go │ │ │ ├── locked.go │ │ │ ├── curwindow.go │ │ │ ├── x11title.go │ │ │ ├── tabs.go │ │ │ ├── light.go │ │ │ ├── x11shot.go │ │ │ ├── retropie.go │ │ │ ├── cacher.go │ │ │ ├── status.md │ │ │ ├── sound.go │ │ │ └── steambox.go │ │ ├── wuphf │ │ │ ├── wuphf.md │ │ │ └── wuphf.go │ │ ├── twilio │ │ │ ├── twilio.md │ │ │ └── twilio.go │ │ ├── img │ │ │ ├── draw.go │ │ │ └── draw.md │ │ └── desktop │ │ │ ├── media-remote.tmpl │ │ │ ├── media-remote.md │ │ │ └── media-remote.js │ ├── allpurpose │ │ ├── yaml │ │ │ ├── yaml2json.md │ │ │ ├── tojson_test.go │ │ │ └── yaml2json.go │ │ ├── uni │ │ │ ├── name2rune.md │ │ │ ├── uni.md │ │ │ ├── alluni.go │ │ │ ├── uni_test.go │ │ │ ├── name2rune.go │ │ │ ├── alluni.md │ │ │ └── uni.go │ │ ├── fn │ │ │ ├── fn.md │ │ │ ├── fn.go │ │ │ └── fn_test.go │ │ ├── netrcpassword │ │ │ ├── testdata │ │ │ │ └── basic.netrc │ │ │ ├── netrc-password.md │ │ │ ├── netrcpassword_test.go │ │ │ └── netrc-password.go │ │ ├── clocks │ │ │ ├── expect.txt │ │ │ ├── clocks.md │ │ │ ├── clocks_test.go │ │ │ └── clocks.go │ │ ├── toml │ │ │ ├── toml2json.md │ │ │ ├── toml_test.go │ │ │ └── toml2json.go │ │ ├── csv │ │ │ ├── csv2md.md │ │ │ ├── csv2json.md │ │ │ ├── tojson_test.go │ │ │ ├── tomarkdown_test.go │ │ │ ├── csv2md.go │ │ │ └── csv2json.go │ │ ├── dumpmozlz4 │ │ │ ├── dump-mozlz4.md │ │ │ └── dump-mozlz4.go │ │ ├── expandurl │ │ │ ├── expand-url.md │ │ │ ├── expandurl_test.go │ │ │ └── expand-url.go │ │ ├── groupbydate │ │ │ ├── group-by-date.md │ │ │ └── groupByDate_test.go │ │ ├── replaceunzip │ │ │ └── replace-unzip.md │ │ ├── debounce │ │ │ ├── debounce.md │ │ │ ├── bouncer.go │ │ │ ├── leading.go │ │ │ ├── leading_test.go │ │ │ ├── trailing.go │ │ │ ├── trailing_test.go │ │ │ └── debounce.go │ │ ├── srv │ │ │ ├── srv.md │ │ │ ├── srv_test.go │ │ │ └── srv.go │ │ ├── pomotimer │ │ │ ├── pomotimer.md │ │ │ └── pomotimer.go │ │ └── minotaur │ │ │ ├── minotaur_test.go │ │ │ ├── minotaur.md │ │ │ └── parseflags.go │ ├── chat │ │ ├── slack │ │ │ ├── slack-status.md │ │ │ ├── slack-open.md │ │ │ ├── slack-deaddrop.md │ │ │ ├── slack-status.go │ │ │ └── slack-open.go │ │ └── automoji │ │ │ ├── emojiset.go │ │ │ └── automoji_test.go │ └── leatherman │ │ └── update │ │ ├── update.md │ │ └── update.go ├── lmlua │ ├── lmlua.go │ ├── fs.go │ ├── luanotes │ │ └── luanotes.go │ ├── regexp.go │ └── goquery.go ├── notes │ ├── west │ │ ├── debug.go │ │ ├── nodebug.go │ │ ├── testdata │ │ │ └── 000.md │ │ ├── fuzz_test.go │ │ └── west_test.go │ ├── fs.go │ ├── todo_test.go │ ├── remind.go │ ├── beerme.go │ ├── notes.go │ ├── todo.go │ ├── article.go │ ├── article_test.go │ └── defer.go ├── lmhttp │ ├── templates │ │ └── list.html │ ├── handlerfunc.go │ ├── lmhttp.go │ └── mux.go ├── selfupdate │ ├── selfupdate_other.go │ ├── versionhandler.go │ └── selfupdate_linux.go ├── build-test ├── log │ └── log.go ├── dropbox │ ├── dropbox.go │ ├── delete.go │ ├── download.go │ ├── longpoll.go │ ├── get_metadata.go │ └── upload.go ├── twilio │ ├── twilio_test.go │ └── twilio.go ├── middleware │ ├── middleware_test.go │ └── middleware.go ├── lmfav │ ├── genpal │ └── pal.go ├── testutil │ └── testutil.go ├── personality │ └── personality.go ├── version │ └── version.go ├── steam │ └── steam.go ├── lmfs │ └── lmfs.go └── reminders │ └── reminders_test.go ├── .gitignore ├── .travis.yml ├── .projections.json ├── pkg ├── netrc │ └── testdata │ │ ├── password.netrc │ │ ├── newlineless.netrc │ │ ├── sample.netrc │ │ ├── permissive.netrc │ │ ├── default_only.netrc │ │ ├── emptypassword.netrc │ │ ├── sample_multi.netrc │ │ ├── sample_with_default.netrc │ │ ├── login.netrc │ │ ├── bad_default_order.netrc │ │ ├── sample_multi_with_default.netrc │ │ ├── good.netrc │ │ └── good.netrc.gpg ├── timeutil │ ├── timeutil.go │ └── timeutil_test.go ├── datefmt │ └── datefmt_test.go ├── shellquote │ ├── examples_test.go │ ├── shellquote_test.go │ └── shellquote.go └── mozlz4 │ ├── examples_test.go │ └── mozlz4.go ├── maint ├── generate ├── pre-push ├── generate-dispatch ├── README_end.md ├── README_begin.md ├── find-tools.go └── generate-readme ├── .github └── dependabot.yml ├── version.go ├── bin └── enqueue-at ├── explode.go ├── Makefile ├── noprintln_test.go ├── cmd └── curwindow │ └── main.go ├── main.go ├── help.go ├── debug.go ├── go.mod └── HACKING.mdwn /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - init: go mod download -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | frew@ziprecruiter.com git@frew.co 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | **/testdata/* linguist-vendored 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: leatherman amygdala -port $PORT 2 | -------------------------------------------------------------------------------- /internal/tool/notes/now/templates/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /leatherman 2 | /leatherman.xz 3 | internal/tool/zine/.posts.db 4 | -------------------------------------------------------------------------------- /internal/tool/misc/steamsrv/templates/zzfooter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /internal/tool/misc/bamboo/export-bamboohr.md: -------------------------------------------------------------------------------- 1 | Exports company directory as JSON. 2 | -------------------------------------------------------------------------------- /internal/tool/notes/zine/zine.md: -------------------------------------------------------------------------------- 1 | Read only view of my notes via the filesystem. 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.13.x 5 | script: 6 | - go test ./... 7 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/yaml/yaml2json.md: -------------------------------------------------------------------------------- 1 | Reads YAML on stdin and writes JSON on stdout. 2 | -------------------------------------------------------------------------------- /.projections.json: -------------------------------------------------------------------------------- 1 | { 2 | "internal/tool/*.go": { 3 | "type": "tool" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /internal/lmlua/lmlua.go: -------------------------------------------------------------------------------- 1 | // package lmlua defines lots of general types for lua. 2 | package lmlua 3 | -------------------------------------------------------------------------------- /internal/tool/misc/prependhist/testdata/hist.txt: -------------------------------------------------------------------------------- 1 | WHITE STAR 2 | BEER MUG 3 | SKULL AND CROSSBONES 4 | -------------------------------------------------------------------------------- /internal/tool/notes/zine/testdata/cats.md: -------------------------------------------------------------------------------- 1 | { 2 | "title":"cats" 3 | } 4 | 5 | # cats 6 | 7 | cats are the best. 8 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/password.netrc: -------------------------------------------------------------------------------- 1 | # this is my password netrc 2 | machine m 3 | password p # this is my password 4 | -------------------------------------------------------------------------------- /internal/notes/west/debug.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | // +build debug 3 | 4 | package west 5 | 6 | const debug = true 7 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/newlineless.netrc: -------------------------------------------------------------------------------- 1 | # this is my netrc 2 | machine m 3 | login l # this is my username 4 | password p -------------------------------------------------------------------------------- /pkg/netrc/testdata/sample.netrc: -------------------------------------------------------------------------------- 1 | # this is my netrc 2 | machine m 3 | login l # this is my username 4 | password p 5 | -------------------------------------------------------------------------------- /internal/notes/west/nodebug.go: -------------------------------------------------------------------------------- 1 | //go:build !debug 2 | // +build !debug 3 | 4 | package west 5 | 6 | const debug = false 7 | -------------------------------------------------------------------------------- /internal/tool/notes/now/templates/simple.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" .}} 2 | {{.Body}} 3 | {{ template "footer.html" .}} 4 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/permissive.netrc: -------------------------------------------------------------------------------- 1 | # this is my netrc 2 | machine m 3 | login l # this is my username 4 | password p 5 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/uni/name2rune.md: -------------------------------------------------------------------------------- 1 | Prints characters based on name. 2 | 3 | ```bash 4 | $ name2rune CAT 5 | 🐈 6 | ``` 7 | -------------------------------------------------------------------------------- /internal/lmhttp/templates/list.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/fn/fn.md: -------------------------------------------------------------------------------- 1 | Creates persistent functions by generating scripts. 2 | 3 | ``` 4 | fn count-users 'wc -l < /etc/passwd' 5 | ``` 6 | -------------------------------------------------------------------------------- /internal/tool/chat/slack/slack-status.md: -------------------------------------------------------------------------------- 1 | Sets slack status. 2 | 3 | ```bash 4 | $ slack-status -text "working for the weekend" -emoji :guitar: 5 | ``` 6 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/default_only.netrc: -------------------------------------------------------------------------------- 1 | # this is my netrc with only a default 2 | default 3 | login ld # this is my default username 4 | password pd 5 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/netrcpassword/testdata/basic.netrc: -------------------------------------------------------------------------------- 1 | machine foo 2 | login bar 3 | password baz 4 | 5 | machine foo 6 | login ball 7 | password barf 8 | -------------------------------------------------------------------------------- /internal/tool/chat/slack/slack-open.md: -------------------------------------------------------------------------------- 1 | Opens a channel, group message, or direct message by name. 2 | 3 | ```bash 4 | $ slack-open -channel general 5 | ``` 6 | -------------------------------------------------------------------------------- /internal/selfupdate/selfupdate_other.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package selfupdate 5 | 6 | func isSameFile(string) error { return nil } 7 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/clocks/expect.txt: -------------------------------------------------------------------------------- 1 | America/Los_Angeles yesterday 20:12:12 8:12:12 PM -8 2 | UTC today 04:12:12 4:12:12 AM +0 3 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/toml/toml2json.md: -------------------------------------------------------------------------------- 1 | Reads TOML on stdin and writes JSON on stdout. 2 | 3 | ```bash 4 | $ echo 'foo = "bar"` | toml2json 5 | {"foo":"bar"} 6 | ``` 7 | -------------------------------------------------------------------------------- /maint/generate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | tools=$(go run -tags generate maint/find-tools.go) 4 | 5 | echo "$tools" | maint/generate-readme 6 | echo "$tools" | maint/generate-dispatch 7 | -------------------------------------------------------------------------------- /internal/notes/fs.go: -------------------------------------------------------------------------------- 1 | package notes 2 | 3 | import "io/fs" 4 | 5 | type WatchDirFS interface { 6 | fs.FS 7 | 8 | Watch(path string) (func(), chan struct{}, error) 9 | } 10 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/uni/uni.md: -------------------------------------------------------------------------------- 1 | Describes unicode characters. 2 | 3 | ```bash 4 | $ uni ⢾ 5 | '⢾' @ 10430 aka BRAILLE PATTERN DOTS-234568 ( graphic | printable | symbol ) 6 | ``` 7 | -------------------------------------------------------------------------------- /internal/tool/notes/zine/testdata/index.tmpl: -------------------------------------------------------------------------------- 1 | {} 2 | {{ define "header" }} 3 | This is the header! 4 | {{ end }} 5 | 6 | {{ define "footer" }} 7 | this is the footer! 8 | {{ end }} 9 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/netrcpassword/netrc-password.md: -------------------------------------------------------------------------------- 1 | Prints password for a hostname, login pair. 2 | 3 | ```bash 4 | $ netrc-password google.com me@gmail.com 5 | supersecretpassword 6 | ``` 7 | -------------------------------------------------------------------------------- /internal/tool/notes/now/templates/q.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" .}} 2 |
3 | {{- range .Records}}
4 | {{printf "%#v" . }}
5 | {{- end -}}
6 | 
7 | {{ template "footer.html" .}} 8 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/emptypassword.netrc: -------------------------------------------------------------------------------- 1 | machine api.heroku.com 2 | login mhale@heroku.com 3 | password 4 | machine api.heroku.com 5 | login mhale@heroku.com 6 | password foo 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "13:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /internal/tool/misc/steamsrv/templates/logs.html: -------------------------------------------------------------------------------- 1 | {{template "zzheader.html" .}} 2 | 7 | {{template "zzfooter.html" .}} 8 | -------------------------------------------------------------------------------- /internal/tool/notes/now/templates/article.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" .}} 2 |
Update {{.ArticleTitle}} 3 |
4 | {{.Body}} 5 | {{ template "footer.html" .}} 6 | -------------------------------------------------------------------------------- /internal/build-test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | for arg in "$@"; do 6 | echo "$arg" 7 | done | grep -E '\.go$' 8 | 9 | go test ./... 10 | go build 11 | echo "Built new leatherman at $(date)" 12 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/csv/csv2md.md: -------------------------------------------------------------------------------- 1 | Reads CSV on stdin and writes Markdown on stdout 2 | 3 | ```bash 4 | $ echo "foo,bar\n1,2\n3,4" | csv2md 5 | foo | bar 6 | --- | --- 7 | 1 | 2 8 | 3 | 4 9 | ``` 10 | -------------------------------------------------------------------------------- /internal/tool/notes/brainstem/brainstem.md: -------------------------------------------------------------------------------- 1 | Interacts with amygdala without using any server components. 2 | 3 | Typically for testing the personality etc, but can also be used as a 4 | lightweight amygdala instance. 5 | -------------------------------------------------------------------------------- /internal/tool/notes/now/templates/list.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" .}} 2 | 7 | {{ template "footer.html" .}} 8 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/dumpmozlz4/dump-mozlz4.md: -------------------------------------------------------------------------------- 1 | Dumps the contents of a `mozlz4` (aka `jsonlz4`) file. 2 | 3 | The format is commonly used by Firefox. Just takes the name of the file to 4 | dump and writes to standard out. 5 | -------------------------------------------------------------------------------- /internal/tool/leatherman/update/update.md: -------------------------------------------------------------------------------- 1 | Installs new version of leatherman if one exists. 2 | 3 | If LM_GH_TOKEN is set to a personal access token this can be called more 4 | frequently without exhausting github api limits. 5 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/sample_multi.netrc: -------------------------------------------------------------------------------- 1 | # this is my netrc with multiple machines 2 | machine m 3 | login lm # this is my m-username 4 | password pm 5 | 6 | machine n 7 | login ln # this is my n-username 8 | password pn 9 | -------------------------------------------------------------------------------- /internal/tool/misc/steamsrv/templates/apps.html: -------------------------------------------------------------------------------- 1 | {{template "zzheader.html" .}} 2 | 7 | {{template "zzfooter.html" .}} 8 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/expandurl/expand-url.md: -------------------------------------------------------------------------------- 1 | Converts links in text to markdown links. 2 | 3 | Reads text on STDIN and writes the same text back, converting any links to 4 | Markdown links, with the title of the page as the title of the link. 5 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/groupbydate/group-by-date.md: -------------------------------------------------------------------------------- 1 | Creates time series data by counting lines and grouping them by a given date 2 | format. 3 | 4 | Takes dates on stdin in format -i, will group them by format -g, and write them 5 | in format -o. 6 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/sample_with_default.netrc: -------------------------------------------------------------------------------- 1 | # this is my netrc with default 2 | machine m 3 | login l # this is my username 4 | password p 5 | 6 | default 7 | login default_login # this is my default username 8 | password default_password 9 | -------------------------------------------------------------------------------- /internal/tool/chat/slack/slack-deaddrop.md: -------------------------------------------------------------------------------- 1 | Sends messages to a slack channel. 2 | 3 | ```bash 4 | $ slack-deaddrop -channel general -text 'good morning!' 5 | ``` 6 | 7 | Useful to avoid loading up a full slack instance or looking at other messages. 8 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/login.netrc: -------------------------------------------------------------------------------- 1 | # this is my login netrc 2 | machine api.heroku.com 3 | login jeff@heroku.com # this is my username 4 | password foo 5 | 6 | machine api.heroku.com 7 | login jeff2@heroku.com # this is my username 8 | password bar 9 | -------------------------------------------------------------------------------- /internal/tool/leatherman/update/update.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/frioux/leatherman/internal/selfupdate" 7 | ) 8 | 9 | func Update([]string, io.Reader) error { 10 | selfupdate.MaybeUpdate() 11 | 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /maint/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | err=$(GOOS=windows go build -o /dev/null) 4 | if [ $? -ne 0 ]; then 5 | echo "$err" 6 | exit 1 7 | fi 8 | 9 | err=$(go test ./...) 10 | if [ $? -ne 0 ]; then 11 | echo "$err" 12 | exit 1 13 | fi 14 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/csv/csv2json.md: -------------------------------------------------------------------------------- 1 | Reads CSV on stdin and writes JSON on stdout 2 | 3 | ```bash 4 | $ echo "foo,bar\n1,2\n3,4" | csv2json 5 | {"bar":"2","foo":"1"} 6 | {"bar":"4","foo":"3"} 7 | ``` 8 | 9 | First line of input is the header, and thus the keys of the JSON. 10 | -------------------------------------------------------------------------------- /internal/tool/misc/steamsrv/templates/zzheader.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{.Title}} 6 | 7 | 8 | 9 | Steam Log | Screenshots 10 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/frioux/leatherman/internal/version" 8 | ) 9 | 10 | // Version prints current version 11 | func Version(args []string, _ io.Reader) error { 12 | version.Render(os.Stdout) 13 | 14 | return nil 15 | } 16 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/replaceunzip/replace-unzip.md: -------------------------------------------------------------------------------- 1 | Does what `unzip` does more safely. 2 | 3 | Run extracts zipfiles, but does not extract `.DS_Store` or `__MACOSX/`. 4 | Automatically extracts into a directory named after the zipfile if there is not 5 | a single root for all files in the zipfile. 6 | -------------------------------------------------------------------------------- /internal/tool/misc/backlight/backlight.md: -------------------------------------------------------------------------------- 1 | Modifies screen brightness. 2 | 3 | Backlight is a faster version of `xbacklight` by directly writing to `/sys`. Example: 4 | 5 | Increase by 10%: 6 | 7 | ``` bash 8 | $ backlight 10 9 | ``` 10 | 11 | Decrease by 10%: 12 | 13 | ``` bash 14 | $ backlight -10 15 | ``` 16 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/bad_default_order.netrc: -------------------------------------------------------------------------------- 1 | # I am a comment 2 | machine mail.google.com 3 | login joe@gmail.com 4 | account gmail 5 | password somethingSecret 6 | # I am another comment 7 | 8 | default 9 | login anonymous 10 | password joe@example.com 11 | 12 | machine ray login demo password mypassword 13 | 14 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/sample_multi_with_default.netrc: -------------------------------------------------------------------------------- 1 | # this is my netrc with multiple machines and a default 2 | machine m 3 | login lm # this is my m-username 4 | password pm 5 | 6 | machine n 7 | login ln # this is my n-username 8 | password pn 9 | 10 | default 11 | login ld # this is my default username 12 | password pd 13 | -------------------------------------------------------------------------------- /internal/notes/west/testdata/000.md: -------------------------------------------------------------------------------- 1 | # header 2 | 3 | plain text 4 | 5 | text `with` code 6 | 7 | [link](http://frew.co), `[notalink](to_anywhere)`, [link2](http://afoolishmanifesto.com) 8 | 9 | ```perl 10 | #!/usr/bin/perl 11 | 12 | print "[hello](/world)!\n"; 13 | ``` 14 | 15 | more text. 16 | 17 | # header2 18 | 19 | [`link3`](/link3) 20 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/csv/tojson_test.go: -------------------------------------------------------------------------------- 1 | package csv_test 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/frioux/leatherman/internal/tool/allpurpose/csv" 7 | ) 8 | 9 | func ExampleToJSON() { 10 | r := strings.NewReader("foo,bar\n1,2\n2,3") 11 | 12 | csv.ToJSON(nil, r) 13 | // Output: 14 | // {"bar":"2","foo":"1"} 15 | // {"bar":"3","foo":"2"} 16 | } 17 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/debounce/debounce.md: -------------------------------------------------------------------------------- 1 | Run debounces input from stdin to stdout 2 | 3 | The default lockout time is one second, you can override that with the 4 | `--lockoutTime` argument. By default the trailing edge triggers output, so 5 | output is emitted after there is no input for the lockout time. You can change 6 | this behavior by passing the `--leadingEdge` flag. 7 | -------------------------------------------------------------------------------- /internal/tool/misc/prependhist/prepend-hist.md: -------------------------------------------------------------------------------- 1 | Combines a history file and stdin. 2 | 3 | Prints out deduplicated lines from the history file in reverse order and 4 | then prints out the lines from STDIN, filtering out what's already been printed. 5 | 6 | ```bash 7 | $ echo "1\n2\n3" > eg_history 8 | $ echo "1\n5 | prepend-hist eg_history 9 | 3 10 | 2 11 | 1 12 | 5 13 | ``` 14 | -------------------------------------------------------------------------------- /internal/tool/notes/now/testdata/add_item/xyzzy.txt: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Now", 3 | "tags": [ "private", "reference", "project" ], 4 | "reviewed_on": "2020-07-15", 5 | } 6 | 7 | ## Stash 8 | 9 | * foo 10 | * bar 11 | * baz 12 | 13 | ## 2020-07-19 ## 14 | 15 | * bong 16 | * biff 17 | * barp 18 | * xyzzy 19 | 20 | ## 2020-07-18 ## 21 | 22 | * ~~herp~~ 23 | * ~~dong~~ 24 | 25 | 26 | -------------------------------------------------------------------------------- /internal/tool/notes/zine/run_other.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package zine 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "os" 10 | "runtime" 11 | ) 12 | 13 | func Run(args []string, _ io.Reader) error { 14 | fmt.Fprintf(os.Stderr, "zine not supported on %s/%s due to lacking support in modernc.org/sqlite\n", runtime.GOOS, runtime.GOARCH) 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /internal/tool/notes/now/notes_other.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package now 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "os" 10 | "runtime" 11 | ) 12 | 13 | func Serve(args []string, _ io.Reader) error { 14 | fmt.Fprintf(os.Stderr, "zine not supported on %s/%s due to lacking support in modernc.org/sqlite\n", runtime.GOOS, runtime.GOARCH) 15 | 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/csv/tomarkdown_test.go: -------------------------------------------------------------------------------- 1 | package csv_test 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/frioux/leatherman/internal/tool/allpurpose/csv" 7 | ) 8 | 9 | func ExampleToMarkdown() { 10 | r := strings.NewReader("foo,bar,baz\n1,2,3\n3,2,1") 11 | csv.ToMarkdown(nil, r) 12 | // Output: 13 | // foo | bar | baz 14 | // --- | --- | --- 15 | // 1 | 2 | 3 16 | // 3 | 2 | 1 17 | } 18 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/debounce/bouncer.go: -------------------------------------------------------------------------------- 1 | package debounce 2 | 3 | import ( 4 | "io" 5 | "time" 6 | ) 7 | 8 | type bouncer interface { 9 | Write(time.Time, []byte) error 10 | } 11 | 12 | func newBouncer(trailing bool, w io.Writer, duration time.Duration) bouncer { 13 | if trailing { 14 | return newTrailingBouncer(w, duration) 15 | } 16 | return &leadingBouncer{w: w, duration: duration} 17 | } 18 | -------------------------------------------------------------------------------- /internal/tool/misc/status/vpn.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | type vpn struct{ value bool } 9 | 10 | func (l *vpn) load() error { 11 | v, err := exec1Fail("pgrep", "openvpn") 12 | if err != nil { 13 | return err 14 | } 15 | l.value = v 16 | return nil 17 | } 18 | 19 | func (l *vpn) render(rw http.ResponseWriter) { fmt.Fprintf(rw, "%t\n", l.value) } 20 | -------------------------------------------------------------------------------- /internal/tool/misc/steamsrv/templates/shots.html: -------------------------------------------------------------------------------- 1 | {{template "zzheader.html" .}} 2 |

{{.Name}} Screenshots

3 | 4 | 12 | {{template "zzfooter.html" .}} 13 | -------------------------------------------------------------------------------- /internal/tool/misc/status/cam.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | type cam struct{ value bool } 9 | 10 | func (v *cam) load() error { 11 | val, err := exec1Fail("lsof", "/dev/video0") 12 | if err != nil { 13 | return err 14 | } 15 | 16 | v.value = val 17 | return nil 18 | } 19 | 20 | func (v *cam) render(rw http.ResponseWriter) { fmt.Fprintf(rw, "%t\n", v.value) } 21 | -------------------------------------------------------------------------------- /internal/tool/misc/status/locked.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | type locked struct{ value bool } 9 | 10 | func (l *locked) load() error { 11 | v, err := exec1Fail("pgrep", "i3lock") 12 | if err != nil { 13 | return err 14 | } 15 | l.value = v 16 | return nil 17 | } 18 | 19 | func (l *locked) render(rw http.ResponseWriter) { fmt.Fprintf(rw, "%t\n", l.value) } 20 | -------------------------------------------------------------------------------- /pkg/timeutil/timeutil.go: -------------------------------------------------------------------------------- 1 | package timeutil // import "github.com/frioux/leatherman/pkg/timeutil" 2 | 3 | import "time" 4 | 5 | // JumpTo starts at the start and jumps to dest. 6 | func JumpTo(start time.Time, dest time.Weekday) time.Time { 7 | offset := (dest - start.Weekday()) % 7 8 | // Go's modulus is dumb? 9 | if offset < 0 { 10 | offset += 7 11 | } 12 | return start.AddDate(0, 0, int(offset)) 13 | } 14 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "time" 7 | ) 8 | 9 | type errline struct { 10 | Time string 11 | Type string 12 | 13 | Message string 14 | } 15 | 16 | var e = json.NewEncoder(os.Stdout) 17 | 18 | func Err(err error) { 19 | e.Encode(errline{ 20 | Time: time.Now().Format(time.RFC3339Nano), 21 | Type: "error", 22 | Message: err.Error(), 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/clocks/clocks.md: -------------------------------------------------------------------------------- 1 | Shows my personal, digital, wall of clocks 2 | 3 | ```bash 4 | $ clocks Africa/Johannesburg America/Los_Angeles Europe/Copenhagen 5 | Africa/Johannesburg tomorrow 02:43 2:43 AM +10 6 | America/Los_Angeles today 16:43 4:43 PM +0 7 | Europe/Copenhagen tomorrow 01:43 1:43 AM +9 8 | ``` 9 | 10 | Pass one or more timezone names to choose which timezones are shown. 11 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/uni/alluni.go: -------------------------------------------------------------------------------- 1 | package uni 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "golang.org/x/text/unicode/rangetable" 8 | "golang.org/x/text/unicode/runenames" 9 | ) 10 | 11 | func All(_ []string, _ io.Reader) error { 12 | t := rangetable.Assigned(unicodeVersion) 13 | 14 | rangetable.Visit(t, func(r rune) { 15 | name := runenames.Name(r) 16 | fmt.Println(name) 17 | }) 18 | 19 | return nil 20 | } 21 | -------------------------------------------------------------------------------- /internal/tool/misc/wuphf/wuphf.md: -------------------------------------------------------------------------------- 1 | Sends notifications on lots (2) of platforms. 2 | 3 | Wuphf sends alerts via both `wall` and [pushover](https://pushover.net). All 4 | arguments are concatenated to produce the sent message. 5 | 6 | The following environment variables should be set: 7 | 8 | * LM_PUSHOVER_TOKEN 9 | * LM_PUSHOVER_USER 10 | * LM_PUSHOVER_DEVICE 11 | 12 | ```bash 13 | $ wuphf 'the shoes are on sale' 14 | ``` 15 | -------------------------------------------------------------------------------- /internal/tool/notes/amygdala/amygdala.md: -------------------------------------------------------------------------------- 1 | Automated assistant. 2 | 3 | The main docs for it are in [amygdala.mdwn in the root of the leatherman 4 | repo](https://github.com/frioux/leatherman/blob/main/amygdala.mdwn). 5 | 6 | Configure it with the following environment variables: 7 | 8 | * LM_DROPBOX_TOKEN 9 | * LM_MY_CEL 10 | * LM_TWILIO_URL 11 | 12 | Takes an optional `-port` argument to specify which port to listen on. 13 | -------------------------------------------------------------------------------- /internal/tool/notes/now/templates/sup.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" .}} 2 | 3 |
4 |
retropie
{{.RetroPie}}
5 |
steamos
{{.SteamOS}}
6 |
pi400
{{.Pi400}}
7 |
8 | 9 |

Versions

10 | 15 | {{ template "footer.html" .}} 16 | 17 | -------------------------------------------------------------------------------- /internal/tool/notes/now/testdata/add_item/create-section.txt: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Now", 3 | "tags": [ "private", "reference", "project" ], 4 | "reviewed_on": "2020-07-15", 5 | } 6 | 7 | ## Stash 8 | 9 | * foo 10 | * bar 11 | * baz 12 | 13 | ## 2020-07-20 ## 14 | 15 | * create-section 16 | 17 | ## 2020-07-19 ## 18 | 19 | * bong 20 | * biff 21 | * barp 22 | 23 | ## 2020-07-18 ## 24 | 25 | * ~~herp~~ 26 | * ~~dong~~ 27 | 28 | 29 | -------------------------------------------------------------------------------- /internal/tool/misc/bamboo/export-bamboohr.go: -------------------------------------------------------------------------------- 1 | package bamboo 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | ) 8 | 9 | func ExportDirectory([]string, io.Reader) error { 10 | k, d := os.Getenv("BAMBOO_APIKEY"), os.Getenv("BAMBOO_COMPANY_DOMAIN") 11 | if k == "" || d == "" { 12 | return errors.New("BAMBOO_APIKEY and BAMBOO_COMPANY_DOMAIN are required") 13 | } 14 | c := newClient(k, d) 15 | 16 | return c.directory(os.Stdout) 17 | } 18 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/netrcpassword/netrcpassword_test.go: -------------------------------------------------------------------------------- 1 | package netrcpassword 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/frioux/leatherman/internal/testutil" 7 | ) 8 | 9 | func TestRun(t *testing.T) { 10 | t.Parallel() 11 | 12 | pass, err := run("./testdata/basic.netrc", "foo", "bar") 13 | if err != nil { 14 | t.Fatalf("Failed to call run: %s", err) 15 | } 16 | 17 | testutil.Equal(t, pass, "baz", "passwords not equal") 18 | } 19 | -------------------------------------------------------------------------------- /pkg/datefmt/datefmt_test.go: -------------------------------------------------------------------------------- 1 | package datefmt_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/frioux/leatherman/internal/testutil" 7 | "github.com/frioux/leatherman/pkg/datefmt" 8 | ) 9 | 10 | func TestTranslateFormat(t *testing.T) { 11 | t.Parallel() 12 | 13 | testutil.Equal(t, datefmt.TranslateFormat("%F"), "2006-01-02", "wrong date") 14 | testutil.Equal(t, datefmt.TranslateFormat("%FT%T"), "2006-01-02T15:04:05", "wrong date") 15 | } 16 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/toml/toml_test.go: -------------------------------------------------------------------------------- 1 | package toml_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/frioux/leatherman/internal/tool/allpurpose/toml" 9 | ) 10 | 11 | func ExampleToJSON() { 12 | r := strings.NewReader("foo = \"bar\"\n") 13 | 14 | err := toml.ToJSON(nil, r) 15 | if err != nil { 16 | fmt.Fprintf(os.Stderr, "Couldn't ToJSON: %s\n", err) 17 | os.Exit(1) 18 | } 19 | // Output: 20 | // {"foo":"bar"} 21 | } 22 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/uni/uni_test.go: -------------------------------------------------------------------------------- 1 | package uni_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/frioux/leatherman/internal/tool/allpurpose/uni" 8 | ) 9 | 10 | func ExampleDescribe() { 11 | err := uni.Describe([]string{"uni", "⢾"}, nil) 12 | if err != nil { 13 | fmt.Fprintf(os.Stderr, "Couldn't Describe: %s\n", err) 14 | os.Exit(1) 15 | } 16 | // Output: '⢾' @ 10430 aka BRAILLE PATTERN DOTS-234568 ( graphic | printable | symbol ) 17 | } 18 | -------------------------------------------------------------------------------- /internal/tool/misc/twilio/twilio.md: -------------------------------------------------------------------------------- 1 | Makes callbacks like twilio. 2 | 3 | It takes four arguments: 4 | 5 | * `-endpoint`: the url to hit (`http://localhost:8080/twilio`, for example) 6 | * `-auth`: the auth token to use 7 | * `-message`: the message to send 8 | * `-from`: the phone number the message is from (`+15555555555`, for example) 9 | 10 | Run `twilio -help` to see the defaults. 11 | 12 | ```bash 13 | $ twilio -message "the building is on fire!" 14 | ``` 15 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/debounce/leading.go: -------------------------------------------------------------------------------- 1 | package debounce 2 | 3 | import ( 4 | "io" 5 | "time" 6 | ) 7 | 8 | type leadingBouncer struct { 9 | w io.Writer 10 | next time.Time 11 | duration time.Duration 12 | } 13 | 14 | func (l *leadingBouncer) Write(t time.Time, s []byte) error { 15 | oldNext := l.next 16 | l.next = t.Add(l.duration) 17 | if t.After(oldNext) { 18 | _, err := l.w.Write([]byte(s)) 19 | return err 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/groupbydate/groupByDate_test.go: -------------------------------------------------------------------------------- 1 | package groupbydate_test 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/frioux/leatherman/internal/tool/allpurpose/groupbydate" 7 | ) 8 | 9 | func ExampleRun() { 10 | 11 | r := strings.NewReader(`2012-12-12T12:12:12.000Z 12 | 2012-12-12T13:12:12.000Z 13 | 2012-12-12T12:14:12.000Z 14 | 2012-12-12T12:12:22.000Z`) 15 | 16 | groupbydate.Run([]string{"group-by-date"}, r) 17 | // Output: 2012-12-12T00:00:00Z,4 18 | } 19 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/good.netrc: -------------------------------------------------------------------------------- 1 | # I am a comment 2 | machine mail.google.com 3 | login joe@gmail.com 4 | account justagmail #end of line comment with trailing space 5 | password somethingSecret 6 | # I am another comment 7 | 8 | macdef allput 9 | put src/* 10 | 11 | macdef allput2 12 | put src/* 13 | put src2/* 14 | 15 | machine ray login demo password mypassword 16 | 17 | machine weirdlogin login uname password pass#pass 18 | 19 | default 20 | login anonymous 21 | password joe@example.com 22 | 23 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/yaml/tojson_test.go: -------------------------------------------------------------------------------- 1 | package yaml_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/frioux/leatherman/internal/tool/allpurpose/yaml" 9 | ) 10 | 11 | func ExampleToJSON() { 12 | r := strings.NewReader("---\n - foo: 1\n - bar: 2\n---\nx: 1\n") 13 | 14 | err := yaml.ToJSON(nil, r) 15 | if err != nil { 16 | fmt.Fprintf(os.Stderr, "Couldn't ToJSON: %s\n", err) 17 | os.Exit(1) 18 | } 19 | // Output: 20 | // [{"foo":1},{"bar":2}] 21 | // {"x":1} 22 | } 23 | -------------------------------------------------------------------------------- /internal/tool/misc/status/curwindow.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os/exec" 7 | ) 8 | 9 | type curWindow struct{ value string } 10 | 11 | func (l *curWindow) load() error { 12 | // curwindow is an auxiliary leatherman tool 13 | cmd := exec.Command("curwindow") 14 | b, err := cmd.Output() 15 | if err != nil { 16 | return err 17 | } 18 | l.value = string(b) 19 | return nil 20 | } 21 | 22 | func (l *curWindow) render(rw http.ResponseWriter) { fmt.Fprint(rw, l.value) } 23 | -------------------------------------------------------------------------------- /internal/tool/misc/status/x11title.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os/exec" 7 | ) 8 | 9 | type x11title struct { 10 | value string 11 | } 12 | 13 | func (v *x11title) load() error { 14 | val, err := exec.Command("xdotool", "getactivewindow", "getwindowname").CombinedOutput() 15 | if err != nil { 16 | return err 17 | } 18 | 19 | v.value = string(val) 20 | return nil 21 | } 22 | 23 | func (v *x11title) render(rw http.ResponseWriter) { 24 | fmt.Fprintln(rw, v.value) 25 | } 26 | -------------------------------------------------------------------------------- /internal/tool/notes/now/templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{.Title}} 7 | 8 | 9 | 10 | list | sup | now 11 | {{if gt .TODOCount 0}} | todos {{.TODOCount}}{{end}} 12 |

13 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/clocks/clocks_test.go: -------------------------------------------------------------------------------- 1 | package clocks 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "testing" 7 | "time" 8 | 9 | "github.com/frioux/leatherman/internal/testutil" 10 | ) 11 | 12 | //go:embed expect.txt 13 | var expect string 14 | 15 | func TestRun(t *testing.T) { 16 | t.Parallel() 17 | 18 | buf := &bytes.Buffer{} 19 | 20 | at := time.Date(2012, 12, 12, 4, 12, 12, 12, time.UTC) 21 | run(at, []string{"America/Los_Angeles", "UTC"}, buf) 22 | testutil.Equal(t, buf.String(), expect, "wrong report") 23 | } 24 | -------------------------------------------------------------------------------- /bin/enqueue-at: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # exit early if there are no CREATEd files 4 | perl -e 'exit 1 unless scalar grep m/^CREATE/, @ARGV' "$@" || exit 5 | 6 | cd ~/Dropbox/notes/.alerts 7 | 8 | for file in *; do 9 | ts="$(echo "$file" | cut -f1 -d_)" 10 | contents=$(cat "$file") 11 | 12 | # if the ts is before now 13 | if perl -e'exit 1 if shift gt shift' "$ts" "$(date -Iseconds)"; then 14 | wuphf "$contents" 15 | else 16 | echo "wuphf $contents" | at "$(date -d "$ts" '+%H:%M %Y-%m-%d')" 17 | fi 18 | rm "$file" 19 | done 20 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/srv/srv.md: -------------------------------------------------------------------------------- 1 | Serves a directory over http, automatically refreshing when files change. 2 | 3 | It takes an optional dir to serve, the default is `.`. 4 | 5 | ```bash 6 | $ srv ~ 7 | Serving /home/frew on [::]:21873 8 | ``` 9 | 10 | You can pass -port if you care to choose the listen port. 11 | 12 | It will set up file watchers and trigger page reloads (via SSE,) this 13 | functionality can be disabled with -no-autoreload. 14 | 15 | ```bash 16 | $ srv -port 8080 -no-autoreload ~ 17 | Serving /home/frew on [::]:8080 18 | ``` 19 | -------------------------------------------------------------------------------- /internal/tool/notes/now/templates/update.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" .}} 2 | 3 |
4 | 5 |
6 | 7 | 8 |
9 | 10 |
11 | {{ template "footer.html" .}} 12 | -------------------------------------------------------------------------------- /internal/notes/west/fuzz_test.go: -------------------------------------------------------------------------------- 1 | //go:build gofuzzbeta 2 | // +build gofuzzbeta 3 | 4 | package west_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/frioux/leatherman/internal/notes/west" 10 | "github.com/frioux/leatherman/internal/testutil" 11 | ) 12 | 13 | func FuzzParse(f *testing.F) { 14 | f.Add([]byte("")) 15 | for _, d := range crashers { 16 | f.Add([]byte(d)) 17 | } 18 | f.Fuzz(func(t *testing.T, mdwn []byte) { 19 | d := west.Parse(mdwn) 20 | testutil.Equal(t, string(d.Markdown()), string(mdwn), "roundtrips") 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /explode.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // Explode all the tools as symlinks 11 | func Explode(_ []string, _ io.Reader) error { 12 | exe, err := os.Executable() 13 | if err != nil { 14 | return fmt.Errorf("Couldn't get Executable to explode: %w", err) 15 | } 16 | dir := filepath.Dir(exe) 17 | for k := range Dispatch { 18 | if k == "help" { 19 | continue 20 | } 21 | if k == "explode" { 22 | continue 23 | } 24 | _ = os.Symlink(exe, dir+"/"+k) 25 | } 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/shellquote/examples_test.go: -------------------------------------------------------------------------------- 1 | package shellquote_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/frioux/leatherman/pkg/shellquote" 8 | ) 9 | 10 | func Example() { 11 | fmt.Println("#!/bin/sh") 12 | fmt.Println("") 13 | quoted, err := shellquote.Quote(os.Args[1:]) 14 | if err != nil { 15 | fmt.Fprintf(os.Stderr, "Couldn't quote input: %s\n", err) 16 | os.Exit(1) 17 | } 18 | // error won't happen if the first input didn't error 19 | doublequoted, _ := shellquote.Quote([]string{quoted}) 20 | fmt.Println("ssh superserver", doublequoted) 21 | } 22 | -------------------------------------------------------------------------------- /pkg/mozlz4/examples_test.go: -------------------------------------------------------------------------------- 1 | package mozlz4_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/frioux/leatherman/pkg/mozlz4" 9 | ) 10 | 11 | func Example() { 12 | file, err := os.Open(os.Args[1]) 13 | if err != nil { 14 | fmt.Fprintf(os.Stderr, "Couldn't open: %s\n", err) 15 | os.Exit(1) 16 | } 17 | 18 | r, err := mozlz4.NewReader(file) 19 | if err != nil { 20 | fmt.Fprintf(os.Stderr, "Couldn't create reader: %s\n", err) 21 | os.Exit(1) 22 | } 23 | if _, err = io.Copy(os.Stdout, r); err != nil { 24 | fmt.Fprintf(os.Stderr, "Couldn't copy: %s\n", err) 25 | os.Exit(1) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/notes/todo_test.go: -------------------------------------------------------------------------------- 1 | package notes 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | "time" 8 | 9 | "github.com/frioux/leatherman/internal/testutil" 10 | ) 11 | 12 | func TestBody(t *testing.T) { 13 | r := body("testing", time.Date(2012, 12, 12, 12, 12, 12, 0, time.UTC)) 14 | buf := &bytes.Buffer{} 15 | if _, err := io.Copy(buf, r); err != nil { 16 | t.Fatalf("Couldn't couldn't copy body: %s", err) 17 | } 18 | 19 | testutil.Equal(t, `{ 20 | "title": "testing", 21 | "date": "2012-12-12T12:12:12", 22 | "tags": [ "private", "inbox" ] 23 | } 24 | * testing 25 | `, buf.String(), "expected content") 26 | } 27 | -------------------------------------------------------------------------------- /internal/tool/misc/img/draw.go: -------------------------------------------------------------------------------- 1 | package img 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "image/png" 7 | "io" 8 | "os" 9 | 10 | "github.com/frioux/leatherman/internal/drawlua" 11 | ) 12 | 13 | func Draw(args []string, _ io.Reader) error { 14 | if len(args) == 1 { 15 | args = append(args, "") 16 | } 17 | 18 | img := image.NewNRGBA(image.Rect(0, 0, 128, 128)) 19 | for x := 0; x < 128; x++ { 20 | for y := 0; y < 128; y++ { 21 | img.Set(x, y, color.Black) 22 | } 23 | } 24 | 25 | if err := drawlua.Eval(img, args[1:]); err != nil { 26 | return err 27 | } 28 | 29 | return png.Encode(os.Stdout, img) 30 | } 31 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/pomotimer/pomotimer.md: -------------------------------------------------------------------------------- 1 | Conveniently counts down a duration. 2 | 3 | Pomotimer starts a timer for 25m or the duration expressed in the first 4 | argument. 5 | 6 | ``` 7 | pomotimer 2.5m 8 | ``` 9 | 10 | or 11 | 12 | ``` 13 | pomotimer 3m12s 14 | ``` 15 | 16 | Originally a timer for use with [the pomodoro][1] [technique][2]. Handy timer in any case 17 | since you can pass it arbitrary durations, pause it, reset it, and see it's 18 | progress. 19 | 20 | [1]: https://blog.afoolishmanifesto.com/posts/the-pomodoro-technique/ 21 | [2]: https://blog.afoolishmanifesto.com/posts/the-pomodoro-technique-three-years-later/ 22 | -------------------------------------------------------------------------------- /internal/selfupdate/versionhandler.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/frioux/leatherman/internal/version" 8 | ) 9 | 10 | var Handler = http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 11 | rw.Header().Set("content-type", "text/plain") 12 | 13 | if mostRecentFailure != nil { 14 | fmt.Fprintf(rw, "update failure: %s\n\n", mostRecentFailure) 15 | } 16 | 17 | if invalidToken { 18 | fmt.Fprintf(rw, "token is invalid, only updating hourly\n\n") 19 | } 20 | 21 | version.Render(rw) 22 | }) 23 | 24 | func init() { 25 | http.DefaultServeMux.Handle("/version", Handler) 26 | } 27 | -------------------------------------------------------------------------------- /internal/selfupdate/selfupdate_linux.go: -------------------------------------------------------------------------------- 1 | package selfupdate 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "syscall" 7 | ) 8 | 9 | func isSameFile(path string) error { 10 | statExe, err := os.Stat(path) 11 | if err != nil { 12 | return fmt.Errorf("couldn't stat %s: %w", path, err) 13 | } 14 | 15 | statSelf, err := os.Stat("/proc/self/exe") 16 | if err != nil { 17 | return fmt.Errorf("couldn't stat /proc/self/exec: %w", err) 18 | } 19 | 20 | if statExe.Sys().(*syscall.Stat_t).Ino != statSelf.Sys().(*syscall.Stat_t).Ino { 21 | return fmt.Errorf("inodes don't match, something else must be updating already") 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/timeutil/timeutil_test.go: -------------------------------------------------------------------------------- 1 | package timeutil_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/frioux/leatherman/internal/testutil" 8 | "github.com/frioux/leatherman/pkg/timeutil" 9 | ) 10 | 11 | func TestJumpTo(t *testing.T) { 12 | t.Parallel() 13 | 14 | testutil.Equal(t, 15 | timeutil.JumpTo(time.Date(2018, 9, 23, 0, 0, 0, 0, time.UTC), time.Friday), 16 | time.Date(2018, 9, 28, 0, 0, 0, 0, time.UTC), 17 | "Sun -> Fri", 18 | ) 19 | 20 | testutil.Equal(t, 21 | timeutil.JumpTo(time.Date(2018, 9, 22, 0, 0, 0, 0, time.UTC), time.Friday), 22 | time.Date(2018, 9, 28, 0, 0, 0, 0, time.UTC), 23 | "Sat -> Fri", 24 | ) 25 | 26 | } 27 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/yaml/yaml2json.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | yaml "gopkg.in/yaml.v3" 10 | ) 11 | 12 | func ToJSON(_ []string, stdin io.Reader) error { 13 | d := yaml.NewDecoder(stdin) 14 | e := json.NewEncoder(os.Stdout) 15 | 16 | var data interface{} 17 | 18 | for { 19 | err := d.Decode(&data) 20 | if err != nil { 21 | if err == io.EOF { 22 | break 23 | } 24 | return fmt.Errorf("Couldn't decode YAML: %w", err) 25 | } 26 | 27 | err = e.Encode(data) 28 | if err != nil { 29 | return fmt.Errorf("Couldn't encode JSON: %w", err) 30 | } 31 | } 32 | 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/tool/misc/status/tabs.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/frioux/leatherman/pkg/mozlz4" 10 | ) 11 | 12 | type tabs struct{ value string } 13 | 14 | func (l *tabs) load() error { 15 | f, err := os.Open(os.Getenv("MOZ_RECOVERY")) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | r, err := mozlz4.NewReader(f) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | b, err := ioutil.ReadAll(r) 26 | if err != nil { 27 | return err 28 | } 29 | l.value = string(b) 30 | return nil 31 | } 32 | 33 | func (l *tabs) render(rw http.ResponseWriter) { fmt.Fprintf(rw, "%s\n", l.value) } 34 | -------------------------------------------------------------------------------- /internal/tool/misc/status/light.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "sync" 7 | ) 8 | 9 | func manageLight(camMu, soundMu *sync.Mutex, c *cam, s *sound) error { 10 | camMu.Lock() 11 | defer camMu.Unlock() 12 | 13 | soundMu.Lock() 14 | defer soundMu.Unlock() 15 | 16 | if err := c.load(); err != nil { 17 | return err 18 | } 19 | 20 | if err := s.load(); err != nil { 21 | return err 22 | } 23 | 24 | var red, green, blue int 25 | if c.value { 26 | green = 255 27 | } 28 | if s.value { 29 | red = 255 30 | } 31 | 32 | colorSpec := fmt.Sprintf("--rgb=%d,%d,%d", red, green, blue) 33 | return exec.Command("blink1-tool", colorSpec).Run() 34 | } 35 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/debounce/leading_test.go: -------------------------------------------------------------------------------- 1 | package debounce 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | 8 | "github.com/frioux/leatherman/internal/testutil" 9 | ) 10 | 11 | func TestLeading(t *testing.T) { 12 | t.Parallel() 13 | 14 | buf := &bytes.Buffer{} 15 | 16 | l := newBouncer(false, buf, time.Millisecond) 17 | 18 | start := time.Date(2012, 12, 12, 0, 0, 0, 0, time.UTC) 19 | l.Write(start, []byte("foo\n")) 20 | l.Write(start.Add(time.Nanosecond), []byte("bar\n")) 21 | l.Write(start.Add(time.Nanosecond), []byte("baz\n")) 22 | l.Write(start.Add(time.Second+2*time.Nanosecond), []byte("biff\n")) 23 | 24 | testutil.Equal(t, buf.String(), "foo\nbiff\n", "wrong output") 25 | } 26 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/dumpmozlz4/dump-mozlz4.go: -------------------------------------------------------------------------------- 1 | package dumpmozlz4 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/frioux/leatherman/pkg/mozlz4" 9 | ) 10 | 11 | func Run(args []string, _ io.Reader) error { 12 | if len(args) != 2 { 13 | return fmt.Errorf("Usage: %s session.jsonlz4", args[0]) 14 | } 15 | file, err := os.Open(args[1]) 16 | if err != nil { 17 | return fmt.Errorf("Couldn't open: %w", err) 18 | } 19 | 20 | r, err := mozlz4.NewReader(file) 21 | if err != nil { 22 | return fmt.Errorf("mozlz4.NewReader: %w", err) 23 | } 24 | _, err = io.Copy(os.Stdout, r) 25 | if err != nil { 26 | return fmt.Errorf("Couldn't copy: %w", err) 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/uni/name2rune.go: -------------------------------------------------------------------------------- 1 | package uni 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | 8 | "golang.org/x/text/unicode/rangetable" 9 | "golang.org/x/text/unicode/runenames" 10 | ) 11 | 12 | func ToRune(args []string, _ io.Reader) error { 13 | t := rangetable.Assigned(unicodeVersion) 14 | 15 | if len(args) != 2 { 16 | return errors.New("name2rune requires a name") 17 | } 18 | search := args[1] 19 | var found bool 20 | rangetable.Visit(t, func(r rune) { 21 | name := runenames.Name(r) 22 | if name == search { 23 | fmt.Println(string(r)) 24 | 25 | found = true 26 | } 27 | }) 28 | 29 | if found { 30 | return nil 31 | } 32 | 33 | return errors.New("no rune found") 34 | } 35 | -------------------------------------------------------------------------------- /internal/tool/notes/brainstem/brainstem.go: -------------------------------------------------------------------------------- 1 | package brainstem 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/frioux/leatherman/internal/notes" 10 | ) 11 | 12 | func Brainstem(args []string, _ io.Reader) error { 13 | var tok string 14 | 15 | tok = os.Getenv("DROPBOX_ACCESS_TOKEN") 16 | if tok == "" { 17 | return errors.New("DROPBOX_ACCESS_TOKEN is unset") 18 | } 19 | 20 | rules, err := notes.NewRules(tok) 21 | if err != nil { 22 | return fmt.Errorf("Couldn't create rules: %s\n", err) 23 | } 24 | 25 | if len(args) < 2 { 26 | return fmt.Errorf("usage: %s \n", args[0]) 27 | } 28 | message, err := rules.Dispatch(args[1], nil) 29 | fmt.Println(message) 30 | return err 31 | } 32 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := $(shell git describe --abbrev=7 --dirty --always) 2 | WHEN := $(shell git log -1 --pretty=%cI $(VERSION) 2>/dev/null) 3 | 4 | leatherman.xz: leatherman 5 | xz --stdout leatherman > leatherman.xz 6 | 7 | leatherman: export GO111MODULE = on 8 | leatherman: 9 | GO111MODULE=off go get -u golang.org/x/lint/golint 10 | go get ./... 11 | golint -set_exit_status ./... 12 | go vet ./... 13 | TZ=America/Los_Angeles go test -coverprofile=cover.cover -race ./... 14 | go mod verify 15 | go build -ldflags "-X 'github.com/frioux/leatherman/internal/version.Version=$(VERSION)' -X 'github.com/frioux/leatherman/internal/version.When=$(WHEN)'" 16 | ./leatherman version 17 | 18 | watch: 19 | minotaur . -- ./internal/build-test 20 | -------------------------------------------------------------------------------- /noprintln_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "os/exec" 7 | "testing" 8 | ) 9 | 10 | func TestNoPrintln(t *testing.T) { 11 | cmd := exec.Command("git", "grep", "--quiet", "--perl-regexp", `\bpr[i]nt(ln)?\(`, "*.go") 12 | err := cmd.Run() 13 | var e *exec.ExitError 14 | 15 | if errors.As(err, &e) && e.ExitCode() != 0 { 16 | return 17 | } 18 | 19 | if err != nil { 20 | t.Errorf("unexpected error from git grep: %s\n", err) 21 | } else { 22 | b := &bytes.Buffer{} 23 | t.Errorf("git grep found the forbidden println:") 24 | cmd := exec.Command("git", "grep", "--perl-regexp", `\bpr[i]nt(ln)?\(`, "*.go") 25 | cmd.Stdout = b 26 | cmd.Stderr = b 27 | cmd.Run() 28 | t.Error(b.String()) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/tool/misc/desktop/media-remote.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | select a player 7 | 8 | 9 | 10 | 11 |

12 | Hover over a player to resume playing on that player. 13 | If it's the player you want media keys to work with click the link. 14 | After that,

media-remote -pause
(or whatever other subcommands) will use that player. 15 |

16 |

Players

17 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/debounce/trailing.go: -------------------------------------------------------------------------------- 1 | package debounce 2 | 3 | import ( 4 | "io" 5 | "time" 6 | ) 7 | 8 | type trailingBouncer chan struct { 9 | t time.Time 10 | in []byte 11 | } 12 | 13 | func (t trailingBouncer) Write(at time.Time, in []byte) error { 14 | t <- struct { 15 | t time.Time 16 | in []byte 17 | }{at, in} 18 | 19 | return nil 20 | } 21 | 22 | func newTrailingBouncer(w io.Writer, duration time.Duration) trailingBouncer { 23 | ch := make(chan struct { 24 | t time.Time 25 | in []byte 26 | }) 27 | 28 | go func() { 29 | v := <-ch 30 | timeout := time.NewTimer(duration) 31 | 32 | for { 33 | select { 34 | case <-timeout.C: 35 | w.Write(v.in) 36 | case v = <-ch: 37 | timeout.Reset(duration) 38 | } 39 | } 40 | }() 41 | 42 | return trailingBouncer(ch) 43 | } 44 | -------------------------------------------------------------------------------- /cmd/curwindow/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // curwindow prints the name of the currently selected window 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | 9 | "github.com/BurntSushi/xgbutil" 10 | "github.com/BurntSushi/xgbutil/ewmh" 11 | ) 12 | 13 | func main() { 14 | if err := run(); err != nil { 15 | fmt.Fprintln(os.Stderr, err) 16 | os.Exit(1) 17 | } 18 | } 19 | 20 | func run() error { 21 | X, err := xgbutil.NewConn() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | if err != nil { 27 | return err 28 | } 29 | 30 | w, err := ewmh.ActiveWindowGet(X) 31 | if err != nil { 32 | return fmt.Errorf("coudln't ActiveWindowGet: %s", err) 33 | } 34 | name, err := ewmh.WmNameGet(X, w) 35 | if err != nil { 36 | return fmt.Errorf("coudln't WmNameGet: %s", err) 37 | } 38 | fmt.Println(name) 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/tool/misc/status/x11shot.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | ) 11 | 12 | type x11shot struct { 13 | pic []byte 14 | } 15 | 16 | func (v *x11shot) load() error { 17 | f, err := ioutil.TempFile("", "*.png") 18 | if err != nil { 19 | return err 20 | } 21 | defer os.Remove(f.Name()) 22 | 23 | cmd := exec.Command("import", "-window", "root", f.Name()) 24 | cmd.Env = append(os.Environ(), "DISPLAY=:0") 25 | if err := cmd.Run(); err != nil { 26 | return err 27 | } 28 | 29 | v.pic, err = ioutil.ReadAll(f) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func (v *x11shot) render(rw http.ResponseWriter) { 38 | rw.Header().Add("Content-Type", "image/png") 39 | io.Copy(rw, bytes.NewBuffer(v.pic)) 40 | } 41 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/toml/toml2json.go: -------------------------------------------------------------------------------- 1 | package toml 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | parser "github.com/BurntSushi/toml" 11 | ) 12 | 13 | func ToJSON(_ []string, stdin io.Reader) error { 14 | buf := new(bytes.Buffer) 15 | if _, err := io.Copy(buf, stdin); err != nil { 16 | return fmt.Errorf("io.Copy: %w", err) 17 | } 18 | var ret interface{} 19 | if err := parser.Unmarshal(buf.Bytes(), &ret); err != nil { 20 | if terr, ok := err.(parser.ParseError); ok { 21 | return fmt.Errorf("toml.Unmarshal: %s", terr.ErrorWithPosition()) 22 | } else { 23 | return fmt.Errorf("toml.Unmarshal: %w", err) 24 | } 25 | } 26 | 27 | e := json.NewEncoder(os.Stdout) 28 | if err := e.Encode(ret); err != nil { 29 | return fmt.Errorf("json.Encode: %w", err) 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/dropbox/dropbox.go: -------------------------------------------------------------------------------- 1 | package dropbox 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | // Client gives access to the Dropbox API 12 | type Client struct { 13 | Token string 14 | *http.Client 15 | } 16 | 17 | // NewClient returns a fully created Client value 18 | func NewClient(cl Client) (Client, error) { 19 | if cl.Token == "" { 20 | return Client{}, errors.New("Token is required") 21 | } 22 | 23 | if cl.Client == nil { 24 | cl.Client = &http.Client{} 25 | } 26 | 27 | return cl, nil 28 | } 29 | 30 | func (cl Client) handleError(resp *http.Response) error { 31 | if resp.StatusCode > 399 { 32 | buf := &bytes.Buffer{} 33 | if _, err := io.Copy(buf, resp.Body); err != nil { 34 | return fmt.Errorf("io.Copy: %w", err) 35 | } 36 | return errors.New(buf.String()) 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/lmhttp/handlerfunc.go: -------------------------------------------------------------------------------- 1 | package lmhttp 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | type HandlerFunc func(http.ResponseWriter, *http.Request) error 11 | 12 | func (f HandlerFunc) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 13 | if err := f(rw, r); err != nil { 14 | rw.WriteHeader(500) 15 | fmt.Fprintln(os.Stderr, err) 16 | } 17 | } 18 | 19 | // TrimHandlerPrefix adapts handlers to a mux or possibly an alternate 20 | // subroute. The prefix is stripped from the url path such that the inner 21 | // handler is unaware of the prefix. 22 | func TrimHandlerPrefix(prefix string, h http.Handler) http.Handler { 23 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 24 | r = r.Clone(r.Context()) 25 | 26 | r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) 27 | 28 | h.ServeHTTP(rw, r) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/csv/csv2md.go: -------------------------------------------------------------------------------- 1 | package csv 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | func ToMarkdown(_ []string, stdin io.Reader) error { 12 | reader := csv.NewReader(stdin) 13 | 14 | header, err := reader.Read() 15 | if err != nil { 16 | return fmt.Errorf("can't read header, giving up: %w", err) 17 | } 18 | 19 | fmt.Println(strings.Join(header, " | ")) 20 | for range header[:len(header)-1] { 21 | fmt.Print(" --- |") 22 | } 23 | fmt.Println(" ---") 24 | 25 | for { 26 | record, err := reader.Read() 27 | if err == io.EOF { 28 | break 29 | } 30 | if len(record) != len(header) { 31 | continue 32 | } 33 | if err != nil { 34 | fmt.Fprintf(os.Stderr, "Couldn't parse row: %s\n", err) 35 | continue 36 | } 37 | fmt.Println(strings.Join(record, " | ")) 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/tool/misc/desktop/media-remote.md: -------------------------------------------------------------------------------- 1 | `media-remote` control media players on Linux. 2 | 3 | The intention is that this tool is bound to media keys, so a play button on 4 | your keyboard runs `media-remote -play`, etc. The `-select-player` feature 5 | is the main reason to use this tool. 6 | 7 | The following flags do the obvious thing: 8 | 9 | * `-play` 10 | * `-pause` 11 | * `-play-pause` 12 | * `-next` 13 | * `-prev` 14 | 15 | You can use the following to call methods not defined in this tool: 16 | 17 | * `-raw` `` 18 | 19 | Finally, the `-select-player` flag will start up a web UI that will allow the 20 | user to select a different player than the default to control with the media 21 | keys. Try it out by playing a youtube video in both firefox and chrome. 22 | `-select-player` should show two links, allowing you to select firefox or 23 | chrome to bind media keys to. 24 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/debounce/trailing_test.go: -------------------------------------------------------------------------------- 1 | package debounce 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | 8 | "github.com/frioux/leatherman/internal/testutil" 9 | ) 10 | 11 | func TestTrailing(t *testing.T) { 12 | t.Parallel() 13 | 14 | t.Skip("flaky test") 15 | 16 | buf := &bytes.Buffer{} 17 | 18 | l := newBouncer(true, buf, 5*time.Millisecond) 19 | 20 | l.Write(time.Now(), []byte("1\n")) 21 | 22 | time.Sleep(time.Millisecond) 23 | l.Write(time.Now(), []byte("2\n")) 24 | 25 | time.Sleep(time.Millisecond) 26 | l.Write(time.Now(), []byte("3\n")) 27 | 28 | time.Sleep(100 * time.Millisecond) // print 3 29 | l.Write(time.Now(), []byte("4\n")) 30 | 31 | time.Sleep(100 * time.Millisecond) // print 4 32 | l.Write(time.Now(), []byte("5\n")) 33 | 34 | time.Sleep(100 * time.Millisecond) // print 5 35 | 36 | testutil.Equal(t, buf.String(), "3\n4\n5\n", "wrong output") 37 | } 38 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/uni/alluni.md: -------------------------------------------------------------------------------- 1 | Prints all unicode character names 2 | 3 | ```bash 4 | $ alluni | grep DENTISTRY 5 | DENTISTRY SYMBOL LIGHT VERTICAL AND TOP RIGHT 6 | DENTISTRY SYMBOL LIGHT VERTICAL AND BOTTOM RIGHT 7 | DENTISTRY SYMBOL LIGHT VERTICAL WITH CIRCLE 8 | DENTISTRY SYMBOL LIGHT DOWN AND HORIZONTAL WITH CIRCLE 9 | DENTISTRY SYMBOL LIGHT UP AND HORIZONTAL WITH CIRCLE 10 | DENTISTRY SYMBOL LIGHT VERTICAL WITH TRIANGLE 11 | DENTISTRY SYMBOL LIGHT DOWN AND HORIZONTAL WITH TRIANGLE 12 | DENTISTRY SYMBOL LIGHT UP AND HORIZONTAL WITH TRIANGLE 13 | DENTISTRY SYMBOL LIGHT VERTICAL AND WAVE 14 | DENTISTRY SYMBOL LIGHT DOWN AND HORIZONTAL WITH WAVE 15 | DENTISTRY SYMBOL LIGHT UP AND HORIZONTAL WITH WAVE 16 | DENTISTRY SYMBOL LIGHT DOWN AND HORIZONTAL 17 | DENTISTRY SYMBOL LIGHT UP AND HORIZONTAL 18 | DENTISTRY SYMBOL LIGHT VERTICAL AND TOP LEFT 19 | DENTISTRY SYMBOL LIGHT VERTICAL AND BOTTOM LEFT 20 | ``` 21 | -------------------------------------------------------------------------------- /internal/tool/notes/zine/zine.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package zine 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "os" 10 | ) 11 | 12 | func Run(args []string, _ io.Reader) error { 13 | command := "render" 14 | if len(args) > 1 { 15 | command = args[1] 16 | } 17 | 18 | cmd, ok := commands[command] 19 | if !ok { 20 | return fmt.Errorf("unknown command «%s»; valid commands are 'render' and 'q'\n", command) 21 | } 22 | 23 | if err := cmd(args[1:]); err != nil { 24 | return err 25 | } 26 | 27 | return nil 28 | } 29 | 30 | func run() error { 31 | command := "render" 32 | if len(os.Args) > 1 { 33 | command = os.Args[1] 34 | } 35 | 36 | cmd, ok := commands[command] 37 | if !ok { 38 | return fmt.Errorf("unknown command «%s»; valid commands are 'render' and 'q'\n", command) 39 | } 40 | 41 | if err := cmd(os.Args[1:]); err != nil { 42 | return err 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/lmlua/fs.go: -------------------------------------------------------------------------------- 1 | package lmlua 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | 7 | lua "github.com/yuin/gopher-lua" 8 | 9 | "github.com/frioux/leatherman/internal/lmfs" 10 | ) 11 | 12 | func RegisterFSType(L *lua.LState) { 13 | mt := L.NewTypeMetatable("fs") 14 | L.SetGlobal("fs", mt) 15 | L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), fsMethods)) 16 | } 17 | 18 | func CheckFS(L *lua.LState, where int) fs.FS { 19 | ud := L.CheckUserData(where) 20 | if v, ok := ud.Value.(fs.FS); ok { 21 | return v 22 | } 23 | L.ArgError(1, fmt.Sprintf("fs expected, saw %T", ud.Value)) 24 | return nil 25 | } 26 | 27 | var fsMethods = map[string]lua.LGFunction{ 28 | // TODO 29 | // open 30 | // create 31 | "writefile": func(L *lua.LState) int { 32 | fss := CheckFS(L, 1) 33 | path := L.CheckString(2) 34 | contents := L.CheckString(3) 35 | lmfs.WriteFile(fss, path, []byte(contents), 0644) 36 | return 0 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/csv/csv2json.go: -------------------------------------------------------------------------------- 1 | package csv 2 | 3 | import ( 4 | "encoding/csv" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | ) 12 | 13 | func ToJSON(_ []string, stdin io.Reader) error { 14 | reader := csv.NewReader(stdin) 15 | writer := json.NewEncoder(os.Stdout) 16 | 17 | header, err := reader.Read() 18 | if err != nil { 19 | return errors.New("can't read header, giving up") 20 | } 21 | 22 | for { 23 | record, err := reader.Read() 24 | if err == io.EOF { 25 | break 26 | } 27 | if len(record) != len(header) { 28 | continue 29 | } 30 | if err != nil { 31 | log.Println(err) 32 | } 33 | toEncode := map[string]string{} 34 | for v, x := range header { 35 | toEncode[x] = record[v] 36 | } 37 | 38 | err = writer.Encode(toEncode) 39 | if err != nil { 40 | return fmt.Errorf("json.Encode: %w", err) 41 | } 42 | } 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/expandurl/expandurl_test.go: -------------------------------------------------------------------------------- 1 | package expandurl 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/frioux/leatherman/internal/testutil" 13 | ) 14 | 15 | //go:embed testdata/test.html 16 | var testhtml []byte 17 | 18 | func TestRun(t *testing.T) { 19 | t.Parallel() 20 | 21 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | w.Header().Set("Content-Type", "text/html") 23 | if _, err := io.Copy(w, bytes.NewReader(testhtml)); err != nil { 24 | panic(err) 25 | } 26 | })) 27 | defer ts.Close() 28 | 29 | buf := &bytes.Buffer{} 30 | err := run(strings.NewReader(ts.URL), buf) 31 | if err != nil { 32 | t.Errorf("run errored: %s", err) 33 | return 34 | } 35 | testutil.Equal(t, buf.String(), "[fREW Schmidt's Foolish Manifesto]("+ts.URL+")\n", "wrong output") 36 | } 37 | -------------------------------------------------------------------------------- /internal/tool/misc/prependhist/prependemojihist_test.go: -------------------------------------------------------------------------------- 1 | package prependemojihist 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/frioux/leatherman/internal/testutil" 10 | ) 11 | 12 | func TestRun(t *testing.T) { 13 | t.Parallel() 14 | 15 | historyPath := "./testdata/hist.txt" 16 | history, err := os.Open(historyPath) 17 | if err != nil { 18 | t.Fatalf("Couldn't open for test: %s", err) 19 | } 20 | fi, err := os.Stat(historyPath) 21 | if err != nil { 22 | t.Fatalf("Couldn't stat for test: %s", err) 23 | } 24 | 25 | pos := int(fi.Size()) 26 | 27 | in := strings.NewReader(`WHITE STAR 28 | RABBIT 29 | BEER MUG 30 | SKULL AND CROSSBONES 31 | `) 32 | out := &bytes.Buffer{} 33 | 34 | if err := run(history, in, pos, out); err != nil { 35 | t.Fatalf("Couldn't run `run`: %s", err) 36 | } 37 | 38 | testutil.Equal(t, out.String(), "SKULL AND CROSSBONES\nBEER MUG\nWHITE STAR\nRABBIT\n", "output not equal") 39 | } 40 | -------------------------------------------------------------------------------- /internal/tool/misc/status/retropie.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | type retropie struct { 12 | Console, Emu, Game, Command string 13 | } 14 | 15 | func (v *retropie) load() error { 16 | f, err := os.Open("/run/shm/runcommand.info") 17 | if err != nil { 18 | if os.IsNotExist(err) { 19 | v.Game = "n/a" 20 | return nil 21 | } 22 | return err 23 | } 24 | 25 | s := bufio.NewScanner(f) 26 | 27 | i := 0 28 | for s.Scan() { 29 | i++ 30 | 31 | switch i { 32 | case 1: 33 | v.Console = s.Text() 34 | case 2: 35 | v.Emu = s.Text() 36 | case 3: 37 | v.Game = s.Text() 38 | case 4: 39 | v.Command = s.Text() 40 | default: 41 | return errors.New("runcommand.info longer than expected") 42 | } 43 | 44 | } 45 | 46 | return s.Err() 47 | } 48 | 49 | func (v *retropie) render(rw http.ResponseWriter) { 50 | e := json.NewEncoder(rw) 51 | e.Encode(v) 52 | } 53 | -------------------------------------------------------------------------------- /internal/lmhttp/lmhttp.go: -------------------------------------------------------------------------------- 1 | package lmhttp 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | 8 | "github.com/frioux/leatherman/internal/version" 9 | ) 10 | 11 | // UserAgent is the canonical UserAgent string for the leatherman. 12 | var UserAgent = "leatherman/" + version.Version 13 | 14 | // NewRequest returns an *http.Request with the UserAgent header properly set. 15 | func NewRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { 16 | req, err := http.NewRequest(method, url, body) 17 | if err != nil { 18 | return nil, err 19 | } 20 | req = req.WithContext(ctx) 21 | 22 | req.Header.Set("User-Agent", UserAgent) 23 | return req, err 24 | } 25 | 26 | // Get requests the url with http.DefaultClient, using NewRequest 27 | func Get(ctx context.Context, url string) (*http.Response, error) { 28 | req, err := NewRequest(ctx, "GET", url, nil) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return http.DefaultClient.Do(req) 33 | } 34 | -------------------------------------------------------------------------------- /internal/tool/misc/steamsrv/steamsrv.md: -------------------------------------------------------------------------------- 1 | Steamsrv renders steam screenshots and the steam log (what games you played and when) over http. 2 | 3 | ## systemd.service 4 | 5 | The following is a working systemd unit you can use to set up a service: 6 | 7 | ``` 8 | [Unit] 9 | Description=Steam Server, port 8080 10 | 11 | [Service] 12 | Environment='LM_GH_TOKEN=Bearer xxx' 13 | ExecStart=/home/pi/leatherman steamsrv -screenshot-prefix %h/.local/share/Steam/userdata/1234324321 14 | Restart=always 15 | StartLimitBurst=0 16 | 17 | [Install] 18 | WantedBy=default.target 19 | ``` 20 | 21 | You can put it at either `/etc/systemd/system/steamsrv.service` or 22 | `~/.config/systemd/user/steamsrv.service`. 23 | 24 | Then do one of these: 25 | 26 | ```bash 27 | $ systemctl --user daemon-reload 28 | $ systemctl --user enable steamsrv 29 | $ systemctl --user start steamsrv 30 | ``` 31 | 32 | ```bash 33 | $ systemctl daemon-reload 34 | $ systemctl enable steamsrv 35 | $ systemctl start steamsrv 36 | ``` 37 | -------------------------------------------------------------------------------- /internal/twilio/twilio_test.go: -------------------------------------------------------------------------------- 1 | package twilio_test 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/frioux/leatherman/internal/twilio" 9 | ) 10 | 11 | func TestCheckMAC(t *testing.T) { 12 | // Example from https://www.twilio.com/docs/usage/security#notes 13 | ok, err := twilio.CheckMAC([]byte("12345"), []byte("https://mycompany.com/myapp.php?foo=1&bar=2"), &http.Request{ 14 | PostForm: url.Values(map[string][]string{ 15 | "CallSid": {"CA1234567890ABCDE"}, 16 | "Caller": {"+12349013030"}, 17 | "Digits": {"1234"}, 18 | "From": {"+12349013030"}, 19 | "To": {"+18005551212"}, 20 | }), 21 | Header: http.Header(map[string][]string{ 22 | "X-Twilio-Signature": {"0/KCTR6DLpKmkAf8muzZqo1nDgQ="}, 23 | "Content-Type": {"application/x-www-form-urlencoded"}, 24 | }), 25 | }) 26 | 27 | if !ok { 28 | t.Error("ok should be true") 29 | } 30 | 31 | if err != nil { 32 | t.Errorf("unexpected error: %s", err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/lmhttp/mux.go: -------------------------------------------------------------------------------- 1 | package lmhttp 2 | 3 | import ( 4 | "net/http" 5 | "sort" 6 | 7 | "embed" 8 | "html/template" 9 | ) 10 | 11 | type ClearMux struct { 12 | endpoints []string 13 | *http.ServeMux 14 | } 15 | 16 | //go:embed templates/* 17 | var templateFS embed.FS 18 | 19 | var templates = template.Must(template.New("tmpl").ParseFS(templateFS, "templates/*")) 20 | 21 | func NewClearMux() *ClearMux { 22 | m := &ClearMux{ServeMux: http.NewServeMux()} 23 | m.ServeMux.Handle("/", HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) error { 24 | rw.Header().Add("Content-Type", "text/html") 25 | if err := templates.ExecuteTemplate(rw, "list.html", m.endpoints); err != nil { 26 | return err 27 | } 28 | 29 | return nil 30 | })) 31 | 32 | return m 33 | } 34 | 35 | func (m *ClearMux) Handle(pattern string, handler http.Handler) { 36 | m.endpoints = append(m.endpoints, pattern) 37 | sort.Strings(m.endpoints) // could use a heap but meh 38 | m.ServeMux.Handle(pattern, handler) 39 | } 40 | -------------------------------------------------------------------------------- /internal/tool/notes/now/add_item_test.go: -------------------------------------------------------------------------------- 1 | package now 2 | 3 | import ( 4 | "io/ioutil" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/frioux/leatherman/internal/testutil" 10 | ) 11 | 12 | func TestAddItem(t *testing.T) { 13 | type testCase struct { 14 | item string 15 | when time.Time 16 | } 17 | 18 | cases := []testCase{ 19 | {item: "xyzzy"}, 20 | { 21 | item: "create-section", 22 | when: time.Date(2020, 7, 20, 0, 0, 0, 0, time.UTC), 23 | }, 24 | } 25 | 26 | for _, c := range cases { 27 | t.Run(c.item, func(t *testing.T) { 28 | expectFile, err := ioutil.ReadFile("testdata/add_item/" + c.item + ".txt") 29 | if err != nil { 30 | t.Fatalf("unexpected error: %s", err) 31 | } 32 | 33 | if c.when.IsZero() { 34 | c.when = time.Date(2020, 7, 19, 0, 0, 0, 0, time.UTC) 35 | } 36 | 37 | b, err := addItem(strings.NewReader(eg), c.when, c.item) 38 | if err != nil { 39 | t.Fatalf("unexpected error: %s", err) 40 | } 41 | 42 | testutil.Equal(t, string(b), string(expectFile), "adding worked") 43 | }) 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /internal/tool/notes/zine/zine_test.go: -------------------------------------------------------------------------------- 1 | package zine 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/frioux/leatherman/internal/testutil" 10 | ) 11 | 12 | func TestFullRender(t *testing.T) { 13 | d, err := ioutil.TempDir("", "") 14 | if err != nil { 15 | t.Fatalf("couldn't create test: %s", err) 16 | } 17 | if os.Getenv("LM_KEEP") == "" { 18 | t.Log("set LM_KEEP and the directory will not be cleaned up") 19 | defer os.RemoveAll(d) 20 | } 21 | 22 | if err := render([]string{"render", "-static", "./testdata", "-root", "./testdata", "-out", d}); err != nil { 23 | t.Errorf("Rendered produced unexpected error: %s", err) 24 | } 25 | 26 | b, err := ioutil.ReadFile(filepath.Join(d, "cats", "index.html")) 27 | if err != nil { 28 | t.Errorf("Couldn't read output: %s", err) 29 | return 30 | } 31 | 32 | testutil.Equal(t, `

This is the header!

33 |

cats

34 |

cats are the best.

35 |

this is the footer!

36 | `, string(b), "cats generated correctly") 37 | 38 | // XXX add test for index 39 | } 40 | -------------------------------------------------------------------------------- /internal/middleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/frioux/leatherman/internal/middleware" 11 | "github.com/frioux/leatherman/internal/testutil" 12 | ) 13 | 14 | func TestLog(t *testing.T) { 15 | req, err := http.NewRequest("GET", "/", nil) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | buf := new(bytes.Buffer) 21 | var inner http.HandlerFunc = func(w http.ResponseWriter, _ *http.Request) { 22 | w.WriteHeader(404) 23 | } 24 | rr := httptest.NewRecorder() 25 | handler := middleware.Adapt(inner, middleware.Log(buf)) 26 | 27 | handler.ServeHTTP(rr, req) 28 | 29 | if status := rr.Code; status != http.StatusNotFound { 30 | t.Errorf("handler returned wrong status code: got %v want %v", 31 | status, http.StatusOK) 32 | } 33 | 34 | d := json.NewDecoder(buf) 35 | var x struct{ StatusCode int } 36 | if err = d.Decode(&x); err != nil { 37 | panic(err) 38 | } 39 | 40 | testutil.Equal(t, x.StatusCode, http.StatusNotFound, "status code recorded") 41 | } 42 | -------------------------------------------------------------------------------- /internal/tool/misc/bamboo/client.go: -------------------------------------------------------------------------------- 1 | package bamboo 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "errors" 7 | "net/http" 8 | 9 | "github.com/frioux/leatherman/internal/lmhttp" 10 | ) 11 | 12 | type client struct { 13 | apiKey string 14 | 15 | companyDomain string 16 | } 17 | 18 | func newClient(apiKey, companyDomain string) client { 19 | return client{ 20 | apiKey: apiKey, 21 | companyDomain: companyDomain, 22 | } 23 | } 24 | 25 | func (c *client) prefix() string { 26 | return "https://api.bamboohr.com/api/gateway.php/" + c.companyDomain 27 | } 28 | 29 | func (c *client) directory(w io.Writer) error { 30 | req, err := lmhttp.NewRequest(context.Background(), "GET", c.prefix() + "/v1/employees/directory", nil) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | req.SetBasicAuth(c.apiKey, "x") 36 | req.Header.Add("Accept", "application/json") 37 | 38 | res, err := http.DefaultClient.Do(req) 39 | if err != nil { 40 | return err 41 | } 42 | defer res.Body.Close() 43 | 44 | if res.StatusCode > 299 { 45 | return errors.New(res.Status) 46 | } 47 | 48 | _, err = io.Copy(w, res.Body) 49 | 50 | return err 51 | } 52 | -------------------------------------------------------------------------------- /internal/tool/misc/status/cacher.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | type value interface { 11 | load() error 12 | render(http.ResponseWriter) 13 | } 14 | 15 | type cacher struct { 16 | mu *sync.Mutex 17 | timeout time.Time 18 | 19 | reloadEvery time.Duration 20 | value value 21 | } 22 | 23 | func (v *cacher) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 24 | v.mu.Lock() 25 | defer v.mu.Unlock() 26 | 27 | if v.reloadEvery == 0 { 28 | if err := v.value.load(); err != nil { 29 | rw.WriteHeader(500) 30 | fmt.Fprint(rw, err.Error()) 31 | return 32 | } 33 | 34 | rw.Header().Set("Cache-Control", "no-cache") 35 | v.value.render(rw) 36 | return 37 | } 38 | 39 | if v.timeout.Before(time.Now()) { 40 | if err := v.value.load(); err != nil { 41 | rw.WriteHeader(500) 42 | fmt.Fprint(rw, err.Error()) 43 | return 44 | } 45 | 46 | v.timeout = time.Now().Add(v.reloadEvery) 47 | } 48 | 49 | rw.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", int(v.timeout.Sub(time.Now()).Seconds()))) 50 | v.value.render(rw) 51 | } 52 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/netrcpassword/netrc-password.go: -------------------------------------------------------------------------------- 1 | package netrcpassword 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/user" 9 | "path/filepath" 10 | 11 | "github.com/frioux/leatherman/pkg/netrc" 12 | ) 13 | 14 | func Run(args []string, _ io.Reader) error { 15 | if len(args) != 3 { 16 | fmt.Println("Usage:\n\tnetrc-password $machine $login") 17 | os.Exit(1) 18 | } 19 | 20 | usr, err := user.Current() 21 | if err != nil { 22 | return fmt.Errorf("Couldn't get current user: %w", err) 23 | } 24 | 25 | password, err := run(filepath.Join(usr.HomeDir, ".netrc"), args[1], args[2]) 26 | if err != nil { 27 | return fmt.Errorf("Couldn't load password: %w", err) 28 | } 29 | 30 | fmt.Println(password) 31 | 32 | return nil 33 | } 34 | 35 | func run(path, machine, user string) (string, error) { 36 | n, err := netrc.Parse(path) 37 | if err != nil { 38 | return "", fmt.Errorf("Couldn't parse netrc: %w", err) 39 | } 40 | 41 | login, ok := n.MachineAndLogin(machine, user) 42 | if !ok { 43 | return "", errors.New("Couldn't find login for " + user + "@" + machine) 44 | } 45 | 46 | return login.Password, nil 47 | } 48 | -------------------------------------------------------------------------------- /maint/generate-dispatch: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | use autodie; 6 | 7 | no warnings 'uninitialized'; 8 | 9 | use JSON::PP; 10 | use File::Basename 'fileparse'; 11 | 12 | my (%imports, %commands); 13 | 14 | while () { 15 | my $c = decode_json($_); 16 | 17 | $imports{$c->{import}} = 1; 18 | 19 | my ($basename) = fileparse($c->{path}, ".go"); 20 | 21 | %commands = ( 22 | %commands, 23 | $basename => "$c->{package}.$c->{func}" 24 | ); 25 | } 26 | 27 | open my $fh, '>', 'dispatch.go'; 28 | 29 | print $fh <<'GOLANG'; 30 | // Code generated by maint/generate-dispatch. DO NOT EDIT. 31 | 32 | package main 33 | 34 | import ( 35 | "io" 36 | 37 | GOLANG 38 | 39 | print $fh qq(\t"$_"\n) for sort keys %imports; 40 | 41 | print $fh <<'GOLANG'; 42 | ) 43 | 44 | func init() { 45 | Dispatch = map[string]func([]string, io.Reader) error{ 46 | GOLANG 47 | 48 | print $fh qq(\t\t"$_": $commands{$_},\n) for sort keys %commands; 49 | 50 | print $fh <<'GOLANG'; 51 | 52 | "help": Help, 53 | "version": Version, 54 | "explode": Explode, 55 | } 56 | } 57 | GOLANG 58 | 59 | close $fh; 60 | 61 | system 'go', 'fmt'; 62 | -------------------------------------------------------------------------------- /internal/tool/chat/automoji/emojiset.go: -------------------------------------------------------------------------------- 1 | package automoji 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | nonNameRE = regexp.MustCompile(`[^a-z_]+`) 10 | secretRE = regexp.MustCompile(`\|\|.+\|\|`) 11 | ) 12 | 13 | func newEmojiSet(m string) (*emojiSet, error) { 14 | s := &emojiSet{ 15 | message: m, 16 | optional: make(map[string]bool), 17 | } 18 | 19 | m = strings.ToLower(m) 20 | 21 | if secretRE.MatchString(m) { 22 | s.optional["🙈"] = true 23 | m = secretRE.ReplaceAllString(m, " ") 24 | } 25 | 26 | m = nonNameRE.ReplaceAllString(m, " ") 27 | s.words = strings.Split(m, " ") 28 | 29 | if err := luaEval(s); err != nil { 30 | return nil, err 31 | } 32 | 33 | return s, nil 34 | } 35 | 36 | type emojiSet struct { 37 | message string 38 | words []string 39 | optional map[string]bool 40 | required []string 41 | } 42 | 43 | func (s *emojiSet) all(c int) []string { 44 | ret := make([]string, len(s.required), c+len(s.required)) 45 | 46 | copy(ret, s.required) 47 | 48 | for e := range s.optional { 49 | if c != 0 && len(ret) == cap(ret) { 50 | break 51 | } 52 | ret = append(ret, e) 53 | } 54 | 55 | return ret 56 | } 57 | -------------------------------------------------------------------------------- /internal/tool/chat/slack/slack-status.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "io" 7 | "net/http" 8 | "os" 9 | "time" 10 | ) 11 | 12 | func Status(args []string, _ io.Reader) error { 13 | // https://api.slack.com/custom-integrations/legacy-tokens 14 | token := os.Getenv("SLACK_TOKEN") 15 | if token == "" { 16 | return errors.New("SLACK_TOKEN is required") 17 | } 18 | 19 | var ( 20 | text, emoji string 21 | expiration time.Duration 22 | debug bool 23 | ) 24 | 25 | flags := flag.NewFlagSet("slack-status", flag.ExitOnError) 26 | flags.StringVar(&text, "text", "", "text to set status to") 27 | flags.StringVar(&emoji, "emoji", "", "emoji to set status to") 28 | flags.DurationVar(&expiration, "expiration", time.Duration(0), "when to expire status") 29 | flags.Parse(args[1:]) 30 | 31 | cl := client{ 32 | Token: token, 33 | Client: &http.Client{}, 34 | debug: debug, 35 | } 36 | i := usersProfileSetInput{ 37 | StatusText: text, 38 | StatusEmoji: emoji, 39 | } 40 | 41 | if expiration != time.Duration(0) { 42 | i.StatusExpiration = time.Now().Add(expiration).Unix() 43 | } 44 | 45 | err := cl.usersProfileSet(i) 46 | return err 47 | } 48 | -------------------------------------------------------------------------------- /maint/README_end.md: -------------------------------------------------------------------------------- 1 | ## Debugging 2 | 3 | In an effort to make debugging simpler, I've created three ways to see what 4 | `leatherman` is doing: 5 | 6 | ### Tracing 7 | 8 | `LMTRACE=$somefile` will write an execution trace to `$somefile`; look at that with `go tool trace $somefile` 9 | 10 | Since so many of the tools are short lived my assumption is that the execution 11 | trace will be the most useful. 12 | 13 | ### Profiling 14 | 15 | `LMPROF=$somefile` will write a cpu profile to `$somefile`; look at that with `go tool pprof -http localhost:10123 $somefile` 16 | 17 | If you have a long running tool, the pprof http endpoint is exposed on 18 | `localhost:6060/debug/pprof` but picks a random port if that port is in use; the 19 | port can be overridden by setting `LMHTTPPROF=$someport`. 20 | 21 | ## Auxiliary Tools 22 | 23 | Some tools are annoying to have in the main leatherman tool. Maybe they pull 24 | in deps that are huge or need cgo, but in any case I try to keep them separate. 25 | For now, these tools are under `leatherman/cmd` and must be built and run 26 | separately. At some point I may come up with a policy around building or naming these, 27 | but for now they are simply extra tools. 28 | -------------------------------------------------------------------------------- /internal/dropbox/delete.go: -------------------------------------------------------------------------------- 1 | package dropbox 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | // Delete a file or folder. Official docs are at 13 | // https://www.dropbox.com/developers/documentation/http/documentation#files-delete 14 | func (cl Client) Delete(path string) error { 15 | buf := &bytes.Buffer{} 16 | e := json.NewEncoder(buf) 17 | if err := e.Encode(struct { 18 | Path string `json:"path"` 19 | }{path}); err != nil { 20 | return fmt.Errorf("dropbox.Client.Delete: %w", err) 21 | } 22 | req, err := http.NewRequest("POST", "https://api.dropboxapi.com/2/files/delete_v2", buf) 23 | if err != nil { 24 | return fmt.Errorf("http.NewRequest: %w", err) 25 | } 26 | 27 | req.Header.Set("Authorization", "Bearer "+cl.Token) 28 | req.Header.Set("Content-Type", "application/json") 29 | 30 | resp, err := cl.Do(req) 31 | if err != nil { 32 | return fmt.Errorf("http.Client.Do: %w", err) 33 | } 34 | 35 | if resp.StatusCode > 399 { 36 | buf := &bytes.Buffer{} 37 | if _, err := io.Copy(buf, resp.Body); err != nil { 38 | return fmt.Errorf("io.Copy: %w", err) 39 | } 40 | return errors.New(buf.String()) 41 | } 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/lmfav/genpal: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use autodie; 5 | 6 | open my $fh, '>', 'pal.go'; 7 | 8 | print $fh <<'GO'; 9 | package lmfav 10 | 11 | import "image/color" 12 | 13 | func init() { 14 | palettes = [][]color.NRGBA{ 15 | GO 16 | 17 | my @p = ( 18 | 'https://coolors.co/palette/9b5de5-f15bb5-fee440-00bbf9-00f5d4', 19 | 'https://coolors.co/palette/177e89-084c61-db3a34-ffc857-323031', 20 | 'https://coolors.co/palette/0c0f0a-ff206e-fbff12-41ead4-ffffff', 21 | 'https://coolors.co/palette/000814-001d3d-003566-ffc300-ffd60a', 22 | 'https://coolors.co/palette/f72585-7209b7-3a0ca3-4361ee-4cc9f0', 23 | 'https://coolors.co/palette/003049-d62828-f77f00-fcbf49-eae2b7', 24 | 'https://coolors.co/palette/606c38-283618-fefae0-dda15e-bc6c25', 25 | 'https://coolors.co/palette/ffbe0b-fb5607-ff006e-8338ec-3a86ff', 26 | ); 27 | 28 | my $i = 0; 29 | for my $p (@p) { 30 | print $fh "\t\t// $p\n"; 31 | print $fh "\t\t$i: {\n"; 32 | for my $c ($p =~ m/[0-9a-f]{6}/gc) { 33 | my ($r, $g, $b) = ($c =~ m/(..)(..)(..)/); 34 | print $fh "\t\t\tcolor.NRGBA{0x$r, 0x$g, 0x$b, 0xff},\n", 35 | } 36 | print $fh "\t\t},\n"; 37 | $i++; 38 | } 39 | 40 | print $fh <<'GO' 41 | } 42 | } 43 | GO 44 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/srv/srv_test.go: -------------------------------------------------------------------------------- 1 | package srv 2 | 3 | import ( 4 | "io/ioutil" 5 | "net" 6 | "net/http" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/frioux/leatherman/internal/testutil" 12 | ) 13 | 14 | func TestServe(t *testing.T) { 15 | t.Parallel() 16 | 17 | ch := make(chan net.Addr) 18 | go serve(false, ".", 0, ch) 19 | 20 | var addr net.Addr 21 | timer := time.NewTimer(time.Second) 22 | select { 23 | case <-timer.C: 24 | t.Fatalf("couldn't get response from server within timeout") 25 | case addr = <-ch: 26 | } 27 | 28 | resp, err := http.Get("http://" + string(addr.String()) + "/srv.go") 29 | if err != nil { 30 | t.Fatalf("Couldn't fetch srv.go: %s", err) 31 | } 32 | 33 | f, err := os.Open("./srv.go") 34 | if err != nil { 35 | t.Fatalf("Couldn't open ./srv.go: %s", err) 36 | } 37 | expected, err := ioutil.ReadAll(f) 38 | if err != nil { 39 | t.Fatalf("Couldn't read ./srv.go: %s", err) 40 | } 41 | 42 | got, err := ioutil.ReadAll(resp.Body) 43 | if err != nil { 44 | t.Fatalf("Couldn't read response: %s", err) 45 | } 46 | 47 | if len(expected) == 0 { 48 | t.Fatal("Somehow got empty test case") 49 | } 50 | 51 | testutil.Equal(t, got, expected, "incorrect body") 52 | } 53 | -------------------------------------------------------------------------------- /internal/testutil/testutil.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | // Equal takes t, got, expected, and a prefix, returning true if got and 12 | // expected are expected. 13 | func Equal(t *testing.T, got, expected interface{}, prefix string, opts ...cmp.Option) bool { 14 | t.Helper() 15 | if diff := cmp.Diff(expected, got, opts...); diff != "" { 16 | t.Errorf("%s (-want +got):\n%s", prefix, diff) 17 | return false 18 | } 19 | 20 | return true 21 | } 22 | 23 | // JSONEqual takes a got and expected string of json and compares the parsed values with Equal. 24 | func JSONEqual(t *testing.T, got, expected string, prefix string, opts ...cmp.Option) bool { 25 | t.Helper() 26 | var gotValue, expectedValue interface{} 27 | if err := json.NewDecoder(strings.NewReader(got)).Decode(&gotValue); err != nil { 28 | t.Errorf("Couldn't decode got: %s", err) 29 | return false 30 | } 31 | 32 | if err := json.NewDecoder(strings.NewReader(expected)).Decode(&expectedValue); err != nil { 33 | t.Errorf("Couldn't decode expected: %s", err) 34 | return false 35 | } 36 | 37 | return Equal(t, gotValue, expectedValue, prefix, opts...) 38 | } 39 | -------------------------------------------------------------------------------- /internal/dropbox/download.go: -------------------------------------------------------------------------------- 1 | package dropbox 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | ) 11 | 12 | func encodeDownloadParams(path string) (string, error) { 13 | buf := &bytes.Buffer{} 14 | 15 | e := json.NewEncoder(buf) 16 | err := e.Encode(struct { 17 | Path string `json:"path"` 18 | }{path}) 19 | if err != nil { 20 | return "", fmt.Errorf("json.Encode: %w", err) 21 | } 22 | 23 | return strings.TrimSuffix(buf.String(), "\n"), nil 24 | } 25 | 26 | // Download a file 27 | func (cl Client) Download(path string) ([]byte, error) { 28 | req, err := http.NewRequest("POST", "https://content.dropboxapi.com/2/files/download", &bytes.Buffer{}) 29 | if err != nil { 30 | return nil, fmt.Errorf("http.NewRequest: %w", err) 31 | } 32 | 33 | req.Header.Set("Authorization", "Bearer "+cl.Token) 34 | apiArg, err := encodeDownloadParams(path) 35 | if err != nil { 36 | return nil, err 37 | } 38 | req.Header.Set("Dropbox-API-Arg", apiArg) 39 | 40 | resp, err := cl.Do(req) 41 | if err != nil { 42 | return nil, fmt.Errorf("http.Client.Do: %w", err) 43 | } 44 | defer resp.Body.Close() 45 | 46 | if err := cl.handleError(resp); err != nil { 47 | return nil, err 48 | } 49 | 50 | return io.ReadAll(resp.Body) 51 | } 52 | -------------------------------------------------------------------------------- /internal/personality/personality.go: -------------------------------------------------------------------------------- 1 | // Package personality returns pseudorandom responses for the lulz. 2 | // If you don't call rand.Seed() with something sensible it won't even be 3 | // pseudorandom. 4 | package personality 5 | 6 | import ( 7 | "math/rand" 8 | ) 9 | 10 | var acks = []string{ 11 | "station", 12 | "got em.", 13 | "👍", 14 | "ack", 15 | "10-4", 16 | "wilco", 17 | "aye aye cap'm'", 18 | } 19 | 20 | // Ack returns a string meaning "yes" 21 | func Ack() string { 22 | const offset = 100 23 | res := rand.Intn(offset + len(acks)) 24 | if res > offset { 25 | return acks[res-offset] 26 | } 27 | 28 | return "Aight" 29 | } 30 | 31 | var errs = []string{ 32 | "COMPTER FAIL", 33 | "Shucks Howdy! 🤠", 34 | "FAIL🐳", 35 | } 36 | 37 | // Err returns a string meaning something went wrong 38 | func Err() string { 39 | return errs[rand.Intn(len(errs))] 40 | } 41 | 42 | var userErrs = []string{ 43 | "PEBCAK", 44 | "You're holding it wrong", 45 | "WRONG", 46 | } 47 | 48 | type invalidInput interface { 49 | Error() string 50 | InvalidInput() 51 | } 52 | 53 | // UserErr returns a string meaning invalid input 54 | func UserErr(err error) string { 55 | if ii, ok := err.(invalidInput); ok { 56 | return userErrs[rand.Intn(len(userErrs))] + ": " + ii.Error() 57 | } 58 | return Err() 59 | } 60 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/fn/fn.go: -------------------------------------------------------------------------------- 1 | package fn 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | 9 | "github.com/frioux/leatherman/pkg/shellquote" 10 | ) 11 | 12 | var dir = os.Getenv("HOME") + "/code/dotfiles/bin" 13 | 14 | func Run(args []string, _ io.Reader) error { 15 | if len(args) < 3 { 16 | fmt.Fprintf(os.Stderr, "Usage: %s $scriptname [-f] $command $tokens\n", args[0]) 17 | os.Exit(1) 18 | } 19 | 20 | script := dir + "/" + args[1] 21 | 22 | if args[2] == "-f" { 23 | os.Remove(script) 24 | args = append(args[:2], args[3:]...) 25 | } 26 | 27 | var body string 28 | if len(args[2:]) == 1 { 29 | body = args[2] 30 | } else { 31 | var err error 32 | body, err = shellquote.Quote(args[2:]) 33 | if err != nil { 34 | return fmt.Errorf("Couldn't quote args to script script: %w", err) 35 | } 36 | } 37 | 38 | // If script exists or we can't stat it 39 | stat, err := os.Stat(script) 40 | if stat != nil { 41 | return fmt.Errorf("Script ("+script+") already exists: %w", err) 42 | } else if !os.IsNotExist(err) { 43 | return fmt.Errorf("Couldn't stat new script: %w", err) 44 | } 45 | 46 | if err := ioutil.WriteFile(script, []byte("#!/bin/sh\n\n"+body+"\n"), 0755); err != nil { 47 | return fmt.Errorf("Couldn't create new script: %w", err) 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/tool/misc/status/status.md: -------------------------------------------------------------------------------- 1 | Serves information about host machine. 2 | 3 | Status runs a little web server that surfaces status information related to how 4 | I'm using the machine. For example, it can say which window is active, what 5 | firefox tabs are loaded, if the screen is locked, etc. The main benefit of the 6 | tool is that it caches the values returned. 7 | 8 | In the background, it interact swith the [blink(1)](http://blink1.thingm.com/). 9 | It turns the light green when I'm in a meeting and red when audio is playing. 10 | 11 | ### systemd.service 12 | 13 | The following is a working systemd unit you can use to set up a service: 14 | 15 | ``` 16 | [Unit] 17 | Description=Status Server, port 8081 18 | 19 | [Service] 20 | Environment='LM_GH_TOKEN=Bearer xxx' 21 | ExecStart=/home/pi/leatherman status 22 | Restart=always 23 | StartLimitBurst=0 24 | 25 | [Install] 26 | WantedBy=default.target 27 | ``` 28 | 29 | You can put it at either `/etc/systemd/system/status.service` or 30 | `~/.config/systemd/user/status.service`. 31 | 32 | Then do one of these: 33 | 34 | ```bash 35 | $ systemctl --user daemon-reload 36 | $ systemctl --user enable status 37 | $ systemctl --user start status 38 | ``` 39 | 40 | ```bash 41 | $ systemctl daemon-reload 42 | $ systemctl enable status 43 | $ systemctl start status 44 | ``` 45 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "runtime" 7 | "runtime/debug" 8 | ) 9 | 10 | func init() { 11 | bi, ok := debug.ReadBuildInfo() 12 | if !ok { 13 | return 14 | } 15 | 16 | BS = bi.Settings[:0] 17 | for _, s := range bi.Settings { 18 | if s.Key == "vcs.revision" { 19 | Version = s.Value 20 | continue 21 | } 22 | if s.Key == "vcs.time" { 23 | When = s.Value 24 | continue 25 | } 26 | BS = append(BS, s) 27 | } 28 | 29 | Deps = bi.Deps 30 | } 31 | 32 | // Version is the git version that produced this binary. 33 | var Version string 34 | 35 | // When is the datestamp that produced this binary. 36 | var When string 37 | 38 | var BS []debug.BuildSetting 39 | 40 | var Deps []*debug.Module 41 | 42 | func Render(w io.Writer) { 43 | fmt.Fprintf(w, "Leatherman built from %s on %s by with %s\n", 44 | Version, When, runtime.Version()) 45 | 46 | fmt.Fprintln(w, "Build Settings:") 47 | for _, s := range BS { 48 | fmt.Fprintf(w, "\t%s=%s\n", s.Key, s.Value) 49 | } 50 | 51 | fmt.Fprintln(w, "\nDeps:") 52 | for _, dep := range Deps { 53 | fmt.Fprintf(w, "\t%s@%s (%s)\n", dep.Path, dep.Version, dep.Sum) 54 | if dep.Replace != nil { 55 | r := dep.Replace 56 | fmt.Fprintf(w, " replaced by %s@%s (%s)\n", r.Path, r.Version, r.Sum) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/netrc/testdata/good.netrc.gpg: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP MESSAGE----- 2 | Version: GnuPG/MacGPG2 v2.0.22 (Darwin) 3 | Comment: GPGTools - https://gpgtools.org 4 | 5 | hQIMA0WkG+IIsDKOARAAxyV9rO09l5vrXAAOyvEkyw6AW9EtZX16rjGi/iF335Wl 6 | 7TfZyStY/wxWXoeWSxCLQEnblZZUHSTgtApR5fztO2I2laowG3uNpey8J2nlKRxX 7 | eSC9n2ed0TOD4qfA+jQ0YDqkFpyOtspuuc0QpzaZYrOjwF4AdSqGlhIUSWmFmGhz 8 | OTHWkwzsfauVVCSwi4utJ1nlAaV3BgEWYI1anwZEVRi0ndGqEcFycyD5RwJg7waA 9 | 5Dml26ORS7InTDHOAfNzmS4hRxIO/zRWX7t5EwOviILj6CxkAo7tvnU6JrzZ2AjF 10 | IRWksjh+Fz3X795ONT9S+ElyrU3+m7GbM5YYf4/wossOoFKpheNj5SUnSUCq+x3K 11 | MQiNPht6aElJfD7DTM5PIm5azne0iIhvRY3rd2egEBCNJtG38rjwsxRbY/nthZaB 12 | n445M7fXG0/2569yahTDI7KVcURFoAMQq5UAZ57Nyi9B9jKUCdT7doWivb+6weWJ 13 | N+ajOCew8Ecz2CFiFftD5YRl5fqodSMp/MJL1nw1n4VET9TT+pbt3nOOZ46WsVPI 14 | rNH6lmlvggdK//sC0iWdOJS6KbegDq4IqMpQBqec9BfjpxJyk0qXQBXe9iVGhcDu 15 | jfMZVscxCRVZGtq3ohjnxz+cSID1zt2BOc4kqP74xhX4n06Lmfn9UGZPh+tFJ13S 16 | wFgBsv/zkNOC8pcAjUmgkPwsJ3N3GdETLZRkq3NK0sR81wJ4k0N09GmM++VzwoyP 17 | LwxVVzSM07BsA/YEX4jGT8szi+ee7qDAGWGBm8XtSyns1/7vkyD5AmPhyEAI+QXt 18 | foA8dnliaUs7VYGZR5G4cTnUYGhsGNEUdHTegafMSG89CC8unVROZ3umpl6cobuE 19 | 7XzdofB2j0QWM2BTErbU+1wkUS8pRTYieOyWwxpLU6Q/GTWv/RPGlzLYFC1dygco 20 | uokRFFOXo/yeCXz6ZxnG3xx1OU0CHGy+h46fjhbv63MEnBEYikj/wKgDJ1L41xmU 21 | Toyy5W+i0mFUPWBjR7vFojWLFFKh2veAIAKo30by9u+RTmnj4hgI1x7G 22 | =a00i 23 | -----END PGP MESSAGE----- 24 | -------------------------------------------------------------------------------- /internal/dropbox/longpoll.go: -------------------------------------------------------------------------------- 1 | package dropbox 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | ) 9 | 10 | // Longpoll signals changes in dir by sending on ch. 11 | func (db Client) Longpoll(ctx context.Context, dir string, ch chan<- []Metadata) { 12 | for { 13 | res, err := db.ListFolder(ListFolderParams{ 14 | Path: dir, 15 | }) 16 | if err != nil { 17 | fmt.Fprintln(os.Stderr, "ListFolder:", err) 18 | continue 19 | } 20 | 21 | for res.HasMore { 22 | res, err = db.ListFolderContinue(res.Cursor) 23 | if err != nil { 24 | fmt.Fprintln(os.Stderr, "ListFolderContinue:", err) 25 | continue 26 | } 27 | } 28 | 29 | cu := res.Cursor 30 | 31 | changed, backoff, err := db.ListFolderLongPoll(ctx, cu, 480) 32 | if err != nil { 33 | fmt.Fprintln(os.Stderr, "ListFolderLongPoll:", err) 34 | continue 35 | } 36 | 37 | if backoff != 0 { 38 | time.Sleep(time.Second * time.Duration(backoff)) 39 | } 40 | 41 | if !changed { 42 | continue 43 | } 44 | 45 | res = ListFolderResult{HasMore: true, Cursor: cu} 46 | for res.HasMore { 47 | res, err = db.ListFolderContinue(res.Cursor) 48 | if err != nil { 49 | fmt.Fprintln(os.Stderr, "ListFolderContinue:", err) 50 | continue 51 | } 52 | 53 | if len(res.Entries) > 0 { 54 | ch <- res.Entries 55 | break 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/tool/notes/proj/proj.go: -------------------------------------------------------------------------------- 1 | package proj 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "os/user" 10 | ) 11 | 12 | var proj, vimSessions, notes, smartcd string 13 | 14 | func init() { 15 | u, err := user.Current() 16 | if err != nil { 17 | panic("Couldn't get current user: " + err.Error()) 18 | } 19 | vimSessions = u.HomeDir + "/.vvar/sessions" 20 | notes = u.HomeDir + "/code/notes/content/posts" 21 | smartcd = u.HomeDir + "/.smartcd/scripts" 22 | 23 | proj = os.Getenv("PROJ") 24 | } 25 | 26 | func Proj(args []string, _ io.Reader) error { 27 | if len(args) < 2 { 28 | return fmt.Errorf("usage: %s init | vim | note", args[0]) 29 | } 30 | switch args[1] { 31 | case "init": 32 | return initialize(args[1:]) 33 | case "vim": 34 | return vim() 35 | case "note": 36 | return errors.New("nyi") 37 | default: 38 | return errors.New("unknown subcommand " + args[1]) 39 | } 40 | 41 | } 42 | 43 | // XXX would be nice to make this use exec instead; I know go doens't 44 | // technically support that but I also know it does actually work. 45 | func vim() error { 46 | if proj == "" { 47 | return errors.New("cannot infer session without PROJ set") 48 | } 49 | 50 | vim := exec.Command("vim", "-S", vimSessions+"/"+proj) 51 | vim.Stdin = os.Stdin 52 | vim.Stdout = os.Stdout 53 | vim.Stderr = os.Stderr 54 | return vim.Run() 55 | } 56 | -------------------------------------------------------------------------------- /internal/notes/remind.go: -------------------------------------------------------------------------------- 1 | package notes 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/frioux/leatherman/internal/dropbox" 11 | "github.com/frioux/leatherman/internal/personality" 12 | "github.com/frioux/leatherman/internal/reminders" 13 | "github.com/frioux/leatherman/internal/twilio" 14 | ) 15 | 16 | // remind format: 17 | // remind me (?:to )xyz (at $time|in $duration) 18 | func remind(cl dropbox.Client) func(string, []twilio.Media) (string, error) { 19 | return func(message string, media []twilio.Media) (string, error) { 20 | when, what, err := reminders.Parse(time.Now(), message) 21 | if err != nil { 22 | return personality.UserErr(err), err 23 | } 24 | 25 | for _, m := range media { 26 | what += " " + m.URL 27 | } 28 | 29 | sha := sha1.Sum([]byte(what)) 30 | id := hex.EncodeToString(sha[:]) 31 | path := "/notes/content/posts/deferred_" + id + ".md" 32 | 33 | const tpl = `{ 34 | "title": "deferred %s", 35 | "tags":["deferred"], 36 | "review_by": "%s", 37 | } 38 | 39 | %s 40 | ` 41 | buf := strings.NewReader(fmt.Sprintf(tpl, id, when.Format("2006-01-02"), what)) 42 | 43 | up := dropbox.UploadParams{Path: path, Autorename: true} 44 | if err := cl.Create(up, buf); err != nil { 45 | return personality.Err(), fmt.Errorf("dropbox.Create: %w", err) 46 | } 47 | 48 | return personality.Ack() + "; will remind you @ " + when.Format(time.RFC3339), nil 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/tool/notes/proj/proj.md: -------------------------------------------------------------------------------- 1 | Integrates my various project management tools. 2 | 3 | Usage is: 4 | 5 | ```bash 6 | $ cd my/cool-project 7 | $ proj init cool-project 8 | ``` 9 | 10 | The following flags are supported before the project name: 11 | 12 | * `-skip-vim`: does not create vim session 13 | * `-force-vim`: overwrites any existing vim session 14 | * `-skip-note`: does not create note 15 | * `-force-note`: overwrites any existing note 16 | * `-skip-smartcd`: does not create smartcd enter script 17 | * `-force-smartcd`: overwrites any existing smartcd enter script 18 | 19 | Once a project has been initialized, you can run: 20 | 21 | ```bash 22 | $ proj vim 23 | ``` 24 | 25 | To open the vim session for that project. 26 | 27 | I use [vim sessions][vim], [a notes system][notes], and of course checkouts of 28 | code all over the place. Proj is meant to make creation of a vim session and a 29 | note easy and eventually allow jumping back and forth between the three. As of 30 | 2019-12-02 it is almost painfully specific to my personal setup, but as I 31 | discover the actual patterns I'll probably generalize. 32 | 33 | Proj uses uses [smartcd][smartcd] both as a mechanism and as the means to 34 | add functionality to projects within shell sessions. 35 | 36 | [vim]: https://blog.afoolishmanifesto.com/posts/vim-session-workflow/ 37 | [notes]: https://blog.afoolishmanifesto.com/posts/a-love-letter-to-plain-text/#notes 38 | [smartcd]: https://github.com/cxreg/smartcd 39 | -------------------------------------------------------------------------------- /pkg/shellquote/shellquote_test.go: -------------------------------------------------------------------------------- 1 | package shellquote_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/frioux/leatherman/internal/testutil" 7 | "github.com/frioux/leatherman/pkg/shellquote" 8 | ) 9 | 10 | func test(t *testing.T, in []string, expected string) { 11 | ret, err := shellquote.Quote(in) 12 | if err != nil { 13 | t.Errorf("Quote errored: %s", err) 14 | return 15 | } 16 | testutil.Equal(t, ret, expected, "wrong quote") 17 | } 18 | 19 | func TestShellQuote(t *testing.T) { 20 | t.Parallel() 21 | 22 | test(t, []string{""}, `''`) 23 | test(t, []string{"foo"}, `foo`) 24 | test(t, []string{"foo", "bar"}, `foo bar`) 25 | test(t, []string{"foo*"}, `'foo*'`) 26 | test(t, []string{"foo bar"}, `'foo bar'`) 27 | test(t, []string{"foo'bar"}, `'foo'\''bar'`) 28 | test(t, []string{"'foo"}, `\''foo'`) 29 | test(t, []string{"foo", "bar*"}, `foo 'bar*'`) 30 | test(t, []string{"foo'foo", "bar", "baz'"}, `'foo'\''foo' bar 'baz'\'`) 31 | test(t, []string{`\`}, `'\'`) 32 | test(t, []string{"'"}, `\'`) 33 | test(t, []string{`\'`}, `'\'\'`) 34 | test(t, []string{"a''b"}, `'a'"''"'b'`) 35 | test(t, []string{"azAZ09_!%+,-./:@^"}, `azAZ09_!%+,-./:@^`) 36 | test(t, []string{"foo=bar", "command"}, `'foo=bar' command`) 37 | test(t, []string{"foo=bar", "baz=quux", "command"}, `'foo=bar' 'baz=quux' command`) 38 | 39 | _, err := shellquote.Quote([]string{"\x00"}) 40 | if err != shellquote.ErrNull { 41 | t.Errorf("err should be ErrNull; was %s", err) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/tool/notes/now/add_item.go: -------------------------------------------------------------------------------- 1 | package now 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // addItem will add item to the end of today's list, and create today's list if 11 | // it doesn't already exist. 12 | func addItem(r io.Reader, when time.Time, item string) ([]byte, error) { 13 | desiredHeader := "## " + when.Format("2006-01-02") + " ##" 14 | ret := &strings.Builder{} 15 | 16 | var inToday, inList, addedItem bool 17 | s := bufio.NewScanner(r) 18 | for s.Scan() { 19 | line := s.Text() 20 | 21 | switch { 22 | case !addedItem && !inToday && strings.HasPrefix(line, "## ") && strings.HasSuffix(line, " ##") && line < desiredHeader: // We found a previous day, stop searching and make a new day: 23 | ret.WriteString(desiredHeader + "\n\n * " + item + "\n\n") 24 | addedItem = true 25 | case !inToday && line == desiredHeader: 26 | inToday = true 27 | case inToday && !inList && strings.HasPrefix(line, " * "): 28 | inList = true 29 | case inToday && strings.HasPrefix(line, "## "): 30 | inToday = false 31 | case inToday && strings.HasPrefix(line, " * ") && !addedItem: 32 | foundItem := strings.TrimPrefix(line, " * ") 33 | if foundItem == item { 34 | addedItem = true 35 | } 36 | case inToday && !addedItem && inList && line == "": 37 | ret.WriteString(" * " + item + "\n") 38 | addedItem = true 39 | } 40 | 41 | ret.WriteString(line) 42 | ret.WriteRune('\n') 43 | } 44 | 45 | return []byte(ret.String()), nil 46 | } 47 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "hash/maphash" 7 | "io" 8 | "math/rand" 9 | "os" 10 | "path/filepath" 11 | "runtime/trace" 12 | 13 | "github.com/frioux/leatherman/internal/selfupdate" 14 | ) 15 | 16 | //go:generate maint/generate 17 | 18 | // Dispatch is the dispatch table that maps command names to functions. 19 | var Dispatch map[string]func([]string, io.Reader) error 20 | 21 | // run returns false when an error occurred 22 | func run() bool { 23 | startDebug() 24 | defer stopDebug() 25 | h := &maphash.Hash{} 26 | h.WriteByte(byte(os.Getpid())) 27 | h.WriteByte(byte(os.Getppid())) 28 | if n, err := os.Hostname(); err == nil { 29 | h.WriteString(n) 30 | } 31 | 32 | rand.Seed(int64(h.Sum64())) 33 | 34 | selfupdate.AutoUpdate() 35 | 36 | which := filepath.Base(os.Args[0]) 37 | args := os.Args 38 | 39 | Dispatch["xyzzy"] = func([]string, io.Reader) error { fmt.Println("nothing happens"); return nil } 40 | if _, ok := Dispatch[which]; !ok && len(args) > 1 { 41 | args = args[1:] 42 | which = args[0] 43 | } 44 | 45 | fn, ok := Dispatch[which] 46 | if !ok { 47 | _ = Help(os.Args, os.Stdin) 48 | return false 49 | } 50 | var err error 51 | 52 | trace.WithRegion(context.Background(), which, func() { 53 | err = fn(args, os.Stdin) 54 | }) 55 | 56 | if err != nil { 57 | fmt.Fprintf(os.Stderr, "%s: %s\n", which, err) 58 | return false 59 | } 60 | return true 61 | } 62 | 63 | func main() { 64 | if !run() { 65 | os.Exit(1) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/tool/misc/prependhist/prepend-hist.go: -------------------------------------------------------------------------------- 1 | package prependemojihist 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/icza/backscanner" 10 | ) 11 | 12 | func Run(args []string, stdin io.Reader) error { 13 | if len(args) != 2 { 14 | fmt.Fprintln(os.Stderr, "you must pass a history file!") 15 | os.Exit(1) 16 | } 17 | 18 | file, err := os.Open(args[1]) 19 | if err != nil && !os.IsNotExist(err) { 20 | return fmt.Errorf("Couldn't open history file: %w", err) 21 | } 22 | fi, err := os.Stat(args[1]) 23 | if err != nil && !os.IsNotExist(err) { 24 | return fmt.Errorf("Couldn't stat history file: %w", err) 25 | } 26 | 27 | var pos int 28 | if fi != nil { 29 | pos = int(fi.Size()) 30 | } 31 | 32 | return run(file, os.Stdin, pos, os.Stdout) 33 | } 34 | 35 | func run(history io.ReaderAt, in io.Reader, historyLength int, out io.Writer) error { 36 | seen := map[string]bool{} 37 | scanner := backscanner.New(history, historyLength) 38 | for { 39 | line, _, err := scanner.Line() 40 | if err == io.EOF { 41 | break 42 | } 43 | if err != nil { 44 | return fmt.Errorf("Couldn't read line: %w", err) 45 | } 46 | if line == "" { 47 | continue 48 | } 49 | if seen[line] { 50 | continue 51 | } 52 | seen[line] = true 53 | fmt.Fprintln(out, line) 54 | } 55 | 56 | r := bufio.NewScanner(in) 57 | for r.Scan() { 58 | line := r.Text() 59 | if seen[line] { 60 | continue 61 | } 62 | seen[line] = true 63 | fmt.Fprintln(out, line) 64 | } 65 | 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /internal/notes/west/west_test.go: -------------------------------------------------------------------------------- 1 | package west 2 | 3 | import ( 4 | _ "embed" 5 | "testing" 6 | 7 | "github.com/frioux/leatherman/internal/testutil" 8 | ) 9 | 10 | //go:embed testdata/000.md 11 | var _000 []byte 12 | 13 | func TestParseBasic(t *testing.T) { 14 | d := Parse(_000) 15 | var linkCount int 16 | Walk(d, func(n Node) error { 17 | if _, ok := n.(*Link); ok { 18 | linkCount += 1 19 | } 20 | return nil 21 | }) 22 | testutil.Equal(t, string(d.Markdown()), string(_000), "roundtrips") 23 | if linkCount != 3 { 24 | t.Error("expected link count of 3") 25 | } 26 | } 27 | 28 | func TestParseEarlyExit(t *testing.T) { 29 | d := Parse(_000) 30 | var runCount int 31 | Walk(d, func(n Node) error { 32 | runCount += 1 33 | if l, ok := n.(*Link); ok { 34 | if l.HRef == "http://frew.co" { 35 | return WalkBreak 36 | } 37 | 38 | if l.HRef == "http://afoolishmanifesto.com" { 39 | t.Error("walk didn't break") 40 | } 41 | } 42 | return nil 43 | }) 44 | } 45 | 46 | func TestParseCodeFenceBlock(t *testing.T) { 47 | p := NewParser([]byte(`~~~ 48 | this a test 49 | `)) 50 | 51 | c := &CodeFenceBlock{} 52 | p.parseCodeFenceBlock(c) 53 | if c.body != "this a test\n" { 54 | t.Errorf("uhh: %q", c.body) 55 | } 56 | 57 | p = NewParser([]byte(`~~~ 58 | this a test 2 59 | ~~~ 60 | rest 61 | `)) 62 | 63 | p.parseCodeFenceBlock(c) 64 | if c.body != "this a test 2\n" { 65 | t.Errorf("uhh: %q", c.body) 66 | } 67 | 68 | if string(p.rest()) != "rest\n" { 69 | t.Errorf("why: %q", p.rest()) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/fn/fn_test.go: -------------------------------------------------------------------------------- 1 | package fn 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "os/exec" 7 | "testing" 8 | 9 | "github.com/frioux/leatherman/internal/testutil" 10 | ) 11 | 12 | type runTest struct { 13 | name, scriptName string 14 | in []string 15 | out string 16 | } 17 | 18 | func TestRun(t *testing.T) { 19 | t.Parallel() 20 | 21 | var err error 22 | dir, err = ioutil.TempDir("", "") 23 | if err != nil { 24 | t.Fatalf("Couldn't setup test dir: %s", err) 25 | } 26 | defer os.RemoveAll(dir) 27 | 28 | tests := []runTest{ 29 | { 30 | name: "basic", 31 | out: "this is a test\n", 32 | in: []string{"basic", `echo "this is a test"`}, 33 | }, 34 | { 35 | name: "replace", 36 | scriptName: "basic", 37 | out: "replaced\n", 38 | in: []string{"basic", "-f", `echo "replaced"`}, 39 | }, 40 | { 41 | name: "tokenized", 42 | out: "foo bar\n", 43 | in: []string{"tokenized", "echo", "foo", "bar"}, 44 | }, 45 | } 46 | 47 | for _, test := range tests { 48 | t.Run(test.name, func(t *testing.T) { 49 | input := []string{"fn"} 50 | input = append(input, test.in...) 51 | Run(input, nil) 52 | name := test.scriptName 53 | if name == "" { 54 | name = test.name 55 | } 56 | cmd := exec.Command(dir + "/" + name) 57 | out, err := cmd.Output() 58 | if err != nil { 59 | t.Fatalf("Couldn't run command: %s", err) 60 | } 61 | 62 | testutil.Equal(t, string(out), test.out, "wrong output") 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/tool/misc/desktop/media-remote.js: -------------------------------------------------------------------------------- 1 | var enabled = true 2 | 3 | function over(x) { 4 | if (!enabled) { return } 5 | let formData = new FormData(); 6 | 7 | formData.append('player', x.innerText); 8 | 9 | fetch('/play', { 10 | method: 'POST', 11 | body: formData 12 | }) 13 | .then(result => { 14 | console.log('Success:', result); 15 | }) 16 | .catch(error => { 17 | console.error('Error:', error); 18 | }); 19 | 20 | } 21 | 22 | function out(x) { 23 | if (!enabled) { return } 24 | let formData = new FormData(); 25 | 26 | formData.append('player', x.innerText); 27 | 28 | fetch('/pause', { 29 | method: 'POST', 30 | body: formData, 31 | }) 32 | .then(result => { 33 | console.log('Success:', result); 34 | }) 35 | .catch(error => { 36 | console.error('Error:', error); 37 | }); 38 | } 39 | 40 | function selectOutput(x) { 41 | if (!enabled) { return } 42 | enabled = false 43 | let formData = new FormData(); 44 | 45 | formData.append('player', x.innerText); 46 | 47 | var myHeaders = new Headers(); 48 | myHeaders.append('Accept', 'text/html'); 49 | 50 | fetch('/select-player', { 51 | method: 'POST', 52 | body: formData, 53 | headers: { 'Accept': 'application/json' }, // work around fraidycat workaround 54 | }) 55 | .then(result => { 56 | console.log('Success:', result); 57 | }) 58 | .catch(error => { 59 | console.error('Error:', error); 60 | }); 61 | } 62 | 63 | -------------------------------------------------------------------------------- /internal/tool/chat/automoji/automoji_test.go: -------------------------------------------------------------------------------- 1 | package automoji 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/frioux/leatherman/internal/dropbox" 8 | ) 9 | 10 | func init() { 11 | if os.Getenv("LM_BOT_LUA_PATH") == "" { 12 | return 13 | } 14 | var dbCl dropbox.Client 15 | if t := os.Getenv("LM_DROPBOX_TOKEN"); t != "" { 16 | var err error 17 | dbCl, err = dropbox.NewClient(dropbox.Client{Token: os.Getenv("LM_DROPBOX_TOKEN")}) 18 | if err != nil { 19 | panic(err) 20 | } 21 | } 22 | if err := loadLua(dbCl, os.Getenv("LM_BOT_LUA_PATH")); err != nil { 23 | panic(err) 24 | } 25 | } 26 | 27 | func BenchmarkAutomojiAlpha(b *testing.B) { 28 | for i := 0; i < b.N; i++ { 29 | _, err := newEmojiSet("a b c d e f g h i j k l m n o p q r s t u v w x y z") 30 | if err != nil { 31 | panic(err) 32 | } 33 | } 34 | } 35 | 36 | func BenchmarkAutomojiOneWord(b *testing.B) { 37 | for i := 0; i < b.N; i++ { 38 | _, err := newEmojiSet("hello!") 39 | if err != nil { 40 | panic(err) 41 | } 42 | } 43 | } 44 | 45 | func BenchmarkAutomojiNeilGen(b *testing.B) { 46 | for i := 0; i < b.N; i++ { 47 | _, err := newEmojiSet("gear grip strength hiccup bleed garbage tourist wriggle miscarriage crash trait feedback application relative prince hilarious matrix reserve velvet account good trick invite attractive disorder period drawer harm monk land cower governor knowledge pedestrian payment sniff beautiful nominate color possession width facility embryo thick refer wind moon mutter battle prove") 48 | if err != nil { 49 | panic(err) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/notes/beerme.go: -------------------------------------------------------------------------------- 1 | package notes 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "math/rand" 10 | "regexp" 11 | 12 | "github.com/frioux/leatherman/internal/dropbox" 13 | "github.com/frioux/leatherman/internal/personality" 14 | "github.com/frioux/leatherman/internal/twilio" 15 | ) 16 | 17 | var isItem = regexp.MustCompile(`^\s?\*\s+(.*?)\s*$`) 18 | var mdLink = regexp.MustCompile(`^\[(.*)\]\((.*)\)$`) 19 | 20 | var errNone = errors.New("never found anything") 21 | 22 | func beerMe(r io.Reader) (string, error) { 23 | s := bufio.NewScanner(r) 24 | 25 | o := []string{} 26 | for s.Scan() { 27 | m := isItem.FindStringSubmatch(s.Text()) 28 | if len(m) != 2 { 29 | continue 30 | } 31 | o = append(o, m[1]) 32 | } 33 | 34 | if len(o) == 0 { 35 | return "", errNone 36 | } 37 | 38 | rand.Shuffle(len(o), func(i, j int) { o[i], o[j] = o[j], o[i] }) 39 | 40 | fmt.Println(mdLink.FindStringSubmatch(o[0])) 41 | if l := mdLink.FindStringSubmatch(o[0]); len(l) == 3 { 42 | return fmt.Sprintf("[%s]( %s )", l[1], l[2]), nil 43 | } 44 | 45 | return o[0], nil 46 | } 47 | 48 | func inspireMe(cl dropbox.Client) func(_ string, _ []twilio.Media) (string, error) { 49 | return func(_ string, _ []twilio.Media) (string, error) { 50 | b, err := cl.Download("/notes/content/posts/inspiration.md") 51 | if err != nil { 52 | return personality.Err(), fmt.Errorf("dropbox.Download: %w", err) 53 | } 54 | n, err := beerMe(bytes.NewReader(b)) 55 | if err != nil { 56 | return personality.Err(), err 57 | } 58 | return n, nil 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/uni/uni.go: -------------------------------------------------------------------------------- 1 | package uni 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "unicode" 8 | 9 | "golang.org/x/text/unicode/runenames" 10 | ) 11 | 12 | var unicodeVersion = "13.0.0" 13 | 14 | func Describe(args []string, _ io.Reader) error { 15 | if len(args) < 2 { 16 | fmt.Printf("Usage: %s \n", args[0]) 17 | return nil 18 | } 19 | 20 | for i, arg := range args[1:] { 21 | if i != 0 { 22 | fmt.Println() 23 | } 24 | for _, r := range arg { 25 | fmt.Println(describe(r)) 26 | } 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func describe(r rune) string { 33 | name := runenames.Name(r) 34 | 35 | var t []string 36 | if unicode.IsControl(r) { 37 | t = append(t, "control") 38 | } 39 | if unicode.IsDigit(r) { 40 | t = append(t, "digit") 41 | } 42 | if unicode.IsGraphic(r) { 43 | t = append(t, "graphic") 44 | } 45 | if unicode.IsLetter(r) { 46 | t = append(t, "letter") 47 | } 48 | if unicode.IsLower(r) { 49 | t = append(t, "lower") 50 | } 51 | if unicode.IsMark(r) { 52 | t = append(t, "mark") 53 | } 54 | if unicode.IsNumber(r) { 55 | t = append(t, "number") 56 | } 57 | if unicode.IsPrint(r) { 58 | t = append(t, "printable") 59 | } 60 | if unicode.IsPunct(r) { 61 | t = append(t, "punct") 62 | } 63 | if unicode.IsSpace(r) { 64 | t = append(t, "space") 65 | } 66 | if unicode.IsSymbol(r) { 67 | t = append(t, "symbol") 68 | } 69 | if unicode.IsTitle(r) { 70 | t = append(t, "title") 71 | } 72 | if unicode.IsUpper(r) { 73 | t = append(t, "upper") 74 | } 75 | return fmt.Sprintf("%q @ %d aka %s ( %s )", r, r, name, strings.Join(t, " | ")) 76 | } 77 | -------------------------------------------------------------------------------- /internal/twilio/twilio.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha1" 7 | "encoding/base64" 8 | "fmt" 9 | "net/http" 10 | "net/url" 11 | "sort" 12 | "strconv" 13 | ) 14 | 15 | func GenerateMAC(key, url []byte, r *http.Request) []byte { 16 | buf := bytes.NewBuffer(url) 17 | 18 | keys := make(sort.StringSlice, 0, len(r.PostForm)) 19 | for k := range r.PostForm { 20 | keys = append(keys, k) 21 | } 22 | 23 | keys.Sort() 24 | 25 | for _, k := range keys { 26 | buf.WriteString(k) 27 | for _, v := range r.PostForm[k] { 28 | buf.WriteString(v) 29 | } 30 | } 31 | 32 | mac := hmac.New(sha1.New, key) 33 | mac.Write(buf.Bytes()) 34 | return mac.Sum(nil) 35 | } 36 | 37 | func CheckMAC(key, url []byte, r *http.Request) (bool, error) { 38 | expectedMAC := GenerateMAC(key, url, r) 39 | messageMAC, err := base64.StdEncoding.DecodeString(r.Header.Get("X-Twilio-Signature")) 40 | if err != nil { 41 | return false, fmt.Errorf("base64.Decode: %w", err) 42 | } 43 | return hmac.Equal(messageMAC, expectedMAC), nil 44 | } 45 | 46 | type Media struct { 47 | ContentType, URL string 48 | } 49 | 50 | func ExtractMedia(f url.Values) ([]Media, error) { 51 | numMedia := f.Get("NumMedia") 52 | if numMedia == "" { 53 | return nil, nil 54 | } 55 | 56 | n, err := strconv.Atoi(numMedia) 57 | if err != nil { 58 | return nil, fmt.Errorf("Couldn't parse NumMedia: %w", err) 59 | } 60 | 61 | ret := make([]Media, n) 62 | 63 | for i := 0; i < n; i++ { 64 | ret[i].URL = f.Get(fmt.Sprintf("MediaUrl%d", i)) 65 | ret[i].ContentType = f.Get(fmt.Sprintf("MediaContentType%d", i)) 66 | } 67 | 68 | return ret, nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "time" 7 | 8 | "encoding/json" 9 | ) 10 | 11 | type Adapter func(http.Handler) http.Handler 12 | 13 | func Adapt(h http.Handler, adapters ...Adapter) http.Handler { 14 | for _, adapter := range adapters { 15 | h = adapter(h) 16 | } 17 | return h 18 | } 19 | 20 | type logline struct { 21 | Time string 22 | Type string 23 | 24 | Duration float64 25 | Method string 26 | URL string 27 | UserAgent string 28 | Proto string 29 | Host string 30 | RemoteAddr string 31 | StatusCode int 32 | } 33 | 34 | func Log(logger io.Writer) Adapter { 35 | e := json.NewEncoder(logger) 36 | 37 | return func(h http.Handler) http.Handler { 38 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 39 | start := time.Now() 40 | 41 | lw := &loggingResponseWriter{ResponseWriter: w} 42 | defer func() { 43 | e.Encode(logline{ 44 | Type: "accesslog", 45 | Time: start.Format(time.RFC3339Nano), 46 | Duration: time.Now().Sub(start).Seconds(), 47 | Method: r.Method, 48 | URL: r.URL.String(), 49 | UserAgent: r.UserAgent(), 50 | Proto: r.Proto, 51 | Host: r.Host, 52 | RemoteAddr: r.RemoteAddr, 53 | StatusCode: lw.statusCode, 54 | }) 55 | }() 56 | h.ServeHTTP(lw, r) 57 | 58 | }) 59 | } 60 | } 61 | 62 | type loggingResponseWriter struct { 63 | http.ResponseWriter 64 | statusCode int 65 | } 66 | 67 | func (lrw *loggingResponseWriter) WriteHeader(code int) { 68 | lrw.statusCode = code 69 | lrw.ResponseWriter.WriteHeader(code) 70 | } 71 | -------------------------------------------------------------------------------- /help.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | "os" 11 | "regexp" 12 | "sort" 13 | ) 14 | 15 | //go:embed README.mdwn 16 | var readme []byte 17 | 18 | // Help prints tool listing 19 | func Help(args []string, _ io.Reader) error { 20 | flags := flag.NewFlagSet("help", flag.ExitOnError) 21 | 22 | var full bool 23 | flags.BoolVar(&full, "v", false, "show full help") 24 | 25 | var command string 26 | flags.StringVar(&command, "command", "", "show help for just command") 27 | 28 | err := flags.Parse(args[1:]) 29 | if err != nil { 30 | return fmt.Errorf("flags.Parse: %w", err) 31 | } 32 | 33 | if full { 34 | comment, err := regexp.Compile(`\n?`) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | fmt.Print(string(comment.ReplaceAll(readme, []byte{}))) 40 | return nil 41 | } 42 | 43 | if command != "" { 44 | doc, err := fs.ReadFile(helpFS, helpPaths[command]) 45 | if err != nil && errors.Is(err, fs.ErrNotExist) { 46 | fmt.Fprintf(os.Stderr, "No such command: %s\n", command) 47 | os.Exit(1) 48 | } 49 | if err != nil { 50 | return err 51 | } 52 | fmt.Print(string(doc)) 53 | return nil 54 | } 55 | 56 | tools := make([]string, 0, len(Dispatch)) 57 | for k := range Dispatch { 58 | if k == "xyzzy" { // nothing to see here 59 | continue 60 | } 61 | tools = append(tools, k) 62 | } 63 | 64 | str := "Tools:\n" 65 | sort.Strings(tools) 66 | for _, k := range tools { 67 | str += " * " + k + "\n" 68 | } 69 | str += "\nGet more help for each tool with `leatherman help -command `, or `leatherman help -v`" 70 | fmt.Println(str) 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | "os" 8 | "runtime/pprof" 9 | "runtime/trace" 10 | 11 | _ "net/http/pprof" 12 | ) 13 | 14 | func startDebug() { 15 | port := os.Getenv("LMHTTPPROF") 16 | if port == "" { 17 | port = "6060" 18 | } 19 | go func() { 20 | err := http.ListenAndServe("localhost:"+port, nil) 21 | if err != nil { 22 | if oerr, ok := err.(*net.OpError); ok && oerr.Op == "listen" { 23 | if ierr, ok := oerr.Err.(*os.SyscallError); ok && ierr.Err.Error() == "address already in use" { 24 | err := http.ListenAndServe("localhost:0", nil) 25 | if err != nil { 26 | fmt.Fprintf(os.Stderr, "failed to http.ListenAndServe: %s\n", err) 27 | } 28 | } 29 | } else { 30 | fmt.Fprintf(os.Stderr, "failed to http.ListenAndServe: %s\n", err) 31 | } 32 | } 33 | }() 34 | if path := os.Getenv("LMTRACE"); path != "" { 35 | fh, err := os.Create(path) 36 | if err != nil { 37 | fmt.Fprintf(os.Stderr, "Couldn't open LMTRACE (%s): %s\n", path, err) 38 | os.Exit(1) 39 | } 40 | err = trace.Start(fh) 41 | if err != nil { 42 | fmt.Fprintf(os.Stderr, "failed to trace.Start: %s\n", err) 43 | } 44 | } 45 | 46 | if path := os.Getenv("LMPROF"); path != "" { 47 | fh, err := os.Create(path) 48 | if err != nil { 49 | fmt.Fprintf(os.Stderr, "Couldn't open LMPROF (%s): %s\n", path, err) 50 | os.Exit(1) 51 | } 52 | err = pprof.StartCPUProfile(fh) 53 | if err != nil { 54 | fmt.Fprintf(os.Stderr, "failed to pprof.StartCPUProfile: %s\n", err) 55 | } 56 | } 57 | } 58 | 59 | func stopDebug() { 60 | if os.Getenv("LMTRACE") != "" { 61 | trace.Stop() 62 | } 63 | 64 | if os.Getenv("LMPROF") != "" { 65 | pprof.StopCPUProfile() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/notes/notes.go: -------------------------------------------------------------------------------- 1 | package notes 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | 7 | "github.com/frioux/leatherman/internal/dropbox" 8 | "github.com/frioux/leatherman/internal/twilio" 9 | ) 10 | 11 | type rule struct { 12 | *regexp.Regexp 13 | action func(string, []twilio.Media) (string, error) 14 | } 15 | 16 | // Rules has an ordered list of regexp+callback rules 17 | type Rules []rule 18 | 19 | func help(_ dropbox.Client) func(string, []twilio.Media) (string, error) { 20 | return func(string, []twilio.Media) (string, error) { 21 | // url is https://github.com/frioux/amygdala/blob/master/README.mdwn#commands 22 | return "• help\n• inspire me\n• defer til \n• remind me [at |in ]\n• \nhttps://git.io/Jeojv", nil 23 | } 24 | } 25 | 26 | // NewRules creates default rule set 27 | func NewRules(token string) (Rules, error) { 28 | cl, err := dropbox.NewClient(dropbox.Client{Token: token}) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return []rule{ 33 | {Regexp: regexp.MustCompile(`(?i)^\s*(?:commands|cmd|cmds)\s*$`), action: help(cl)}, 34 | {Regexp: regexp.MustCompile(`(?i)^\s*inspire\s+me\s*$`), action: inspireMe(cl)}, 35 | {Regexp: regexp.MustCompile(`(?i)^\s*remind\s+me\s*`), action: remind(cl)}, 36 | {Regexp: deferPattern, action: deferMessage(cl)}, 37 | {Regexp: regexp.MustCompile(``), action: todo(cl)}, 38 | }, nil 39 | } 40 | 41 | var errNoRule = errors.New("no rules matched") 42 | 43 | // Dispatch selects and runs rules based on input 44 | func (rules Rules) Dispatch(input string, media []twilio.Media) (string, error) { 45 | for _, r := range rules { 46 | if !r.MatchString(input) { 47 | continue 48 | } 49 | return r.action(input, media) 50 | } 51 | 52 | return "", errNoRule 53 | } 54 | -------------------------------------------------------------------------------- /internal/tool/notes/now/notes.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package now 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "io" 12 | "io/fs" 13 | "net" 14 | "net/http" 15 | "os" 16 | 17 | _ "modernc.org/sqlite" 18 | 19 | "github.com/frioux/leatherman/internal/dropbox" 20 | "github.com/frioux/leatherman/internal/lmfs" 21 | ) 22 | 23 | func Serve(args []string, _ io.Reader) error { 24 | var ( 25 | listen string 26 | ) 27 | flags := flag.NewFlagSet("notes", flag.ContinueOnError) 28 | flags.StringVar(&listen, "listen", ":0", "location to listen on; default is random") 29 | if err := flags.Parse(args[1:]); err != nil { 30 | return err 31 | } 32 | 33 | path := os.Getenv("LM_NOTES_PATH") 34 | if path == "" { 35 | return errors.New("must set LM_NOTES_PATH") 36 | } 37 | 38 | var f fs.FS 39 | if t := os.Getenv("LM_DROPBOX_TOKEN"); t != "" { 40 | cl, err := dropbox.NewClient(dropbox.Client{ 41 | Token: os.Getenv("LM_DROPBOX_TOKEN"), 42 | }) 43 | if err != nil { 44 | return err 45 | } 46 | f = cl.AsFS(context.TODO()) 47 | f, err = fs.Sub(f, path) 48 | if err != nil { 49 | return err 50 | } 51 | } else { 52 | f = lmfs.OpenDirFS(path) 53 | } 54 | 55 | generation := make(chan bool) 56 | z, err := loadDB(f, &generation) 57 | if err != nil { 58 | return fmt.Errorf("loadDB: %w", err) 59 | } 60 | 61 | listener, err := net.Listen("tcp", listen) 62 | if err != nil { 63 | return fmt.Errorf("net.Listen: %w", err) 64 | } 65 | 66 | fmt.Fprintf(os.Stderr, "listening on %s\n", listener.Addr()) 67 | h, err := server(f, z, &generation) 68 | if err != nil { 69 | return fmt.Errorf("server: %w", err) 70 | } 71 | 72 | s := http.Server{Handler: h} 73 | return s.Serve(listener) 74 | } 75 | -------------------------------------------------------------------------------- /pkg/mozlz4/mozlz4.go: -------------------------------------------------------------------------------- 1 | package mozlz4 2 | 3 | // Package mozlz4 implements the undocumented format used by Mozilla Firefox. 4 | 5 | // The mozlz4 format (also known as jsonlz4 and json.lz4) is used by Firefox for 6 | // various storage backends. The format is a magic header, a length, and an lz4 7 | // compressed body. 8 | 9 | import ( 10 | "bytes" 11 | "encoding/binary" 12 | "errors" 13 | "fmt" 14 | "io" 15 | "io/ioutil" 16 | 17 | "github.com/pierrec/lz4/v3" 18 | ) 19 | 20 | const magicHeader = "mozLz40\x00" 21 | 22 | // Errors 23 | var ( 24 | ErrWrongHeader = errors.New("no mozLz4 header") 25 | ErrWrongSize = errors.New("header size incorrect") 26 | ) 27 | 28 | // NewReader returns an io.Reader that decompresses the data from r. 29 | func NewReader(r io.Reader) (io.Reader, error) { 30 | header := make([]byte, len(magicHeader)) 31 | _, err := r.Read(header) 32 | if err != nil { 33 | return nil, fmt.Errorf("couldn't read header: %w", err) 34 | } 35 | if string(header) != magicHeader { 36 | return nil, ErrWrongHeader 37 | } 38 | 39 | var size uint32 40 | err = binary.Read(r, binary.LittleEndian, &size) 41 | if err != nil { 42 | return nil, fmt.Errorf("couldn't read size: %w", err) 43 | } 44 | 45 | src, err := ioutil.ReadAll(r) 46 | if err != nil { 47 | return nil, fmt.Errorf("couldn't read compressed data: %w", err) 48 | } 49 | 50 | out := make([]byte, size) 51 | sz, err := lz4.UncompressBlock(src, out) 52 | 53 | if err != nil { 54 | return nil, fmt.Errorf("couldn't decompress data: %w", err) 55 | } 56 | // This could maybe be a warning or ignored entirely 57 | if sz != int(size) { 58 | return nil, fmt.Errorf("Header size %d, got %d: %w", size, sz, ErrWrongSize) 59 | } 60 | 61 | return bytes.NewReader(out), nil 62 | } 63 | -------------------------------------------------------------------------------- /internal/tool/misc/twilio/twilio.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "encoding/base64" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | 13 | coretwilio "github.com/frioux/leatherman/internal/twilio" 14 | ) 15 | 16 | func Twilio(args []string, _ io.Reader) error { 17 | var endpoint, auth, message, from string 18 | 19 | fs := flag.NewFlagSet("twilio", flag.ContinueOnError) 20 | 21 | fs.StringVar(&endpoint, "endpoint", "http://localhost:8080/twilio", "endpoint to post to") 22 | fs.StringVar(&auth, "auth", "xyzzy", "auth token") 23 | fs.StringVar(&message, "message", "this is a test", "message to submit to amygdala") 24 | fs.StringVar(&from, "from", "+15555555555", "cell message is from") 25 | 26 | if err := fs.Parse(args[1:]); err != nil { 27 | return err 28 | } 29 | 30 | vals := url.Values{ 31 | "Body": {message}, 32 | "From": {from}, 33 | } 34 | r := strings.NewReader(vals.Encode()) 35 | req, err := http.NewRequest("POST", endpoint, r) 36 | if err != nil { 37 | return err 38 | } 39 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 40 | req.ParseForm() 41 | sig := coretwilio.GenerateMAC([]byte(auth), []byte(endpoint), req) 42 | encodedSignature := base64.StdEncoding.EncodeToString(sig) 43 | 44 | req.Header.Set("X-Twilio-Signature", encodedSignature) 45 | req.Header.Set("User-Agent", "twilioemu") 46 | 47 | r.Reset(vals.Encode()) 48 | 49 | cl := &http.Client{} 50 | resp, err := cl.Do(req) 51 | if err != nil { 52 | return err 53 | } 54 | fmt.Println(resp.Status) 55 | for k, v := range resp.Header { 56 | fmt.Printf("%s: %s\n", k, v[0]) 57 | } 58 | fmt.Println() 59 | if _, err := io.Copy(os.Stdout, resp.Body); err != nil { 60 | return err 61 | } 62 | 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /maint/README_begin.md: -------------------------------------------------------------------------------- 1 | # Leatherman - fREW's favorite multitool 2 | 3 | This is a little project simply to make trivial tools in Go effortless for my 4 | personal usage. These tools are almost surely of low utility to most people, 5 | but may be instructive nonetheless. 6 | 7 | [I have CI/CD to build this into a single 8 | binary](https://github.com/frioux/leatherman/blob/master/.travis.yml) and [an 9 | `explode` tool that builds 10 | symlinks](https://github.com/frioux/leatherman/blob/master/explode.go) for each 11 | tool in the busybox style. 12 | 13 | [I have automation in my 14 | dotfiles](https://github.com/frioux/dotfiles/blob/bef8303c19e2cefac7dfbec420ad8d45b95415b8/install.sh#L133-L141) 15 | to pull the latest binary at install time and run the `explode` tool. 16 | 17 | ## Installation 18 | 19 | Here's a copy pasteable script to install the leatherman on OSX or Linux: 20 | 21 | ``` bash 22 | OS=$([ $(uname -s) = "Darwin" ] && echo "-osx") 23 | LMURL="$(curl -s https://api.github.com/repos/frioux/leatherman/releases/latest | 24 | grep browser_download_url | 25 | cut -d '"' -f 4 | 26 | grep -F leatherman${OS}.xz )" 27 | mkdir -p ~/bin 28 | curl -sL "$LMURL" > ~/bin/leatherman.xz 29 | xz -d -f ~/bin/leatherman.xz 30 | chmod +x ~/bin/leatherman 31 | ~/bin/leatherman explode 32 | ``` 33 | 34 | This asssumes that `~/bin` is in your path. The `explode` command will create a 35 | symlink for each of the tools listed below. 36 | 37 | ## Usage 38 | 39 | Each tool takes different args, but to run a tool you can either use a symlink 40 | (presumably created by `explode`): 41 | 42 | ``` bash 43 | $ echo "---\nfoo: 1" | yaml2json 44 | {"foo":1} 45 | ``` 46 | 47 | or use it as a subcommand: 48 | 49 | ``` bash 50 | echo "---\nfoo: 1" | leatherman yaml2json 51 | {"foo":1} 52 | ``` 53 | 54 | ## Tools 55 | 56 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/minotaur/minotaur_test.go: -------------------------------------------------------------------------------- 1 | package minotaur 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "testing" 8 | 9 | "github.com/frioux/leatherman/internal/testutil" 10 | "github.com/google/go-cmp/cmp" 11 | ) 12 | 13 | func TestParseArgs(t *testing.T) { 14 | t.Parallel() 15 | 16 | type row struct { 17 | name string 18 | 19 | in []string 20 | 21 | expectedConfig config 22 | expectedErr error 23 | } 24 | 25 | include := regexpFlag{*regexp.MustCompile("")} 26 | ignore := regexpFlag{*regexp.MustCompile("(^.git|/.git$|/.git/)")} 27 | 28 | var table = []row{{ 29 | name: "simple and correct", 30 | in: []string{ 31 | "./foo", "./bar", 32 | "--", 33 | "foo", "bar", 34 | }, 35 | expectedConfig: config{ 36 | dirs: []string{"foo", "bar"}, 37 | script: []string{"foo", "bar"}, 38 | include: include, 39 | ignore: ignore, 40 | }, 41 | }, { 42 | name: "missing --", 43 | in: []string{ 44 | "./foo", "./bar", 45 | "foo", "bar", 46 | }, 47 | expectedErr: errNoScript, 48 | }} 49 | 50 | opt := cmp.Comparer(func(x, y config) bool { 51 | var sameInclude bool 52 | if x.include.String() == y.include.String() { 53 | sameInclude = true 54 | } 55 | 56 | var sameIgnore bool 57 | if x.ignore.String() == y.ignore.String() { 58 | sameIgnore = true 59 | } 60 | 61 | return sameInclude && sameIgnore && 62 | x.verbose == y.verbose && 63 | cmp.Equal(x.dirs, y.dirs) && 64 | cmp.Equal(x.script, y.script) 65 | }) 66 | 67 | for i, test := range table { 68 | c, err := parseFlags(test.in) 69 | testutil.Equal(t, c, test.expectedConfig, fmt.Sprintf("%s (%d): c", test.name, i), opt) 70 | if !errors.Is(err, test.expectedErr) { 71 | t.Errorf("expected err to be %s, instead it's: %s", test.expectedErr, err) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/dropbox/get_metadata.go: -------------------------------------------------------------------------------- 1 | package dropbox 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | // GetMetadataParams maps to the parameters to get-metadata, documented here: 11 | // https://www.dropbox.com/developers/documentation/http/documentation#files-get_metadata 12 | type GetMetadataParams struct { 13 | Path string `json:"path"` 14 | 15 | IncludeMediaInfo bool `json:"include_media_info,omitempty"` 16 | IncludeDeleted bool `json:"include_deleted,omitempty"` 17 | IncludeHasExplicitSharedMembers bool `json:"include_has_explicit_shared_members,omitempty"` 18 | 19 | IncludePropertyGroups []string `json:"include_property_groups,omitempty"` 20 | } 21 | 22 | // GetMetadata gets metadata for a file or directory. 23 | func (cl Client) GetMetadata(p GetMetadataParams) (Metadata, error) { 24 | body := &bytes.Buffer{} 25 | 26 | e := json.NewEncoder(body) 27 | 28 | if err := e.Encode(p); err != nil { 29 | return Metadata{}, fmt.Errorf("dropbox.Client.GetMetadata: json.Encode: %w", err) 30 | } 31 | 32 | req, err := http.NewRequest("POST", "https://api.dropboxapi.com/2/files/get_metadata", body) 33 | if err != nil { 34 | return Metadata{}, fmt.Errorf("dropbox.Client.GetMetadata: http.NewRequest: %w", err) 35 | } 36 | 37 | req.Header.Set("Authorization", "Bearer "+cl.Token) 38 | req.Header.Set("Content-Type", "application/json") 39 | 40 | resp, err := cl.Do(req) 41 | if err != nil { 42 | return Metadata{}, fmt.Errorf("dropbox.Client.GetMetadata: http.Client.Do: %w", err) 43 | } 44 | defer resp.Body.Close() 45 | 46 | var ret Metadata 47 | d := json.NewDecoder(resp.Body) 48 | 49 | if err := d.Decode(&ret); err != nil { 50 | return Metadata{}, fmt.Errorf("dropbox.Client.GetMetadata: json.Decode: %w", err) 51 | } 52 | 53 | return ret, nil 54 | 55 | } 56 | -------------------------------------------------------------------------------- /internal/tool/misc/status/sound.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "regexp" 12 | "strings" 13 | ) 14 | 15 | var ( 16 | startMatcher = regexp.MustCompile(`index:\s`) 17 | splitter = regexp.MustCompile(`^\s*(.+?)\s*[:=]\s*(.+?)\s*$`) 18 | ) 19 | 20 | type sound struct{ value bool } 21 | 22 | func (v *sound) load() error { 23 | is, err := v.listSinkInputs() 24 | if err != nil { 25 | return err 26 | } 27 | 28 | for _, i := range is { 29 | // these are always connected 30 | if strings.HasPrefix(i["application.name"], "speech-dispatcher") { 31 | continue 32 | } 33 | 34 | if i["state"] == "RUNNING" { 35 | json.NewEncoder(os.Stderr).Encode(i) 36 | v.value = true 37 | return nil 38 | } 39 | } 40 | 41 | v.value = false 42 | return nil 43 | } 44 | 45 | func (v *sound) listSinkInputs() ([]map[string]string, error) { 46 | c := exec.Command("pacmd", "list-sink-inputs") 47 | c.Stderr = os.Stderr 48 | out, err := c.Output() 49 | if err != nil { 50 | return nil, fmt.Errorf("pacmd list-sink-inputs: %w", err) 51 | } 52 | 53 | r := bytes.NewBuffer(out) 54 | s := bufio.NewScanner(r) 55 | 56 | var ret []map[string]string 57 | var current map[string]string 58 | for s.Scan() { 59 | l := s.Text() 60 | if startMatcher.MatchString(l) { 61 | if current != nil { 62 | ret = append(ret, current) 63 | } 64 | current = map[string]string{} 65 | } 66 | 67 | matches := splitter.FindStringSubmatch(l) 68 | if len(matches) == 3 { 69 | matches[2] = strings.TrimSuffix(matches[2], `"`) 70 | matches[2] = strings.TrimPrefix(matches[2], `"`) 71 | current[matches[1]] = matches[2] 72 | } 73 | } 74 | ret = append(ret, current) 75 | 76 | return ret, nil 77 | } 78 | 79 | func (v *sound) render(rw http.ResponseWriter) { fmt.Fprintf(rw, "%t\n", v.value) } 80 | -------------------------------------------------------------------------------- /internal/dropbox/upload.go: -------------------------------------------------------------------------------- 1 | package dropbox 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | // UploadParams maps to the parameters to file-upload, documented here: 14 | // https://www.dropbox.com/developers/documentation/http/documentation#files-upload 15 | type UploadParams struct { 16 | Path string `json:"path"` 17 | Autorename bool `json:"autorename,omitempty"` 18 | 19 | Mode string `json:"mode,omitempty"` 20 | // Mute bool `json:"mute,omitempty"` 21 | // StrictConflict bool `json:"strict_conflict,omitempty"` 22 | // ClientModified string `json:"client_modified,omitempty"` // should be time.Time? 23 | } 24 | 25 | func encodeUploadParams(up UploadParams) (string, error) { 26 | buf := &bytes.Buffer{} 27 | 28 | e := json.NewEncoder(buf) 29 | err := e.Encode(up) 30 | if err != nil { 31 | return "", fmt.Errorf("json.Encode: %w", err) 32 | } 33 | 34 | return strings.TrimSuffix(buf.String(), "\n"), nil 35 | } 36 | 37 | // Create writes the body to path. 38 | func (cl Client) Create(up UploadParams, body io.Reader) error { 39 | req, err := http.NewRequest("POST", "https://content.dropboxapi.com/2/files/upload", body) 40 | if err != nil { 41 | return fmt.Errorf("http.NewRequest: %w", err) 42 | } 43 | 44 | req.Header.Set("Authorization", "Bearer "+cl.Token) 45 | req.Header.Set("Content-Type", "application/octet-stream") 46 | apiArg, err := encodeUploadParams(up) 47 | if err != nil { 48 | return err 49 | } 50 | req.Header.Set("Dropbox-API-Arg", apiArg) 51 | 52 | resp, err := cl.Do(req) 53 | if err != nil { 54 | return fmt.Errorf("http.Client.Do: %w", err) 55 | } 56 | 57 | if resp.StatusCode > 399 { 58 | buf := &bytes.Buffer{} 59 | if _, err := io.Copy(buf, resp.Body); err != nil { 60 | return fmt.Errorf("io.Copy: %w", err) 61 | } 62 | return errors.New(buf.String()) 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/debounce/debounce.go: -------------------------------------------------------------------------------- 1 | package debounce 2 | 3 | import ( 4 | "bufio" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "os" 9 | "time" 10 | ) 11 | 12 | func Run(args []string, stdin io.Reader) error { 13 | var timeout time.Duration 14 | var leading, h, help bool 15 | 16 | flags := flag.NewFlagSet("debounce", flag.ExitOnError) 17 | 18 | flags.DurationVar(&timeout, "lockoutTime", time.Second, "amount of time between output") 19 | flags.BoolVar(&leading, "leadingEdge", false, "trigger at leading edge of cycle") 20 | flags.BoolVar(&h, "h", false, "help for debounce") 21 | flags.BoolVar(&help, "help", false, "help for debounce") 22 | 23 | err := flags.Parse(args[1:]) 24 | if err != nil { 25 | return fmt.Errorf("flags.Parse: %w", err) 26 | } 27 | 28 | if h || help { 29 | fmt.Print("\n" + 30 | " debounce [--leadingEdge] [--lockoutTime 2s]\n" + 31 | " [-h|--help]\n" + 32 | "\n" + 33 | " --leadingEdge pass this flag to output at the leading edge of a cycle\n" + 34 | " (off by default)\n" + 35 | " --lockoutTime set the lockout time in seconds, default is 1 second\n" + 36 | "\n" + 37 | " -h --help print usage message and exit\n" + 38 | "\n" + 39 | "\n" + 40 | "debounce creates cycles based on the lockout time. The cycle\n" + 41 | "starts on the first line sent and stops after no lines are sent\n" + 42 | "within a period of the lockout time\n" + 43 | "\n" + 44 | "\n" + 45 | "The following would run tests after a second of 'silence' after a\n" + 46 | "save\n" + 47 | "\n" + 48 | " inotifywait -mr -e modify,move . | debounce | xargs -i{} make test\n" + 49 | "\n", 50 | ) 51 | return nil 52 | } 53 | 54 | b := newBouncer(!leading, os.Stdout, timeout) 55 | 56 | s := bufio.NewScanner(os.Stdin) 57 | for s.Scan() { 58 | line := s.Text() 59 | 60 | b.Write(time.Now(), []byte(line+"\n")) 61 | } 62 | 63 | return s.Err() 64 | } 65 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/minotaur/minotaur.md: -------------------------------------------------------------------------------- 1 | Watches directories and runs a command when files change. 2 | 3 | ```bash 4 | minotaur -include-args -include internal -ignore yaml \ 5 | ~/code/leatherman -- \ 6 | go test ~/code/leatherman/... 7 | ``` 8 | 9 | If the `-include-args` flag is set, the script receives the events as 10 | arguments, so you can exit early if only irrelevant files changed. 11 | 12 | The arguments are of the form `$event\t$filename`; for example `CREATE x.pl`. 13 | As far as I know the valid events are; 14 | 15 | * `CHMOD` 16 | * `CREATE` 17 | * `REMOVE` 18 | * `RENAME` 19 | * `WRITE` 20 | 21 | The events are deduplicated and also debounced, so your script will never fire 22 | more often than once a second. If events are happening every half second the 23 | debouncing will cause the script to never run. 24 | 25 | The underlying library supports emitting multiple events in a single line (ie 26 | `CREATE|CHMOD`) though I've not seen that in Linux. 27 | 28 | `minotaur` reëmits all output (both stderr and stdout) of the passed script to 29 | standard out, so you could make a script like this to experiment with the 30 | events with timestamps: 31 | 32 | ```bash 33 | #!/bin/sh 34 | 35 | for x in "$@"; do 36 | echo "$x" 37 | done | ts 38 | ``` 39 | 40 | You can do all kinds of interesting things in the script, for example you could 41 | verify that the events deserve a restart, then restart a service, then block till 42 | the service can serve traffic, then restart some other related service. 43 | 44 | The `-include` and `-ignore` arguments are optional; by default `-include` is 45 | empty, so matches everything, and `-ignore` matches `.git`. You can also pass 46 | `-verbose` to include output about minotaur itself, like which directories it's 47 | watching. 48 | 49 | The flag `-no-run-at-start` will not the the script until there are any events. 50 | 51 | The flag `-report` will decorate output with a text wrapper to clarify when the 52 | script is run. 53 | -------------------------------------------------------------------------------- /internal/tool/misc/wuphf/wuphf.go: -------------------------------------------------------------------------------- 1 | package wuphf 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "os/exec" 11 | "strings" 12 | ) 13 | 14 | var drivers = map[string]driver{ 15 | "wall": wall, 16 | "pushover": pushover, 17 | "notify": notify, 18 | } 19 | 20 | func Wuphf(args []string, _ io.Reader) error { 21 | if len(args) == 1 { 22 | return fmt.Errorf("usage: %s \n", args[0]) 23 | } 24 | 25 | message := strings.Join(args[1:], " ") 26 | for n, d := range drivers { 27 | err := d(message) 28 | if err != nil { 29 | return fmt.Errorf("%s failed: %s\n", n, err) 30 | } 31 | } 32 | 33 | return nil 34 | } 35 | 36 | type driver func(string) error 37 | 38 | var ( 39 | errNoPushoverToken = errors.New("LM_PUSHOVER_TOKEN not set") 40 | errNoPushoverUser = errors.New("LM_PUSHOVER_USER not set") 41 | errNoPushoverDevice = errors.New("LM_PUSHOVER_DEVICE not set") 42 | ) 43 | 44 | func pushover(message string) error { 45 | token := os.Getenv("LM_PUSHOVER_TOKEN") 46 | if token == "" { 47 | return errNoPushoverToken 48 | } 49 | user := os.Getenv("LM_PUSHOVER_USER") 50 | if user == "" { 51 | return errNoPushoverUser 52 | } 53 | device := os.Getenv("LM_PUSHOVER_DEVICE") 54 | if device == "" { 55 | return errNoPushoverDevice 56 | } 57 | 58 | resp, err := http.PostForm("https://api.pushover.net/1/messages.json", url.Values{ 59 | "token": {token}, 60 | "user": {user}, 61 | "message": {message}, 62 | "device": {device}, 63 | }) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if resp.StatusCode != 200 { 69 | return fmt.Errorf("failed to pushover: %s (%s)", err, resp.Status) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func wall(message string) error { 76 | cmd := exec.Command("wall", "-t", "2", message) 77 | return cmd.Run() 78 | } 79 | 80 | func notify(message string) error { 81 | cmd := exec.Command("notify-send", "-u", "critical", "wuphf", message) 82 | return cmd.Run() 83 | } 84 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/frioux/leatherman 2 | 3 | require ( 4 | github.com/BurntSushi/toml v1.5.0 5 | github.com/BurntSushi/xgb v0.0.0-20200324125942-20f126ea2843 // indirect 6 | github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 7 | github.com/PuerkitoBio/goquery v1.9.2 8 | github.com/brandondube/tai v0.1.0 9 | github.com/bwmarrin/discordgo v0.28.1 10 | github.com/fsnotify/fsnotify v1.9.0 11 | github.com/godbus/dbus v4.1.0+incompatible 12 | github.com/google/go-cmp v0.6.0 13 | github.com/hackebrot/turtle v0.2.0 14 | github.com/icza/backscanner v0.0.0-20180226082541-a77511ef4f0f 15 | github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 // indirect 16 | github.com/jmoiron/sqlx v1.4.0 17 | github.com/mattn/go-isatty v0.0.20 18 | github.com/pierrec/lz4/v3 v3.3.5 19 | github.com/tailscale/hujson v0.0.0-20190930033718-5098e564d9b3 20 | github.com/ulikunitz/xz v0.5.12 21 | github.com/yuin/goldmark v1.7.8 22 | github.com/yuin/gopher-lua v1.1.1 23 | golang.org/x/crypto v0.31.0 // indirect 24 | golang.org/x/text v0.22.0 25 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 26 | modernc.org/sqlite v1.29.6 27 | ) 28 | 29 | require gopkg.in/yaml.v3 v3.0.1 30 | 31 | require ( 32 | github.com/andybalholm/cascadia v1.3.2 // indirect 33 | github.com/dustin/go-humanize v1.0.1 // indirect 34 | github.com/google/uuid v1.3.0 // indirect 35 | github.com/gorilla/websocket v1.4.2 // indirect 36 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 37 | github.com/ncruces/go-strftime v0.1.9 // indirect 38 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 39 | golang.org/x/net v0.33.0 // indirect 40 | golang.org/x/sys v0.28.0 // indirect 41 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect 42 | modernc.org/libc v1.41.0 // indirect 43 | modernc.org/mathutil v1.6.0 // indirect 44 | modernc.org/memory v1.7.2 // indirect 45 | modernc.org/strutil v1.2.0 // indirect 46 | modernc.org/token v1.1.0 // indirect 47 | ) 48 | 49 | go 1.18 50 | -------------------------------------------------------------------------------- /internal/steam/steam.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "math/rand" 9 | "os" 10 | "sync" 11 | "time" 12 | 13 | "github.com/frioux/leatherman/internal/lmhttp" 14 | ) 15 | 16 | // AppIDs automatically maintains a map of steam appids to steam names. 17 | type AppIDs struct { 18 | mu sync.Mutex 19 | raw map[int]string 20 | 21 | LastLoad time.Time 22 | } 23 | 24 | // Autoload repopulates the internal map about daily. 25 | func (a *AppIDs) Autoload() { 26 | rnd := rand.New(rand.NewSource(time.Now().Unix())) 27 | for { 28 | if err := a.Load(context.Background()); err != nil { 29 | fmt.Fprintln(os.Stderr, err) 30 | } 31 | 32 | // reload some time between 0 and 24h from now 33 | time.Sleep(time.Duration(rnd.Float32() * float32(time.Hour*24))) 34 | } 35 | } 36 | 37 | func (a *AppIDs) Load(ctx context.Context) error { 38 | a.mu.Lock() 39 | defer a.mu.Unlock() 40 | 41 | if time.Now().Sub(a.LastLoad) < time.Hour*24 { 42 | return nil 43 | } 44 | 45 | if a.raw == nil { 46 | a.raw = map[int]string{} 47 | } 48 | 49 | ctx, cancel := context.WithTimeout(ctx, time.Minute) 50 | defer cancel() 51 | resp, err := lmhttp.Get(ctx, "https://api.steampowered.com/ISteamApps/GetAppList/v2/") 52 | if err != nil { 53 | return err 54 | } 55 | defer resp.Body.Close() 56 | 57 | if resp.StatusCode != 200 { 58 | return errors.New("non-200 status") 59 | } 60 | 61 | var remoteData struct { 62 | Applist struct { 63 | Apps []struct { 64 | AppID int 65 | Name string 66 | } 67 | } 68 | } 69 | 70 | d := json.NewDecoder(resp.Body) 71 | if err := d.Decode(&remoteData); err != nil { 72 | return err 73 | } 74 | 75 | for k := range a.raw { 76 | delete(a.raw, k) 77 | } 78 | 79 | for _, app := range remoteData.Applist.Apps { 80 | a.raw[app.AppID] = app.Name 81 | } 82 | 83 | a.LastLoad = time.Now() 84 | 85 | return nil 86 | } 87 | 88 | func (a *AppIDs) App(appid int) string { 89 | a.mu.Lock() 90 | defer a.mu.Unlock() 91 | 92 | return a.raw[appid] 93 | } 94 | -------------------------------------------------------------------------------- /internal/tool/misc/img/draw.md: -------------------------------------------------------------------------------- 1 | Draws images with lua. 2 | 3 | ```bash 4 | $ draw 'rect(10, 10, 118, 118, red, yellow)' > x.png 5 | ``` 6 | 7 | Inspired by pico-8. This tool takes lua scripts as strings and writes a png to 8 | standard out. 9 | 10 | Consider this tool unstable, I'll likely make it read scripts from either 11 | standard in, or files, or both, and make arguments no longer the default. 12 | 13 | ## Lua API 14 | 15 | ### `set(x, y, c)` 16 | 17 | Takes an x, y coordinate and sets it to a color. 18 | 19 | ### `rgb(r, g, b)` 20 | 21 | Takes a red, green, and blue value (from 0 to 255 or as floating points from 0 22 | to 1), returns a color value. 23 | 24 | The following colors are defined as globals for you: 25 | 26 | * black 27 | * white 28 | * red 29 | * blue 30 | * yellow 31 | * green 32 | * orange 33 | * purple 34 | * cyan 35 | * magenta 36 | * clear 37 | 38 | ### `sin(t)` 39 | 40 | Returns sine of t, in terms of pi, not degrees. 41 | 42 | ### `cos(t)` 43 | 44 | Returns cosine of t, in terms of pi, not degrees. 45 | 46 | ### `tan(t)` 47 | 48 | Returns tangent of t, in terms of pi, not degrees. 49 | 50 | ### `PI` 51 | 52 | Constant for pi. 53 | 54 | ### `rect(x1, y1, x2, y2, bordercolor, fillcolor)` 55 | 56 | Draws a rectangle from (x1, y1) to (x2, y2) with a border of bordercolor and 57 | filled with fillcolor. 58 | 59 | ### `circ(x, y, r, bordercolor, fillcolor)` 60 | 61 | Draws a circle around (x, y) with radius r with a border of bordercolor and 62 | filled with fillcolor. 63 | 64 | ### `line(x1, y1, x2, y2, color)` 65 | 66 | Draws a line from (x1, y1) to (x2, y2) in color. 67 | 68 | ### `bg(color)` 69 | 70 | Fills the whole picture with color. 71 | 72 | ## BUGS 73 | 74 | Line currently has gaps when the absolute value of the slope is high. I intend 75 | to fix that soon. 76 | 77 | ## DEBUGGING 78 | 79 | If you set the `LM_DEBUG_DRAW` env var a debug.gif and debug.log will be 80 | created with a frame / logline per event. The env var should be set to a regex 81 | that filters events, so `.` will show all events. 82 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/srv/srv.go: -------------------------------------------------------------------------------- 1 | package srv // import "github.com/frioux/leatherman/internal/tool/srv" 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | "time" 13 | 14 | "github.com/frioux/leatherman/internal/selfupdate" 15 | ) 16 | 17 | func Serve(args []string, _ io.Reader) error { 18 | var ( 19 | port int 20 | noreload bool 21 | ) 22 | fs := flag.NewFlagSet("srv", flag.ContinueOnError) 23 | fs.IntVar(&port, "port", 0, "port to listen on; default is random") 24 | fs.BoolVar(&noreload, "no-autoreload", false, "disable auto-reloading") 25 | if err := fs.Parse(args[1:]); err != nil { 26 | return err 27 | } 28 | 29 | dir := "." 30 | if len(fs.Args()) > 0 { 31 | dir = fs.Arg(0) 32 | } 33 | 34 | dir = filepath.Clean(dir) 35 | 36 | ch := make(chan net.Addr) 37 | 38 | go func() { 39 | addr := <-ch 40 | fmt.Fprintf(os.Stderr, "Serving %s on %s\n", dir, addr) 41 | }() 42 | 43 | return serve(!noreload, dir, port, ch) 44 | } 45 | 46 | func logReqs(h http.Handler) http.Handler { 47 | return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 48 | fmt.Fprintln(os.Stderr, time.Now(), r.URL) 49 | 50 | if r.URL.Path == "/version" { 51 | selfupdate.Handler.ServeHTTP(rw, r) 52 | return 53 | } 54 | 55 | h.ServeHTTP(rw, r) 56 | }) 57 | } 58 | 59 | func serve(reload bool, dir string, port int, log chan net.Addr) error { 60 | listener, err := net.Listen("tcp", ":"+strconv.Itoa(port)) 61 | if err != nil { 62 | return fmt.Errorf("net.Listen: %w", err) 63 | } 64 | 65 | log <- listener.Addr() 66 | 67 | var sinking chan error 68 | 69 | h := http.FileServer(http.Dir(dir)) 70 | if reload { 71 | h, sinking, err = autoReload(h, dir) 72 | if err != nil { 73 | return err 74 | } 75 | } 76 | 77 | s := http.Server{Handler: logReqs(h)} 78 | 79 | if reload { 80 | go func() { 81 | err = <-sinking 82 | fmt.Fprintln(os.Stderr, "auto-reload:", err) 83 | s.Close() 84 | }() 85 | } 86 | 87 | return s.Serve(listener) 88 | } 89 | -------------------------------------------------------------------------------- /internal/notes/todo.go: -------------------------------------------------------------------------------- 1 | package notes 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "strings" 10 | "text/template" 11 | "time" 12 | 13 | "github.com/frioux/leatherman/internal/dropbox" 14 | "github.com/frioux/leatherman/internal/personality" 15 | "github.com/frioux/leatherman/internal/twilio" 16 | ) 17 | 18 | var bodyTemplate *template.Template 19 | 20 | type bodyArgs struct { 21 | Message, At string 22 | } 23 | 24 | func init() { 25 | var err error 26 | bodyTemplate, err = template.New("xxx").Parse(`{ 27 | "title": {{.Message | printf "%q"}}, 28 | "date": "{{.At}}", 29 | "tags": [ "private", "inbox" ] 30 | } 31 | * {{.Message}} 32 | `) 33 | if err != nil { 34 | panic(err) 35 | } 36 | } 37 | 38 | func body(message string, at time.Time) io.Reader { 39 | buf := &bytes.Buffer{} 40 | 41 | bodyTemplate.Execute(buf, bodyArgs{message, at.Format("2006-01-02T15:04:05")}) 42 | 43 | return buf 44 | } 45 | 46 | // todo creates an item tagged inbox 47 | func todo(cl dropbox.Client) func(message string, media []twilio.Media) (string, error) { 48 | return func(message string, media []twilio.Media) (string, error) { 49 | sum := sha1.New() 50 | // it's impossible for sha1 to emit an error 51 | sum.Write([]byte(message)) 52 | for _, m := range media { 53 | sum.Write([]byte(m.URL)) 54 | } 55 | sha := sum.Sum([]byte("")) 56 | id := hex.EncodeToString(sha[:]) 57 | 58 | for i, m := range media { 59 | if strings.HasPrefix(m.ContentType, "image/") { 60 | message += fmt.Sprintf(` attachment %d on %s`, m.URL, i, id) 61 | } else { 62 | message += fmt.Sprintf(" [attachment %d on %s](%s)", i, id, m.URL) 63 | } 64 | } 65 | 66 | buf := body(message, time.Now()) 67 | 68 | path := "/notes/content/posts/todo-" + id + ".md" 69 | up := dropbox.UploadParams{Path: path, Autorename: true} 70 | if err := cl.Create(up, buf); err != nil { 71 | return personality.Err(), fmt.Errorf("dropbox.Create: %w", err) 72 | } 73 | 74 | return personality.Ack(), nil 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/lmfs/lmfs.go: -------------------------------------------------------------------------------- 1 | package lmfs 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | 11 | "github.com/fsnotify/fsnotify" 12 | ) 13 | 14 | type WriteFileFS interface { 15 | fs.FS 16 | 17 | WriteFile(string, []byte, fs.FileMode) error 18 | } 19 | 20 | type CreateFS interface { 21 | fs.FS 22 | 23 | Create(string) (FileWriter, error) 24 | } 25 | 26 | // don't love the name here, but want a WriteFile function 27 | type FileWriter interface { 28 | fs.File 29 | 30 | Write([]byte) (int, error) 31 | } 32 | 33 | var errUnsupported = errors.New("filesystem does not support requested operation") 34 | 35 | func WriteFile(fss fs.FS, name string, contents []byte, mode fs.FileMode) (err error) { 36 | if wffs, ok := fss.(WriteFileFS); ok { 37 | return wffs.WriteFile(name, contents, mode) 38 | } 39 | 40 | // XXX this leg doesn't handle the mode (yet) 41 | if cfs, ok := fss.(CreateFS); ok { 42 | f, err := cfs.Create(name) 43 | if err != nil { 44 | return err 45 | } 46 | defer func() { 47 | if err == nil { 48 | err = f.Close() 49 | } else { 50 | f.Close() 51 | } 52 | }() 53 | 54 | if _, err := io.Copy(f, bytes.NewReader(contents)); err != nil { 55 | return err 56 | } 57 | return nil 58 | } 59 | 60 | return fmt.Errorf("couldn't write file via %T: %w", fss, errUnsupported) 61 | } 62 | 63 | // XXX: add lmfs.DirFS for WriteFile (and watch) support 64 | 65 | type WatchFS interface { 66 | fs.FS 67 | 68 | Watch(context.Context, string) (chan []fsnotify.Event, error) 69 | } 70 | 71 | func Watch(ctx context.Context, fss fs.FS, dir string) (chan []fsnotify.Event, error) { 72 | wfs, ok := fss.(WatchFS) 73 | if !ok { 74 | return nil, fmt.Errorf("couldn't watch dir via %T: %w", fss, errUnsupported) 75 | } 76 | 77 | return wfs.Watch(ctx, dir) 78 | } 79 | 80 | type RemoveFS interface { 81 | fs.FS 82 | Remove(string) error 83 | } 84 | 85 | func Remove(fss fs.FS, path string) error { 86 | rfs, ok := fss.(RemoveFS) 87 | if !ok { 88 | return errUnsupported 89 | } 90 | 91 | return rfs.Remove(path) 92 | } 93 | -------------------------------------------------------------------------------- /internal/tool/misc/backlight/backlight_test.go: -------------------------------------------------------------------------------- 1 | package backlight 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "os" 8 | "strconv" 9 | "testing" 10 | 11 | "github.com/frioux/leatherman/internal/testutil" 12 | ) 13 | 14 | func runAndCheck(t *testing.T, change, newBrightness int) { 15 | if err := run(change); err != nil { 16 | t.Errorf("run failed: %s", err) 17 | return 18 | } 19 | 20 | f, err := os.Open("./brightness") 21 | if err != nil { 22 | t.Errorf("os.Open failed: %s", err) 23 | return 24 | } 25 | 26 | buf := &bytes.Buffer{} 27 | _, err = io.Copy(buf, f) 28 | if err != nil { 29 | t.Errorf("io.Copy failed: %s", err) 30 | return 31 | } 32 | 33 | raw := buf.String() 34 | i, err := strconv.Atoi(raw[:len(raw)-1]) 35 | if err != nil { 36 | t.Errorf("strconv.Atoi failed: %s", err) 37 | return 38 | } 39 | 40 | testutil.Equal(t, i, newBrightness, "wrong brightness") 41 | } 42 | 43 | func TestRun(t *testing.T) { 44 | t.Parallel() 45 | 46 | d, err := ioutil.TempDir("", "") 47 | if err != nil { 48 | t.Fatalf("Couldn't create TempDir: %s", err) 49 | } 50 | defer os.RemoveAll(d) 51 | 52 | err = os.Chdir(d) 53 | if err != nil { 54 | t.Fatalf("Couldn't Chdir: %s", err) 55 | } 56 | 57 | // max_brightness 58 | f, err := os.Create("./max_brightness") 59 | if err != nil { 60 | t.Fatalf("Couldn't create max_brightness: %s", err) 61 | } 62 | _, err = f.WriteString("1000\n") 63 | if err != nil { 64 | t.Fatalf("Couldn't write max_brightness: %s", err) 65 | } 66 | err = f.Close() 67 | if err != nil { 68 | t.Fatalf("Couldn't close max_brightness: %s", err) 69 | } 70 | 71 | // brightness 72 | f, err = os.Create("./brightness") 73 | if err != nil { 74 | t.Fatalf("Couldn't create brightness: %s", err) 75 | } 76 | _, err = f.WriteString("750\n") 77 | if err != nil { 78 | t.Fatalf("Couldn't write brightness: %s", err) 79 | } 80 | err = f.Close() 81 | if err != nil { 82 | t.Fatalf("Couldn't close brightness: %s", err) 83 | } 84 | 85 | runAndCheck(t, 1, 760) 86 | runAndCheck(t, 2, 780) 87 | runAndCheck(t, -5, 730) 88 | } 89 | -------------------------------------------------------------------------------- /HACKING.mdwn: -------------------------------------------------------------------------------- 1 | ## Creating a new tool 2 | 3 | To create a new tool create a file of the form 4 | `./internal/tool/$category/$package/$tool_name.go`. 5 | 6 | * The category will be shown as the section in the readme 7 | * the $package is internal and solely to allow sharing stuff across tools 8 | * the tool_name is how you specify the subcommand used to run the tool 9 | 10 | The file should have a single public function (with a capital letter) with the 11 | signature `func Herp(args []string, r io.Reader) error`. 12 | 13 | Create a matching documentation file of the form 14 | `./internal/tool/$category/$package/$tool_name.go`. Ensure that it has a brief 15 | description appropriate for use in a list (see `README.mdwn` for examples.) 16 | 17 | After that's done, run `go generate ./...` to have the tool added to 18 | `dispatch.go`, `help_generated.go`, and `README.mdwn`. 19 | 20 | ## Making a mini-leatherman 21 | 22 | If you want to shrink the leatherman for some reason, you can set LM_TOOL to a 23 | regex to limit which tools get built. It is checked against the full path 24 | of each tool source file, so to only build a single tool you might do: 25 | 26 | ```bash 27 | $ LM_TOOL='alluni.go$' go generate ./... 28 | $ go build 29 | $ ./leatherman 30 | Tools: 31 | * alluni 32 | * explode 33 | * help 34 | * version 35 | 36 | Get more help for each tool with `leatherman help -command `, or `leatherman help -v` 37 | ``` 38 | 39 | Or if you wanted to only build the general purpose tools you could match the category: 40 | 41 | ```bash 42 | $ LM_TOOL='/allpurpose' go generate ./... 43 | $ go build 44 | $ ./leatherman 45 | Tools: 46 | * alluni 47 | * clocks 48 | * csv2json 49 | * csv2md 50 | * debounce 51 | * dump-mozlz4 52 | * expand-url 53 | * explode 54 | * fn 55 | * group-by-date 56 | * help 57 | * minotaur 58 | * name2rune 59 | * netrc-password 60 | * pomotimer 61 | * replace-unzip 62 | * srv 63 | * toml2json 64 | * uni 65 | * version 66 | * yaml2json 67 | 68 | Get more help for each tool with `leatherman help -command `, or `leatherman help -v` 69 | ``` 70 | -------------------------------------------------------------------------------- /internal/lmlua/luanotes/luanotes.go: -------------------------------------------------------------------------------- 1 | package luanotes 2 | 3 | import ( 4 | lua "github.com/yuin/gopher-lua" 5 | 6 | "github.com/frioux/leatherman/internal/lmlua" 7 | "github.com/frioux/leatherman/internal/notes" 8 | ) 9 | 10 | func RegisterNotesPackage(L *lua.LState) { 11 | ns := L.NewTable() 12 | L.SetGlobal("notes", ns) 13 | 14 | registerNotesFunctions(L, ns) 15 | registerZineType(L) 16 | registerArticleType(L) 17 | } 18 | 19 | func registerNotesFunctions(L *lua.LState, ns *lua.LTable) { 20 | ns.RawSet(lua.LString("readarticlefromfs"), L.NewFunction(func(L *lua.LState) int { 21 | fss := lmlua.CheckFS(L, 1) 22 | path := L.CheckString(2) 23 | 24 | a, err := notes.ReadArticleFromFS(fss, path) 25 | if err != nil { 26 | L.Error(lua.LString(err.Error()), 0) 27 | return 0 28 | } 29 | 30 | ud := L.NewUserData() 31 | ud.Value = a 32 | L.SetMetatable(ud, L.GetTypeMetatable("article")) 33 | L.Push(ud) 34 | 35 | return 1 36 | })) 37 | } 38 | 39 | var articleMethods = map[string]lua.LGFunction{ 40 | 41 | "rawcontents": func(L *lua.LState) int { 42 | a := checkArticle(L, 1) 43 | 44 | L.Push(lua.LString(string(a.RawContents))) 45 | 46 | return 1 47 | }, 48 | } 49 | 50 | func checkArticle(L *lua.LState, where int) notes.Article { 51 | ud := L.CheckUserData(where) 52 | if v, ok := ud.Value.(notes.Article); ok { 53 | return v 54 | } 55 | L.ArgError(1, "notes.Article expected") 56 | return notes.Article{} 57 | } 58 | 59 | func registerArticleType(L *lua.LState) { 60 | mt := L.NewTypeMetatable("article") 61 | L.SetGlobal("article", mt) 62 | L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), articleMethods)) 63 | } 64 | 65 | var zineMethods = map[string]lua.LGFunction{} 66 | 67 | func checkZine(L *lua.LState, where int) *notes.Zine { 68 | ud := L.CheckUserData(where) 69 | if v, ok := ud.Value.(*notes.Zine); ok { 70 | return v 71 | } 72 | L.ArgError(1, "*notes.Zine expected") 73 | return nil 74 | } 75 | 76 | func registerZineType(L *lua.LState) { 77 | mt := L.NewTypeMetatable("zine") 78 | L.SetGlobal("zine", mt) 79 | L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), zineMethods)) 80 | } 81 | -------------------------------------------------------------------------------- /internal/notes/article.go: -------------------------------------------------------------------------------- 1 | package notes 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "regexp" 9 | 10 | "github.com/tailscale/hujson" 11 | ) 12 | 13 | type Article struct { 14 | Title string 15 | 16 | // Filename will be set after parsing. 17 | Filename string `json:"-"` 18 | 19 | // URL will be set after parsing. 20 | URL string `json:"-"` 21 | 22 | // Raw tells the parser not to include the standard header and footer. 23 | Raw bool 24 | 25 | Tags []string 26 | 27 | ReviewedOn *string `json:"reviewed_on" db:"reviewed_on"` 28 | 29 | ReviewBy *string `json:"review_by" db:"review_by"` 30 | 31 | Extra map[string]string 32 | 33 | Body []byte 34 | 35 | // MarkdownLua can be used both to filter the Body at render time as 36 | // well as allowing interactive functionality implemented in the page 37 | // itself 38 | MarkdownLua []byte 39 | 40 | // RawContents contains the full, unparsed contents of the source 41 | // file. 42 | RawContents []byte 43 | } 44 | 45 | var mdluaMatcher = regexp.MustCompile("(?s)```mdlua\n(.*?)```\n") 46 | 47 | func ReadArticle(r io.Reader) (Article, error) { 48 | // copy data so we can store the raw bytes in the Article for later 49 | // recovery. I would like to be able to rebuild the raw data based on 50 | // the contents of Article, but this is easier and good enough for now. 51 | b, err := io.ReadAll(r) 52 | if err != nil { 53 | return Article{}, err 54 | } 55 | 56 | a := Article{RawContents: b} 57 | 58 | r = bytes.NewReader(b) 59 | d := hujson.NewDecoder(r) 60 | 61 | if err := d.Decode(&a); err != nil { 62 | return a, fmt.Errorf("hujson.Decoder.Decode: %w", err) 63 | } 64 | raw, err := ioutil.ReadAll(d.Buffered()) 65 | if err != nil { 66 | return a, fmt.Errorf("hujson.Decoder.Buffered+ioutil.ReadAll: %w", err) 67 | } 68 | 69 | c, err := ioutil.ReadAll(r) 70 | if err != nil { 71 | return a, err 72 | } 73 | 74 | raw = append(raw, c...) 75 | 76 | found := mdluaMatcher.FindAllSubmatch(raw, -1) 77 | for _, f := range found { 78 | a.MarkdownLua = append(a.MarkdownLua, f[1]...) 79 | } 80 | 81 | a.Body = mdluaMatcher.ReplaceAll(raw, nil) 82 | 83 | return a, err 84 | } 85 | -------------------------------------------------------------------------------- /internal/notes/article_test.go: -------------------------------------------------------------------------------- 1 | package notes_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/frioux/leatherman/internal/notes" 8 | "github.com/frioux/leatherman/internal/testutil" 9 | ) 10 | 11 | func TestReadArticle(t *testing.T) { 12 | str := ` 13 | { 14 | "title": "frew", 15 | "tags": ["foo", "bar"], 16 | "id": "xyzzy", 17 | "extra": { "foo": "bar" } 18 | } 19 | # markdown 20 | 21 | goes here` 22 | 23 | a, err := notes.ReadArticle(strings.NewReader(str)) 24 | 25 | if err != nil { 26 | t.Fatalf("couldn't readMetadata: %s", err) 27 | } 28 | testutil.Equal(t, a, notes.Article{ 29 | Title: "frew", 30 | Tags: []string{"foo", "bar"}, 31 | Extra: map[string]string{"foo": "bar"}, 32 | RawContents: []byte(str), 33 | Body: []byte(` 34 | # markdown 35 | 36 | goes here`), 37 | }, "basic") 38 | } 39 | 40 | func TestReadArticleAndLua(t *testing.T) { 41 | str := ` 42 | { 43 | "title": "frew", 44 | "tags": ["foo", "bar"], 45 | "id": "xyzzy", 46 | "extra": { "foo": "bar" } 47 | } 48 | # markdown 49 | 50 | goes here 51 | 52 | ` + "```mdlua\n" + ` 53 | function foo() 54 | 55 | end 56 | 57 | function bar() 58 | 59 | end 60 | ` + "```\n" 61 | a, err := notes.ReadArticle(strings.NewReader(str)) 62 | 63 | if err != nil { 64 | t.Fatalf("couldn't readMetadata: %s", err) 65 | } 66 | testutil.Equal(t, a, notes.Article{ 67 | Title: "frew", 68 | Tags: []string{"foo", "bar"}, 69 | Extra: map[string]string{"foo": "bar"}, 70 | RawContents: []byte(str), 71 | Body: []byte(` 72 | # markdown 73 | 74 | goes here 75 | 76 | `), 77 | MarkdownLua: []byte(` 78 | function foo() 79 | 80 | end 81 | 82 | function bar() 83 | 84 | end 85 | `), 86 | }, "basic") 87 | } 88 | 89 | var A notes.Article 90 | 91 | func BenchmarkReadArticle(b *testing.B) { 92 | var a notes.Article 93 | for i := 0; i < b.N; i++ { 94 | a, _ = notes.ReadArticle(strings.NewReader(` 95 | { 96 | "title": "frew", 97 | "tags": ["foo", "bar"], 98 | "id": "xyzzy", 99 | "extra": { "foo": "bar" } 100 | } 101 | # markdown 102 | 103 | goes here 104 | `)) 105 | } 106 | A = a 107 | } 108 | -------------------------------------------------------------------------------- /internal/lmlua/regexp.go: -------------------------------------------------------------------------------- 1 | package lmlua 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | lua "github.com/yuin/gopher-lua" 8 | ) 9 | 10 | func RegisterRegexpPackage(L *lua.LState) { 11 | ns := RegisterRegexpNS(L) 12 | RegisterRegexpFunctions(L, ns) 13 | RegisterRegexpType(L, ns) 14 | } 15 | 16 | func RegisterRegexpFunctions(L *lua.LState, ns *lua.LTable) { 17 | ns.RawSet(lua.LString("compile"), L.NewFunction(func(L *lua.LState) int { 18 | s := L.CheckString(1) 19 | re, err := regexp.Compile(s) 20 | if err != nil { 21 | L.Error(lua.LString(err.Error()), 0) 22 | return 0 23 | } 24 | ud := L.NewUserData() 25 | ud.Value = re 26 | L.SetMetatable(ud, ns.RawGet(lua.LString("regexp"))) 27 | L.Push(ud) 28 | return 1 29 | })) 30 | } 31 | 32 | func RegisterRegexpNS(L *lua.LState) *lua.LTable { 33 | ns := L.NewTable() 34 | L.SetGlobal("regexp", ns) 35 | return ns 36 | } 37 | 38 | func RegisterRegexpType(L *lua.LState, ns *lua.LTable) { 39 | mt := L.NewTypeMetatable("") 40 | ns.RawSet(lua.LString("regexp"), mt) 41 | L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), regexpMethods)) 42 | } 43 | 44 | func checkRegexp(L *lua.LState, which int) *regexp.Regexp { 45 | ud := L.CheckUserData(which) 46 | if v, ok := ud.Value.(*regexp.Regexp); ok { 47 | return v 48 | } 49 | L.ArgError(1, fmt.Sprintf("*regexp.Regexp expected, saw %T", ud.Value)) 50 | return nil 51 | } 52 | 53 | var regexpMethods = map[string]lua.LGFunction{ 54 | 55 | "findallstringsubmatch": func(L *lua.LState) int { 56 | re := checkRegexp(L, 1) 57 | s := L.CheckString(2) 58 | i := L.CheckNumber(3) 59 | 60 | found := re.FindAllStringSubmatch(s, int(i)) 61 | ret := L.NewTable() 62 | 63 | for i := range found { 64 | t := L.NewTable() 65 | for j := range found[i] { 66 | t.RawSet(lua.LNumber(float64(j+1)), lua.LString(found[i][j])) 67 | } 68 | ret.RawSet(lua.LNumber(float64(i+1)), t) 69 | } 70 | L.Push(ret) 71 | 72 | return 1 73 | }, 74 | 75 | "replaceallstringfunc": func(L *lua.LState) int { 76 | re := checkRegexp(L, 1) 77 | s := L.CheckString(2) 78 | f := L.CheckFunction(3) 79 | 80 | str := re.ReplaceAllStringFunc(s, func(in string) string { 81 | L.Push(f) 82 | L.Push(lua.LString(in)) 83 | L.Call(1, 1) 84 | 85 | return L.CheckString(4) 86 | }) 87 | 88 | L.Push(lua.LString(str)) 89 | 90 | return 1 91 | }, 92 | } 93 | -------------------------------------------------------------------------------- /internal/tool/misc/steamsrv/screenshots.go: -------------------------------------------------------------------------------- 1 | package steamsrv 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "regexp" 8 | "sort" 9 | "time" 10 | 11 | "github.com/frioux/leatherman/internal/steam" 12 | ) 13 | 14 | // shotPattern decomposes steam screenshots filenames 15 | // 1 2 3 4 5 6 7 8 9 16 | // ? appid year month day hour min sec i 17 | // 760 319630 2021 04 15 21 05 01 1 18 | var shotPattern = regexp.MustCompile(`([^/]+)/remote/([^/]+)/screenshots/(\d\d\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)_(\d+).jpg`) 19 | 20 | type screenshot struct { 21 | Name, Thumbnail string 22 | 23 | AppID int 24 | Date time.Time 25 | } 26 | 27 | func (s screenshots) Screenshots(wantAppID int) ([]screenshot, error) { 28 | filenames, err := fs.Glob(s.fss, "*/remote/*/screenshots/*.jpg") 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | ret := make([]screenshot, 0, 1000) 34 | for _, filename := range filenames { 35 | m := shotPattern.FindStringSubmatch(filename) 36 | if len(m) == 0 { 37 | fmt.Fprintf(os.Stderr, "path didn't match pattern: %s\n", filename) 38 | continue 39 | } 40 | 41 | appID := mustAtoi(m[2]) 42 | if wantAppID != 0 && wantAppID != appID { 43 | continue 44 | } 45 | 46 | date := time.Date( 47 | // year month day 48 | mustAtoi(m[3]), time.Month(mustAtoi(m[4])), mustAtoi(m[5]), 49 | // hour minute second 50 | mustAtoi(m[6]), mustAtoi(m[7]), mustAtoi(m[8]), 51 | // ns timezone 52 | 0, time.Local) 53 | 54 | thumbnail := fmt.Sprintf("%s/remote/%s/screenshots/thumbnails/%s%s%s%s%s%s_%s.jpg", m[1], m[2], m[3], m[4], m[5], m[6], m[7], m[8], m[9]) 55 | 56 | if f, err := s.fss.Open(thumbnail); err == nil { 57 | f.Close() 58 | } else { 59 | thumbnail = "" 60 | } 61 | ret = append(ret, screenshot{ 62 | AppID: appID, 63 | Name: filename, 64 | Thumbnail: thumbnail, 65 | Date: date, 66 | }) 67 | } 68 | 69 | sort.Slice(ret, func(i, j int) bool { return ret[i].Date.Before(ret[j].Date) }) 70 | return ret, nil 71 | } 72 | 73 | type screenshots struct { 74 | a *steam.AppIDs 75 | fss fs.FS 76 | } 77 | -------------------------------------------------------------------------------- /internal/tool/misc/status/steambox.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "os/user" 12 | "path/filepath" 13 | "regexp" 14 | "strings" 15 | "time" 16 | 17 | "github.com/frioux/leatherman/internal/lmhttp" 18 | ) 19 | 20 | type steambox struct{ game string } 21 | 22 | var event = regexp.MustCompile(`^.*AppID (\d+) state changed : (.*),$`) 23 | 24 | func (l *steambox) gameName(id string) (string, error) { 25 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 26 | defer cancel() 27 | 28 | resp, err := lmhttp.Get(ctx, "https://store.steampowered.com/api/appdetails/?filters=basic&appids="+id) 29 | if err != nil { 30 | return "", err 31 | } 32 | defer resp.Body.Close() 33 | 34 | var details map[string]struct { 35 | Data struct { 36 | Name string 37 | } 38 | } 39 | d := json.NewDecoder(resp.Body) 40 | if err := d.Decode(&details); err != nil { 41 | return "", err 42 | } 43 | 44 | return details[id].Data.Name, nil 45 | } 46 | 47 | func (l *steambox) runningGame(r io.Reader) (string, error) { 48 | var ret string 49 | 50 | s := bufio.NewScanner(r) 51 | LINE: 52 | for s.Scan() { 53 | m := event.FindSubmatch(s.Bytes()) 54 | if m == nil { 55 | continue 56 | } 57 | 58 | appID := string(m[1]) 59 | 60 | events := strings.Split(string(m[2]), ",") 61 | for _, e := range events { 62 | if e == "App Running" { 63 | ret = appID 64 | continue LINE 65 | } 66 | } 67 | if ret == appID { // didn't see App Running and since ret was already appID it stopped running 68 | ret = "" 69 | } 70 | } 71 | 72 | return ret, s.Err() 73 | 74 | } 75 | 76 | func (l *steambox) load() error { 77 | u, err := user.Current() 78 | if err != nil { 79 | return err 80 | } 81 | 82 | path := filepath.Join(u.HomeDir, ".local", "share", "Steam", "logs", "content_log.txt") 83 | f, err := os.Open(path) 84 | if err != nil { 85 | return err 86 | } 87 | defer f.Close() 88 | 89 | appID, err := l.runningGame(f) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | l.game, err = l.gameName(appID) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | return nil 100 | } 101 | 102 | func (l *steambox) render(rw http.ResponseWriter) { 103 | fmt.Fprintf(rw, "%s\n", l.game) 104 | } 105 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/minotaur/parseflags.go: -------------------------------------------------------------------------------- 1 | package minotaur 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "path/filepath" 8 | "regexp" 9 | ) 10 | 11 | var ( 12 | errNoScript = errors.New("no script passed, forgot -- ?") 13 | errNoDirs = errors.New("no dirs passed") 14 | errUsage = errors.New("usage: minotaur [dir2 dir3] -- [args to cmd]") 15 | ) 16 | 17 | type regexpFlag struct { 18 | regexp.Regexp 19 | } 20 | 21 | func (f *regexpFlag) Set(s string) error { 22 | re, err := regexp.Compile(s) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | f.Regexp = *re 28 | 29 | return nil 30 | } 31 | 32 | type config struct { 33 | dirs []string 34 | script []string 35 | 36 | include, ignore regexpFlag 37 | 38 | verbose, report, includeArgs, noRunAtStart bool 39 | } 40 | 41 | func parseFlags(args []string) (config, error) { 42 | flags := flag.NewFlagSet("minotaur", flag.ExitOnError) 43 | 44 | var c config 45 | 46 | if err := c.ignore.Set("(^.git|/.git$|/.git/)"); err != nil { 47 | return config{}, fmt.Errorf("couldn't create default ignore value: %w", err) 48 | } 49 | 50 | if err := c.include.Set(""); err != nil { 51 | return config{}, fmt.Errorf("couldn't create default include value: %w", err) 52 | } 53 | 54 | flags.Var(&c.include, "include", "regexp matching directories to include") 55 | flags.Var(&c.ignore, "ignore", "regexp matching directories to include") 56 | flags.BoolVar(&c.verbose, "verbose", false, "enable verbose output") 57 | flags.BoolVar(&c.includeArgs, "include-args", false, "include event args args to script") 58 | flags.BoolVar(&c.noRunAtStart, "no-run-at-start", false, "do not run the script when you start") 59 | flags.BoolVar(&c.report, "report", false, "wrap script runs with an ascii report") 60 | 61 | err := flags.Parse(args) 62 | if err != nil { 63 | return config{}, fmt.Errorf("flags.Parse: %w", err) 64 | } 65 | 66 | args = flags.Args() 67 | 68 | if len(args) < 3 { 69 | return config{}, errUsage 70 | } 71 | 72 | var token string 73 | 74 | token, args = args[0], args[1:] 75 | for len(args) > 0 && token != "--" { 76 | c.dirs = append(c.dirs, filepath.Clean(token)) 77 | 78 | token, args = args[0], args[1:] 79 | } 80 | 81 | c.script = args 82 | 83 | if len(c.script) == 0 { 84 | return config{}, errNoScript 85 | } 86 | 87 | if len(c.dirs) == 0 { 88 | return config{}, errNoDirs 89 | } 90 | 91 | return c, nil 92 | } 93 | -------------------------------------------------------------------------------- /maint/find-tools.go: -------------------------------------------------------------------------------- 1 | //go:build generator 2 | // +build generator 3 | 4 | package main 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "encoding/json" 10 | "fmt" 11 | "os" 12 | "os/exec" 13 | "regexp" 14 | "strings" 15 | ) 16 | 17 | var toolMatch = regexp.MustCompile(os.Getenv("LM_TOOL")) 18 | 19 | func main() { 20 | if err := run(); err != nil { 21 | fmt.Fprintln(os.Stderr, err) 22 | os.Exit(1) 23 | } 24 | } 25 | 26 | func run() error { 27 | cmd := exec.Command("/bin/sh", "-c", "go list -json ./internal/tool/... | jq -c .") 28 | cmd.Stderr = os.Stderr 29 | o, err := cmd.Output() 30 | if err != nil { 31 | return err 32 | } 33 | 34 | s := bufio.NewScanner(bytes.NewReader(o)) 35 | e := json.NewEncoder(os.Stdout) 36 | 37 | for s.Scan() { 38 | var c struct { 39 | ImportPath string 40 | 41 | Dir string 42 | GoFiles []string 43 | } 44 | if err := json.Unmarshal(s.Bytes(), &c); err != nil { 45 | return err 46 | } 47 | dir := c.Dir 48 | 49 | for _, file := range c.GoFiles { 50 | path := dir + "/" + file 51 | if !toolMatch.MatchString(path) { 52 | continue 53 | } 54 | cmd := exec.Command("goblin", "-file", path) 55 | cmd.Stderr = os.Stderr 56 | o, err := cmd.Output() 57 | if err != nil { 58 | return err 59 | } 60 | var g struct { 61 | PackageName struct { 62 | Value string 63 | } `json:"package-name"` 64 | Declarations []struct { 65 | Type string 66 | Name struct { 67 | Value string 68 | } 69 | } 70 | } 71 | if err := json.Unmarshal(o, &g); err != nil { 72 | return err 73 | } 74 | 75 | for _, decl := range g.Declarations { 76 | if decl.Type != "function" { 77 | continue 78 | } 79 | if strings.ToUpper(decl.Name.Value[:1]) != decl.Name.Value[:1] { 80 | continue 81 | } 82 | 83 | if os.Getenv("LM_TOOL") != "" { 84 | fmt.Fprintf(os.Stderr, "# %s matched LM_TOOL\n", path) 85 | } 86 | 87 | out := struct { 88 | Func string `json:"func"` 89 | Import string `json:"import"` 90 | Package string `json:"package"` 91 | Path string `json:"path"` 92 | }{ 93 | decl.Name.Value, c.ImportPath, g.PackageName.Value, path, 94 | } 95 | 96 | if err := e.Encode(out); err != nil { 97 | return err 98 | } 99 | } 100 | } 101 | } 102 | 103 | return s.Err() 104 | } 105 | -------------------------------------------------------------------------------- /internal/tool/notes/now/template.go: -------------------------------------------------------------------------------- 1 | package now 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "html/template" 7 | 8 | "github.com/frioux/leatherman/internal/notes" 9 | ) 10 | 11 | //go:embed templates/* 12 | var templates embed.FS 13 | 14 | var tpl *template.Template 15 | 16 | func init() { 17 | var err error 18 | tpl, err = template.ParseFS(templates, "templates/*") 19 | if err != nil { 20 | panic(err) 21 | } 22 | } 23 | 24 | type HTMLVars struct { 25 | *notes.Zine 26 | 27 | Title string 28 | body []byte 29 | } 30 | 31 | func (v *HTMLVars) Body() template.HTML { 32 | return template.HTML(string(v.body)) 33 | } 34 | 35 | func (v *HTMLVars) TODOCount() (int, error) { 36 | const sql = `SELECT COUNT(*) FROM _ WHERE tag = 'inbox' AND title != '000 IN'` 37 | var c int 38 | if err := v.Zine.DB.Get(&c, sql); err != nil { 39 | return 0, err 40 | } 41 | return c, nil 42 | } 43 | 44 | func (v *HTMLVars) Write(b []byte) (int, error) { 45 | v.body = append(v.body, b...) 46 | return len(b), nil 47 | } 48 | 49 | type articleVars struct { 50 | *HTMLVars 51 | ArticleTitle, Filename string 52 | } 53 | 54 | func (v articleVars) Title() string { return "now: " + v.ArticleTitle } 55 | 56 | type listVars struct { 57 | *HTMLVars 58 | Articles []struct { 59 | Title, URL string 60 | } 61 | } 62 | 63 | func (v listVars) Title() string { return "now: list" } 64 | 65 | type qVars struct { 66 | *HTMLVars 67 | Records []map[string]interface{} 68 | } 69 | 70 | func (v qVars) Title() string { return "now: q" } 71 | 72 | type updateVars struct { 73 | *HTMLVars 74 | File, Content string 75 | } 76 | 77 | func (v updateVars) Title() string { return "now: update " + v.File } 78 | 79 | type option struct { 80 | error 81 | value interface{} 82 | } 83 | 84 | func (o option) HTML() template.HTML { 85 | if err := o.error; err != nil { 86 | return template.HTML(`` + err.Error() + ``) 87 | } 88 | 89 | return template.HTML(fmt.Sprint(o.value)) 90 | } 91 | 92 | type supVars struct { 93 | *HTMLVars 94 | Versions []option 95 | retroPie, steamOS, pi400 option 96 | } 97 | 98 | func (v supVars) RetroPie() template.HTML { return v.retroPie.HTML() } 99 | 100 | func (v supVars) SteamOS() template.HTML { return v.steamOS.HTML() } 101 | 102 | func (v supVars) Pi400() template.HTML { return v.pi400.HTML() } 103 | 104 | func (v supVars) Title() string { return "now: sup" } 105 | -------------------------------------------------------------------------------- /maint/generate-readme: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | use autodie; 6 | 7 | use File::Basename 'fileparse'; 8 | use File::Spec (); 9 | use JSON::PP; 10 | 11 | no warnings 'uninitialized'; 12 | 13 | my %doc; 14 | 15 | while () { 16 | my $c = decode_json($_); 17 | 18 | my $doc_path = ($c->{path} =~ s/\.go$/.md/r); 19 | my ($d) = split /\n\n/, do { open my $fh, '<:encoding(UTF-8)', $doc_path; local $/; <$fh> }, 2; 20 | chomp $d; 21 | $d =~ s/\n/ /g; 22 | 23 | my ($tool, $dir) = fileparse($c->{path}, '.go'); 24 | 25 | my ($cat) = ($dir =~ m{/internal/tool/([^/]+)/[^/]+}); 26 | 27 | if (!$cat) { 28 | die "no category for $tool\n"; 29 | next; 30 | } 31 | 32 | $doc{$cat}{$tool} = { path => File::Spec->abs2rel($doc_path), doc => $d }; 33 | } 34 | 35 | open my $fh, '<:encoding(UTF-8)', 'maint/README_begin.md'; 36 | my $begin = "\n" . 37 | do { local $/; <$fh> }; 38 | close $fh; 39 | 40 | open $fh, '<:encoding(UTF-8)', 'maint/README_end.md'; 41 | my $end = do { local $/; <$fh> }; 42 | close $fh; 43 | 44 | for my $category (keys %doc) { 45 | for my $tool (keys %{$doc{$category}}) { 46 | $doc{$category}{$tool}{doc} = " * `$tool`: $doc{$category}{$tool}{doc} ([$doc{$category}{$tool}{path}](https://github.com/frioux/leatherman/blob/main/$doc{$category}{$tool}{path}))\n" 47 | } 48 | } 49 | 50 | my $body = $begin; 51 | 52 | for my $category (sort keys %doc) { 53 | $body .= "\n### $category\n\n"; 54 | 55 | for my $tool (sort keys %{$doc{$category}}) { 56 | $body .= $doc{$category}{$tool}{doc}; 57 | } 58 | } 59 | 60 | $body .= "\n$end"; 61 | 62 | open my $readme, '>:encoding(UTF-8)', 'README.mdwn'; 63 | print $readme $body; 64 | 65 | close $readme; 66 | 67 | open my $help, '>:encoding(UTF-8)', 'help_generated.go'; 68 | print $help "package main\n\n"; 69 | print $help qq(import "embed"\n\n); 70 | 71 | for my $category (sort keys %doc) { 72 | for my $tool (sort keys %{$doc{$category}}) { 73 | print $help "//go:embed $doc{$category}{$tool}{path}\n"; 74 | } 75 | } 76 | 77 | print $help "var helpFS embed.FS\n\n"; 78 | 79 | print $help "var helpPaths = map[string]string{\n"; 80 | 81 | for my $category (sort keys %doc) { 82 | for my $tool (sort keys %{$doc{$category}}) { 83 | print $help qq(\t"$tool": "$doc{$category}{$tool}{path}",\n\n); 84 | } 85 | } 86 | 87 | print $help "}\n"; 88 | 89 | close $help; 90 | 91 | system 'go', 'fmt'; 92 | -------------------------------------------------------------------------------- /internal/lmlua/goquery.go: -------------------------------------------------------------------------------- 1 | package lmlua 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/PuerkitoBio/goquery" 7 | lua "github.com/yuin/gopher-lua" 8 | ) 9 | 10 | func RegisterGoqueryPackage(L *lua.LState) { 11 | registerSelectionType(L) 12 | registerGoqueryFunctions(L) 13 | } 14 | 15 | func registerGoqueryFunctions(L *lua.LState) { 16 | ns := L.NewTable() 17 | L.SetGlobal("goquery", ns) 18 | 19 | ns.RawSet(lua.LString("newdocumentfromstring"), L.NewFunction(func(L *lua.LState) int { 20 | s := L.CheckString(1) 21 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(s)) 22 | if err != nil { 23 | L.Error(lua.LString(err.Error()), 0) 24 | return 0 25 | } 26 | ud := L.NewUserData() 27 | ud.Value = doc.Selection 28 | L.SetMetatable(ud, L.GetTypeMetatable("goqueryselection")) 29 | L.Push(ud) 30 | return 1 31 | })) 32 | } 33 | 34 | func registerSelectionType(L *lua.LState) { 35 | mt := L.NewTypeMetatable("goqueryselection") 36 | L.SetGlobal("goqueryselection", mt) 37 | L.SetField(mt, "__index", L.SetFuncs(L.NewTable(), goqueryselectionMethods)) 38 | } 39 | 40 | func checkGoQuerySelection(L *lua.LState) *goquery.Selection { 41 | ud := L.CheckUserData(1) 42 | if v, ok := ud.Value.(*goquery.Selection); ok { 43 | return v 44 | } 45 | L.ArgError(1, "*goquery.Selection expected") 46 | return nil 47 | } 48 | 49 | var goqueryselectionMethods = map[string]lua.LGFunction{ 50 | 51 | "attr": func(L *lua.LState) int { 52 | sel := checkGoQuerySelection(L) 53 | s := L.CheckString(2) 54 | found, _ := sel.Attr(s) 55 | L.Push(lua.LString(found)) 56 | 57 | return 1 58 | }, 59 | 60 | "each": func(L *lua.LState) int { 61 | sel := checkGoQuerySelection(L) 62 | f := L.CheckFunction(2) 63 | 64 | ud := L.NewUserData() 65 | ud.Value = sel.Each(func(i int, sel *goquery.Selection) { 66 | L.Push(f) 67 | ud := L.NewUserData() 68 | ud.Value = sel 69 | L.SetMetatable(ud, L.GetTypeMetatable("goqueryselection")) 70 | L.Push(ud) 71 | L.Push(lua.LNumber(float64(i))) 72 | L.Call(2, 0) 73 | }) 74 | L.SetMetatable(ud, L.GetTypeMetatable("goqueryselection")) 75 | L.Push(ud) 76 | return 1 77 | }, 78 | 79 | "find": func(L *lua.LState) int { 80 | sel := checkGoQuerySelection(L) 81 | s := L.CheckString(2) 82 | 83 | ud := L.NewUserData() 84 | ud.Value = sel.Find(s) 85 | L.SetMetatable(ud, L.GetTypeMetatable("goqueryselection")) 86 | L.Push(ud) 87 | return 1 88 | }, 89 | 90 | "text": func(L *lua.LState) int { 91 | sel := checkGoQuerySelection(L) 92 | L.Push(lua.LString(sel.Text())) 93 | return 1 94 | }, 95 | } 96 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/clocks/clocks.go: -------------------------------------------------------------------------------- 1 | package clocks 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "strconv" 9 | "text/tabwriter" 10 | "time" 11 | 12 | "github.com/brandondube/tai" 13 | ) 14 | 15 | func cmpDates(there, here time.Time) int8 { 16 | tDate := there.Truncate(time.Duration(24) * time.Hour) 17 | hDate := here.Truncate(time.Duration(24) * time.Hour) 18 | if tDate == hDate { 19 | return 0 20 | } else if tDate.Before(hDate) { 21 | return -1 22 | } else { 23 | return 1 24 | } 25 | } 26 | 27 | func t(now time.Time, l string) string { 28 | var thereNow time.Time 29 | if l == "TAI" { 30 | tt := tai.FromTime(now) 31 | gt := tt.AsGregorian() 32 | thereNow = time.Date(gt.Year, time.Month(gt.Month), gt.Day, gt.Hour, gt.Min, gt.Sec, 0, time.UTC) 33 | } else { 34 | loc, err := time.LoadLocation(l) 35 | if err != nil { 36 | log.Fatal(err) 37 | } 38 | thereNow = now.In(loc) 39 | } 40 | 41 | relativeHere := time.Date(now.Year(), now.Month(), now.Day(), 42 | now.Hour(), now.Minute(), now.Second(), now.Nanosecond(), 43 | time.UTC, 44 | ) 45 | relativeThere := time.Date(thereNow.Year(), thereNow.Month(), thereNow.Day(), 46 | thereNow.Hour(), thereNow.Minute(), thereNow.Second(), thereNow.Nanosecond(), 47 | time.UTC, 48 | ) 49 | 50 | offset := relativeThere.Sub(relativeHere).Hours() 51 | 52 | offsetStr := strconv.FormatFloat(offset, 'g', -1, 64) 53 | if offset >= 0 { 54 | offsetStr = "+" + offsetStr 55 | } 56 | 57 | day := "wtf" 58 | switch cmpDates(relativeThere, relativeHere) { 59 | case 0: 60 | day = "today" 61 | case 1: 62 | day = "tomorrow" 63 | case -1: 64 | day = "yesterday" 65 | } 66 | // I can't figure out why I need two tabs at the end or why the final column 67 | // isn't right aligned :( 68 | return l + "\t" + day + "\t" + relativeThere.Format("15:04:05\t3:04:05 PM") + "\t\t" + offsetStr 69 | } 70 | 71 | func Run(args []string, _ io.Reader) error { 72 | if len(args) > 1 && args[1] == "-h" { 73 | fmt.Println("my personal, digital, wall of clocks") 74 | return nil 75 | } 76 | 77 | now := time.Now().In(time.Local) 78 | 79 | zones := []string{"Local", "America/Los_Angeles", "America/Chicago", "America/New_York", "Asia/Jerusalem", "UTC", "TAI"} 80 | if len(args) > 1 { 81 | zones = args[1:] 82 | } 83 | run(now, zones, os.Stdout) 84 | 85 | return nil 86 | } 87 | 88 | func run(now time.Time, zones []string, out io.Writer) { 89 | w := tabwriter.NewWriter(out, 0, 8, 2, ' ', tabwriter.AlignRight) 90 | for _, tz := range zones { 91 | fmt.Fprintln(w, t(now, tz)) 92 | } 93 | w.Flush() 94 | } 95 | -------------------------------------------------------------------------------- /internal/reminders/reminders_test.go: -------------------------------------------------------------------------------- 1 | package reminders 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/frioux/leatherman/internal/testutil" 8 | ) 9 | 10 | func TestAssertRegexLocations(t *testing.T) { 11 | sn := remindFormat.SubexpNames() 12 | testutil.Equal(t, sn[MESSAGE], "message", "") 13 | testutil.Equal(t, sn[WHEN], "when", "") 14 | testutil.Equal(t, sn[DURATION], "duration", "") 15 | } 16 | 17 | var LA *time.Location 18 | 19 | func init() { 20 | var err error 21 | LA, err = time.LoadLocation("America/Los_Angeles") 22 | if err != nil { 23 | panic(err) 24 | } 25 | } 26 | func TestNextTime(t *testing.T) { 27 | now := time.Date(2012, 12, 12, 06, 00, 00, 00, LA) 28 | 29 | type assertion struct { 30 | start, clock, result time.Time 31 | } 32 | assertions := []assertion{ 33 | {now, time.Date(0, 0, 0, 7, 0, 0, 0, LA), time.Date(2012, 12, 12, 7, 0, 0, 0, LA)}, 34 | {now, time.Date(0, 0, 0, 5, 0, 0, 0, LA), time.Date(2012, 12, 13, 5, 0, 0, 0, LA)}, 35 | } 36 | for _, a := range assertions { 37 | testutil.Equal(t, a.result, nextTime(a.start, a.clock), "") 38 | } 39 | } 40 | 41 | func TestParse(t *testing.T) { 42 | now := time.Date(2012, 12, 12, 00, 00, 00, 00, LA) 43 | 44 | type assertion struct { 45 | in, message string 46 | 47 | t time.Time 48 | err bool 49 | } 50 | 51 | assertions := []assertion{ 52 | {"", "", time.Time{}, true}, 53 | {"remind me to frew in an hour", "frew", now.Add(time.Hour), false}, 54 | {"remind me to frew in 10m", "frew", now.Add(10 * time.Minute), false}, 55 | {"remind me to frioux at 10am", "frioux", now.Add(10 * time.Hour), false}, 56 | {"remind me to frioux at 10AM", "frioux", now.Add(10 * time.Hour), false}, 57 | {"remind me to frioux at 10:01am", "frioux", now.Add(10*time.Hour + time.Minute), false}, 58 | {"remind me to frioux at noon", "frioux", time.Date(2012, 12, 12, 12, 0, 0, 0, LA), false}, 59 | {"remind me to frioux at midnight", "frioux", time.Date(2012, 12, 12, 0, 0, 0, 0, LA), false}, 60 | } 61 | 62 | for _, a := range assertions { 63 | t.Run(a.in, func(t *testing.T) { 64 | when, mess, err := Parse(now, a.in) 65 | if a.err && err == nil { 66 | t.Error("should have errored but didn't") 67 | } else if !a.err && err != nil { 68 | t.Errorf("unexpected error: %s", err) 69 | } 70 | testutil.Equal(t, a.t, when, "") 71 | testutil.Equal(t, a.message, mess, "") 72 | }) 73 | } 74 | 75 | // deferred 76 | // when, mess, err = parseReminder(time.Time{}, "remind me to frew on Wed") 77 | // when, mess, err = parseReminder(time.Time{}, "remind me to frew on Wednesday") 78 | } 79 | -------------------------------------------------------------------------------- /internal/lmfav/pal.go: -------------------------------------------------------------------------------- 1 | package lmfav 2 | 3 | import "image/color" 4 | 5 | func init() { 6 | palettes = [][]color.NRGBA{ 7 | // https://coolors.co/palette/9b5de5-f15bb5-fee440-00bbf9-00f5d4 8 | 0: { 9 | color.NRGBA{0x9b, 0x5d, 0xe5, 0xff}, 10 | color.NRGBA{0xf1, 0x5b, 0xb5, 0xff}, 11 | color.NRGBA{0xfe, 0xe4, 0x40, 0xff}, 12 | color.NRGBA{0x00, 0xbb, 0xf9, 0xff}, 13 | color.NRGBA{0x00, 0xf5, 0xd4, 0xff}, 14 | }, 15 | // https://coolors.co/palette/177e89-084c61-db3a34-ffc857-323031 16 | 1: { 17 | color.NRGBA{0x17, 0x7e, 0x89, 0xff}, 18 | color.NRGBA{0x08, 0x4c, 0x61, 0xff}, 19 | color.NRGBA{0xdb, 0x3a, 0x34, 0xff}, 20 | color.NRGBA{0xff, 0xc8, 0x57, 0xff}, 21 | color.NRGBA{0x32, 0x30, 0x31, 0xff}, 22 | }, 23 | // https://coolors.co/palette/0c0f0a-ff206e-fbff12-41ead4-ffffff 24 | 2: { 25 | color.NRGBA{0x0c, 0x0f, 0x0a, 0xff}, 26 | color.NRGBA{0xff, 0x20, 0x6e, 0xff}, 27 | color.NRGBA{0xfb, 0xff, 0x12, 0xff}, 28 | color.NRGBA{0x41, 0xea, 0xd4, 0xff}, 29 | color.NRGBA{0xff, 0xff, 0xff, 0xff}, 30 | }, 31 | // https://coolors.co/palette/000814-001d3d-003566-ffc300-ffd60a 32 | 3: { 33 | color.NRGBA{0x00, 0x08, 0x14, 0xff}, 34 | color.NRGBA{0x00, 0x1d, 0x3d, 0xff}, 35 | color.NRGBA{0x00, 0x35, 0x66, 0xff}, 36 | color.NRGBA{0xff, 0xc3, 0x00, 0xff}, 37 | color.NRGBA{0xff, 0xd6, 0x0a, 0xff}, 38 | }, 39 | // https://coolors.co/palette/f72585-7209b7-3a0ca3-4361ee-4cc9f0 40 | 4: { 41 | color.NRGBA{0xf7, 0x25, 0x85, 0xff}, 42 | color.NRGBA{0x72, 0x09, 0xb7, 0xff}, 43 | color.NRGBA{0x3a, 0x0c, 0xa3, 0xff}, 44 | color.NRGBA{0x43, 0x61, 0xee, 0xff}, 45 | color.NRGBA{0x4c, 0xc9, 0xf0, 0xff}, 46 | }, 47 | // https://coolors.co/palette/003049-d62828-f77f00-fcbf49-eae2b7 48 | 5: { 49 | color.NRGBA{0x00, 0x30, 0x49, 0xff}, 50 | color.NRGBA{0xd6, 0x28, 0x28, 0xff}, 51 | color.NRGBA{0xf7, 0x7f, 0x00, 0xff}, 52 | color.NRGBA{0xfc, 0xbf, 0x49, 0xff}, 53 | color.NRGBA{0xea, 0xe2, 0xb7, 0xff}, 54 | }, 55 | // https://coolors.co/palette/606c38-283618-fefae0-dda15e-bc6c25 56 | 6: { 57 | color.NRGBA{0x60, 0x6c, 0x38, 0xff}, 58 | color.NRGBA{0x28, 0x36, 0x18, 0xff}, 59 | color.NRGBA{0xfe, 0xfa, 0xe0, 0xff}, 60 | color.NRGBA{0xdd, 0xa1, 0x5e, 0xff}, 61 | color.NRGBA{0xbc, 0x6c, 0x25, 0xff}, 62 | }, 63 | // https://coolors.co/palette/ffbe0b-fb5607-ff006e-8338ec-3a86ff 64 | 7: { 65 | color.NRGBA{0xff, 0xbe, 0x0b, 0xff}, 66 | color.NRGBA{0xfb, 0x56, 0x07, 0xff}, 67 | color.NRGBA{0xff, 0x00, 0x6e, 0xff}, 68 | color.NRGBA{0x83, 0x38, 0xec, 0xff}, 69 | color.NRGBA{0x3a, 0x86, 0xff, 0xff}, 70 | }, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/expandurl/expand-url.go: -------------------------------------------------------------------------------- 1 | package expandurl 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "os" 12 | "regexp" 13 | "sync" 14 | 15 | "github.com/PuerkitoBio/goquery" 16 | "github.com/frioux/leatherman/internal/lmhttp" 17 | ) 18 | 19 | var tidyRE = regexp.MustCompile(`^\s*(.*?)\s*$`) 20 | 21 | func Run(args []string, stdin io.Reader) error { 22 | return run(stdin, os.Stdout) 23 | } 24 | 25 | func run(r io.Reader, w io.Writer) error { 26 | // some cookies cause go to log warnings to stderr 27 | log.SetOutput(ioutil.Discard) 28 | 29 | ua := &http.Client{} 30 | 31 | scanner := bufio.NewScanner(r) 32 | lines := []string{} 33 | 34 | for scanner.Scan() { 35 | lines = append(lines, scanner.Text()) 36 | } 37 | if err := scanner.Err(); err != nil { 38 | return fmt.Errorf("reading standard input: %w", err) 39 | } 40 | 41 | // tokens limits parallelism to 10 42 | tokens := make(chan struct{}, 10) 43 | 44 | // wg ensures that we block till all lines are done 45 | wg := sync.WaitGroup{} 46 | 47 | for i := range lines { 48 | i := i 49 | wg.Add(1) 50 | tokens <- struct{}{} 51 | 52 | go func() { 53 | lines[i] = replaceLink(ua, lines[i]) 54 | <-tokens 55 | wg.Done() 56 | }() 57 | } 58 | 59 | wg.Wait() 60 | 61 | for _, line := range lines { 62 | fmt.Fprintln(w, line) 63 | } 64 | 65 | return nil 66 | } 67 | 68 | // urlToLink downloads the contents of url, extracts the title, and produces a markdown link. 69 | func urlToLink(ua *http.Client, url string) (string, error) { 70 | resp, err := lmhttp.Get(context.TODO(), url) 71 | if err != nil { 72 | return "", fmt.Errorf("lmhttp.Get: %s", err) 73 | } 74 | 75 | doc, err := goquery.NewDocumentFromReader(resp.Body) 76 | if err != nil { 77 | return "", fmt.Errorf("goquery.NewDocumentFromReader: %s", err) 78 | } 79 | title := tidyRE.FindStringSubmatch(doc.Find("title").Text()) 80 | if len(title) != 2 { 81 | return "", fmt.Errorf("title is blank") 82 | } 83 | return fmt.Sprintf("[%s](%s)", title[1], url), nil 84 | } 85 | 86 | var urlFinder = regexp.MustCompile(`^(|.*\s)(https?://\S+)(\s.*|)$`) 87 | 88 | // replaceLink replaces one or more raw http or https links on the passed 89 | // line with markdown links. 90 | func replaceLink(ua *http.Client, line string) string { 91 | for { 92 | if match := urlFinder.FindStringSubmatch(line); len(match) > 0 { 93 | md, err := urlToLink(ua, match[2]) 94 | if err != nil { 95 | fmt.Fprintf(os.Stderr, "%s\n", err) 96 | break 97 | } 98 | line = match[1] + md + match[3] 99 | continue 100 | } 101 | break 102 | } 103 | return line 104 | } 105 | -------------------------------------------------------------------------------- /internal/notes/defer.go: -------------------------------------------------------------------------------- 1 | package notes 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | "regexp" 9 | "strings" 10 | "time" 11 | 12 | "github.com/frioux/leatherman/internal/dropbox" 13 | "github.com/frioux/leatherman/internal/personality" 14 | "github.com/frioux/leatherman/internal/twilio" 15 | ) 16 | 17 | // jumpTo starts at the start and jumps to dest. 18 | // 19 | // Copied from leatherman's timeutil 20 | func jumpTo(start time.Time, dest time.Weekday) time.Time { 21 | offset := (dest - start.Weekday()) % 7 22 | // Go's modulus is dumb? 23 | if offset < 0 { 24 | offset += 7 25 | } 26 | return start.AddDate(0, 0, int(offset)) 27 | } 28 | 29 | var deferPattern = regexp.MustCompile(`(?i)^\s*defer\s+(?:(.*)\s+)?(?:until|till|til)\s+(\d{4}-\d\d-\d\d|mon|monday|tue|tuesday|wed|wednesday|thu|thur|thursday|fri|friday|sat|saturday|sun|sunday)\s*`) 30 | 31 | // deferMessage creates a deferred message for future frew. Format is 32 | // defer till 2006-01-02 33 | // or 34 | // defer till mon 35 | func deferMessage(cl dropbox.Client) func(string, []twilio.Media) (string, error) { 36 | return func(input string, media []twilio.Media) (string, error) { 37 | m := deferPattern.FindStringSubmatch(input) 38 | if len(m) != 3 { 39 | return personality.Err(), errors.New("deferMessage: input didn't match pattern (" + input + ")") 40 | } 41 | 42 | message, when := m[1], m[2] 43 | weekday := time.Weekday(7) // intentionally invalid 44 | switch strings.ToLower(when) { 45 | case "mon", "monday": 46 | weekday = time.Monday 47 | case "tue", "tuesday": 48 | weekday = time.Tuesday 49 | case "wed", "wednesday": 50 | weekday = time.Wednesday 51 | case "thu", "thur", "thursday": 52 | weekday = time.Thursday 53 | case "fri", "friday": 54 | weekday = time.Friday 55 | case "sat", "saturday": 56 | weekday = time.Saturday 57 | case "sun", "sunday": 58 | weekday = time.Sunday 59 | } 60 | 61 | if weekday != time.Weekday(7) { 62 | when = jumpTo(time.Now(), weekday).Format("2006-01-02") 63 | } 64 | for i, m := range media { 65 | if strings.HasPrefix(m.ContentType, "image/") { 66 | message += fmt.Sprintf(` attachment %d`, i, m.URL) 67 | } else { 68 | message += fmt.Sprintf(" [attachment %d](%s)", i, m.URL) 69 | } 70 | } 71 | 72 | sha := sha1.Sum([]byte(message)) 73 | id := hex.EncodeToString(sha[:]) 74 | path := "/notes/.deferred/" + when + "-" + id + ".md" 75 | 76 | up := dropbox.UploadParams{Path: path, Autorename: true} 77 | if err := cl.Create(up, strings.NewReader(" * "+message+"\n")); err != nil { 78 | return personality.Err(), fmt.Errorf("dropbox.Create: %w", err) 79 | } 80 | 81 | return personality.Ack(), nil 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pkg/shellquote/shellquote.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package shellquote quotes strings for shell scripts. 3 | 4 | Sometimes you get strings from the internet and need to quote them for security, 5 | other times you'll need to quote your own strings because doing it by hand is 6 | just too much work. 7 | 8 | Another option is http://github.com/kballard/go-shellquote. The quoting algorithms are 9 | completely different and the results vary as well, but both produce working 10 | results in my brief testing. See 11 | https://github.com/frioux/go-scraps/tree/master/cmd/quotetest for a tool that 12 | shows the results of quoting with each package. 13 | */ 14 | package shellquote 15 | 16 | import ( 17 | "errors" 18 | "regexp" 19 | "strings" 20 | ) 21 | 22 | // ErrNull will be returned from Quote if any of the strings contains a null 23 | // byte. 24 | var ErrNull = errors.New("No way to quote string containing null bytes") 25 | 26 | // Quote will return a shell quoted string for the passed tokens. 27 | func Quote(in []string) (string, error) { 28 | tmp := make([]string, len(in)) 29 | var sawNonEqual bool 30 | for i, x := range in { 31 | if x == "" { 32 | tmp[i] = `''` 33 | continue 34 | } 35 | if strings.Contains(x, "\x00") { 36 | return "", ErrNull 37 | } 38 | 39 | var escape bool 40 | hasEqual := strings.Contains(x, "=") 41 | if hasEqual { 42 | if !sawNonEqual { 43 | escape = true 44 | } 45 | } else { 46 | sawNonEqual = true 47 | } 48 | 49 | toEsc := regexp.MustCompile(`[^\w!%+,\-./:=@^]`) 50 | if !escape && toEsc.MatchString(x) { 51 | escape = true 52 | } 53 | 54 | if escape || (!sawNonEqual && hasEqual) { 55 | y := strings.Replace(x, `'`, `'\''`, -1) 56 | 57 | simplifyRe := regexp.MustCompile(`(?:'\\''){2,}`) 58 | y = simplifyRe.ReplaceAllStringFunc(y, func(str string) string { 59 | var inner string 60 | for i := 0; i < len(str)/4; i++ { 61 | inner += "'" 62 | } 63 | return `'"` + inner + `"'` 64 | }) 65 | 66 | y = `'` + y + `'` 67 | y = strings.TrimSuffix(y, `''`) 68 | y = strings.TrimPrefix(y, `''`) 69 | 70 | tmp[i] = y 71 | continue 72 | } 73 | tmp[i] = x 74 | } 75 | return strings.Join(tmp, " "), nil 76 | } 77 | 78 | /* 79 | Copyright 2018 Arthur Axel fREW Schmidt 80 | 81 | Licensed under the Apache License, Version 2.0 (the "License"); 82 | you may not use this file except in compliance with the License. 83 | You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 84 | Unless required by applicable law or agreed to in writing, software 85 | 86 | distributed under the License is distributed on an "AS IS" BASIS, 87 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 88 | See the License for the specific language governing permissions and 89 | limitations under the License. 90 | */ 91 | -------------------------------------------------------------------------------- /internal/tool/misc/backlight/backlight.go: -------------------------------------------------------------------------------- 1 | package backlight 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strconv" 9 | ) 10 | 11 | const path = "/sys/class/backlight/intel_backlight" 12 | 13 | func Run(args []string, _ io.Reader) error { 14 | err := os.Chdir(path) 15 | if err != nil { 16 | return fmt.Errorf("Couldn't chdir: %w", err) 17 | } 18 | 19 | if len(args) != 2 { 20 | return fmt.Errorf("Usage: %s ", args[0]) 21 | } 22 | 23 | change, err := strconv.Atoi(args[1]) 24 | if err != nil { 25 | return fmt.Errorf("Couldn't parse arg: %w", err) 26 | } 27 | 28 | return run(change) 29 | } 30 | 31 | func run(change int) error { 32 | max, err := getMaxBrightness() 33 | if err != nil { 34 | return fmt.Errorf("Couldn't getMaxBrightness: %w", err) 35 | } 36 | 37 | cur, err := getCurBrightness() 38 | if err != nil { 39 | return fmt.Errorf("getCurBrightness: %w", err) 40 | } 41 | 42 | var toWrite = change*max/100 + cur 43 | fmt.Fprintf(os.Stderr, "%d = %d*%d/100 + %d\n", toWrite, change, max, cur) 44 | if toWrite < 0 { 45 | toWrite = 0 46 | } 47 | if toWrite > max { 48 | toWrite = max 49 | } 50 | 51 | file, err := os.OpenFile("./brightness", os.O_RDWR, 0) 52 | if err != nil { 53 | return fmt.Errorf("Couldn't open brightness for writing: %w", err) 54 | } 55 | 56 | fmt.Fprintf(os.Stderr, "Setting brightness to %d\n", toWrite) 57 | 58 | _, err = file.WriteString(fmt.Sprintf("%d\n", toWrite)) 59 | if err != nil { 60 | return fmt.Errorf("file.WriteString: %w", err) 61 | } 62 | err = file.Close() 63 | if err != nil { 64 | return fmt.Errorf("Couldn't write brightness: %w", err) 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func getMaxBrightness() (int, error) { 71 | file, err := os.Open("./max_brightness") 72 | if err != nil { 73 | return 0, fmt.Errorf("couldn't open max_brightness: %s", err) 74 | } 75 | defer file.Close() 76 | 77 | r := bufio.NewReader(file) 78 | line, err := r.ReadSlice('\n') 79 | if err != nil { 80 | return 0, fmt.Errorf("couldn't read line: %s", err) 81 | } 82 | 83 | i, err := strconv.Atoi(string(line[:len(line)-1])) 84 | if err != nil { 85 | return 0, fmt.Errorf("couldn't parse line: %s", err) 86 | } 87 | 88 | return i, nil 89 | } 90 | 91 | func getCurBrightness() (int, error) { 92 | file, err := os.Open("./brightness") 93 | if err != nil { 94 | return 0, fmt.Errorf("couldn't open brightness: %s", err) 95 | } 96 | defer file.Close() 97 | 98 | r := bufio.NewReader(file) 99 | line, err := r.ReadSlice('\n') 100 | if err != nil { 101 | return 0, fmt.Errorf("couldn't read line: %s", err) 102 | } 103 | 104 | i, err := strconv.Atoi(string(line[:len(line)-1])) 105 | if err != nil { 106 | return 0, fmt.Errorf("couldn't parse line: %s", err) 107 | } 108 | 109 | return i, nil 110 | } 111 | -------------------------------------------------------------------------------- /internal/tool/allpurpose/pomotimer/pomotimer.go: -------------------------------------------------------------------------------- 1 | package pomotimer 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "math" 7 | "os" 8 | "os/exec" 9 | "time" 10 | ) 11 | 12 | const clear = "\r\x1b[J" 13 | 14 | func Run(args []string, stdin io.Reader) error { 15 | duration := 25 * time.Minute 16 | if len(args) > 1 { 17 | var err error 18 | duration, err = time.ParseDuration(args[1]) 19 | if err != nil { 20 | fmt.Fprintf(os.Stderr, "pomotimer: couldn't parse duration: %s", err) 21 | os.Exit(1) 22 | } 23 | } 24 | 25 | // disable input buffering 26 | err := exec.Command("stty", "-F", "/dev/tty", "cbreak", "min", "1").Run() 27 | if err != nil { 28 | return fmt.Errorf("couldn't disable input buffering: %w", err) 29 | } 30 | // do not display entered characters on the screen 31 | err = exec.Command("stty", "-F", "/dev/tty", "-echo").Run() 32 | if err != nil { 33 | return fmt.Errorf("couldn't hide input: %w", err) 34 | } 35 | 36 | // restore the echoing state when exiting 37 | defer func() { 38 | _ = exec.Command("stty", "-F", "/dev/tty", "echo").Run() 39 | setProcessName("") 40 | }() 41 | 42 | ticker := time.NewTicker(time.Second) 43 | defer ticker.Stop() 44 | deadline := time.Now().Add(duration) 45 | remaining := deadline.Sub(time.Now()) 46 | 47 | fmt.Print("[p]ause [r]eset abort[!]\n\n") 48 | setProcessName("PT" + formatTime(remaining+time.Second)) 49 | fmt.Print(clear+formatTime(remaining+time.Second), " remaining") 50 | 51 | kb := make(chan rune) 52 | go kbChan(kb, stdin) 53 | 54 | running := true 55 | LOOP: 56 | for { 57 | remaining = deadline.Sub(time.Now()) 58 | select { 59 | case <-ticker.C: 60 | if time.Now().After(deadline) { 61 | fmt.Println(clear + "Take a break!\a") 62 | break LOOP 63 | } 64 | if int(math.Round(remaining.Seconds()))%30 == 1 { 65 | setProcessName("PT" + formatTime(remaining)) 66 | } 67 | fmt.Print(clear+formatTime(remaining), " remaining") 68 | case key := <-kb: 69 | switch key { 70 | case 'p': 71 | { 72 | if running { 73 | ticker.Stop() 74 | } else { 75 | ticker = time.NewTicker(time.Second) 76 | deadline = time.Now().Add(remaining) 77 | } 78 | running = !running 79 | } 80 | case 'r': 81 | deadline = time.Now().Add(duration) 82 | case '!', 'q': 83 | { 84 | fmt.Println(clear + "aborting timer!") 85 | break LOOP 86 | } 87 | } 88 | } 89 | } 90 | 91 | return nil 92 | } 93 | 94 | // XXX messy 95 | func kbChan(keys chan<- rune, stdin io.Reader) { 96 | var b = make([]byte, 1) 97 | for { 98 | _, err := stdin.Read(b) 99 | if err != nil { 100 | break 101 | } 102 | keys <- rune(b[0]) 103 | } 104 | } 105 | 106 | func formatTime(r time.Duration) string { 107 | s := int(math.Round(r.Seconds())) - 1 108 | return fmt.Sprintf("%02d:%02d", s/60, s%60) 109 | } 110 | 111 | func setProcessName(name string) { 112 | fmt.Print("\033k" + name + "\033\\") 113 | } 114 | -------------------------------------------------------------------------------- /internal/tool/chat/slack/slack-open.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "regexp" 11 | "sort" 12 | "strings" 13 | ) 14 | 15 | func Open(args []string, _ io.Reader) error { 16 | // https://api.slack.com/custom-integrations/legacy-tokens 17 | token := os.Getenv("SLACK_TOKEN") 18 | if token == "" { 19 | return errors.New("SLACK_TOKEN is required") 20 | } 21 | 22 | var channel, conversationType string 23 | var debug, exact, dryRun bool 24 | 25 | flags := flag.NewFlagSet("slack-open", flag.ExitOnError) 26 | flags.StringVar(&channel, "channel", "", "Channel to send open") 27 | flags.StringVar(&conversationType, "type", "public_channel", "Type of channel to send to (public_channel, private_channel, im, msim; public_channel is default.)") 28 | flags.BoolVar(&exact, "exact", false, "Set to disable regexp based channel matching") 29 | flags.BoolVar(&dryRun, "dry-run", false, "Set to not actually send message") 30 | flags.BoolVar(&debug, "debug", false, "Print full HTTP conversation") 31 | flags.Parse(args[1:]) 32 | 33 | cl := client{ 34 | Token: token, 35 | Client: &http.Client{}, 36 | debug: debug, 37 | } 38 | 39 | if channel == "" { 40 | fmt.Fprint(os.Stderr, "-channel is required\n\n") 41 | flags.Usage() 42 | os.Exit(2) 43 | } 44 | 45 | team, err := cl.teamInfo("") 46 | if err != nil { 47 | fmt.Fprintf(os.Stderr, "Couldn't get team info: %s\n", err) 48 | os.Exit(1) 49 | } 50 | 51 | var channels []slackConversation 52 | if conversationType == "im" { 53 | var err error 54 | channels, err = cl.autopageUsersList(usersListInput{limit: 200}) 55 | if err != nil { 56 | return err 57 | } 58 | } else { 59 | var err error 60 | channels, err = cl.autopageConversationsList(conversationsListInput{ 61 | limit: 200, 62 | excludeArchived: true, 63 | types: conversationType, 64 | }) 65 | if err != nil { 66 | return err 67 | } 68 | } 69 | 70 | var channelMatches *regexp.Regexp 71 | 72 | if !exact { 73 | var err error 74 | channelMatches, err = regexp.Compile(channel) 75 | if err != nil { 76 | return err 77 | } 78 | } 79 | matched := make([]slackConversation, 0, 1) 80 | for _, c := range channels { 81 | if !exact && channelMatches.MatchString(c.Name) { 82 | matched = append(matched, c) 83 | } 84 | if exact && c.Name == channel { 85 | matched = append(matched, c) 86 | } 87 | } 88 | if len(matched) == 0 { 89 | return errors.New("no channels matched " + channel) 90 | } 91 | if len(matched) != 1 { 92 | names := make([]string, 0, len(matched)) 93 | for _, m := range matched { 94 | names = append(names, " * "+m.Name+"\n") 95 | } 96 | sort.Strings(names) 97 | return errors.New("too many channels matched: \n" + strings.Join(names, "")) 98 | } 99 | 100 | fmt.Println("https://slack.com/app_redirect?team=" + team + "&channel=" + matched[0].ID) 101 | 102 | return nil 103 | } 104 | --------------------------------------------------------------------------------