Browse Source

Initial commit

Signed-off-by: Erik Hollensbe <github@hollensbe.org>
master
Erik Hollensbe 8 months ago
commit
897dab733a
14 changed files with 576 additions and 0 deletions
  1. +22
    -0
      README.md
  2. +13
    -0
      go.mod
  3. +18
    -0
      go.sum
  4. +121
    -0
      mdinvoice/config.go
  5. +2
    -0
      mdinvoice/example/.gitignore
  6. BIN
      mdinvoice/example/dimension.png
  7. +55
    -0
      mdinvoice/example/index.html.gotmpl
  8. BIN
      mdinvoice/example/logo.png
  9. +21
    -0
      mdinvoice/example/mdinvoice.yaml
  10. +134
    -0
      mdinvoice/example/style.css
  11. +48
    -0
      mdinvoice/main.go
  12. +78
    -0
      mdinvoice/template.go
  13. +6
    -0
      mdinvoice/timeformats.go
  14. +58
    -0
      mdinvoice/timewarrior.go

+ 22
- 0
README.md View File

@@ -0,0 +1,22 @@
# xena: time/task warrior princess

Some utilities for timewarrior & taskwarrior.

## mdinvoice: small tool to generate HTML invoices

This tool is still very simple and largely tailored to my needs. Patches are
accepted if they do not disturb my own way of working, otherwise you should
probably just fork this.

- Put the `example/mdinvoice.yaml` file in `~/.timewarrior/mdinvoice.yaml` and edit it.
- Must be run in `example` directory (or you can make your own theme if you want).
- To install, `go install ./mdinvoice`, then put in
`~/.taskwarrior/extensions`, or add a symlink to your `$GOBIN/mdinvoice`
- To use:
- `timew report mdinvoice [tag] > index.html` in example directory.
- Then you can optionally: `wkhtmltopdf index.html index.pdf` (needs
`wkhtmltopdf` installed)

## Author

Erik Hollensbe <erik+git@hollensbe.org>

+ 13
- 0
go.mod View File

@@ -0,0 +1,13 @@
module code.hollensbe.org/erikh/xena

go 1.14

require (
github.com/Ambrevar/blackfriday-latex v0.0.0-20171128113613-dc387d576233
github.com/gogo/protobuf v1.3.1
github.com/pkg/errors v0.9.1
github.com/russross/blackfriday v2.0.0+incompatible
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
gopkg.in/russross/blackfriday.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.2.8
)

+ 18
- 0
go.sum View File

@@ -0,0 +1,18 @@
github.com/Ambrevar/blackfriday-latex v0.0.0-20171128113613-dc387d576233 h1:N74lSeC1l+zNx13jAAh1uSk3RUpRpvrPWvq5cDyUM6M=
github.com/Ambrevar/blackfriday-latex v0.0.0-20171128113613-dc387d576233/go.mod h1:Gq7GVkiUFiOVot5Ycu4gCUkqGByvd0DlkWLRg+ENjwQ=
github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/russross/blackfriday v2.0.0+incompatible h1:cBXrhZNUf9C+La9/YpS+UHpUT8YD6Td9ZMSU9APFcsk=
github.com/russross/blackfriday v2.0.0+incompatible/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/russross/blackfriday.v2 v2.0.0 h1:+FlnIV8DSQnT7NZ43hcVKcdJdzZoeCmJj4Ql8gq5keA=
gopkg.in/russross/blackfriday.v2 v2.0.0/go.mod h1:6sSBNz/GtOm/pJTuh5UmBK2ZHfmnxGbl2NZg1UliSOI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

+ 121
- 0
mdinvoice/config.go View File

@@ -0,0 +1,121 @@
package main

import (
"io/ioutil"
"math"
"os"
"time"

"github.com/pkg/errors"
"gopkg.in/yaml.v2"
)

type Config struct {
CompanyName string `yaml:"company_name"`
Address string `yaml:"address"`
Location string `yaml:"location"`
Phone string `yaml:"phone"`
Email string `yaml:"email"`
Client Client `yaml:"client"`
OmitTags []string `yaml:"omit_tags"`
}

type Client struct {
Project string `yaml:"project"`
Name string `yaml:"name"`
Address string `yaml:"address"`
Rate float64 `yaml:"rate"`
DurationLimit time.Duration `yaml:"duration_limit"`
}

func getConfig() (*Config, error) {
configFile := os.Getenv("MDINVOICE_CONFIG")
if configFile == "" {
configFile = defaultConfigFile
}

if _, err := os.Stat(configFile); err != nil {
return nil, err
}

config, err := ioutil.ReadFile(configFile)
if err != nil {
return nil, errors.Wrap(err, "while reading configuration file")
}

c := &Config{}

if err := yaml.Unmarshal(config, c); err != nil {
return nil, errors.Wrap(err, "while parsing configuration")
}

return c, nil
}

func (c *Config) getTotals(rep []*Report) (float64, float64, time.Duration) {
var (
grandTotal float64
discount float64
timeTotal time.Duration
)

for _, r := range rep {
grandTotal += r.Total
timeTotal += r.Duration
}

if timeTotal > c.Client.DurationLimit {
total := float64(c.Client.DurationLimit/time.Hour) * c.Client.Rate
discount = math.Ceil(grandTotal - total)
grandTotal = total
}

return grandTotal, discount, timeTotal
}

func (c *Config) generateReport(twr []timeWarriorReport) ([]*Report, error) {
rep := []*Report{}
repReducer := map[string]*Report{}

for _, tw := range twr {
start, err := time.Parse(timeFormat, tw.Start)
if err != nil {
return nil, err
}

end, err := time.Parse(timeFormat, tw.End)
if err != nil {
return nil, err
}

for _, tag := range tw.Tags {
var skip bool
for _, omit := range c.OmitTags {
if tag == omit {
skip = true
break
}
}

if skip {
continue
}

r, ok := repReducer[tag]
if !ok {
r = &Report{}
repReducer[tag] = r
}

r.Service = tag
r.Duration += end.Sub(start)
r.Total = math.Trunc(float64(uint64(float64(r.Duration)/float64(time.Hour)*c.Client.Rate*100))) * 0.01
}
}

for _, r := range repReducer {
rep = append(rep, r)
}

return rep, nil
}

+ 2
- 0
mdinvoice/example/.gitignore View File

@@ -0,0 +1,2 @@
index.html
index.pdf

BIN
mdinvoice/example/dimension.png View File

Before After
Width: 43  |  Height: 50  |  Size: 556 B

+ 55
- 0
mdinvoice/example/index.html.gotmpl View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css" media="all" />
</head>
<body>
<header class="clearfix">
<h1>{{ .Config.CompanyName }}</h1>
<div id="company" class="clearfix">
<div>{{ .Config.CompanyName }}</div>
<div>{{ .Config.Address }},<br /> {{ .Config.Location }}</div>
<div>{{ .Config.Phone }}</div>
<div><a href="mailto:{{ .Config.Email }}">{{ .Config.Email }}</a></div>
</div>
<div id="project">
<div><span>PROJECT</span> {{ .Config.Client.Project }}</div>
<div><span>CLIENT</span> {{ .Config.Client.Name }}</div>
<div><span>ADDRESS</span> {{ .Config.Client.Address }}</div>
<div><span>DATE</span> {{ .Start }} - {{ .End }}</div>
</div>
</header>
<main>
<table>
<thead>
<tr>
<th class="service">SERVICE</th>
<th>RATE</th>
<th>DURATION</th>
<th>TOTAL</th>
</tr>
</thead>
<tbody>
{{- range $report := .Report }}
<tr>
<td class="service">{{ $report.Service }}</td>
<td class="unit">{{ rate $.Config.Client.Rate }}</td>
<td class="qty">{{ $report.Duration }}</td>
<td class="total">{{ rate $report.Total }}</td>
</tr>
{{- end }}
<tr>
<td class="grand total">GRAND TOTAL</td>
<td class="grand total">Time: {{ .TimeTotal }}</td>
<td class="grand total">Time Discount: {{ .Discount }}</td>
<td class="grand total">{{ .GrandTotal }}</td>
</tr>
</tbody>
</table>
</main>
<footer>
Please pay on receipt; thank you for your generosity!
</footer>
</body>
</html>

BIN
mdinvoice/example/logo.png View File

Before After
Width: 438  |  Height: 432  |  Size: 48 KiB

+ 21
- 0
mdinvoice/example/mdinvoice.yaml View File

@@ -0,0 +1,21 @@
# this file should be kept in ~/.timewarrior/mdinvoice.yaml; or set the
# MDINVOICE_CONFIG environment var when running the report.
---
# Your company info
company_name: "My Engineering Services"
address: "123 My Street"
location: "My City, CA, 90210"
phone: "123-456-7890"
email: "me@example.org"

# client company info
client:
project: "Engineering Services"
name: "Initech Systems"
address: "1 Initech Dr, Santa Clara, CA 95000"
rate: 25.00
duration_limit: 80h

# tags to filter from the output
omit_tags:
- work

+ 134
- 0
mdinvoice/example/style.css View File

@@ -0,0 +1,134 @@
.clearfix:after {
content: "";
display: table;
clear: both;
}

a {
color: #5D6975;
text-decoration: underline;
}

body {
position: relative;
width: 21cm;
height: 29.7cm;
margin: 0 auto;
color: #001028;
background: #FFFFFF;
font-family: Arial, sans-serif;
font-size: 12px;
font-family: Arial;
}

header {
padding: 10px 0;
margin-bottom: 30px;
}

#logo {
text-align: center;
margin-bottom: 10px;
}

#logo img {
width: 90px;
}

h1 {
border-top: 1px solid #5D6975;
border-bottom: 1px solid #5D6975;
color: #5D6975;
font-size: 2.4em;
line-height: 1.4em;
font-weight: normal;
text-align: center;
margin: 0 0 20px 0;
background: url(dimension.png);
}

#project {
float: left;
}

#project span {
color: #5D6975;
text-align: right;
width: 52px;
margin-right: 10px;
display: inline-block;
font-size: 0.8em;
}

#company {
float: right;
text-align: right;
}

#project div,
#company div {
white-space: nowrap;
}

table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
margin-bottom: 20px;
}

table tr:nth-child(2n-1) td {
background: #F5F5F5;
}

table th,
table td {
text-align: center;
}

table th {
padding: 5px 20px;
color: #5D6975;
border-bottom: 1px solid #C1CED9;
white-space: nowrap;
font-weight: normal;
}

table .service,
table .desc {
text-align: left;
}

table td {
padding: 20px;
text-align: right;
}

table td.service,
table td.desc {
vertical-align: top;
}

table td.unit,
table td.qty,
table td.total {
font-size: 1.2em;
}

table td.grand {
border-top: 1px solid #5D6975;;
}

#notices .notice {
color: #5D6975;
font-size: 1.2em;
}

footer {
color: #5D6975;
width: 100%;
height: 30px;
border-top: 1px solid #C1CED9;
padding: 8px 0;
text-align: center;
}

+ 48
- 0
mdinvoice/main.go View File

@@ -0,0 +1,48 @@
package main

import (
"fmt"
"os"
"path"
)

var defaultConfigFile = path.Join(os.Getenv("HOME"), ".timewarrior", "mdinvoice.yaml")

var settings = map[string]string{}

func errExit(err interface{}) {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}

func main() {
c, err := getConfig()
if err != nil {
errExit(err)
}

timewData, settings, err := getReport()
if err != nil {
errExit(err)
}

rep, err := c.generateReport(timewData)
if err != nil {
errExit(err)
}

grandTotal, discount, timeTotal := c.getTotals(rep)

tpl := &Template{
Config: *c,
Report: rep,
GrandTotal: rate(grandTotal),
Discount: rate(discount),
TimeTotal: timeTotal,
Settings: settings,
}

if err := tpl.run(); err != nil {
errExit(err)
}
}

+ 78
- 0
mdinvoice/template.go View File

@@ -0,0 +1,78 @@
package main

import (
"fmt"
"io/ioutil"
"os"
"text/template"
"time"

"github.com/pkg/errors"
)

type Template struct {
Config Config
Report []*Report
Start string
End string
TimeTotal time.Duration
Discount string
GrandTotal string
Settings map[string]string
}

type Report struct {
Service string
Duration time.Duration
Total float64
}

func (t *Template) run() error {
content, err := ioutil.ReadFile("index.html.gotmpl")
if err != nil {
return errors.Wrap(err, "while loading template")
}

tpl, err := template.New("").Funcs(map[string]interface{}{"rate": rate}).Parse(string(content))
if err != nil {
return errors.Wrap(err, "while configuring template engine")
}

startTime, err := time.Parse(timeFormat, t.Settings["temp.report.start"])
if err != nil {
return errors.Wrap(err, "while parsing start time")
}

t.Start = startTime.Format(dateFormat)

var endTime time.Time

if t.Settings["temp.report.end"] == "" {
endTime = time.Now()
} else {
var err error
endTime, err = time.Parse(timeFormat, t.Settings["temp.report.end"])
if err != nil {
return errors.Wrap(err, "while parsing end time")
}
}

t.End = endTime.Format(dateFormat)

if err := tpl.Execute(os.Stdout, t); err != nil {
return errors.Wrap(err, "while executing template")
}

return nil
}

func rate(i interface{}) string {
switch i := i.(type) {
case uint64:
return fmt.Sprintf("%0.2f", float64(i)*0.01)
case float64:
return fmt.Sprintf("%0.2f", i)
}

return ""
}

+ 6
- 0
mdinvoice/timeformats.go View File

@@ -0,0 +1,6 @@
package main

const (
timeFormat = "20060102T150405Z"
dateFormat = "Jan 02, 2006"
)

+ 58
- 0
mdinvoice/timewarrior.go View File

@@ -0,0 +1,58 @@
package main

import (
"encoding/json"
"io/ioutil"
"os"
"strings"

"github.com/pkg/errors"
)

type timeWarriorReport struct {
Start string `json:"start"`
End string `json:"end"`
Tags []string `json:"tags"`
}

func getReport() ([]timeWarriorReport, map[string]string, error) {
content, err := ioutil.ReadAll(os.Stdin)
if err != nil {
return nil, nil, err
}

parts := strings.SplitN(string(content), "\n\n", 2)
if len(parts) != 2 {
return nil, nil, errors.New("could not parse document")
}

params, jsonData := parts[0], parts[1]

settings, err := parseSettings(params)
if err != nil {
return nil, nil, err
}

var timewData []timeWarriorReport

if err := json.Unmarshal([]byte(jsonData), &timewData); err != nil {
return nil, nil, errors.Wrap(err, "while parsing timewarrior json")
}

return timewData, settings, nil
}

func parseSettings(params string) (map[string]string, error) {
settings := map[string]string{}

for _, line := range strings.Split(params, "\n") {
parts := strings.SplitN(line, ": ", 2)
if len(parts) != 2 {
return nil, errors.Errorf("could not parse line %q", line)
}

settings[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}

return settings, nil
}

Loading…
Cancel
Save