subtree(users/wpcarro): docking briefcase at '24f5a642'

git-subtree-dir: users/wpcarro
git-subtree-mainline: 464bbcb15c
git-subtree-split: 24f5a642af
Change-Id: I6105b3762b79126b3488359c95978cadb3efa789
This commit is contained in:
Vincent Ambo 2021-12-14 01:51:19 +03:00
commit 019f8fd211
766 changed files with 175420 additions and 0 deletions

View file

@ -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)"

View file

@ -0,0 +1,3 @@
/ynab/fixture.json
/monzo/fixture.json
/kv.json

View file

@ -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

View file

@ -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
}

View file

@ -0,0 +1,12 @@
{ depot, briefcase, ... }:
depot.buildGo.program {
name = "job";
srcs = [
./main.go
];
deps = with briefcase.gopkgs; [
kv
utils
];
}

View file

@ -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)
}

View file

@ -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{}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -0,0 +1,10 @@
let
briefcase = import <briefcase> {};
pkgs = briefcase.third_party.pkgs;
in pkgs.mkShell {
buildInputs = [
pkgs.go
pkgs.goimports
pkgs.godef
];
}

View file

@ -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)
}
})))
}

View file

@ -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
];
}

View file

@ -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"
}
]
}
}

View file

@ -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)
}

View file

@ -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.

View file

@ -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

View file

@ -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 = [];
}

View file

@ -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)
}

View file

@ -0,0 +1,2 @@
source_up
use_nix

View file

@ -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
```

View file

@ -0,0 +1,11 @@
{ pkgs, depot, briefcase, ... }:
depot.buildGo.program {
name = "run";
srcs = [
./main.go
];
deps = with briefcase.gopkgs; [
utils
];
}

View file

@ -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())
}

View file

@ -0,0 +1,10 @@
let
briefcase = import <briefcase> {};
pkgs = briefcase.third_party.pkgs;
in pkgs.mkShell {
buildInputs = with pkgs; [
go
goimports
godef
];
}

View file

@ -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

View file

@ -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 ];
}

View file

@ -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
```

View file

@ -0,0 +1,11 @@
{ depot, briefcase, ... }:
depot.buildGo.program {
name = "symlink-mgr";
srcs = [
./main.go
];
deps = with briefcase.gopkgs; [
utils
];
}

View file

@ -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)
}

View file

@ -0,0 +1,2 @@
source_up
use_nix

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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"
]
}
]
}
]

View file

@ -0,0 +1,10 @@
let
briefcase = import <briefcase> {};
in briefcase.buildHaskell.shell {
deps = hpkgs: with hpkgs; [
time
aeson
either
hspec
];
}