(feat): JSON/YAML support for input data (#5)

Rose Thatcher and Maas Lalani created

* Added JSON support for input data

* data from files

* Data flags override imported files again

* Merge conflicts

* Cleaned string parsing

---------

Co-authored-by: Maas Lalani <maas@lalani.dev>

Change summary

.gitignore |   3 +
README.md  |  15 ++++++
go.mod     |   8 +-
go.sum     |   8 +-
import.go  |  73 +++++++++++++++++++++++++++++++++
main.go    | 122 +++++++++++++++++++++++++++++++++++--------------------
pdf.go     |   6 +-
7 files changed, 178 insertions(+), 57 deletions(-)

Detailed changes

README.md 🔗

@@ -24,7 +24,7 @@ open invoice.pdf
 
 <img width="574" alt="Example invoice" src="https://github.com/maaslalani/nap/assets/42545625/13153de2-dfa1-41e6-a18e-4d3a5cea5b74">
 
-Save repeated information with environment variables:
+Save repeated information with environment variables or in an overridable JSON file:
 
 ```bash
 export INVOICE_LOGO=/path/to/image.png
@@ -34,6 +34,16 @@ export INVOICE_TAX=0.13
 export INVOICE_RATE=25
 ```
 
+```json
+{
+	"logo": "/path/to/image.png",
+	"from": "Dream, Inc.",
+	"to": "Imagine, Inc.",
+	"tax": 0.13,
+	"rates": 25
+}
+```
+
 Generate new invoice:
 
 ```bash
@@ -43,6 +53,9 @@ invoice generate \
     --note "For debugging purposes." \
     --output duck-invoice.pdf
 ```
+```bash
+invoice generate --import path/to/data.json --output duck-invoice.pdf
+```
 
 ### Custom Templates
 

go.mod 🔗

@@ -1,11 +1,12 @@
 module github.com/maaslalani/invoice
 
-go 1.19
+go 1.20
 
 require (
 	github.com/signintech/gopdf v0.18.0
 	github.com/spf13/cobra v1.7.0
 	github.com/spf13/viper v1.16.0
+	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
@@ -22,8 +23,7 @@ require (
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/subosito/gotenv v1.4.2 // indirect
-	golang.org/x/sys v0.9.0 // indirect
-	golang.org/x/text v0.10.0 // indirect
+	golang.org/x/sys v0.8.0 // indirect
+	golang.org/x/text v0.9.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 )

go.sum 🔗

@@ -316,8 +316,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
-golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -327,8 +327,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
-golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=

import.go 🔗

@@ -0,0 +1,73 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"os"
+	"strings"
+
+	"github.com/spf13/pflag"
+	"gopkg.in/yaml.v3"
+)
+
+func importData(path string, structure *Invoice, flags *pflag.FlagSet) error {
+	fileText, err := os.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("unable to read file")
+	}
+
+	var b []byte
+	var byteBuffer [][]byte
+	flags.Visit(func(f *pflag.Flag) {
+		if f.Value.Type() != "string" {
+			b = []byte(fmt.Sprintf(`{"%s":%s}`, f.Name, f.Value))
+		} else {
+			b = []byte(fmt.Sprintf(`{"%s":"%s"}`, f.Name, f.Value))
+		}
+		byteBuffer = append(byteBuffer, b)
+	})
+
+	if strings.HasSuffix(path, ".json") {
+		err = importJson(fileText, structure)
+	} else if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") {
+		err = importYaml(fileText, structure)
+
+	} else {
+		return fmt.Errorf("unsupported file type")
+	}
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	for _, bytes := range byteBuffer {
+		err = importJson(bytes, structure)
+		if err != nil {
+			log.Fatal(err)
+		}
+	}
+
+	return err
+}
+
+func importJson(text []byte, structure *Invoice) error {
+	if !json.Valid(text) {
+		return fmt.Errorf("json file not correctly formatted")
+	}
+
+	err := json.Unmarshal(text, structure)
+	if err != nil {
+		return fmt.Errorf("json file not correctly formatted")
+	}
+
+	return nil
+}
+
+func importYaml(text []byte, structure *Invoice) error {
+	err := yaml.Unmarshal(text, structure)
+	if err != nil {
+		return fmt.Errorf("yaml file not correctly formatted")
+	}
+
+	return nil
+}

main.go 🔗

@@ -19,49 +19,73 @@ var interFont []byte
 //go:embed Inter-Bold.ttf
 var interBoldFont []byte
 
-var (
-	id    string
-	title string
+type Invoice struct {
+	Id    string `json:"id" yaml:"id"`
+	Title string `json:"title" yaml:"title"`
+
+	Logo string `json:"logo" yaml:"logo"`
+	From string `json:"from" yaml:"from"`
+	To   string `json:"to" yaml:"to"`
+	Date string `json:"date" yaml:"date"`
+	Due  string `json:"due" yaml:"due"`
+
+	Items      []string  `json:"items" yaml:"items"`
+	Quantities []int     `json:"quantities" yaml:"quantities"`
+	Rates      []float64 `json:"rates" yaml:"rates"`
 
-	logo string
-	from string
-	to   string
-	date string
-	due  string
+	Tax      float64 `json:"tax" yaml:"tax"`
+	Discount float64 `json:"discount" yaml:"discount"`
+	Currency string  `json:"currency" yaml:"currency"`
 
-	items      []string
-	quantities []int
-	rates      []float64
+	Note string `json:"note" yaml:"note"`
+}
 
-	tax      float64
-	discount float64
-	currency string
+func DefaultInvoice() Invoice {
+	return Invoice{
+		Id:         time.Now().Format("20060102"),
+		Title:      "INVOICE",
+		Rates:      []float64{25},
+		Quantities: []int{2},
+		Items:      []string{"Paper Cranes"},
+		From:       "Project Folded, Inc.",
+		To:         "Untitled Corporation, Inc.",
+		Date:       time.Now().Format("Jan 02, 2006"),
+		Due:        time.Now().AddDate(0, 0, 14).Format("Jan 02, 2006"),
+		Tax:        0,
+		Discount:   0,
+		Currency:   "USD",
+	}
+}
 
-	note   string
-	output string
+var (
+	importPath     string
+	output         string
+	file           = Invoice{}
+	defaultInvoice = DefaultInvoice()
 )
 
 func init() {
 	viper.AutomaticEnv()
 
-	generateCmd.Flags().StringVar(&id, "id", time.Now().Format("20060102"), "ID")
-	generateCmd.Flags().StringVar(&title, "title", "INVOICE", "Title")
+	generateCmd.Flags().StringVar(&importPath, "import", "", "Imported file (.json/.yaml)")
+	generateCmd.Flags().StringVar(&file.Id, "id", time.Now().Format("20060102"), "ID")
+	generateCmd.Flags().StringVar(&file.Title, "title", "INVOICE", "Title")
 
-	generateCmd.Flags().Float64SliceVarP(&rates, "rate", "r", []float64{25}, "Rates")
-	generateCmd.Flags().IntSliceVarP(&quantities, "quantity", "q", []int{2}, "Quantities")
-	generateCmd.Flags().StringSliceVarP(&items, "item", "i", []string{"Paper Cranes"}, "Items")
+	generateCmd.Flags().Float64SliceVarP(&file.Rates, "rate", "r", defaultInvoice.Rates, "Rates")
+	generateCmd.Flags().IntSliceVarP(&file.Quantities, "quantity", "q", defaultInvoice.Quantities, "Quantities")
+	generateCmd.Flags().StringSliceVarP(&file.Items, "item", "i", defaultInvoice.Items, "Items")
 
-	generateCmd.Flags().StringVarP(&logo, "logo", "l", "", "Company logo")
-	generateCmd.Flags().StringVarP(&from, "from", "f", "Project Folded, Inc.", "Issuing company")
-	generateCmd.Flags().StringVarP(&to, "to", "t", "Untitled Corporation, Inc.", "Recipient company")
-	generateCmd.Flags().StringVar(&date, "date", time.Now().Format("Jan 02, 2006"), "Date")
-	generateCmd.Flags().StringVar(&due, "due", time.Now().AddDate(0, 0, 14).Format("Jan 02, 2006"), "Payment due date")
+	generateCmd.Flags().StringVarP(&file.Logo, "logo", "l", defaultInvoice.Logo, "Company logo")
+	generateCmd.Flags().StringVarP(&file.From, "from", "f", defaultInvoice.From, "Issuing company")
+	generateCmd.Flags().StringVarP(&file.To, "to", "t", defaultInvoice.To, "Recipient company")
+	generateCmd.Flags().StringVar(&file.Date, "date", defaultInvoice.Date, "Date")
+	generateCmd.Flags().StringVar(&file.Due, "due", defaultInvoice.Due, "Payment due date")
 
-	generateCmd.Flags().Float64Var(&tax, "tax", 0, "Tax")
-	generateCmd.Flags().Float64VarP(&discount, "discount", "d", 0.0, "Discount")
-	generateCmd.Flags().StringVarP(&currency, "currency", "c", "USD", "Currency")
+	generateCmd.Flags().Float64Var(&file.Tax, "tax", defaultInvoice.Tax, "Tax")
+	generateCmd.Flags().Float64VarP(&file.Discount, "discount", "d", defaultInvoice.Discount, "Discount")
+	generateCmd.Flags().StringVarP(&file.Currency, "currency", "c", defaultInvoice.Currency, "Currency")
 
-	generateCmd.Flags().StringVarP(&note, "note", "n", "", "Note")
+	generateCmd.Flags().StringVarP(&file.Note, "note", "n", "", "Note")
 	generateCmd.Flags().StringVarP(&output, "output", "o", "invoice.pdf", "Output file (.pdf)")
 
 	flag.Parse()
@@ -78,6 +102,14 @@ var generateCmd = &cobra.Command{
 	Short: "Generate an invoice",
 	Long:  `Generate an invoice`,
 	RunE: func(cmd *cobra.Command, args []string) error {
+
+		if importPath != "" {
+			err := importData(importPath, &file, cmd.Flags())
+			if err != nil {
+				fmt.Println(err)
+			}
+		}
+
 		pdf := gopdf.GoPdf{}
 		pdf.Start(gopdf.Config{
 			PageSize: *gopdf.PageSizeA4,
@@ -94,33 +126,33 @@ var generateCmd = &cobra.Command{
 			return err
 		}
 
-		writeLogo(&pdf, logo, from)
-		writeTitle(&pdf, title, id, date)
-		writeBillTo(&pdf, to)
+		writeLogo(&pdf, file.Logo, file.From)
+		writeTitle(&pdf, file.Title, file.Id, file.Date)
+		writeBillTo(&pdf, file.To)
 		writeHeaderRow(&pdf)
 		subtotal := 0.0
-		for i := range items {
+		for i := range file.Items {
 			q := 1
-			if len(quantities) > i {
-				q = quantities[i]
+			if len(file.Quantities) > i {
+				q = file.Quantities[i]
 			}
 
 			r := 0.0
-			if len(rates) > i {
-				r = rates[i]
+			if len(file.Rates) > i {
+				r = file.Rates[i]
 			}
 
-			writeRow(&pdf, items[i], q, r)
+			writeRow(&pdf, file.Items[i], q, r)
 			subtotal += float64(q) * r
 		}
-		if note != "" {
-			writeNotes(&pdf, note)
+		if file.Note != "" {
+			writeNotes(&pdf, file.Note)
 		}
-		writeTotals(&pdf, subtotal, subtotal*tax, subtotal*discount)
-		if due != "" {
-			writeDueDate(&pdf, due)
+		writeTotals(&pdf, subtotal, subtotal*file.Tax, subtotal*file.Discount)
+		if file.Due != "" {
+			writeDueDate(&pdf, file.Due)
 		}
-		writeFooter(&pdf, id)
+		writeFooter(&pdf, file.Id)
 		output = strings.TrimSuffix(output, ".pdf") + ".pdf"
 		err = pdf.WritePdf(output)
 		if err != nil {

pdf.go 🔗

@@ -125,9 +125,9 @@ func writeRow(pdf *gopdf.GoPdf, item string, quantity int, rate float64) {
 	pdf.SetX(quantityColumnOffset)
 	_ = pdf.Cell(nil, strconv.Itoa(quantity))
 	pdf.SetX(rateColumnOffset)
-	_ = pdf.Cell(nil, currencySymbols[currency]+strconv.FormatFloat(rate, 'f', 2, 64))
+	_ = pdf.Cell(nil, currencySymbols[file.Currency]+strconv.FormatFloat(rate, 'f', 2, 64))
 	pdf.SetX(amountColumnOffset)
-	_ = pdf.Cell(nil, currencySymbols[currency]+amount)
+	_ = pdf.Cell(nil, currencySymbols[file.Currency]+amount)
 	pdf.Br(24)
 }
 
@@ -155,7 +155,7 @@ func writeTotal(pdf *gopdf.GoPdf, label string, total float64) {
 	if label == totalLabel {
 		_ = pdf.SetFont("Inter-Bold", "", 11.5)
 	}
-	_ = pdf.Cell(nil, currencySymbols[currency]+strconv.FormatFloat(total, 'f', 2, 64))
+	_ = pdf.Cell(nil, currencySymbols[file.Currency]+strconv.FormatFloat(total, 'f', 2, 64))
 	pdf.Br(24)
 }