diff options
Diffstat (limited to 'users/wpcarro/tools')
34 files changed, 1793 insertions, 0 deletions
diff --git a/users/wpcarro/tools/monzo_ynab/.envrc b/users/wpcarro/tools/monzo_ynab/.envrc new file mode 100644 index 000000000000..f368d0b7e813 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/.envrc @@ -0,0 +1,8 @@ +source_up +use_nix +export monzo_client_id="$(jq -j '.monzo | .clientId' < ~/briefcase/secrets.json)" +export monzo_client_secret="$(jq -j '.monzo | .clientSecret' < ~/briefcase/secrets.json)" +export ynab_personal_access_token="$(jq -j '.ynab | .personalAccessToken' < ~/briefcase/secrets.json)" +export ynab_account_id="$(jq -j '.ynab | .accountId' < ~/briefcase/secrets.json)" +export ynab_budget_id="$(jq -j '.ynab | .budgetId' < ~/briefcase/secrets.json)" +export store_path="$(pwd)" diff --git a/users/wpcarro/tools/monzo_ynab/.gitignore b/users/wpcarro/tools/monzo_ynab/.gitignore new file mode 100644 index 000000000000..e92078303bec --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/.gitignore @@ -0,0 +1,3 @@ +/ynab/fixture.json +/monzo/fixture.json +/kv.json diff --git a/users/wpcarro/tools/monzo_ynab/README.md b/users/wpcarro/tools/monzo_ynab/README.md new file mode 100644 index 000000000000..4ccbb35d8c5d --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/README.md @@ -0,0 +1,41 @@ +# monzo_ynab + +Exporting Monzo transactions to my YouNeedABudget.com (i.e. YNAB) account. YNAB +unfortunately doesn't currently offer an Monzo integration. As a workaround and +a practical excuse to learn Go, I decided to write one myself. + +This job is going to run N times per 24 hours. Monzo offers webhooks for +reacting to certain types of events. I don't expect I'll need realtime data for +my YNAB integration. That may change, however, so it's worth noting. + +## Installation + +Like many other packages in this repository, `monzo_ynab` is packaged using +Nix. To install and use, you have two options: + +You can install using `nix-build` and then run the resulting +`./result/bin/monzo_ynab`. + +```shell +> nix-build . && ./result/bin/monzo_ynab +``` + +Or you can install using `nix-env` if you'd like to create the `monzo_ynab` +symlink. + +```shell +> nix-env -f ~/briefcase/monzo_ynab -i +``` + +## Deployment + +While this project is currently not deployed, my plan is to host it on Google +Cloud and run it as a Cloud Run application. What I don't yet know is whether or +not this is feasible or a good idea. One complication that I foresee is that the +OAuth 2.0 login flow requires a web browser until the access token and refresh +tokens are acquired. I'm unsure how to workaround this at the moment. + +For more information about the general packaging and deployment strategies I'm +currently using, refer to the [deployments][deploy] writeup. + +[deploy]: ../deploy/README.md diff --git a/users/wpcarro/tools/monzo_ynab/auth.go b/users/wpcarro/tools/monzo_ynab/auth.go new file mode 100644 index 000000000000..b66bacb10687 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/auth.go @@ -0,0 +1,101 @@ +package auth + +//////////////////////////////////////////////////////////////////////////////// +// Dependencies +//////////////////////////////////////////////////////////////////////////////// + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "utils" +) + +//////////////////////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////////////////////// + +var ( + BROWSER = os.Getenv("BROWSER") + REDIRECT_URI = "http://localhost:8080/authorization-code" +) + +//////////////////////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////////////////////// + +// This is the response returned from Monzo when we exchange our authorization +// code for an access token. While Monzo returns additional fields, I'm only +// interested in AccessToken and RefreshToken. +type accessTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +type Tokens struct { + AccessToken string + RefreshToken string + ExpiresIn int +} + +//////////////////////////////////////////////////////////////////////////////// +// Functions +//////////////////////////////////////////////////////////////////////////////// + +// Returns the access token and refresh tokens for the Monzo API. +func GetTokensFromAuthCode(authCode string, clientID string, clientSecret string) *Tokens { + res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + "redirect_uri": {REDIRECT_URI}, + "code": {authCode}, + }) + utils.FailOn(err) + defer res.Body.Close() + payload := &accessTokenResponse{} + json.NewDecoder(res.Body).Decode(payload) + + return &Tokens{payload.AccessToken, payload.RefreshToken, payload.ExpiresIn} +} + +// Open a web browser to allow the user to authorize this application. Return +// the authorization code sent from Monzo. +func GetAuthCode(clientID string) string { + // TODO(wpcarro): Consider generating a random string for the state when the + // application starts instead of hardcoding it here. + state := "xyz123" + url := fmt.Sprintf( + "https://auth.monzo.com/?client_id=%s&redirect_uri=%s&response_type=code&state=%s", + clientID, REDIRECT_URI, state) + exec.Command(BROWSER, url).Start() + + authCode := make(chan string) + go func() { + log.Fatal(http.ListenAndServe(":8080", + http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // 1. Get authorization code from Monzo. + if req.URL.Path == "/authorization-code" { + params := req.URL.Query() + reqState := params["state"][0] + code := params["code"][0] + + if reqState != state { + log.Fatalf("Value for state returned by Monzo does not equal our state. %s != %s", reqState, state) + } + authCode <- code + + fmt.Fprintf(w, "Authorized!") + } else { + log.Printf("Unhandled request: %v\n", *req) + } + }))) + }() + result := <-authCode + return result +} diff --git a/users/wpcarro/tools/monzo_ynab/job.nix b/users/wpcarro/tools/monzo_ynab/job.nix new file mode 100644 index 000000000000..1e10751012e2 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/job.nix @@ -0,0 +1,12 @@ +{ depot, briefcase, ... }: + +depot.buildGo.program { + name = "job"; + srcs = [ + ./main.go + ]; + deps = with briefcase.gopkgs; [ + kv + utils + ]; +} diff --git a/users/wpcarro/tools/monzo_ynab/main.go b/users/wpcarro/tools/monzo_ynab/main.go new file mode 100644 index 000000000000..06f1944eab70 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/main.go @@ -0,0 +1,43 @@ +// Exporting Monzo transactions to my YouNeedABudget.com (i.e. YNAB) +// account. YNAB unfortunately doesn't currently offer an Monzo integration. As +// a workaround and a practical excuse to learn Go, I decided to write one +// myself. +// +// This job is going to run N times per 24 hours. Monzo offers webhooks for +// reacting to certain types of events. I don't expect I'll need realtime data +// for my YNAB integration. That may change, however, so it's worth noting. + +package main + +import ( + "fmt" +) + +var ( + ynabAccountID = os.Getenv("ynab_account_id") +) + +//////////////////////////////////////////////////////////////////////////////// +// Business Logic +//////////////////////////////////////////////////////////////////////////////// + +// Convert a Monzo transaction struct, `tx`, into a YNAB transaction struct. +func toYnab(tx monzoSerde.Transaction) ynabSerde.Transaction { + return ynabSerde.Transaction{ + Id: tx.Id, + Date: tx.Created, + Amount: tx.Amount, + Memo: tx.Notes, + AccountId: ynabAccountID, + } +} + +func main() { + txs := monzo.TransactionsLast24Hours() + var ynabTxs []ynabSerde.Transaction{} + for tx := range txs { + append(ynabTxs, toYnab(tx)) + } + ynab.PostTransactions(ynabTxs) + os.Exit(0) +} diff --git a/users/wpcarro/tools/monzo_ynab/monzo/client.go b/users/wpcarro/tools/monzo_ynab/monzo/client.go new file mode 100644 index 000000000000..8c6c41e29f40 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/monzo/client.go @@ -0,0 +1,52 @@ +package monzoClient + +import ( + "fmt" + "log" + "monzoSerde" + "net/http" + "net/url" + "strings" + "time" + "tokens" + "utils" +) + +const ( + accountID = "pizza" +) + +type Client struct{} + +// Ensure that the token server is running and return a new instance of a Client +// struct. +func Create() *Client { + tokens.StartServer() + time.Sleep(time.Second * 1) + return &Client{} +} + +// Returns a slice of transactions from the last 24 hours. +func (c *Client) Transactions24Hours() []monzoSerde.Transaction { + token := tokens.AccessToken() + form := url.Values{"account_id": {accountID}} + client := http.Client{} + req, _ := http.NewRequest("POST", "https://api.monzo.com/transactions", + strings.NewReader(form.Encode())) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("User-Agent", "monzo-ynab") + res, err := client.Do(req) + + utils.DebugRequest(req) + utils.DebugResponse(res) + + if err != nil { + utils.DebugRequest(req) + utils.DebugResponse(res) + log.Fatal(err) + } + defer res.Body.Close() + + return []monzoSerde.Transaction{} +} diff --git a/users/wpcarro/tools/monzo_ynab/monzo/serde.go b/users/wpcarro/tools/monzo_ynab/monzo/serde.go new file mode 100644 index 000000000000..a38585eca632 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/monzo/serde.go @@ -0,0 +1,82 @@ +// This package hosts the serialization and deserialization logic for all of the +// data types with which our application interacts from the Monzo API. +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "time" +) + +type TxMetadata struct { + FasterPayment string `json:"faster_payment"` + FpsPaymentId string `json:"fps_payment_id"` + Insertion string `json:"insertion"` + Notes string `json:"notes"` + Trn string `json:"trn"` +} + +type TxCounterparty struct { + AccountNumber string `json:"account_number"` + Name string `json:"name"` + SortCode string `json:"sort_code"` + UserId string `json:"user_id"` +} + +type Transaction struct { + Id string `json:"id"` + Created time.Time `json:"created"` + Description string `json:"description"` + Amount int `json:"amount"` + Currency string `json:"currency"` + Notes string `json:"notes"` + Metadata TxMetadata + AccountBalance int `json:"account_balance"` + International interface{} `json:"international"` + Category string `json:"category"` + IsLoad bool `json:"is_load"` + Settled time.Time `json:"settled"` + LocalAmount int `json:"local_amount"` + LocalCurrency string `json:"local_currency"` + Updated time.Time `json:"updated"` + AccountId string `json:"account_id"` + UserId string `json:"user_id"` + Counterparty TxCounterparty `json:"counterparty"` + Scheme string `json:"scheme"` + DedupeId string `json:"dedupe_id"` + Originator bool `json:"originator"` + IncludeInSpending bool `json:"include_in_spending"` + CanBeExcludedFromBreakdown bool `json:"can_be_excluded_from_breakdown"` + CanBeMadeSubscription bool `json:"can_be_made_subscription"` + CanSplitTheBill bool `json:"can_split_the_bill"` + CanAddToTab bool `json:"can_add_to_tab"` + AmountIsPending bool `json:"amount_is_pending"` + // Fees interface{} `json:"fees"` + // Merchant interface `json:"merchant"` + // Labels interface{} `json:"labels"` + // Attachments interface{} `json:"attachments"` + // Categories interface{} `json:"categories"` +} + +// Attempts to encode a Monzo transaction struct into a string. +func serializeTx(tx *Transaction) (string, error) { + x, err := json.Marshal(tx) + return string(x), err +} + +// Attempts to parse a string encoding a transaction presumably sent from a +// Monzo server. +func deserializeTx(x string) (*Transaction, error) { + target := &Transaction{} + err := json.Unmarshal([]byte(x), target) + return target, err +} + +func main() { + b, _ := ioutil.ReadFile("./fixture.json") + tx := string(b) + target, _ := deserializeTx(tx) + out, _ := serializeTx(target) + fmt.Println(out) +} diff --git a/users/wpcarro/tools/monzo_ynab/requests.txt b/users/wpcarro/tools/monzo_ynab/requests.txt new file mode 100644 index 000000000000..2da17c0b326a --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/requests.txt @@ -0,0 +1,80 @@ +################################################################################ +# YNAB +################################################################################ +:ynab = https://api.youneedabudget.com/v1 +:ynab-access-token := (getenv "ynab_personal_access_token") +:ynab-budget-id := (getenv "ynab_budget_id") +:ynab-account-id := (getenv "ynab_account_id") + +# Test +GET :ynab/budgets +Authorization: Bearer :ynab-access-token + +# List transactions +GET :ynab/budgets/:ynab-budget-id/transactions +Authorization: Bearer :ynab-access-token + +# Post transactions +POST :ynab/budgets/:ynab-budget-id/transactions +Authorization: Bearer :ynab-access-token +Content-Type: application/json +{ + "transactions": [ + { + "account_id": ":ynab-account-id", + "date": "2019-12-30", + "amount": 10000, + "payee_name": "Richard Stallman", + "memo": "Not so free software after all...", + "cleared": "cleared", + "approved": true, + "flag_color": "red", + "import_id": "xyz-123" + } + ] +} + +################################################################################ +# Monzo +################################################################################ +:monzo = https://api.monzo.com +:monzo-access-token := (getenv "monzo_cached_access_token") +:monzo-refresh-token := (getenv "monzo_cached_refresh_token") +:monzo-client-id := (getenv "monzo_client_id") +:monzo-client-secret := (getenv "monzo_client_secret") +:monzo-account-id := (getenv "monzo_account_id") + +# List transactions +GET :monzo/transactions +Authorization: Bearer :monzo-access-token +account_id==:monzo-account-id + +# Refresh access token +# According from the docs, the access token expires in 6 hours. +POST :monzo/oauth2/token +Content-Type: application/x-www-form-urlencoded +Authorization: Bearer :monzo-access-token +grant_type=refresh_token&client_id=:monzo-client-id&client_secret=:monzo-client-secret&refresh_token=:monzo-refresh-token + +################################################################################ +# Tokens server +################################################################################ +:tokens = http://localhost:4242 + +# Get tokens +GET :tokens/tokens + +# Get application state for debugging purposes +GET :tokens/state + +# Force refresh tokens +POST :tokens/refresh-tokens + +# Set tokens +POST :tokens/set-tokens +Content-Type: application/json +{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_in": 120 +} diff --git a/users/wpcarro/tools/monzo_ynab/shell.nix b/users/wpcarro/tools/monzo_ynab/shell.nix new file mode 100644 index 000000000000..910d7c1829e2 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/shell.nix @@ -0,0 +1,10 @@ +let + briefcase = import <briefcase> {}; + pkgs = briefcase.third_party.pkgs; +in pkgs.mkShell { + buildInputs = [ + pkgs.go + pkgs.goimports + pkgs.godef + ]; +} diff --git a/users/wpcarro/tools/monzo_ynab/tokens.go b/users/wpcarro/tools/monzo_ynab/tokens.go new file mode 100644 index 000000000000..4be967ccb803 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/tokens.go @@ -0,0 +1,283 @@ +// Creating a Tokens server to manage my access and refresh tokens. Keeping this +// as a separate server allows me to develop and use the access tokens without +// going through client authorization. +package main + +//////////////////////////////////////////////////////////////////////////////// +// Dependencies +//////////////////////////////////////////////////////////////////////////////// + +import ( + "auth" + "encoding/json" + "fmt" + "io" + "kv" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "syscall" + "time" + "utils" +) + +//////////////////////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////////////////////// + +// This is the response from Monzo's API after we request an access token +// refresh. +type refreshTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ClientId string `json:"client_id"` + ExpiresIn int `json:"expires_in"` +} + +// This is the shape of the request from clients wishing to set state of the +// server. +type setTokensRequest struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +// This is our application state. +type state struct { + accessToken string `json:"access_token"` + refreshToken string `json:"refresh_token"` +} + +type readMsg struct { + sender chan state +} + +type writeMsg struct { + state state + sender chan bool +} + +type channels struct { + reads chan readMsg + writes chan writeMsg +} + +//////////////////////////////////////////////////////////////////////////////// +// Top-level Definitions +//////////////////////////////////////////////////////////////////////////////// + +var chans = &channels{ + reads: make(chan readMsg), + writes: make(chan writeMsg), +} + +var ( + monzoClientId = os.Getenv("monzo_client_id") + monzoClientSecret = os.Getenv("monzo_client_secret") + storePath = os.Getenv("store_path") +) + +//////////////////////////////////////////////////////////////////////////////// +// Utils +//////////////////////////////////////////////////////////////////////////////// + +// Print the access and refresh tokens for debugging. +func logTokens(access string, refresh string) { + log.Printf("Access: %s\n", access) + log.Printf("Refresh: %s\n", refresh) +} + +func (state *state) String() string { + return fmt.Sprintf("state{\n\taccessToken: \"%s\",\n\trefreshToken: \"%s\"\n}\n", state.accessToken, state.refreshToken) +} + +// Schedule a token refresh for `expiresIn` seconds using the provided +// `refreshToken`. This will update the application state with the access token +// and schedule an additional token refresh for the newly acquired tokens. +func scheduleTokenRefresh(expiresIn int, refreshToken string) { + duration := time.Second * time.Duration(expiresIn) + timestamp := time.Now().Local().Add(duration) + // TODO(wpcarro): Consider adding a more human readable version that will + // log the number of hours, minutes, etc. until the next refresh. + log.Printf("Scheduling token refresh for %v\n", timestamp) + time.Sleep(duration) + log.Println("Refreshing tokens now...") + accessToken, refreshToken := refreshTokens(refreshToken) + log.Println("Successfully refreshed tokens.") + logTokens(accessToken, refreshToken) + setState(accessToken, refreshToken) +} + +// Exchange existing credentials for a new access token and `refreshToken`. Also +// schedule the next refresh. This function returns the newly acquired access +// token and refresh token. +func refreshTokens(refreshToken string) (string, string) { + // TODO(wpcarro): Support retries with exponential backoff. + res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {monzoClientId}, + "client_secret": {monzoClientSecret}, + "refresh_token": {refreshToken}, + }) + if res.StatusCode != http.StatusOK { + // TODO(wpcarro): Considering panicking here. + utils.DebugResponse(res) + } + if err != nil { + utils.DebugResponse(res) + log.Fatal("The request to Monzo to refresh our access token failed.", err) + } + defer res.Body.Close() + payload := &refreshTokenResponse{} + err = json.NewDecoder(res.Body).Decode(payload) + if err != nil { + log.Fatal("Could not decode the JSON response from Monzo.", err) + } + + go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken) + + // Interestingly, JSON decoding into the refreshTokenResponse can success + // even if the decoder doesn't populate any of the fields in the + // refreshTokenResponse struct. From what I read, it isn't possible to make + // these fields as required using an annotation, so this guard must suffice + // for now. + if payload.AccessToken == "" || payload.RefreshToken == "" { + log.Fatal("JSON parsed correctly but failed to populate token fields.") + } + + return payload.AccessToken, payload.RefreshToken +} + +func persistTokens(access string, refresh string) { + log.Println("Persisting tokens...") + kv.Set(storePath, "monzoAccessToken", access) + kv.Set(storePath, "monzoRefreshToken", refresh) + log.Println("Successfully persisted tokens.") +} + +// Listen for SIGINT and SIGTERM signals. When received, persist the access and +// refresh tokens and shutdown the server. +func handleInterrupts() { + // Gracefully handle interruptions. + sigs := make(chan os.Signal, 1) + done := make(chan bool) + + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigs + log.Printf("Received signal to shutdown. %v\n", sig) + state := getState() + persistTokens(state.accessToken, state.refreshToken) + done <- true + }() + + <-done + log.Println("Exiting...") + os.Exit(0) +} + +// Set `accessToken` and `refreshToken` on application state. +func setState(accessToken string, refreshToken string) { + msg := writeMsg{state{accessToken, refreshToken}, make(chan bool)} + chans.writes <- msg + <-msg.sender +} + +// Return our application state. +func getState() state { + msg := readMsg{make(chan state)} + chans.reads <- msg + return <-msg.sender +} + +//////////////////////////////////////////////////////////////////////////////// +// Main +//////////////////////////////////////////////////////////////////////////////// + +func main() { + // Manage application state. + go func() { + state := &state{} + for { + select { + case msg := <-chans.reads: + log.Println("Reading from state...") + log.Println(state) + msg.sender <- *state + case msg := <-chans.writes: + log.Println("Writing to state.") + log.Printf("Old: %s\n", state) + *state = msg.state + log.Printf("New: %s\n", state) + // As an attempt to maintain consistency between application + // state and persisted state, everytime we write to the + // application state, we will write to the store. + persistTokens(state.accessToken, state.refreshToken) + msg.sender <- true + } + } + }() + + // Retrieve cached tokens from store. + accessToken := fmt.Sprintf("%v", kv.Get(storePath, "monzoAccessToken")) + refreshToken := fmt.Sprintf("%v", kv.Get(storePath, "monzoRefreshToken")) + + log.Println("Attempting to retrieve cached credentials...") + logTokens(accessToken, refreshToken) + + if accessToken == "" || refreshToken == "" { + log.Println("Cached credentials are absent. Authorizing client...") + authCode := auth.GetAuthCode(monzoClientId) + tokens := auth.GetTokensFromAuthCode(authCode, monzoClientId, monzoClientSecret) + setState(tokens.AccessToken, tokens.RefreshToken) + go scheduleTokenRefresh(tokens.ExpiresIn, tokens.RefreshToken) + } else { + setState(accessToken, refreshToken) + // If we have tokens, they may be expiring soon. We don't know because + // we aren't storing the expiration timestamp in the state or in the + // store. Until we have that information, and to be safe, let's refresh + // the tokens. + go scheduleTokenRefresh(0, refreshToken) + } + + // Gracefully handle shutdowns. + go handleInterrupts() + + // Listen to inbound requests. + fmt.Println("Listening on http://localhost:4242 ...") + log.Fatal(http.ListenAndServe(":4242", + http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/refresh-tokens" && req.Method == "POST" { + state := getState() + go scheduleTokenRefresh(0, state.refreshToken) + fmt.Fprintf(w, "Done.") + } else if req.URL.Path == "/set-tokens" && req.Method == "POST" { + // Parse + payload := &setTokensRequest{} + err := json.NewDecoder(req.Body).Decode(payload) + if err != nil { + log.Fatal("Could not decode the user's JSON request.", err) + } + + // Update application state + setState(payload.AccessToken, payload.RefreshToken) + + // Refresh tokens + go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken) + + // Ack + fmt.Fprintf(w, "Done.") + } else if req.URL.Path == "/state" && req.Method == "GET" { + // TODO(wpcarro): Ensure that this returns serialized state. + w.Header().Set("Content-type", "application/json") + state := getState() + payload, _ := json.Marshal(state) + io.WriteString(w, string(payload)) + } else { + log.Printf("Unhandled request: %v\n", *req) + } + }))) +} diff --git a/users/wpcarro/tools/monzo_ynab/tokens.nix b/users/wpcarro/tools/monzo_ynab/tokens.nix new file mode 100644 index 000000000000..97de09d741e9 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/tokens.nix @@ -0,0 +1,23 @@ +{ depot, briefcase, ... }: + +let + auth = depot.buildGo.package { + name = "auth"; + srcs = [ + ./auth.go + ]; + deps = with briefcase.gopkgs; [ + utils + ]; + }; +in depot.buildGo.program { + name = "token-server"; + srcs = [ + ./tokens.go + ]; + deps = with briefcase.gopkgs; [ + kv + utils + auth + ]; +} diff --git a/users/wpcarro/tools/monzo_ynab/ynab/client.go b/users/wpcarro/tools/monzo_ynab/ynab/client.go new file mode 100644 index 000000000000..0492b9071adc --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/ynab/client.go @@ -0,0 +1,24 @@ +package client + +import ( + "serde" +) + +// See requests.txt for more details. +func PostTransactions(accountID string, txs []serde.Transaction{}) error { + return map[string]string{ + "transactions": [ + { + "account_id": accountID, + "date": "2019-12-30", + "amount": 10000, + "payee_name": "Richard Stallman", + "memo": "Not so free software after all...", + "cleared": "cleared", + "approved": true, + "flag_color": "red", + "import_id": "xyz-123" + } + ] + } +} diff --git a/users/wpcarro/tools/monzo_ynab/ynab/serde.go b/users/wpcarro/tools/monzo_ynab/ynab/serde.go new file mode 100644 index 000000000000..53dd33e83637 --- /dev/null +++ b/users/wpcarro/tools/monzo_ynab/ynab/serde.go @@ -0,0 +1,52 @@ +// This package hosts the serialization and deserialization logic for all of the +// data types with which our application interacts from the YNAB API. +package main + +import ( + "encoding/json" + "fmt" + "time" +) + +type Transaction struct { + Id string `json:"id"` + Date time.Time `json:"date"` + Amount int `json:"amount"` + Memo string `json:"memo"` + Cleared string `json:"cleared"` + Approved bool `json:"approved"` + FlagColor string `json:"flag_color"` + AccountId string `json:"account_id"` + AccountName string `json:"account_name"` + PayeeId string `json:"payeed_id"` + PayeeName string `json:"payee_name"` + CategoryId string `json:"category_id"` + CategoryName string `json:"category_name"` + Deleted bool `json:"deleted"` + // TransferAccountId interface{} `json:"transfer_account_id"` + // TransferTransactionId interface{} `json:"transfer_transaction_id"` + // MatchedTransactionId interface{} `json:"matched_transaction_id"` + // ImportId interface{} `json:"import_id"` + // Subtransactions interface{} `json:"subtransactions"` +} + +// Attempts to encode a YNAB transaction into a string. +func serializeTx(tx *Transaction) (string, error) { + x, err := json.Marshal(tx) + return string(x), err +} + +// Attempts to parse a string encoding a transaction presumably sent from a +// YNAB server. +func deserializeTx(x string) (*Transaction, error) { + target := &Transaction{} + err := json.Unmarshal([]byte(x), target) + return target, err +} + +func main() { + target, _ := deserializeTx(tx) + out, _ := serializeTx(target) + fmt.Println(out) + fmt.Println(ynabOut) +} diff --git a/users/wpcarro/tools/rfcToKindle/LICENSE b/users/wpcarro/tools/rfcToKindle/LICENSE new file mode 100644 index 000000000000..7a4a3ea2424c --- /dev/null +++ b/users/wpcarro/tools/rfcToKindle/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/users/wpcarro/tools/rfcToKindle/README.md b/users/wpcarro/tools/rfcToKindle/README.md new file mode 100644 index 000000000000..e7b4fa841ef6 --- /dev/null +++ b/users/wpcarro/tools/rfcToKindle/README.md @@ -0,0 +1,30 @@ +# rfcToKindle + +Wirelessly transfer RFC documents to your Kindle to device for an alternative +medium for reading. + +## Installation + +`rfcToKindle` makes use of [`buildGo.nix`][2] to package itself. If you're +using [Nix][1], you can install `rfcToKindle` using `nix-env`: + +```shell +> nix-env -f https://github.com/wpcarro/rfcToKindle -i +``` + +## Usage + +```shell +> rfcToKindle -document rfc6479 -recipient username@kindle.com +``` + +## Dependencies + +This uses `sendgmr` to send the file to the Kindle. Make sure: +1. That `sendgmr` is installed and available on $PATH. +2. That it is configured to work with your preferred email address. +3. That the email address `sendgmr` is configured to use is whitelisted in + your Kindle "Personal Document Settings". + +[1]: https://nixos.org/nix/ +[2]: https://git.tazj.in/tree/nix/buildGo diff --git a/users/wpcarro/tools/rfcToKindle/default.nix b/users/wpcarro/tools/rfcToKindle/default.nix new file mode 100644 index 000000000000..8fb93c3bb5b8 --- /dev/null +++ b/users/wpcarro/tools/rfcToKindle/default.nix @@ -0,0 +1,11 @@ +{ depot, ... }: + +# TODO: This doesn't depend on `sendgmr` at the moment, but it should. As such, +# it's an imcomplete packaging. +depot.buildGo.program { + name = "rfcToKindle"; + srcs = [ + ./main.go + ]; + deps = []; +} diff --git a/users/wpcarro/tools/rfcToKindle/main.go b/users/wpcarro/tools/rfcToKindle/main.go new file mode 100644 index 000000000000..0f4f2dd9ec4f --- /dev/null +++ b/users/wpcarro/tools/rfcToKindle/main.go @@ -0,0 +1,89 @@ +// Author: wpcarro@gmail.com +// +// Wirelessly transfer RFC documents to your Kindle to device for an alternative +// medium for reading. +// +// Usage: +// ```shell +// > go run rfcToKindle.go -document rfc6479 -recipient username@kindle.com +// ``` +// +// This uses `sendgmr` to send the file to the Kindle. Make sure: +// 1. That `sendgmr` is installed and available on $PATH. +// 2. That it is configured to work with your preferred email address. +// 3. That the email address `sendgmr` is configured to use is whitelisted in +// your Kindle "Personal Document Settings". + +package main + +import ( + "flag" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "os/exec" + "strings" +) + +func main() { + document := flag.String("document", "", "(Required) The name of the document to fetch. For example \"RFC6479\".") + recipient := flag.String("recipient", "", "(Required) The email address of the Kindle device.") + subject := flag.String("subject", "", "(Optional) The email address of the Kindle device.") + flag.Parse() + + if *document == "" { + // TODO: Is log.Fatal the best function to use here? + log.Fatal("-document cannot be empty. See -help for more information.") + } + + if *recipient == "" { + log.Fatal("-recipient cannot be empty. See -help for more information.") + } + + *document = strings.ToLower(*document) + + url := fmt.Sprintf("https://www.ietf.org/rfc/%s.txt", *document) + resp, err := http.Get(url) + fmt.Printf("Downloading %s ... ", url) + + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + f, err := ioutil.TempFile("", fmt.Sprintf("%s-*.txt", *document)) + if err != nil { + log.Fatal(err) + } + // TODO: Verify if this is cleaning up or not. + defer os.Remove(f.Name()) + + _, err = io.Copy(f, resp.Body) + if err != nil { + log.Fatal(err) + } + fmt.Println("done.") + + if *subject == "" { + *subject = fmt.Sprintf("%s - Sent from rfcToKindle.go", *document) + } + + // Although I couldn't find it documented anywhere, the email sent to the + // Kindle must have a body, even if the body isn't used for anything. + fmt.Printf("Emailing %s to %s ... ", f.Name(), *recipient) + cmd := exec.Command("sendgmr", + fmt.Sprintf("--to=%s", *recipient), + fmt.Sprintf("--body_file=%s", f.Name()), + fmt.Sprintf("--subject=%s", *subject), + fmt.Sprintf("--attachment_files=%s", f.Name())) + err = cmd.Run() + if err != nil { + log.Fatal(err) + } + fmt.Println("done.") + + os.Exit(0) +} diff --git a/users/wpcarro/tools/run/.envrc b/users/wpcarro/tools/run/.envrc new file mode 100644 index 000000000000..a4a62da526d3 --- /dev/null +++ b/users/wpcarro/tools/run/.envrc @@ -0,0 +1,2 @@ +source_up +use_nix diff --git a/users/wpcarro/tools/run/README.md b/users/wpcarro/tools/run/README.md new file mode 100644 index 000000000000..d3cccecf910c --- /dev/null +++ b/users/wpcarro/tools/run/README.md @@ -0,0 +1,30 @@ +# run + +Simplify the commands you call to run scripts on the command line. + +```shell +> run path/to/file.py +> run path/to/file.ts +``` + +## How? + +Define a run.json configuration mapping commands to filename extensions like +so: +```json +{ + ".ts": "npx ts-node $file", + ".py": "python3 $file" +} +``` + +Then call `run path/to/some/file.ts` on the command line, and `npx ts-node +file.ts` will run. + +## Installation + +Install `run` using Nix. + +```shell +> nix-env -iA briefcase.run +``` diff --git a/users/wpcarro/tools/run/default.nix b/users/wpcarro/tools/run/default.nix new file mode 100644 index 000000000000..7d772c3f9079 --- /dev/null +++ b/users/wpcarro/tools/run/default.nix @@ -0,0 +1,11 @@ +{ pkgs, depot, briefcase, ... }: + +depot.buildGo.program { + name = "run"; + srcs = [ + ./main.go + ]; + deps = with briefcase.gopkgs; [ + utils + ]; +} diff --git a/users/wpcarro/tools/run/main.go b/users/wpcarro/tools/run/main.go new file mode 100644 index 000000000000..04906ece91f7 --- /dev/null +++ b/users/wpcarro/tools/run/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "utils" +) + +func main() { + if len(os.Args) != 2 { + log.Fatal("You can only call run with a single file at a time.") + } + + rulesPath := utils.Resolve("run.json", []string{"/home/wpcarro/.config/run/run.json"}) + b, err := ioutil.ReadFile(rulesPath) + if err != nil { + log.Fatal("Could not locate a run.json file: ", err) + } + rules := map[string]string{} + err = json.Unmarshal(b, &rules) + if err != nil { + log.Fatal("Could not decode run.json as JSON: ", err) + } + + fileName := os.Args[1] + ext := filepath.Ext(fileName) + cmd, ok := rules[ext] + + if !ok { + log.Fatalf("No rules for extension, %s, have been defined.", ext) + } + + // TODO(wpcarro): Support more sophisticated parsing than just string + // splitting. To handle 'cases like this'. + tokens := strings.Split(strings.Replace(cmd, "$file", fileName, 1), " ") + c := exec.Command(tokens[0], tokens[1:]...) + err = c.Start() + // TODO(wpcarro): Forward STDERR and STDOUT. + if err != nil { + log.Fatal(err) + } + fmt.Println(c.Wait()) +} diff --git a/users/wpcarro/tools/run/shell.nix b/users/wpcarro/tools/run/shell.nix new file mode 100644 index 000000000000..e14bffae487c --- /dev/null +++ b/users/wpcarro/tools/run/shell.nix @@ -0,0 +1,10 @@ +let + briefcase = import <briefcase> {}; + pkgs = briefcase.third_party.pkgs; +in pkgs.mkShell { + buildInputs = with pkgs; [ + go + goimports + godef + ]; +} diff --git a/users/wpcarro/tools/simple_vim/config.vim b/users/wpcarro/tools/simple_vim/config.vim new file mode 100644 index 000000000000..ea40964ee803 --- /dev/null +++ b/users/wpcarro/tools/simple_vim/config.vim @@ -0,0 +1,98 @@ +" My barebones vimrc without any Vundle dependencies. +" +" I'm attempting to optimize the following: +" - Minimize dependencies +" - Maximize ergonomics +" - Maximize Tmux compatibility +" - Minimize shadowing of existing Vim KBDs +" +" Warning: This is currently unstable as it is a work-in-progress. +" +" Author: William Carroll <wpcarro@gmail.com> + +" Use <Space> as the leader key. +let mapleader = " " +nnoremap <leader>ev :tabnew<CR>:edit ~/.vimrc<CR> +nnoremap <leader>sv :source ~/.vimrc<CR> +nnoremap <leader>w :w<CR> +nnoremap <leader>h :help + +" increment,decrement numbers +nnoremap + <C-a> +" TODO: Restore with better KBD +" nnoremap - <C-x> + +" Visit the CWD +nnoremap - :e .<CR> + +" Turn line numbers on. +set number + +" Easily create vertical, horizontal window splits. +nnoremap sh :vsplit<CR> +nnoremap sj :split<CR>:wincmd j<CR> +nnoremap sk :split<CR> +nnoremap sl :vsplit<CR>:wincmd l<CR> + +" Move across window splits. +" TODO: Change to <M-{h,j,k,l}>. +nnoremap <C-h> :wincmd h<CR> +nnoremap <C-j> :wincmd j<CR> +nnoremap <C-k> :wincmd k<CR> +nnoremap <C-l> :wincmd l<CR> + +" TODO: Support these. +" nnoremap <M-q> :q<CR> +" nnoremap <M-h> :wincmd h<CR> +" nnoremap <M-j> :wincmd j<CR> +" nnoremap <M-k> :wincmd k<CR> +" nnoremap <M-l> :wincmd l<CR> + +" Use <Enter> instead of G to support: +" 20<Enter> - to jump to line 20 +" d20<Enter> - to delete from the current line until line 20 +" <C-v>20<Enter> - to select from the current line until line 20 +nnoremap <Enter> G +onoremap <Enter> G +vnoremap <Enter> G + +" Easily change modes on keyboards that don't have CapsLock mapped to <Esc> +inoremap jk <ESC> + +" CRUD tabs. +nnoremap <TAB> :tabnext<CR> +nnoremap <S-TAB> :tabprevious<CR> +nnoremap <C-t> :tabnew<CR>:edit .<CR> +nnoremap <C-w> :tabclose<CR> +" TODO: Re-enable these once <M-{h,j,k,l}> are supported. +" nnoremap <C-l> :+tabmove<CR> +" nnoremap <C-h> :-tabmove<CR> + +" Use H,L to goto beggining,end of a line. +" Swaps the keys to ensure original functionality of H,L are preserved. +nnoremap H ^ +nnoremap L $ +nnoremap ^ H +nnoremap $ L + +" Use H,L in visual mode too +vnoremap H ^ +vnoremap L $ +vnoremap ^ H +vnoremap $ L + +" Emacs hybrid mode +" TODO: model this after tpope's rsi.vim (Readline-style insertion) +cnoremap <C-g> <C-c> +cnoremap <C-a> <C-b> +inoremap <C-a> <C-o>^ +inoremap <C-e> <C-o>$ +inoremap <C-b> <C-o>h +inoremap <C-f> <C-o>l + +" Indenting +" The following three settings are based on option 2 of `:help tabstop` +set tabstop=4 +set shiftwidth=4 +set expandtab +set autoindent diff --git a/users/wpcarro/tools/simple_vim/default.nix b/users/wpcarro/tools/simple_vim/default.nix new file mode 100644 index 000000000000..f8f965f2c024 --- /dev/null +++ b/users/wpcarro/tools/simple_vim/default.nix @@ -0,0 +1,15 @@ +{ pkgs, ... }: + +let + configVim = builtins.path { + path = ./config.vim; + name = "config.vim"; + }; + + script = pkgs.writeShellScriptBin "simple_vim" '' + ${pkgs.vim}/bin/vim -u ${configVim} + ''; +in pkgs.stdenv.mkDerivation { + name = "simple_vim"; + buildInputs = [ script ]; +} diff --git a/users/wpcarro/tools/symlinkManager/README.md b/users/wpcarro/tools/symlinkManager/README.md new file mode 100644 index 000000000000..b0fc58c8e989 --- /dev/null +++ b/users/wpcarro/tools/symlinkManager/README.md @@ -0,0 +1,14 @@ +# Dotfile Symlink Manager + +Find and delete all symlinks to the dotfiles defined in `$BRIEFCASE`. + +Oftentimes I corrupt the state of my configuration files. The intention with +this script is to help me clean things up when this happens. An example workflow +might look like: + +```shell +> symlink-mgr --audit +> symlink-mgr --seriously +> briefcase # changes directory to $BRIEFCASE +> make install +``` diff --git a/users/wpcarro/tools/symlinkManager/default.nix b/users/wpcarro/tools/symlinkManager/default.nix new file mode 100644 index 000000000000..16bb26bb3c2e --- /dev/null +++ b/users/wpcarro/tools/symlinkManager/default.nix @@ -0,0 +1,11 @@ +{ depot, briefcase, ... }: + +depot.buildGo.program { + name = "symlink-mgr"; + srcs = [ + ./main.go + ]; + deps = with briefcase.gopkgs; [ + utils + ]; +} diff --git a/users/wpcarro/tools/symlinkManager/main.go b/users/wpcarro/tools/symlinkManager/main.go new file mode 100644 index 000000000000..e682867fb850 --- /dev/null +++ b/users/wpcarro/tools/symlinkManager/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "utils" +) + +var hostnames = map[string]string{ + os.Getenv("DESKTOP"): "desktop", + os.Getenv("LAPTOP"): "work_laptop", +} + +func main() { + audit := flag.Bool("audit", false, "Output all symlinks that would be deleted. This is the default behavior. This option is mutually exclusive with the --seriously option.") + seriously := flag.Bool("seriously", false, "Actually delete the symlinks. This option is mutually exclusive with the --audit option.") + repoName := flag.String("repo-name", "briefcase", "The name of the repository.") + deviceOnly := flag.Bool("device-only", false, "Only output the device-specific dotfiles.") + flag.Parse() + + if !*audit && !*seriously { + log.Fatal(errors.New("Either -audit or -seriously needs to be set.")) + } + if *audit == *seriously { + log.Fatal(errors.New("Arguments -audit and -seriously are mutually exclusive")) + } + + home, err := os.UserHomeDir() + utils.FailOn(err) + count := 0 + + err = filepath.Walk(home, func(path string, info os.FileInfo, err error) error { + if utils.IsSymlink(info.Mode()) { + dest, err := os.Readlink(path) + utils.FailOn(err) + + var predicate func(string) bool + + if *deviceOnly { + predicate = func(dest string) bool { + var hostname string + hostname, err = os.Hostname() + utils.FailOn(err) + seeking, ok := hostnames[hostname] + if !ok { + log.Fatal(fmt.Sprintf("Hostname \"%s\" not supported in the hostnames map.", hostname)) + } + return strings.Contains(dest, *repoName) && strings.Contains(dest, seeking) + } + } else { + predicate = func(dest string) bool { + return strings.Contains(dest, *repoName) + } + } + + if predicate(dest) { + if *audit { + fmt.Printf("%s -> %s\n", path, dest) + } else if *seriously { + fmt.Printf("rm %s\n", path) + err = os.Remove(path) + utils.FailOn(err) + } + count += 1 + } + } + return nil + }) + utils.FailOn(err) + if *audit { + fmt.Printf("Would have deleted %d symlinks.\n", count) + } else if *seriously { + fmt.Printf("Successfully deleted %d symlinks.\n", count) + } + + os.Exit(0) +} diff --git a/users/wpcarro/tools/url-blocker/.envrc b/users/wpcarro/tools/url-blocker/.envrc new file mode 100644 index 000000000000..a4a62da526d3 --- /dev/null +++ b/users/wpcarro/tools/url-blocker/.envrc @@ -0,0 +1,2 @@ +source_up +use_nix diff --git a/users/wpcarro/tools/url-blocker/Main.hs b/users/wpcarro/tools/url-blocker/Main.hs new file mode 100644 index 000000000000..926412ce91f9 --- /dev/null +++ b/users/wpcarro/tools/url-blocker/Main.hs @@ -0,0 +1,205 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE DeriveGeneric #-} +module Main ( main ) where + +-------------------------------------------------------------------------------- +-- Dependencies +-------------------------------------------------------------------------------- + +import qualified Data.Maybe as Maybe +import qualified Data.Time.Clock as Clock +import qualified Data.Time.Calendar as Calendar +import qualified Data.Time.LocalTime as LocalTime +import qualified Data.ByteString.Lazy as LazyByteString +import qualified Data.Aeson as Aeson +import qualified Data.Either.Combinators as Either +import qualified Data.HashMap.Strict as HashMap +import qualified Data.Text as Text +import qualified Data.Text.IO as TextIO +import qualified Data.Text.Read as TextRead +import qualified Data.List as List + +import GHC.Generics +import Data.Aeson ((.:)) +import Data.Text (Text) + +-------------------------------------------------------------------------------- +-- Types +-------------------------------------------------------------------------------- + +newtype URL = URL { getURL :: Text } deriving (Show, Eq, Generic) + +newtype IPAddress = IPAddress { getIPAddress :: Text } deriving (Show) + +newtype Domain = Domain { getDomain :: Text } deriving (Show) + +newtype Hour = Hour { getHour :: Int } deriving (Show, Eq, Generic) + +newtype Minute = Minute { getMinute :: Int } deriving (Show, Eq, Generic) + +data EtcHostsEntry = EtcHostsEntry { ip :: IPAddress + , domains :: [Domain] + } deriving (Show) + +-- | Write these in terms of your system's local time (i.e. `date`). +data TimeSlot = TimeSlot { beg :: (Hour, Minute) + , end :: (Hour, Minute) + } deriving (Show, Eq, Generic) + +data Allowance = Allowance { day :: Calendar.DayOfWeek + , timeslots :: [TimeSlot] + } deriving (Show, Eq, Generic) + +data Rule = Rule { urls :: [URL] + , allowed :: [Allowance] + } deriving (Show, Eq, Generic) + +-------------------------------------------------------------------------------- +-- Instances +-------------------------------------------------------------------------------- + +instance Aeson.FromJSON TimeSlot where + parseJSON = Aeson.withText "timeslot" $ \x -> do + let [a, b] = Text.splitOn "-" x + [ah, am] = Text.splitOn ":" a + [bh, bm] = Text.splitOn ":" b + case extractTimeSlot ah am bh bm of + Left s -> fail s + Right x -> pure x + where + extractTimeSlot :: Text -> Text -> Text -> Text -> Either String TimeSlot + extractTimeSlot ah am bh bm = do + (begh, _) <- TextRead.decimal ah + (begm, _) <- TextRead.decimal am + (endh, _) <- TextRead.decimal bh + (endm, _) <- TextRead.decimal bm + pure $ TimeSlot{ beg = (Hour begh, Minute begm) + , end = (Hour endh, Minute endm) + } + +instance Aeson.FromJSON Allowance where + parseJSON = Aeson.withObject "allowance" $ \x -> do + day <- x .: "day" + timeslots <- x .: "timeslots" + pure $ Allowance{day, timeslots} + +instance Aeson.FromJSON URL where + parseJSON = Aeson.withText "URL" $ \x -> do + pure $ URL { getURL = x } + +instance Aeson.FromJSON Rule where + parseJSON = Aeson.withObject "rule" $ \x -> do + urls <- x .: "urls" + allowed <- x .: "allowed" + pure Rule{urls, allowed} + +-------------------------------------------------------------------------------- +-- Functions +-------------------------------------------------------------------------------- + +-- | Pipe operator +(|>) :: a -> (a -> b) -> b +(|>) a f = f a +infixl 1 |> + +-- | Returns True if the current time falls within any of the `timeslots`. +isWithinTimeSlot :: LocalTime.LocalTime -> [TimeSlot] -> Bool +isWithinTimeSlot date timeslots = + List.any withinTimeSlot timeslots + where + withinTimeSlot :: TimeSlot -> Bool + withinTimeSlot TimeSlot{ beg = (Hour ah, Minute am) + , end = (Hour bh, Minute bm) + } = + let LocalTime.TimeOfDay{LocalTime.todHour, LocalTime.todMin} = + LocalTime.localTimeOfDay date + in (todHour > ah) && (todMin > am) && (todHour < bh) && (todMin < bm) + +-- | Returns True if `day` is the same day as today. +isToday :: LocalTime.LocalTime -> Calendar.DayOfWeek -> Bool +isToday date day = today == day + where + today = Calendar.dayOfWeek (LocalTime.localDay date) + +-- | Returns True if a list of none of the `allowances` are valid. +shouldBeBlocked :: LocalTime.LocalTime -> [Allowance] -> Bool +shouldBeBlocked _ [] = True +shouldBeBlocked date allowances = do + case filter (isToday date . day) allowances of + [Allowance{timeslots}] -> not $ isWithinTimeSlot date timeslots + [] -> True + -- Error when more than one rule per day + _ -> True + +-- | Maps an EtcHostsEntry to the line of text url-blocker will append to /etc/hosts. +serializeEtcHostEntry :: EtcHostsEntry -> Text +serializeEtcHostEntry EtcHostsEntry{ip, domains} = + (getIPAddress ip) <> "\t" <> (Text.unwords $ fmap getDomain domains) + +-- | Create an EtcHostsEntry mapping the URLs in `rule` to 127.0.0.1 if the +-- URLs should be blocked. +maybeBlockURL :: LocalTime.LocalTime -> Rule -> Maybe EtcHostsEntry +maybeBlockURL date Rule{urls, allowed} = + if shouldBeBlocked date allowed then + Just $ EtcHostsEntry { ip = IPAddress "127.0.0.1" + , domains = fmap (Domain . getURL) urls + } + else + Nothing + +-- | Read and parse the rules.json file. +-- TODO(wpcarro): Properly handle errors for file not found. +-- TODO(wpcarro): Properly handle errors for parse failures. +-- TODO(wpcarro): How can we resolve the $HOME directory when this is run as +-- root? +getRules :: IO [Rule] +getRules = do + contents <- LazyByteString.readFile "/home/wpcarro/.config/url-blocker/rules.json" + let payload = Aeson.eitherDecode contents + pure $ Either.fromRight [] payload + +-- | Informational header added to /etc/hosts before the entries that +-- url-blocker adds. +urlBlockerHeader :: Text +urlBlockerHeader = + Text.unlines [ "################################################################################" + , "# Added by url-blocker." + , "#" + , "# Warning: url-blocker will remove anything that you add beneath this header." + , "################################################################################" + ] + +-- | Removes all entries that url-blocker may have added to /etc/hosts. +removeURLBlockerEntries :: Text -> Text +removeURLBlockerEntries etcHosts = + case Text.breakOn urlBlockerHeader etcHosts of + (etcHosts', _) -> etcHosts' + +-- | Appends the newly created `entries` to `etcHosts`. +addURLBlockerEntries :: Text -> Text -> Text +addURLBlockerEntries entries etcHosts = + Text.unlines [ etcHosts + , urlBlockerHeader + , entries + ] + +-- | This script reads the current /etc/hosts, removes any entries that +-- url-blocker may have added in a previous run, and adds new entries to block +-- URLs according to the rules.json file. +main :: IO () +main = do + rules <- getRules + tz <- LocalTime.getCurrentTimeZone + ct <- Clock.getCurrentTime + let date = LocalTime.utcToLocalTime tz ct + entries = rules + |> fmap (maybeBlockURL date) + |> Maybe.catMaybes + |> fmap serializeEtcHostEntry + |> Text.unlines + existingEtcHosts <- TextIO.readFile "/etc/hosts" + existingEtcHosts + |> removeURLBlockerEntries + |> addURLBlockerEntries entries + |> \x -> writeFile "/etc/hosts" (Text.unpack x) diff --git a/users/wpcarro/tools/url-blocker/README.md b/users/wpcarro/tools/url-blocker/README.md new file mode 100644 index 000000000000..1b7fea8c15e0 --- /dev/null +++ b/users/wpcarro/tools/url-blocker/README.md @@ -0,0 +1,47 @@ +# url-blocker + +`url-blocker` blocks the URLs that you want to block when you want it to block +them. + +Let's say that you don't want to visit Twitter during the work week. Create the +file `~/.config/url-blocker/rules.json` with the following contents and +`url-blocker` will take care of the rest. + +```json +# ~/.config/url-blocker/rules.json +[ + { + "urls": [ + "twitter.com", + "www.twitter.com", + ], + "allowed": [ + { + "day": "Saturday", + "timeslots": [ + "00:00-11:59" + ] + }, + { + "day": "Sunday", + "timeslots": [ + "00:00-11:59" + ] + } + ] + } +] +``` + +## Installation + +```shell +$ nix-env -iA 'briefcase.tools.url-blocker' +``` + +## How does it work? + +`systemd` is intended to run `url-blocker` once every minute. `url-blocker` will +read `/etc/hosts` and map the URLs defined in `rules.json` to `127.0.0.1` when +you want them blocked. Because `systemd` run once every minute, `/etc/hosts` +should be current to the minute as well. diff --git a/users/wpcarro/tools/url-blocker/default.nix b/users/wpcarro/tools/url-blocker/default.nix new file mode 100644 index 000000000000..943644e5f542 --- /dev/null +++ b/users/wpcarro/tools/url-blocker/default.nix @@ -0,0 +1,33 @@ +{ pkgs, ... }: + +let + ghc = pkgs.haskellPackages.ghcWithPackages (hpkgs: [ + hpkgs.time + hpkgs.aeson + hpkgs.either + ]); + + # This is the systemd service unit + service = pkgs.stdenv.mkDerivation { + name = "url-blocker"; + src = builtins.path { path = ./.; name = "url-blocker"; }; + buildPhase = '' + ${ghc}/bin/ghc Main.hs + ''; + installPhase = '' + mv ./Main $out + ''; + }; + + # This is the systemd timer unit. + # Run once every minute. + # Give root privilege. + systemdUnit = { + systemd = { + timers.simple-timer = { + wantedBy = [ "timers.target" ]; + partOf = []; + }; + }; + }; +in null diff --git a/users/wpcarro/tools/url-blocker/rules.json b/users/wpcarro/tools/url-blocker/rules.json new file mode 100644 index 000000000000..95e4dc9a90c1 --- /dev/null +++ b/users/wpcarro/tools/url-blocker/rules.json @@ -0,0 +1,28 @@ +[ + { + "urls": [ + "facebook.com", + "www.facebook.com", + "twitter.com", + "www.twitter.com", + "youtube.com", + "www.youtube.com", + "instagram.com", + "www.instagram.com" + ], + "allowed": [] + }, + { + "urls": [ + "chat.googleplex.com" + ], + "allowed": [ + { + "day": "Sunday", + "timeslots": [ + "18:35-18:39" + ] + } + ] + } +] diff --git a/users/wpcarro/tools/url-blocker/shell.nix b/users/wpcarro/tools/url-blocker/shell.nix new file mode 100644 index 000000000000..1adc566c0121 --- /dev/null +++ b/users/wpcarro/tools/url-blocker/shell.nix @@ -0,0 +1,10 @@ +let + briefcase = import <briefcase> {}; +in briefcase.buildHaskell.shell { + deps = hpkgs: with hpkgs; [ + time + aeson + either + hspec + ]; +} |