From 3ae5125d0eed37d1fb4b61b9bfc9987f75025bdb Mon Sep 17 00:00:00 2001 From: Brian Buller Date: Thu, 15 Jan 2026 11:41:21 -0600 Subject: [PATCH] Rounding out library --- invoice.go | 210 ++++++++++++++++++++++++++++++++++++++++++++---- invoice_list.go | 189 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+), 17 deletions(-) create mode 100644 invoice_list.go diff --git a/invoice.go b/invoice.go index 4a44dda..2617331 100644 --- a/invoice.go +++ b/invoice.go @@ -1,18 +1,26 @@ // Package invoicetxt implements structs ad routines for working with invoice.txt files package invoicetxt -import "strings" +import ( + "fmt" + "sort" + "strconv" + "strings" +) +// An invoice.txt entry looks like: +// [retainerHours] [retainerRate] [retainerRollover] type Invoice struct { - ID int `json:"id"` // Invoice id - Original string `json:"original"` // original raw invoice text - Hours float64 `json:"hours"` - Rate float64 `json:"rate"` - RetainerHours float64 `json:"retainerHours,omitempty"` - RetainerRate float64 `json:"retainerRate,omitempty"` - Contexts []string `json:"contexts"` - Projects []string `json:"projects"` - AdditionalTags map[string]string `json:"additionalTags"` + ID int `json:"id"` // Invoice id + Original string `json:"original"` // original raw invoice text + Hours float64 `json:"hours"` + Rate float64 `json:"rate"` + RetainerHours float64 `json:"retainerHours,omitempty"` + RetainerRate float64 `json:"retainerRate,omitempty"` + RetainerRollover float64 `json:"retainerRollover,omitempty"` + Contexts []string `json:"contexts"` + Projects []string `json:"projects"` + AdditionalTags map[string]string `json:"additionalTags"` } // 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") parts := getParts(invoice.Original) - var pIdx int - for pIdx = range parts { - if partIsSpecial(parts[pIdx]) { - break + id, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("Error parsing invoice id: %w", err) + } + 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 } @@ -37,10 +96,10 @@ func partIsSpecial(pt string) bool { if len(pt) == 0 { 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 func getParts(text string) []string { var ret []string @@ -73,3 +132,120 @@ func getParts(text string) []string { } 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 +} diff --git a/invoice_list.go b/invoice_list.go new file mode 100644 index 0000000..b7e7ef2 --- /dev/null +++ b/invoice_list.go @@ -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 +}