aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Anthony <sam@samanthony.xyz>2026-05-20 20:34:49 -0400
committerSam Anthony <sam@samanthony.xyz>2026-05-20 20:34:49 -0400
commit5b3f9269e7a4c709d5ced93c0f13492a3189c4f6 (patch)
tree9f356fc520eab81786e7b41f2d560d0b60e9cc75
parent2a4b7f4cd36aadd3448a303e6bcf1a703ad575d8 (diff)
downloadlulu-5b3f9269e7a4c709d5ced93c0f13492a3189c4f6.zip
cli: job command
-rw-r--r--cmd/lulu/cli.go2
-rw-r--r--cmd/lulu/cost.go33
-rw-r--r--cmd/lulu/indent_tab_writer.go58
-rw-r--r--cmd/lulu/job.go125
-rwxr-xr-xcmd/lulu/test8
-rw-r--r--cmd/lulu/testchecks/cost5
-rw-r--r--cmd/lulu/testchecks/job27
-rw-r--r--cmd/lulu/testchecks/vi6
-rw-r--r--cmd/lulu/testchecks/vi_basic6
-rw-r--r--cmd/lulu/tests/job3
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--lulu.go10
13 files changed, 256 insertions, 30 deletions
diff --git a/cmd/lulu/cli.go b/cmd/lulu/cli.go
index deedb92..0ff077a 100644
--- a/cmd/lulu/cli.go
+++ b/cmd/lulu/cli.go
@@ -27,7 +27,7 @@ type CLI struct {
CoverDimensions CoverDimensionsCmd `cmd name:"cd" help:"Calculate cover dimensions"`
Cost CostCmd `cmd help:"Calculate the cost of a print job"`
Jobs JobsCmd `cmd help:"Retrieve past print jobs"`
- //TODO Info JobCmd `cmd help:"Retrieve information about a particular print job"`
+ Job JobCmd `cmd help:"Retrieve information about a particular print job"`
List ListCmd `cmd help:"Print a list of valid argument values"`
}
diff --git a/cmd/lulu/cost.go b/cmd/lulu/cost.go
index 93c3ab4..66e1cb5 100644
--- a/cmd/lulu/cost.go
+++ b/cmd/lulu/cost.go
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"fmt"
+ "io"
"os"
"regexp"
"strconv"
@@ -41,20 +42,6 @@ func (cmd *CostCmd) Run(cli *kong.Kong, clnt *lulu.Client) error {
return err
}
- w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
- for _, fee := range cost.Fees {
- fmt.Fprintf(w, "fee %s %s:\t%s %s\t\n", fee.Type, fee.Sku, fee.TotalCostExclTax, fee.Currency)
- }
- for i, item := range cost.LineItemCosts {
- fmt.Fprintf(w, "item %d:\t%s %s\t\n", i, item.TotalCostExclTax, cost.Currency)
- }
- fmt.Fprintf(w, "shipping:\t%s %s\t\n", cost.ShipCost.TotalCostExclTax, cost.Currency)
- fmt.Fprintf(w, "fulfillment:\t%s %s\t\n", cost.FulfillmentCost.TotalCostExclTax, cost.Currency)
- fmt.Fprintf(w, "discount:\t%s %s\t\n", cost.TotalDiscount, cost.Currency)
- fmt.Fprintf(w, "tax:\t%s %s\t\n", cost.TotalTax, cost.Currency)
- fmt.Fprintf(w, "total:\t%s %s\t\n", cost.TotalCostInclTax, cost.Currency)
- w.Flush()
-
if len(addrVal.Warnings) > 0 {
j, err := json.Marshal(addrVal)
if err != nil {
@@ -63,7 +50,9 @@ func (cmd *CostCmd) Run(cli *kong.Kong, clnt *lulu.Client) error {
cli.Errorf("%s\n", j)
}
- return nil
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
+ printCost(w, cost)
+ return w.Flush()
}
type PrintJobCostLineItem struct {
@@ -95,3 +84,17 @@ func (item *PrintJobCostLineItem) UnmarshalText(text []byte) error {
return nil
}
+
+func printCost(w io.Writer, cost lulu.PrintJobCost) {
+ for _, fee := range cost.Fees {
+ fmt.Fprintf(w, "fee %s %s:\t%s %s\t\n", fee.Type, fee.Sku, fee.TotalCostExclTax, fee.Currency)
+ }
+ for i, item := range cost.LineItemCosts {
+ fmt.Fprintf(w, "item-%d:\t%s %s\t\n", i, item.TotalCostExclTax, cost.Currency)
+ }
+ fmt.Fprintf(w, "shipping:\t%s %s\t\n", cost.ShipCost.TotalCostExclTax, cost.Currency)
+ fmt.Fprintf(w, "fulfillment:\t%s %s\t\n", cost.FulfillmentCost.TotalCostExclTax, cost.Currency)
+ fmt.Fprintf(w, "discount:\t%s %s\t\n", cost.TotalDiscount, cost.Currency)
+ fmt.Fprintf(w, "tax:\t%s %s\t\n", cost.TotalTax, cost.Currency)
+ fmt.Fprintf(w, "total:\t%s %s\t\n", cost.TotalCostInclTax, cost.Currency)
+}
diff --git a/cmd/lulu/indent_tab_writer.go b/cmd/lulu/indent_tab_writer.go
new file mode 100644
index 0000000..102cb33
--- /dev/null
+++ b/cmd/lulu/indent_tab_writer.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "text/tabwriter"
+
+ "github.com/shurcooL/go/indentwriter"
+)
+
+type indentTabWriter struct {
+ w io.Writer
+ tabwFlags uint
+ tabw *tabwriter.Writer
+ indent int
+}
+
+func newIndentTabWriter(w io.Writer, tabwriterFlags uint) *indentTabWriter {
+ return &indentTabWriter{
+ w,
+ tabwriterFlags,
+ tabwriter.NewWriter(w, 0, 0, 1, ' ', tabwriterFlags),
+ 0,
+ }
+}
+
+func (w *indentTabWriter) Write(p []byte) (n int, err error) { return w.tabw.Write(p) }
+
+func (w *indentTabWriter) Flush() error {
+ return w.tabw.Flush()
+}
+
+func (w *indentTabWriter) Indent() error {
+ if err := w.tabw.Flush(); err != nil {
+ return err
+ }
+ w.indent++
+ w.reset()
+ return nil
+}
+
+func (w *indentTabWriter) Unindent() error {
+ if err := w.tabw.Flush(); err != nil {
+ return err
+ }
+ if w.indent <= 0 {
+ return fmt.Errorf("cannot unindent: already at depth %d\n", w.indent)
+ }
+ w.indent--
+ w.reset()
+ return nil
+}
+
+func (w *indentTabWriter) reset() {
+ w.tabw = tabwriter.NewWriter(
+ indentwriter.New(w.w, w.indent),
+ 0, 0, 1, ' ', w.tabwFlags)
+}
diff --git a/cmd/lulu/job.go b/cmd/lulu/job.go
new file mode 100644
index 0000000..49948f0
--- /dev/null
+++ b/cmd/lulu/job.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "time"
+
+ "git.samanthony.xyz/lulu"
+)
+
+type JobCmd struct {
+ Id uint64 `arg help:"ID of the job to retrieve"`
+}
+
+func (cmd JobCmd) Run(clnt *lulu.Client) error {
+ job, err := clnt.GetPrintJob(cmd.Id)
+ if err != nil {
+ return err
+ }
+
+ w := newIndentTabWriter(os.Stdout, 0)
+ fmtTime := func(t time.Time) string { return t.UTC().Format(time.RFC3339) }
+ fmt.Fprintf(w, "extid:\t%s\t\n", job.ExternalId)
+ fmt.Fprintf(w, "id:\t%d\t\n", job.Id)
+ fmt.Fprintf(w, "orderid:\t%s\t\n", job.OrderId)
+ fmt.Fprintf(w, "contact:\t%s\t\n", job.Contact)
+ fmt.Fprintf(w, "created:\t%s\t\n", fmtTime(job.Created))
+ fmt.Fprintf(w, "modified:\t%s\t\n", fmtTime(job.Modified))
+
+ fmt.Fprintf(w, "items:\t\n")
+ if err := w.Indent(); err != nil {
+ return err
+ }
+ for _, item := range job.LineItems {
+ fmt.Fprintf(w, "extid:\t%s\t\n", item.ExternalId)
+ fmt.Fprintf(w, "id:\t%d\t\n", item.Id)
+ fmt.Fprintf(w, "printable-id:\t%s\t\n", item.PrintableId)
+ fmt.Fprintf(w, "mfg:\t%s\t\n", item.Mfg)
+ fmt.Fprintf(w, "quantity:\t%d\t\n", item.Quantity)
+ fmt.Fprintf(w, "npages:\t%d\t\n", item.NPages)
+ fmt.Fprintf(w, "title:\t%s\t\n", item.Title)
+ if !isEmptyNormalization(item.PrintableNormalization.Interior) {
+ fmt.Fprintf(w, "interior-normalization:\t\n")
+ if err := w.Indent(); err != nil {
+ return err
+ }
+ printNormalization(w, item.PrintableNormalization.Interior)
+ if err := w.Unindent(); err != nil {
+ return err
+ }
+ }
+ if !isEmptyNormalization(item.PrintableNormalization.Cover) {
+ fmt.Fprintf(w, "cover-normalization:\t\n")
+ if err := w.Indent(); err != nil {
+ return err
+ }
+ printNormalization(w, item.PrintableNormalization.Cover)
+ if err := w.Unindent(); err != nil {
+ return err
+ }
+ }
+ if item.TrackingId != "" {
+ fmt.Fprintf(w, "tracking-id:\t%s\t\n", item.TrackingId)
+ }
+ if len(item.TrackingUrls) == 1 {
+ fmt.Fprintf(w, "tracking-url:\t%s\t\n", item.TrackingUrls[0])
+ } else if len(item.TrackingUrls) > 1 {
+ for i, url := range item.TrackingUrls {
+ fmt.Fprintf(w, "tracking-url-%d:\t%s\t\n", i, url)
+ }
+ }
+ if item.Carrier != "" {
+ fmt.Fprintf(w, "carrier:\t%s\t\n", item.Carrier)
+ }
+ }
+ if err := w.Unindent(); err != nil {
+ return err
+ }
+
+ fmt.Fprintf(w, "cost:\t\n")
+ if err := w.Indent(); err != nil {
+ return err
+ }
+ printCost(w, job.Cost)
+ if err := w.Unindent(); err != nil {
+ return err
+ }
+
+ fmt.Fprintf(w, "production-delay:\t%s\t\n", job.ProductionDelay)
+ if !job.ProductionDue.IsZero() {
+ fmt.Fprintf(w, "production-due:\t%s\t\n", fmtTime(job.ProductionDue))
+ }
+ fmt.Fprintf(w, "shipping-level:\t%s\t\n", job.ShipOpt)
+ if job.TaxCountry != "" {
+ fmt.Fprintf(w, "tax-country:\t%s\t\n", job.TaxCountry)
+ }
+ fmt.Fprintf(w, "status:\t%s\t\n", job.Status.Status)
+ fmt.Fprintf(w, "status-msg:\t%s\t\n", job.Status.Msg)
+ fmt.Fprintf(w, "status-changed:\t%s\t\n", fmtTime(job.Status.Changed))
+
+ return w.Flush()
+}
+
+func printNormalization(w io.Writer, n lulu.NormalizationJob) {
+ if n.JobId != 0 {
+ fmt.Fprintf(w, "jobid:\t%d\t\n", n.JobId)
+ }
+ if n.NormalizedFile.Id != 0 {
+ fmt.Fprintf(w, "normalized-file-id:\t%d\t\n", n.NormalizedFile.Id)
+ }
+ if n.NormalizedFile.Name != "" {
+ fmt.Fprintf(w, "normalized-file-name:\t%s\t\n", n.NormalizedFile.Name)
+ }
+ if n.SrcMd5Sum != "" {
+ fmt.Fprintf(w, "src-md5:\t%s\t\n", n.SrcMd5Sum)
+ }
+ if n.SrcUrl != "" {
+ fmt.Fprintf(w, "src-url:\t%s\t\n", n.SrcUrl)
+ }
+}
+
+func isEmptyNormalization(n lulu.NormalizationJob) bool {
+ return n.JobId == 0 && n.NormalizedFile.Id == 0 && n.NormalizedFile.Name == "" && n.SrcMd5Sum == "" && n.SrcUrl == ""
+}
diff --git a/cmd/lulu/test b/cmd/lulu/test
index e446356..ee6b18d 100755
--- a/cmd/lulu/test
+++ b/cmd/lulu/test
@@ -2,7 +2,13 @@
set -u
-flags="-e -u"
+flags='-e -u'
+
+export duration='([0-9]+h)?([0-9]+m)?[0-9]+s'
+export email='.+@.+\..+'
+export money='[0-9]+(\.[0-9]+)? [A-Z]{3}'
+export pkgid='[0-9]{4}X[0-9]{4}\.[A-Z]{2}\.[A-Z]{3}\.[A-Z]{2}\.[A-Z0-9]{5,8}\.[A-Z]{3}'
+export time='[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z'
[[ ! -d testout ]] && mkdir testout
[[ ! -d testerr ]] && mkdir testerr
diff --git a/cmd/lulu/testchecks/cost b/cmd/lulu/testchecks/cost
index 479bdf4..488d6f4 100644
--- a/cmd/lulu/testchecks/cost
+++ b/cmd/lulu/testchecks/cost
@@ -1,11 +1,10 @@
-money='[0-9]+(\.[0-9]+)? [A-Z]{3}'
-for field in "item 0" "item 1" "shipping" "fulfillment" "discount" "tax" "total"
+for field in "item-0" "item-1" "shipping" "fulfillment" "discount" "tax" "total"
do
re="$(printf '^%s: +%s *$' "${field}" "${money}")"
echo "$re"
grep -E "$re" $1
done
-grep -E -v '^item [^01]' $1
+grep -E -v '^item-[^01]' $1
awk '
# Ensure sum of fields equals total.
!/^total/ { wantTotal += $(NF-1) }
diff --git a/cmd/lulu/testchecks/job b/cmd/lulu/testchecks/job
new file mode 100644
index 0000000..80d6cf9
--- /dev/null
+++ b/cmd/lulu/testchecks/job
@@ -0,0 +1,27 @@
+grep -E '^extid: +[0-9a-zA-Z_-]+' $1
+grep -E '^id: +[0-9]+' $1
+grep -E '^orderid: +[0-9]+' $1
+grep -E "$(printf '^contact: +%s' "$email")" $1
+grep -E "$(printf '^created: +%s' "$time")" $1
+grep -E "$(printf '^modified: +%s' "$time")" $1
+
+grep -E '^items: *$' $1
+grep -E '^ extid: +[0-9a-zA-Z_-]+' $1
+grep -E '^ id: +[0-9]+' $1
+grep -E '^ printable-id: +[0-9a-z-]+' $1
+grep -E "$(printf '^ mfg: +%s' "$pkgid")" $1
+grep -E '^ quantity: +[0-9]+' $1
+grep -E '^ npages: +[0-9]+' $1
+grep -E '^ title: +.+' $1
+
+grep -E '^cost: *$' $1
+for field in "item-0" "shipping" "fulfillment" "discount" "tax" "total"
+do
+ grep -E "$(printf '^ %s: +%s *$' "${field}" "${money}")" $1
+done
+
+grep -E "$(printf '^production-delay: +%s' "$duration")" $1
+grep -E '^shipping-level: +[A-Z_]+' $1
+grep -E '^status: +[A-Z_]+' $1
+grep -E '^status-msg: *.*' $1
+grep -E "$(printf '^status-changed: +%s' "$time")" $1
diff --git a/cmd/lulu/testchecks/vi b/cmd/lulu/testchecks/vi
index 19de67c..d3b3ebc 100644
--- a/cmd/lulu/testchecks/vi
+++ b/cmd/lulu/testchecks/vi
@@ -1,3 +1,3 @@
-grep '^status: NORMALIZED$' $1
-grep '^id: [1-9][0-9]*$' $1
-grep '^valid PkgIds: \[[A-Z0-9. ]\+\]$' $1
+grep -E '^status: NORMALIZED$' $1
+grep -E '^id: [1-9][0-9]*$' $1
+grep -E "$(printf '^valid PkgIds: \[(%s *)+\]$' "$pkgid")" $1
diff --git a/cmd/lulu/testchecks/vi_basic b/cmd/lulu/testchecks/vi_basic
index 50acc33..48aca9b 100644
--- a/cmd/lulu/testchecks/vi_basic
+++ b/cmd/lulu/testchecks/vi_basic
@@ -1,3 +1,3 @@
-grep '^status: VALIDATED$' $1
-grep '^id: [1-9][0-9]*$' $1
-grep '^valid PkgIds: \[[A-Z0-9. ]\+\]$' $1
+grep -E '^status: VALIDATED$' $1
+grep -E '^id: [1-9][0-9]*$' $1
+grep -E "$(printf '^valid PkgIds: \[(%s *)+\]$' "$pkgid")" $1
diff --git a/cmd/lulu/tests/job b/cmd/lulu/tests/job
new file mode 100644
index 0000000..ac39591
--- /dev/null
+++ b/cmd/lulu/tests/job
@@ -0,0 +1,3 @@
+# Get info on the most recent job.
+lulu -s jobs | tail -n1 | awk '{id=$3; print id}' \
+ | xargs lulu -s job
diff --git a/go.mod b/go.mod
index 058aff4..6da2272 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sam-rba/string-enumer v0.0.0-20260516150728-84dfedf65d7e // indirect
+ github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
diff --git a/go.sum b/go.sum
index 210e9f6..4dc8fd9 100644
--- a/go.sum
+++ b/go.sum
@@ -16,6 +16,8 @@ github.com/sam-rba/string-enumer v0.0.0-20260516150728-84dfedf65d7e h1:4iVoDEBWL
github.com/sam-rba/string-enumer v0.0.0-20260516150728-84dfedf65d7e/go.mod h1:IqjpIrSTuoqcSL5noZPLd8b6nnliL0nmIdAoREz3Di4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a h1:ZHfoO7ZJhws9NU1kzZhStUnnVQiPtDe1PzpUnc6HirU=
+github.com/shurcooL/go v0.0.0-20230706063926-5fe729b41b3a/go.mod h1:DNrlr0AR9NsHD/aoc2pPeu4uSBZ/71yCHkR42yrzW3M=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
diff --git a/lulu.go b/lulu.go
index 643b65d..212a895 100644
--- a/lulu.go
+++ b/lulu.go
@@ -86,7 +86,7 @@ func (c *Client) ValidateInterior(ctx context.Context, srcUrl string, mfg PkgId)
if err != nil {
return InteriorValidation{}, err
}
- return c.pollInteriorValidation(ctx, id)
+ return c.pollInteriorValidation(ctx, id, InteriorStatusNormalized)
}
// ValidateInteriorBasic is like ValidateInterior but without the
@@ -96,13 +96,15 @@ func (c *Client) ValidateInteriorBasic(ctx context.Context, srcUrl string) (Inte
if err != nil {
return InteriorValidation{}, err
}
- return c.pollInteriorValidation(ctx, id)
+ return c.pollInteriorValidation(ctx, id, InteriorStatusValidated)
}
-func (c *Client) pollInteriorValidation(ctx context.Context, id uint) (InteriorValidation, error) {
+func (c *Client) pollInteriorValidation(ctx context.Context, id uint, wantStatus InteriorValidationStatus) (InteriorValidation, error) {
return poll(ctx, func() (InteriorValidation, bool, error) {
val, err := c.GetInteriorValidation(id)
- return val, val.Status.IsFinal(), err
+ done := val.Status == wantStatus ||
+ val.Status == InteriorStatusError
+ return val, done, err
})
}