Rounding out library
This commit is contained in:
210
invoice.go
210
invoice.go
@@ -1,18 +1,26 @@
|
|||||||
// Package invoicetxt implements structs ad routines for working with invoice.txt files
|
// Package invoicetxt implements structs ad routines for working with invoice.txt files
|
||||||
package invoicetxt
|
package invoicetxt
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An invoice.txt entry looks like:
|
||||||
|
// <invoiceId> <hours> <rate> [retainerHours] [retainerRate] [retainerRollover] <contexts> <projects> <tags>
|
||||||
type Invoice struct {
|
type Invoice struct {
|
||||||
ID int `json:"id"` // Invoice id
|
ID int `json:"id"` // Invoice id
|
||||||
Original string `json:"original"` // original raw invoice text
|
Original string `json:"original"` // original raw invoice text
|
||||||
Hours float64 `json:"hours"`
|
Hours float64 `json:"hours"`
|
||||||
Rate float64 `json:"rate"`
|
Rate float64 `json:"rate"`
|
||||||
RetainerHours float64 `json:"retainerHours,omitempty"`
|
RetainerHours float64 `json:"retainerHours,omitempty"`
|
||||||
RetainerRate float64 `json:"retainerRate,omitempty"`
|
RetainerRate float64 `json:"retainerRate,omitempty"`
|
||||||
Contexts []string `json:"contexts"`
|
RetainerRollover float64 `json:"retainerRollover,omitempty"`
|
||||||
Projects []string `json:"projects"`
|
Contexts []string `json:"contexts"`
|
||||||
AdditionalTags map[string]string `json:"additionalTags"`
|
Projects []string `json:"projects"`
|
||||||
|
AdditionalTags map[string]string `json:"additionalTags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseInvoice parses the input test string into an Invoice struct
|
// ParseInvoice parses the input test string into an Invoice struct
|
||||||
@@ -23,13 +31,64 @@ func ParseInvoice(text string) (*Invoice, error) {
|
|||||||
}
|
}
|
||||||
invoice.Original = strings.Trim(text, "\t\n\r")
|
invoice.Original = strings.Trim(text, "\t\n\r")
|
||||||
parts := getParts(invoice.Original)
|
parts := getParts(invoice.Original)
|
||||||
var pIdx int
|
id, err := strconv.Atoi(parts[0])
|
||||||
for pIdx = range parts {
|
if err != nil {
|
||||||
if partIsSpecial(parts[pIdx]) {
|
return nil, fmt.Errorf("Error parsing invoice id: %w", err)
|
||||||
break
|
}
|
||||||
|
invoice.ID = id
|
||||||
|
hr, err := strconv.ParseFloat(parts[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Error parsing hours: %w", err)
|
||||||
|
}
|
||||||
|
invoice.Hours = hr
|
||||||
|
rt, err := strconv.ParseFloat(parts[2], 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Error parsing rate: %w", err)
|
||||||
|
}
|
||||||
|
invoice.Rate = rt
|
||||||
|
lpI := 3
|
||||||
|
var hitS bool
|
||||||
|
for i := 3; i < len(parts); i++ {
|
||||||
|
if partIsSpecial(parts[i]) {
|
||||||
|
hitS = true
|
||||||
|
}
|
||||||
|
if !hitS {
|
||||||
|
wrk, err := strconv.ParseFloat(parts[i], 64)
|
||||||
|
switch i {
|
||||||
|
case 3: // retainer hours
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Error parsing retainer hours: %w", err)
|
||||||
|
}
|
||||||
|
invoice.RetainerHours = wrk
|
||||||
|
case 4: // retainer rate
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Error parsing retainer rate: %w", err)
|
||||||
|
}
|
||||||
|
invoice.RetainerRate = wrk
|
||||||
|
case 5: // retainer rollover
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Error parsing retainer rollover: %w", err)
|
||||||
|
}
|
||||||
|
invoice.RetainerRollover = wrk
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// We're in the 'special' parts
|
||||||
|
switch parts[i][0] {
|
||||||
|
case '@': // context
|
||||||
|
invoice.Contexts = append(invoice.Contexts, parts[i][1:])
|
||||||
|
case '+': // project
|
||||||
|
invoice.Projects = append(invoice.Projects, parts[i][1:])
|
||||||
|
default:
|
||||||
|
if strings.Contains(parts[i], ":") {
|
||||||
|
// Additional Tags
|
||||||
|
tagPts := strings.Split(parts[i], ":")
|
||||||
|
if tagPts[0] != "" && tagPts[1] != "" {
|
||||||
|
invoice.AdditionalTags[tagPts[0]] = tagPts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &invoice, err
|
return &invoice, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,10 +96,10 @@ func partIsSpecial(pt string) bool {
|
|||||||
if len(pt) == 0 {
|
if len(pt) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return pt[0] == '#' || pt[0] == '@'
|
return pt[0] == '#' || pt[0] == '@' || strings.Contains(pts, ":")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getParts parses the text from 'text' pu
|
// getParts parses the text from 'text' pulling out each part.
|
||||||
// Generally it just splits everything on spaces, except if we're in a double-quote
|
// Generally it just splits everything on spaces, except if we're in a double-quote
|
||||||
func getParts(text string) []string {
|
func getParts(text string) []string {
|
||||||
var ret []string
|
var ret []string
|
||||||
@@ -73,3 +132,120 @@ func getParts(text string) []string {
|
|||||||
}
|
}
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// String returns a complete invoice string in invoice.txt format.
|
||||||
|
//
|
||||||
|
// Contexts, ProjectTags, and Additional Tags are alphabetically sorted,
|
||||||
|
// and appended at the end in the following order:
|
||||||
|
// Contexts, ProjectTags, Tags
|
||||||
|
//
|
||||||
|
// For example:
|
||||||
|
// 315 32.5 100 25 90 @personal +invoicetxt rate:100 retainer:25 retainer_rate:95 retainer_rollover:5
|
||||||
|
func (invoice Invoice) String() string {
|
||||||
|
text := fmt.Sprintf("%d %.2f %.2f", invoice.ID, invoice.Hours, invoice.Rate)
|
||||||
|
var sTxt string
|
||||||
|
if len(invoice.Contexts) > 0 {
|
||||||
|
sort.Strings(invoice.Contexts)
|
||||||
|
for _, c := range invoice.Contexts {
|
||||||
|
sTxt = fmt.Sprintf("%s @%s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(invoice.Projects) > 0 {
|
||||||
|
sort.Strings(invoice.Projects)
|
||||||
|
for _, p := range invoice.Projects {
|
||||||
|
sTxt = fmt.Sprintf("%s +%s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(invoice.AdditionalTags) > 0 {
|
||||||
|
keys := make([]string, 0, len(invoice.AdditionalTags))
|
||||||
|
for key := range invoice.AdditionalTags {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
v := invoice.AdditionalTags[key]
|
||||||
|
if strings.Contains(key, " ") {
|
||||||
|
key = fmt.Sprintf("\"%s\"", key)
|
||||||
|
}
|
||||||
|
if strings.Contains(v, " ") {
|
||||||
|
v = fmt.Sprintf("\"%s\"", v)
|
||||||
|
}
|
||||||
|
sTxt = fmt.Sprintf("%s %s:%s", sTxt, key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if invoice.RetainerHours == 0 {
|
||||||
|
return fmt.Sprintf("%s %s", text, sTxt)
|
||||||
|
}
|
||||||
|
text = fmt.Sprintf("%s %.2f", text, invoice.RetainerHours)
|
||||||
|
if invoice.RetainerRate == 0 {
|
||||||
|
return fmt.Sprintf("%s %s", text, sTxt)
|
||||||
|
}
|
||||||
|
text = fmt.Sprintf("%s %.2f", text, invoice.RetainerRate)
|
||||||
|
if invoice.RetainerRollover == 0 {
|
||||||
|
return fmt.Sprintf("%s %s", text, sTxt)
|
||||||
|
}
|
||||||
|
text = fmt.Sprintf("%s %.2f", text, invoice.RetainerRollover)
|
||||||
|
return fmt.Sprintf("%s %s", text, sTxt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoice *Invoice) HasContext(context string) bool {
|
||||||
|
for _, v := range invoice.Contexts {
|
||||||
|
if v == context {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoice *Invoice) GetContextsString() string {
|
||||||
|
var text string
|
||||||
|
sort.Strings(invoice.Contexts)
|
||||||
|
for _, context := range invoice.Contexts {
|
||||||
|
text = fmt.Sprintf("%s %s", context)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoice *Invoice) HasProject(project string) bool {
|
||||||
|
for _, v := range invoice.Projects {
|
||||||
|
if v == project {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoice *Invoice) GetProjectsString() string {
|
||||||
|
var text string
|
||||||
|
sort.Strings(invoice.Projects)
|
||||||
|
for _, project := range invoice.Projects {
|
||||||
|
text = fmt.Sprintf("%s %s", project)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoice *Invoice) SetTag(name, val string) {
|
||||||
|
invoice.AdditionalTags[name] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoice *Invoice) HasTag(name string) bool {
|
||||||
|
_, ok := invoice.AdditionalTags[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoice *Invoice) GetTag(name string) string {
|
||||||
|
return invoice.AdditionalTags[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoice *Invoice) GetTagsString() string {
|
||||||
|
var text string
|
||||||
|
keys := make([]string, 0, len(invoice.AdditionalTags))
|
||||||
|
for key := range invoice.AdditionalTags {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, key := range keys {
|
||||||
|
text = fmt.Sprintf("%s %s:%s", text, key, invoice.AdditionnalTags[key])
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|||||||
189
invoice_list.go
Normal file
189
invoice_list.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package invoicetxt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InvoiceList struct {
|
||||||
|
Invoices []*Invoice `json:"invoices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInvoiceList() *InvoiceList { return &InvoiceList{Invoices: []*Invoice{}} }
|
||||||
|
func (invoicelist *InvoiceList) Size() int { return len(invoicelist.Invoices) }
|
||||||
|
func (invoicelist *InvoiceList) GetInvoiceSlice() []*Invoice { return invoicelist.Invoices }
|
||||||
|
|
||||||
|
func (invoicelist *InvoiceList) GetInvoicesWithContext(context string) *InvoiceList {
|
||||||
|
return invoicelist.Filter(func(i *Invoice) bool {
|
||||||
|
return i.HasContext(context)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoicelist *InvoiceList) GetInvoicesWithProject(project string) *InvoiceList {
|
||||||
|
return invoicelist.Filter(func(i *Invoice) bool {
|
||||||
|
return i.HasProject(project)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter filters the current InvoiceList for the given predicate (a function that takes an invoice as input and returns a
|
||||||
|
// bool), and returns a new InvoiceList. The original InvoiceList is not modified.
|
||||||
|
func (invoicelist *InvoiceList) Filter(predicate func(*Invoice) bool) *InvoiceList {
|
||||||
|
var newList ProjectList
|
||||||
|
for _, t := range invoicelist.Invoices {
|
||||||
|
if predicate(t) {
|
||||||
|
newList.AddInvoice(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &newList
|
||||||
|
}
|
||||||
|
|
||||||
|
func (invoicelist *InvoiceList) GetNextId() int {
|
||||||
|
nextId := 0
|
||||||
|
for _, i := range invoicelist.Invoices {
|
||||||
|
if i.ID > nextId {
|
||||||
|
nextId = i.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nextId + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddInvoice prepe
|
||||||
|
func (invoicelist *InvoiceList) AddInvoice(invoice *Invoice) {
|
||||||
|
invoicelist.Invoices = append(invoicelist.Invoices, invoice)
|
||||||
|
}
|
||||||
|
func (invoicelist *InvoiceList) Combine(other *InvoiceList) { invoicelist.AddInvoices(other.Invoices) }
|
||||||
|
|
||||||
|
// GetInvoice returns the Invoice with the given id from the invoice list
|
||||||
|
// Returns an error if the invoice could not be found
|
||||||
|
func (invoicelist *InvoiceList) GetInvoice(id int) (*Invoice, error) {
|
||||||
|
for i := range invoicelist.Invoices {
|
||||||
|
if invoicelist.Invoices[i].ID == id {
|
||||||
|
return invoicelist.Invoices[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("invoice not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveInvoice removes any Invoice with given Invoice id from the InvoiceList.
|
||||||
|
// Returns an error if no invoice was removed.
|
||||||
|
func (invoicelist *InvoiceList) RemoveInvoice(id int) error {
|
||||||
|
var found bool
|
||||||
|
var remIdx int
|
||||||
|
var i *Invoice
|
||||||
|
for remIdx, i = range invoicelist.Invoices {
|
||||||
|
if i.ID == id {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return errors.New("invoice not found")
|
||||||
|
}
|
||||||
|
invoicelist.Invoices = append(invoicelist.Invoices[:remIdx], invoicelist.Invoices[remIdx+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveInvoiceToFile removes the invoice from the active list and concatenates it to
|
||||||
|
// the passed in filename
|
||||||
|
// Returns an err if any part of that fails
|
||||||
|
func (invoicelist *InvoiceList) ArchiveInvoiceToFile(invoice Invoice, filename string) error {
|
||||||
|
if err := invoicelist.RemoveInvoice(invoice.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Open(filename, os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.WriteString(invoice.String() + "\n")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFromFile loads an InvoiceList from *os.File.
|
||||||
|
// Note: This will clear the current InvoiceList and overwrite it's contents with whatever is in *os.File
|
||||||
|
func (invoicelist *InvoiceList) LoadFromFile(file *os.File) error {
|
||||||
|
invoicelist.Invoices = []*Invoice{} // Empty invoicelist
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
text := strings.Trim(scanner.Text(), "\t\n\r") // Read Line
|
||||||
|
// ignore blank lines
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
invoice, err := ParseInvoice(text)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
invoicelist.Invoices = append(invoicelist.Invoices, invoice)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteToFile writes an InvoiceList to *os.File
|
||||||
|
func (invoicelist *InvoiceList) WriteToFile(file *os.File) error {
|
||||||
|
writer := bufio.NewWriter(file)
|
||||||
|
_, err := writer.WriteString(invoicelist.String())
|
||||||
|
writer.Flush()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFromFilename loads an InvoiceList from the filename
|
||||||
|
// (it piggybacks on LoadFromFile)
|
||||||
|
func (invoicelist *InvoiceList) LoadFromFilename(filename strinng) error {
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return invoicelist.LoadFromFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteToFilename writes an InvoiceList to the specified file (most likely called "invoice.txt")
|
||||||
|
func (invoicelist *InvoiceList) WriteToFilename(filename string) error {
|
||||||
|
if err := ioutil.WriteFile(filename, []byte(invoicelist.String()), 0640); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a complete list of invoices in invoice.txt format.
|
||||||
|
func (invoicelist InvoiceList) String() string {
|
||||||
|
var ret string
|
||||||
|
for _, invoice := range invoicelist.Invoices {
|
||||||
|
if len(ret) > 0 {
|
||||||
|
ret = fmt.Sprintf("%s\n", ret)
|
||||||
|
}
|
||||||
|
ret = fmt.Sprintf("%s%s", ret, invoice.String())
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-Instance Functions
|
||||||
|
// LoadFromFile loads and returns an InvoiceList from *os.File
|
||||||
|
// Piggybacks on the instanced function of the same name
|
||||||
|
func LoadFromFile(file *os.File) (*InvoiceList, error) {
|
||||||
|
invoicelist := InvoiceList{}
|
||||||
|
if err := invoicelist.LoadFromFile(file); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &invoicelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFromFilename loads and returns a InvoiceList from a file (most likely called "project.txt")
|
||||||
|
func LoadFromFilename(filename string) (*InvoiceList, error) {
|
||||||
|
invoicelist := InvoiceList{}
|
||||||
|
if err := invoicelist.LoadFromFilename(filename); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &invoicelist, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user