1// ===== pkg/config/config.go =====
2// Package config pkg/config/config.go β the store's configuration.
3//
4// Values are sourced from a bash config file named by the MENV environment
5// variable (see `m2 gen` for the template), overridable by CLI flags. The
6// flag-registration helpers here bind a Values field to a flag whose default
7// is read from the MENV file, so every command that registers flags gets the
8// file-sourced defaults for free.
9package config
10
11import (
12 "embed"
13 "fmt"
14 "os"
15 "reflect"
16 "runtime"
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/bitfield/script"
22 "github.com/spf13/cobra"
23 "github.com/stripe/stripe-go/v81"
24)
25
26//go:embed *.go
27var Source embed.FS
28
29// MENV names the bash-sourced config file, from the environment.
30var MENV = os.Getenv("MENV")
31
32// Values holds every configurable value; field names map to the MENV file's
33// uppercase variable names (SITENAME, PRODUCTSCSV, ...).
34type Values struct {
35 Teststripekey bool
36 ProductsCSV string
37 WebPort int
38 StripelivePK string
39 StripeliveSK string
40 StripetestPK string
41 StripetestSK string
42 StripeSK string
43 StripePK string
44 Siteimagesrc string
45 Siteordersurl string
46 Sitename string
47 Siteext string
48 Sitedomain string
49 Sitelongname string
50 Sitetagline string
51 Sitemeta string
52 Siteprettyname string
53 Siteprettynamecap string
54 Siteprettynamecaps string
55 SiteASCIILogo string
56 Tgcontact string
57 Tgchannel string
58 UseTinygo bool
59 WasmSRC []string
60 WasmExecPath string
61 WasmExecPathGo string
62 WasmExecPathTinyGo string
63 Gobuild string
64 Tinygobuild string
65 Buildwasmwith string
66 LDFlagsX string
67 PrinterName string // CUPS queue name (blank = default)
68 CupsOptions string // comma-separated -o options
69 LpTimeout time.Duration // timeout for `lp`
70 Storeurl string // tui client mode: browse this store over http instead of local files
71}
72
73// F is the live configuration, shared by every package.
74var F = Values{
75 // WasmSRC: []string{"wasm/stl2.go","wasm/checkout_wasm.go"},
76 WasmExecPath: runtime.GOROOT() + "/lib/wasm/wasm_exec.js", //nolint
77 WasmExecPathGo: runtime.GOROOT() + "/lib/wasm/wasm_exec.js", //nolint
78 WasmExecPathTinyGo: strings.TrimSuffix(runtime.GOROOT(), "go") + "tinygo" + "/targets/wasm_exec.js", //nolint
79 Gobuild: "go build",
80 Tinygobuild: "tinygo build -target=wasm --no-debug",
81 Buildwasmwith: "go build",
82 LDFlagsX: "stripePK",
83}
84
85// InitStripe selects the live or test key pair, hands the secret key to the
86// stripe library, and bakes the publishable key into the wasm ldflags.
87func InitStripe() {
88 F.StripeSK = F.StripeliveSK
89 F.StripePK = F.StripelivePK
90 if F.Teststripekey {
91 F.StripeSK = F.StripetestSK
92 F.StripePK = F.StripetestPK
93 }
94 stripe.Key = F.StripeSK
95 // awkward way to do this
96 F.LDFlagsX += "=" + F.StripePK
97}
98
99var (
100 // Hardcoded array of valid shorthand characters, excluding "h"
101 shorthandChars = []rune("abcdefgijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
102 nextShortIndex = 0 // Index for the next shorthand flag
103)
104
105func getNextShortFlag() string {
106 if nextShortIndex >= len(shorthandChars) {
107 return ""
108 }
109 short := shorthandChars[nextShortIndex]
110 nextShortIndex++
111 return string(short)
112}
113
114var a = true
115var b = false
116
117// AddStringFlag registers a string flag bound to a field of F on each command,
118// defaulting to the MENV file's value for the field's uppercase name.
119func AddStringFlag(cmds []*cobra.Command, fieldPtr *string, description string) {
120 for i := range cmds {
121 cmds[i].Flags().StringVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecString(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(s string) string {
122 if s != "" {
123 s = "-" + s
124 }
125 return s
126 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
127 }
128}
129
130// AddStringSliceFlag is AddStringFlag for []string fields (bash arrays).
131func AddStringSliceFlag(cmds []*cobra.Command, fieldPtr *[]string, description string) {
132 for i := range cmds {
133 cmds[i].Flags().StringSliceVarP(
134 fieldPtr,
135 ccc(fieldPtr, &F, b),
136 getNextShortFlag(),
137 ScriptExecStringSlice(fmt.Sprintf("${%s[@]}", ccc(fieldPtr, &F, a))),
138 fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
139 )
140 }
141}
142
143// AddBoolFlag is AddStringFlag for bool fields.
144func AddBoolFlag(cmds []*cobra.Command, fieldPtr *bool, description string) {
145 for i := range cmds {
146 cmds[i].Flags().BoolVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecBool(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(b bool) string {
147 return "-" + strconv.FormatBool(b)
148 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
149 }
150}
151
152// AddIntFlag is AddStringFlag for int fields.
153func AddIntFlag(cmds []*cobra.Command, fieldPtr *int, description string) {
154 for i := range cmds {
155 cmds[i].Flags().IntVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecInt(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(i int) string {
156 return fmt.Sprintf("-%d", i)
157 }(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
158 }
159}
160
161// AddDurationFlag is AddStringFlag for time.Duration fields.
162func AddDurationFlag(cmds []*cobra.Command, fieldPtr *time.Duration, description string) {
163 for i := range cmds {
164 // Keep parity with the pattern of embedding a "-" when a non-zero default is present.
165 def := ScriptExecDuration(fmt.Sprintf("${%s%s}",
166 ccc(fieldPtr, &F, a),
167 func(d time.Duration) string {
168 if d != 0 {
169 return "-" + d.String() // e.g. "-5s"
170 }
171 return ""
172 }(*fieldPtr),
173 ))
174 cmds[i].Flags().DurationVarP(
175 fieldPtr,
176 ccc(fieldPtr, &F, b),
177 getNextShortFlag(),
178 def,
179 fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
180 )
181 }
182}
183
184// ccc finds the name of the struct field val points into, upper or lower case.
185func ccc(val interface{}, strct interface{}, upper bool) string {
186 v := reflect.ValueOf(strct)
187 if v.Kind() == reflect.Ptr {
188 v = v.Elem()
189 }
190 if v.Kind() != reflect.Struct {
191 panic("uc: second argument must be a pointer to a struct")
192 }
193 for i := 0; i < v.NumField(); i++ {
194 field := v.Field(i)
195 if field.CanAddr() && field.Addr().Interface() == val {
196 if upper {
197 return strings.ToUpper(v.Type().Field(i).Name)
198 }
199 return strings.ToLower(v.Type().Field(i).Name)
200 }
201 }
202 return ""
203}
204
205// ScriptExecString evaluates a bash expression with the MENV file sourced.
206func ScriptExecString(s string) string {
207 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
208 if err == nil {
209 return strings.TrimSpace(z)
210 }
211 return ""
212}
213
214// ScriptExecStringSlice evaluates a bash array expression with the MENV file
215// sourced. Empty entries are dropped: an empty or unset array still prints
216// one empty line, which would otherwise come back as [""] and defeat every
217// `len == 0` check downstream (e.g. WASMSRC=() disabling wasm).
218func ScriptExecStringSlice(s string) []string {
219 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s" "%s"'`, MENV, "%s\n", s)).Slice()
220 if err != nil {
221 return nil
222 }
223 out := z[:0]
224 for _, e := range z {
225 if strings.TrimSpace(e) != "" {
226 out = append(out, e)
227 }
228 }
229 return out
230}
231
232// ScriptExecBool evaluates a bash expression as a bool with the MENV file sourced.
233func ScriptExecBool(s string) bool {
234 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
235 if err == nil {
236 b, err := strconv.ParseBool(z)
237 if err == nil {
238 return b
239 }
240 }
241 return false
242}
243
244// ScriptExecInt evaluates a bash expression as an int with the MENV file sourced.
245func ScriptExecInt(s string) int {
246 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
247 if err == nil {
248 if z == "" {
249 return 0
250 }
251 i, err := strconv.Atoi(z)
252 if err == nil {
253 return i
254 }
255 }
256 return 0
257}
258
259// ScriptExecDuration evaluates a bash expression as a duration with the MENV
260// file sourced. Accepts Go duration strings ("750ms", "2s", "5m", "1h") and
261// bare integers (treated as seconds).
262func ScriptExecDuration(s string) time.Duration {
263 z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
264 if err != nil {
265 return 0
266 }
267 z = strings.TrimSpace(z)
268 if z == "" {
269 return 0
270 }
271 z = strings.TrimPrefix(z, "-") // keep parity with how defaults are built
272
273 // Try full Go duration syntax first.
274 if d, err := time.ParseDuration(z); err == nil {
275 return d
276 }
277 // Fallback: plain integer means seconds.
278 if n, err := strconv.ParseInt(z, 10, 64); err == nil {
279 return time.Duration(n) * time.Second
280 }
281 return 0
282}
283
284
285// ===== pkg/product/csv.go =====
286// Package product pkg/product/csv.go β catalog CSV loading.
287package product
288
289import (
290 "bufio"
291 "bytes"
292 "embed"
293 "fmt"
294 "log"
295 "strings"
296
297 "github.com/bitfield/script"
298)
299
300//go:embed *.go
301var Source embed.FS
302
303func readproductscsv(csvFile string) (data []byte) {
304 data, err := script.File(csvFile).Bytes() //nolint
305 if err != nil {
306 log.Printf(`Error reading %s file %v`, csvFile, err)
307 }
308 return data
309}
310
311const csvMinFields = 51 // f[0] through f[50]
312
313// ReadCSV reads the catalog from a file. The parsing is in ParseCSV so that
314// it can be tested without one.
315func ReadCSV(csvFile string) Products {
316 return ParseCSV(readproductscsv(csvFile))
317}
318
319// ParseCSV turns the catalog bytes into products, skipping rows that are not
320// enabled and rows too short to fill one.
321func ParseCSV(data []byte) (prods Products) {
322 scanner := bufio.NewScanner(bytes.NewReader(data))
323 lineNum := 0
324 for scanner.Scan() {
325 lineNum++
326 line := scanner.Text()
327 f := strings.Split(line, ",")
328 if len(f) < 4 {
329 continue
330 }
331 if f[3] != "TRUE" {
332 continue
333 }
334 if len(f) < csvMinFields {
335 log.Printf("csv line %d: expected %d fields, got %d β skipping", lineNum, csvMinFields, len(f))
336 continue
337 }
338 q := Product{
339 Image1: f[0],
340 Partno: f[1],
341 Name: f[2],
342 Enable: f[3],
343 Price: f[4],
344 Quantity: f[5],
345 Shippable: f[6],
346 Minorder: f[7],
347 Maxorder: f[8],
348 Defaultquantity: f[9],
349 Stepquantity: f[10],
350 Mfgpartno: f[11],
351 Mfgname: f[12],
352 Category: f[13],
353 Subcategory: f[14],
354 Location: f[15],
355 Msrp: f[16],
356 Cost: f[17],
357 Typ: f[18],
358 Packagetype: f[19],
359 Technology: f[20],
360 Materials: f[21],
361 Value: f[22],
362 ValUnit: f[23],
363 Resistance: f[24],
364 ResUnit: f[25],
365 Tolerance: f[26],
366 VoltsRating: f[27],
367 AmpsRating: f[28],
368 WattsRating: f[29],
369 TempRating: f[30],
370 TempUnit: f[31],
371 Description1: f[32],
372 Description2: f[33],
373 Color1: f[34],
374 Color2: f[35],
375 Sourceinfo: f[36],
376 Datasheet: f[37],
377 Docs: f[38],
378 Reference: f[39],
379 Attributes: f[40],
380 Year: f[41],
381 Condition: f[42],
382 Note: f[43],
383 Warning: f[44],
384 CableLengthInches: f[45],
385 LengthInches: f[46],
386 WidthInches: f[47],
387 HeightInches: f[48],
388 WeightLb: f[49],
389 WeightOz: f[50],
390 }
391 prods = append(prods, q)
392 }
393 return prods
394}
395
396// ValidateCSV scans products for patterns that could cause issues in HTML/JS rendering.
397// Returns a list of warnings. Call after ReadCSV to check data integrity.
398func ValidateCSV(prods Products) []string {
399 var warnings []string
400 for i, pr := range prods {
401 check := func(field, value string) {
402 if strings.ContainsAny(value, "<>\"'&") {
403 warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains HTML-unsafe characters: %q", i, pr.Partno, field, value))
404 }
405 if strings.Contains(value, "|") {
406 warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains pipe character: %q", i, pr.Partno, field, value))
407 }
408 }
409 check("Partno", pr.Partno)
410 check("Name", pr.Name)
411 check("Description1", pr.Description1)
412 check("Description2", pr.Description2)
413 check("Note", pr.Note)
414 check("Warning", pr.Warning)
415 check("Category", pr.Category)
416 check("Subcategory", pr.Subcategory)
417 check("Mfgname", pr.Mfgname)
418 check("Mfgpartno", pr.Mfgpartno)
419 }
420 return warnings
421}
422
423
424// ===== pkg/product/csv_test.go =====
425package product
426
427import (
428 "strings"
429 "testing"
430)
431
432// row builds a catalog line with the fields the parser needs, so a test can
433// set the few it cares about without writing fifty-one commas.
434func row(set map[int]string) string {
435 f := make([]string, csvMinFields)
436 f[0] = "img.jpg"
437 f[1] = "PN-1"
438 f[2] = "A Part"
439 f[3] = "TRUE"
440 f[4] = "1.50"
441 f[5] = "10"
442 for i, v := range set {
443 f[i] = v
444 }
445 return strings.Join(f, ",")
446}
447
448func TestParseCSVReadsAProduct(t *testing.T) {
449 prods := ParseCSV([]byte(row(nil)))
450 if len(prods) != 1 {
451 t.Fatalf("got %d products, want 1", len(prods))
452 }
453 got := prods[0]
454 if got.Partno != "PN-1" || got.Name != "A Part" || got.Price != "1.50" || got.Quantity != "10" {
455 t.Errorf("fields did not land where they should: %+v", got)
456 }
457}
458
459// The last field is the fiftieth, and off-by-one at the end of a fifty-one
460// column row is invisible in a spreadsheet.
461func TestParseCSVReadsTheLastField(t *testing.T) {
462 prods := ParseCSV([]byte(row(map[int]string{50: "3.25", 49: "1"})))
463 if len(prods) != 1 {
464 t.Fatalf("got %d products, want 1", len(prods))
465 }
466 if prods[0].WeightOz != "3.25" {
467 t.Errorf("WeightOz = %q, want the last column", prods[0].WeightOz)
468 }
469 if prods[0].WeightLb != "1" {
470 t.Errorf("WeightLb = %q, want the second to last column", prods[0].WeightLb)
471 }
472}
473
474// Only enabled rows are sold. A row that says anything but TRUE is a product
475// deliberately withdrawn, and showing it anyway sells something not in stock.
476func TestParseCSVSkipsRowsThatAreNotEnabled(t *testing.T) {
477 for _, enable := range []string{"FALSE", "false", "true", "", "1", "yes"} {
478 prods := ParseCSV([]byte(row(map[int]string{3: enable})))
479 if len(prods) != 0 {
480 t.Errorf("Enable=%q produced a product; only TRUE should", enable)
481 }
482 }
483}
484
485// A short row cannot fill a product, and reading one anyway would panic on a
486// live catalog rather than skip a line.
487func TestParseCSVSkipsShortRows(t *testing.T) {
488 short := strings.Join([]string{"img.jpg", "PN-1", "A Part", "TRUE", "1.50"}, ",")
489 if prods := ParseCSV([]byte(short)); len(prods) != 0 {
490 t.Errorf("a row with 5 fields produced %d products", len(prods))
491 }
492 // Exactly one short of the minimum is the boundary worth pinning.
493 f := make([]string, csvMinFields-1)
494 for i := range f {
495 f[i] = "x"
496 }
497 f[3] = "TRUE"
498 if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 0 {
499 t.Errorf("a row one field short produced %d products", len(prods))
500 }
501}
502
503func TestParseCSVSkipsVeryShortAndBlankRows(t *testing.T) {
504 for _, line := range []string{"", ",", "a,b,c", "\n\n"} {
505 if prods := ParseCSV([]byte(line)); len(prods) != 0 {
506 t.Errorf("%q produced %d products", line, len(prods))
507 }
508 }
509}
510
511func TestParseCSVReadsSeveralRows(t *testing.T) {
512 data := strings.Join([]string{
513 row(map[int]string{1: "AAA"}),
514 row(map[int]string{3: "FALSE", 1: "SKIPPED"}),
515 row(map[int]string{1: "BBB"}),
516 }, "\n")
517 prods := ParseCSV([]byte(data))
518 if len(prods) != 2 {
519 t.Fatalf("got %d products, want 2", len(prods))
520 }
521 if prods[0].Partno != "AAA" || prods[1].Partno != "BBB" {
522 t.Errorf("got %q and %q, want AAA and BBB", prods[0].Partno, prods[1].Partno)
523 }
524}
525
526// The catalog is split on commas rather than parsed as CSV, so a quoted
527// field containing one shifts every column after it β including Enable, which
528// is column 3. The row then does not say TRUE where the parser looks, and the
529// product is dropped from the store without a word.
530//
531// A spreadsheet writes that quoting itself the moment a name contains a comma,
532// so this is reachable from ordinary editing rather than from bad data.
533//
534// This test records what the parser does today. If it is ever changed to use
535// encoding/csv, this test is the one that should fail.
536func TestParseCSVSilentlyDropsRowsWithAQuotedComma(t *testing.T) {
537 f := make([]string, csvMinFields)
538 for i := range f {
539 f[i] = "x"
540 }
541 f[1] = "PN-1"
542 f[2] = `"Resistor, 10k"` // one field in a spreadsheet, two after a split
543 f[3] = "TRUE"
544 f[4] = "1.50"
545 line := strings.Join(f, ",")
546
547 // The row is long enough β it is the shift that loses it, not the length.
548 if n := len(strings.Split(line, ",")); n <= csvMinFields {
549 t.Fatalf("the fixture has %d fields; it needs more than %d to isolate the shift", n, csvMinFields)
550 }
551
552 prods := ParseCSV([]byte(line))
553 if len(prods) != 0 {
554 t.Fatalf("the parser now keeps rows with a quoted comma (%d products); "+
555 "replace this test with one asserting the fields are correct", len(prods))
556 }
557
558 // Without the comma the same row is read, which is what shows the comma is
559 // the cause rather than anything else in the fixture.
560 f[2] = "Resistor 10k"
561 if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 1 {
562 t.Errorf("the same row without the comma gave %d products, want 1", len(prods))
563 }
564}
565
566func TestValidateCSVAcceptsCleanData(t *testing.T) {
567 prods := ParseCSV([]byte(row(nil)))
568 if w := ValidateCSV(prods); len(w) != 0 {
569 t.Errorf("clean data produced warnings: %v", w)
570 }
571}
572
573// These characters are what turn a product name into markup on the page.
574func TestValidateCSVFlagsCharactersThatBreakThePage(t *testing.T) {
575 for _, bad := range []string{`<script>`, `a"b`, "a'b", "a&b", "a>b"} {
576 prods := ParseCSV([]byte(row(map[int]string{2: bad})))
577 if len(prods) != 1 {
578 t.Fatalf("%q: got %d products", bad, len(prods))
579 }
580 w := ValidateCSV(prods)
581 if len(w) == 0 {
582 t.Errorf("%q in a product name was not flagged", bad)
583 continue
584 }
585 if !strings.Contains(w[0], "Name") {
586 t.Errorf("%q: the warning does not name the field: %s", bad, w[0])
587 }
588 }
589}
590
591// The pipe is the field separator in the cart's stored format, so a pipe in a
592// product name corrupts the cart rather than the page.
593func TestValidateCSVFlagsPipes(t *testing.T) {
594 prods := ParseCSV([]byte(row(map[int]string{2: "A|B"})))
595 w := ValidateCSV(prods)
596 if len(w) == 0 {
597 t.Fatal("a pipe in a product name was not flagged")
598 }
599 if !strings.Contains(w[0], "pipe") {
600 t.Errorf("the warning does not mention the pipe: %s", w[0])
601 }
602}
603
604// Every field that reaches the page is checked, not just the name.
605func TestValidateCSVChecksEveryRenderedField(t *testing.T) {
606 fields := map[int]string{
607 1: "PN<", // Partno
608 2: "N<", // Name
609 12: "MFG<", // Mfgname
610 11: "MP<", // Mfgpartno
611 13: "CAT<", // Category
612 14: "SUB<", // Subcategory
613 32: "D1<", // Description1
614 33: "D2<", // Description2
615 43: "NT<", // Note
616 44: "WN<", // Warning
617 }
618 for col, val := range fields {
619 prods := ParseCSV([]byte(row(map[int]string{3: "TRUE", col: val})))
620 if len(prods) != 1 {
621 t.Fatalf("column %d: got %d products", col, len(prods))
622 }
623 if w := ValidateCSV(prods); len(w) == 0 {
624 t.Errorf("an unsafe character in column %d was not flagged", col)
625 }
626 }
627}
628
629func TestValidateCSVOnNoProducts(t *testing.T) {
630 if w := ValidateCSV(nil); len(w) != 0 {
631 t.Errorf("an empty catalog produced warnings: %v", w)
632 }
633}
634
635// The warning names the row so it can be found in a spreadsheet of thousands.
636func TestValidateCSVNamesTheProduct(t *testing.T) {
637 prods := ParseCSV([]byte(row(map[int]string{1: "PN-SPECIAL", 2: "bad<"})))
638 w := ValidateCSV(prods)
639 if len(w) == 0 {
640 t.Fatal("no warning")
641 }
642 if !strings.Contains(w[0], "PN-SPECIAL") {
643 t.Errorf("the warning does not name the part: %s", w[0])
644 }
645}
646
647
648// ===== pkg/product/product.go =====
649// Package product pkg/product/product.go
650package product
651
652type Product struct {
653 Enable string
654 Partno string
655 Name string
656 Image1 string
657 Price string
658 Quantity string
659 Shippable string
660 Minorder string
661 Maxorder string
662 Defaultquantity string
663 Stepquantity string
664 Mfgpartno string
665 Mfgname string
666 Category string
667 Subcategory string
668 Location string
669 Msrp string
670 Cost string
671 Typ string
672 Packagetype string
673 Technology string
674 Materials string
675 Value string
676 ValUnit string
677 Resistance string
678 ResUnit string
679 Tolerance string
680 VoltsRating string
681 AmpsRating string
682 WattsRating string
683 TempRating string
684 TempUnit string
685 Description1 string
686 Description2 string
687 Color1 string
688 Color2 string
689 Sourceinfo string
690 Datasheet string
691 Docs string
692 Reference string
693 Attributes string
694 Year string
695 Condition string
696 Note string
697 Warning string
698 CableLengthInches string
699 LengthInches string
700 WidthInches string
701 HeightInches string
702 WeightLb string
703 WeightOz string
704}
705
706// Products is an array of Product
707type Products []Product
708
709
710// ===== pkg/tui/catalog.go =====
711// Package tui pkg/tui/catalog.go β the site's structures, derived the same
712// way the templates derive them: category ordering from getcategories, the
713// navigation tree from htmpl/catsubcats.html, the product page from
714// htmpl/product.html. Where the website renders HTML, this renders the same
715// data to terminal text.
716package tui
717
718import (
719 "fmt"
720 "sort"
721 "strconv"
722 "strings"
723
724 "github.com/0magnet/m2/pkg/product"
725)
726
727// catalogInfo mirrors what pageMeta hands the templates: categories sorted
728// by product count, subcategories likewise, with counts.
729type catalogInfo struct {
730 prods product.Products
731 cats []string
732 catCounts map[string]int
733 subcatCounts map[string]map[string]int
734 subcatsByCat map[string][]string
735}
736
737func newCatalogInfo(prods product.Products) *catalogInfo {
738 c := &catalogInfo{
739 prods: prods,
740 catCounts: make(map[string]int),
741 subcatCounts: make(map[string]map[string]int),
742 subcatsByCat: make(map[string][]string),
743 }
744 for _, prod := range prods {
745 if prod.Category == "" {
746 continue
747 }
748 c.catCounts[prod.Category]++
749 if prod.Subcategory != "" {
750 if c.subcatCounts[prod.Category] == nil {
751 c.subcatCounts[prod.Category] = make(map[string]int)
752 }
753 c.subcatCounts[prod.Category][prod.Subcategory]++
754 }
755 }
756 for cat := range c.catCounts {
757 c.cats = append(c.cats, cat)
758 }
759 sort.Slice(c.cats, func(i, j int) bool {
760 if c.catCounts[c.cats[i]] != c.catCounts[c.cats[j]] {
761 return c.catCounts[c.cats[i]] > c.catCounts[c.cats[j]]
762 }
763 return c.cats[i] < c.cats[j]
764 })
765 for cat, subs := range c.subcatCounts {
766 var names []string
767 for sub := range subs {
768 names = append(names, sub)
769 }
770 sort.Slice(names, func(i, j int) bool {
771 if subs[names[i]] != subs[names[j]] {
772 return subs[names[i]] > subs[names[j]]
773 }
774 return names[i] < names[j]
775 })
776 c.subcatsByCat[cat] = names
777 }
778 return c
779}
780
781// products returns the rows the matching category/subcategory table lists.
782// A category with subcategories lists only its uncategorized remainder on
783// its own page ("Other Products in X"), exactly as front.html does.
784func (c *catalogInfo) products(cat, subcat string) product.Products {
785 var out product.Products
786 for _, p := range c.prods {
787 switch {
788 case cat == "":
789 out = append(out, p)
790 case subcat == "":
791 if p.Category == cat && (len(c.subcatsByCat[cat]) == 0 || p.Subcategory == "") {
792 out = append(out, p)
793 }
794 default:
795 if p.Category == cat && p.Subcategory == subcat {
796 out = append(out, p)
797 }
798 }
799 }
800 return out
801}
802
803func (c *catalogInfo) find(partno string) *product.Product {
804 for i := range c.prods {
805 if strings.EqualFold(c.prods[i].Partno, partno) {
806 return &c.prods[i]
807 }
808 }
809 return nil
810}
811
812// navTarget is a place the categories tree can go: all products, a
813// category, or a subcategory.
814type navTarget struct{ cat, subcat string }
815
816type navItem struct {
817 label string
818 target navTarget
819 hasSubs bool
820}
821
822// buildTree reproduces htmpl/catsubcats.html: counts right-aligned by β
823// padding, β/β branches, β¬ where a category opens into subcategories.
824// Like the template's nested <details>, a category's subcategories are
825// listed only when it is in the expanded set.
826func (c *catalogInfo) buildTree(expanded map[string]bool) []navItem {
827 lenall := len(c.prods)
828 laLen := len(strconv.Itoa(lenall))
829 items := []navItem{{fmt.Sprintf("βββ%d All Products", lenall), navTarget{}, false}}
830 for ci, cat := range c.cats {
831 count := c.catCounts[cat]
832 cs := strconv.Itoa(count)
833 pad := strings.Repeat("β", laLen-len(cs))
834 branch := "β"
835 if ci == len(c.cats)-1 {
836 branch = "β"
837 }
838 tee := "β"
839 subs := c.subcatsByCat[cat]
840 if len(subs) > 0 {
841 tee = "β¬"
842 }
843 items = append(items, navItem{fmt.Sprintf("%sβ%s%s%d %s", branch, tee, pad, count, cat), navTarget{cat: cat}, len(subs) > 0})
844 if expanded != nil && !expanded[cat] {
845 continue
846 }
847 lead := "β "
848 if ci == len(c.cats)-1 {
849 lead = " "
850 }
851 for si, sub := range subs {
852 scount := c.subcatCounts[cat][sub]
853 extrapad := strings.Repeat("β", len(cs)-len(strconv.Itoa(scount)))
854 sb := "β"
855 if si == len(subs)-1 {
856 sb = "β"
857 }
858 items = append(items, navItem{fmt.Sprintf("%s%s%s%s%d %s", lead, sb, extrapad, pad, scount, sub), navTarget{cat: cat, subcat: sub}, false})
859 }
860 }
861 return items
862}
863
864// checkerboard styles alternate letters inverse-video, as the site's
865// checkerBoard wraps every other letter of the domain in an .nv span.
866func checkerboard(s string) string {
867 var out strings.Builder
868 for i, ch := range s {
869 if i%2 == 0 {
870 fmt.Fprintf(&out, "[black:white]%c[white:black]", ch)
871 } else {
872 out.WriteRune(ch)
873 }
874 }
875 out.WriteString("[-:-]")
876 return out.String()
877}
878
879// set reports a field the way product.html's conditions do: present unless
880// empty or a bare zero.
881func set(v string) bool {
882 return v != "" && v != "0" && v != "0.0"
883}
884
885// productLines renders htmpl/product.html: the same fields, labels, and
886// order, as terminal text.
887func productLines(p *product.Product) string {
888 var b strings.Builder
889 line := func(format string, args ...interface{}) {
890 fmt.Fprintf(&b, format+"\n", args...)
891 }
892 line("[::b]%s[-:-:-]", esc(p.Name))
893 line("Price: [white]$%s[-]", esc(p.Price))
894 line("In stock: %s", esc(p.Quantity))
895 if p.Quantity != "0" {
896 line("partno: %s", esc(p.Partno))
897 }
898 if !strings.EqualFold(strings.Join(strings.Fields(p.Description1), ""), strings.Join(strings.Fields(p.Name), "")) && p.Description1 != "" {
899 line("\n%s\n", esc(p.Description1))
900 }
901 if p.Mfgname != "" {
902 line("Brand: %s", esc(p.Mfgname))
903 }
904 if p.Mfgpartno != "" {
905 line("MPN: %s", esc(p.Mfgpartno))
906 }
907 line("Category: [aqua]%s[-]", esc(p.Category))
908 if p.Subcategory != "" {
909 line("Subcategory: [aqua]%s[-]", esc(p.Subcategory))
910 }
911 if set(p.VoltsRating) {
912 line("Voltage: %s", esc(p.VoltsRating))
913 }
914 if set(p.Value) {
915 line("Value: %s%s", esc(p.Value), esc(p.ValUnit))
916 }
917 if set(p.AmpsRating) {
918 line("Amperage: %s", esc(p.AmpsRating))
919 }
920 if p.Tolerance != "0" && p.Tolerance != "" {
921 if f, err := strconv.ParseFloat(p.Tolerance, 64); err == nil {
922 line("Tolerance: %.2f%%", 100*f)
923 }
924 }
925 if p.Typ != "" {
926 line("Typ: %s", esc(p.Typ))
927 }
928 if p.Packagetype != "" {
929 line("Package Type: %s", esc(p.Packagetype))
930 }
931 if p.Technology != "" {
932 line("Technology: %s", esc(p.Technology))
933 }
934 if p.Materials != "" {
935 line("Materials: %s", esc(p.Materials))
936 }
937 if set(p.WattsRating) {
938 line("Watts Rating: %s", esc(p.WattsRating))
939 }
940 if p.Year != "0" && p.Year != "" {
941 line("Year: %s", esc(p.Year))
942 }
943 if set(p.CableLengthInches) {
944 line("Cable Length: %s inches", esc(p.CableLengthInches))
945 }
946 if p.WeightOz != "0" && p.WeightOz != "0.0" && p.WeightOz != "" {
947 line("Weight: %s oz", esc(p.WeightOz))
948 }
949 if p.TempRating != "0" && p.TempRating != "0.0" && p.TempRating != "" {
950 line("Temp rating: %s%s", esc(p.TempRating), esc(p.TempUnit))
951 }
952 if p.Condition != "" {
953 line("Condition: %s", esc(p.Condition))
954 }
955 if p.Datasheet != "" {
956 line("Datasheet: [aqua]%s[-]", esc(p.Datasheet))
957 }
958 if p.Docs != "" {
959 line("Documentation: %s", esc(p.Docs))
960 }
961 if p.Note != "" {
962 line("Note: [yellow]%s[-]", esc(p.Note))
963 }
964 if p.Warning != "" {
965 line("Warning: [red]%s[-]", esc(p.Warning))
966 }
967 if p.Description2 != "" {
968 line("Additional Description: %s", esc(p.Description2))
969 }
970 return b.String()
971}
972
973
974// ===== pkg/tui/checkout.go =====
975// Package tui pkg/tui/checkout.go β the cart, the shipping form, and
976// checkout, mirroring the website's footer commerce (footer.html + the
977// cart wasm). On the website the cart and the shipping form are
978// <details> dropdowns rising from the fixed footer and checkout is a
979// <dialog>; here they are overlays above the footer bar, outside the
980// navigation history β v toggles the cart, Esc closes.
981//
982// The TUI is a client of the same store server the browser cart talks
983// to: it builds the identical items payload ("partno X qty" lines plus
984// a "shipping-to|..." line) and POSTs the same /create-payment-intent.
985// The card-entry step lives behind payWithElements (pay_native.go / a
986// future pay_js.go): when the TUI runs in the browser as wasm, that
987// seam mounts the Stripe Payment Element over the terminal exactly as
988// the cart wasm does today; a native terminal cannot take card details,
989// and says so.
990package tui
991
992import (
993 "bytes"
994 "encoding/json"
995 "fmt"
996 "io"
997 "net/http"
998 "regexp"
999 "strings"
1000 "time"
1001
1002 "github.com/gdamore/tcell/v3"
1003)
1004
1005// shippingLine is the "Add Shipping Info" form's result: a cart line,
1006// exactly as the cart wasm stores it ("shipping-to|name|address|...").
1007type shippingLine struct {
1008 Cents int
1009 Name, Address, City, State, Zip, Country, Phone string
1010}
1011
1012func (s *shippingLine) id() string {
1013 return strings.Join([]string{"shipping-to", s.Name, s.Address, s.City, s.State, s.Zip, s.Country, s.Phone}, "|")
1014}
1015
1016// usStates is the shipping form's State dropdown, from footer.html.
1017var usStates = []string{"", "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "DC",
1018 "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA",
1019 "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND",
1020 "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA",
1021 "WV", "WI", "WY"}
1022
1023var zipRe = regexp.MustCompile(`^[0-9]{5}$`)
1024
1025// commerceRect anchors an overlay just above the footer summary it
1026// expands from β the cart (and checkout) above View Cart, the form
1027// above Add Shipping Info.
1028func (u *ui) commerceRect() rect {
1029 w, h := u.commerce.size()
1030 sw, sh := u.screen.Size()
1031 if w > sw-2 {
1032 w = sw - 2
1033 }
1034 x := 1
1035 region := 0
1036 if _, ok := u.commerce.(*shippingOverlay); ok {
1037 region = 1
1038 }
1039 if region < len(u.footRegions) {
1040 x = u.footRegions[region].x1 - 1
1041 }
1042 if x+w > sw-1 {
1043 x = sw - 1 - w
1044 }
1045 if x < 0 {
1046 x = 0
1047 }
1048 y := sh - 2 - h
1049 if y < 1 {
1050 y = 1
1051 h = sh - 3
1052 }
1053 return rect{x, y, w, h}
1054}
1055
1056func (u *ui) closeCommerce() {
1057 u.commerce = nil
1058}
1059
1060func (u *ui) toggleCart() {
1061 if u.commerce != nil {
1062 u.closeCommerce()
1063 return
1064 }
1065 u.openCart()
1066}
1067
1068// ---- the cart overlay ----
1069
1070type cartOverlay struct {
1071 ta *textArea
1072 lines int
1073}
1074
1075func (u *ui) openCart() {
1076 var b strings.Builder
1077 total := 0
1078 for _, partno := range u.cartOrder {
1079 qty := u.cartQty[partno]
1080 if qty == 0 {
1081 continue
1082 }
1083 p := u.cat.find(partno)
1084 if p == nil {
1085 continue
1086 }
1087 cents := priceCents(p.Price) * qty
1088 total += cents
1089 fmt.Fprintf(&b, "%3d Γ [aqua]%s[-] $%d.%02d\n", qty, esc(p.Name), cents/100, cents%100)
1090 }
1091 if u.shipping != nil {
1092 total += u.shipping.Cents
1093 to := strings.TrimSpace(u.shipping.Name + " " + u.shipping.City + " " + u.shipping.State)
1094 fmt.Fprintf(&b, " shipping[gray] to %s[-] $%d.%02d\n",
1095 esc(to), u.shipping.Cents/100, u.shipping.Cents%100)
1096 }
1097 if total == 0 {
1098 b.WriteString("[gray]your cart is empty[-]\n")
1099 }
1100 fmt.Fprintf(&b, "\nTotal: $%d.%02d\n", total/100, total%100)
1101 b.WriteString("\n[#5fd7ff]s[-] add shipping info Β· [#5fd7ff]p[-] checkout Β· [#5fd7ff]x[-] empty Β· [#5fd7ff]v[-] close\n")
1102 fmt.Fprintf(&b, "[gray]checkout talks to the store server at %s[-]", esc(ordersURL()))
1103 content := b.String()
1104 u.commerce = &cartOverlay{ta: newTextArea(content, false), lines: strings.Count(content, "\n") + 1}
1105}
1106
1107func (c *cartOverlay) title() string { return "View Cart" }
1108func (c *cartOverlay) size() (int, int) {
1109 return 70, c.lines + 2
1110}
1111
1112func (c *cartOverlay) draw(u *ui, sc tcell.Screen, r rect) {
1113 c.ta.draw(sc, r)
1114}
1115
1116func (c *cartOverlay) key(u *ui, ev *tcell.EventKey) bool {
1117 switch keyRune(ev) {
1118 case 'x':
1119 u.cartQty = map[string]int{}
1120 u.cartOrder = nil
1121 u.shipping = nil
1122 u.openCart()
1123 return true
1124 case 's':
1125 u.openShipping()
1126 return true
1127 case 'p':
1128 u.startCheckout()
1129 return true
1130 }
1131 return false
1132}
1133
1134// ---- the shipping form, from footer.html's Add Shipping Info ----
1135
1136type shippingOverlay struct {
1137 frm *form
1138 h int
1139}
1140
1141func (s *shippingOverlay) title() string { return "Add Shipping Info" }
1142func (s *shippingOverlay) size() (int, int) { return 62, s.h }
1143func (s *shippingOverlay) draw(u *ui, sc tcell.Screen, r rect) { s.frm.draw(sc, r) }
1144func (s *shippingOverlay) key(u *ui, ev *tcell.EventKey) bool { return s.frm.key(ev) }
1145
1146func (u *ui) openShipping() {
1147 prev := u.shipping
1148 if prev == nil {
1149 prev = &shippingLine{Cents: 700, Country: "United States"}
1150 }
1151 amount := &finput{lbl: "Amount ($, min 7):", text: []rune(fmt.Sprintf("%d.%02d", prev.Cents/100, prev.Cents%100)), width: 10}
1152 amount.cur = len(amount.text)
1153 mkInput := func(lbl, val string, w int) *finput {
1154 in := &finput{lbl: lbl, text: []rune(val), width: w}
1155 in.cur = len(in.text)
1156 return in
1157 }
1158 name := mkInput("Name:", prev.Name, 38)
1159 address := mkInput("Address:", prev.Address, 38)
1160 city := mkInput("City:", prev.City, 28)
1161 state := &fdropdown{lbl: "State (β βΈ or type):", opts: usStates}
1162 for i, s := range usStates {
1163 if s == prev.State {
1164 state.sel = i
1165 }
1166 }
1167 zip := mkInput("ZIP Code:", prev.Zip, 8)
1168 country := &fdropdown{lbl: "Country:", opts: []string{"United States"}}
1169 phone := mkInput("Phone Number:", prev.Phone, 12)
1170
1171 items := []formItem{amount, name, address, city, state, zip, country, phone}
1172 // Declared separately on purpose: the buttons below close over frm, so it
1173 // has to exist before newForm is called.
1174 var frm *form //nolint:staticcheck
1175 frm = newForm(items, []fbutton{
1176 {"Add Shipping to Cart", func() {
1177 cents := priceCents(amount.value())
1178 if cents < 700 {
1179 u.notice = "shipping is at least $7.00"
1180 return
1181 }
1182 z := zip.value()
1183 if z != "" && !zipRe.MatchString(z) {
1184 u.notice = "ZIP code must be 5 digits"
1185 return
1186 }
1187 u.shipping = &shippingLine{
1188 Cents: cents, Name: name.value(), Address: address.value(), City: city.value(),
1189 State: state.value(), Zip: z, Country: country.value(), Phone: phone.value(),
1190 }
1191 u.openCart()
1192 }},
1193 {"Cancel", func() { u.openCart() }},
1194 }, func() { u.openCart() })
1195 u.commerce = &shippingOverlay{frm: frm, h: len(items)*2 + 3}
1196}
1197
1198// ---- checkout: the cart wasm's flow, against the same server ----
1199
1200type checkoutOverlay struct {
1201 ta *textArea
1202}
1203
1204func (c *checkoutOverlay) title() string { return "Checkout" }
1205func (c *checkoutOverlay) size() (int, int) { return 78, 14 }
1206func (c *checkoutOverlay) draw(u *ui, sc tcell.Screen, r rect) { c.ta.draw(sc, r) }
1207func (c *checkoutOverlay) key(u *ui, ev *tcell.EventKey) bool { return c.ta.key(ev) }
1208
1209// ordersURL is where checkout is served from: the store being browsed
1210// in client mode, else SITEORDERSURL when the config names one (as the
1211// website's cart uses it), else the local store server.
1212func ordersURL() string {
1213 if s := storeURL(); s != "" {
1214 return s
1215 }
1216 if f.Siteordersurl != "" {
1217 return f.Siteordersurl
1218 }
1219 port := f.WebPort
1220 if port == 0 {
1221 port = 9883
1222 }
1223 return fmt.Sprintf("http://127.0.0.1:%d", port)
1224}
1225
1226// cartItem matches the JSON the cart wasm POSTs to /create-payment-intent.
1227type cartItem struct {
1228 ID string `json:"ID"`
1229 Amount int64 `json:"Amount"`
1230}
1231
1232func (u *ui) startCheckout() {
1233 if len(u.cartOrder) == 0 {
1234 u.notice = "the cart is empty"
1235 return
1236 }
1237 if u.shipping == nil {
1238 u.notice = "add shipping info first (s in the cart)"
1239 return
1240 }
1241
1242 // The same payload the cart wasm sends: one line per product as
1243 // "partno X qty" with the line total, plus the shipping line.
1244 var items []cartItem
1245 for _, partno := range u.cartOrder {
1246 qty := u.cartQty[partno]
1247 p := u.cat.find(partno)
1248 if qty == 0 || p == nil {
1249 continue
1250 }
1251 items = append(items, cartItem{
1252 ID: fmt.Sprintf("%s X %d", partno, qty),
1253 Amount: int64(priceCents(p.Price) * qty),
1254 })
1255 }
1256 items = append(items, cartItem{ID: u.shipping.id(), Amount: int64(u.shipping.Cents)})
1257
1258 co := &checkoutOverlay{ta: newTextArea("\n contacting "+esc(ordersURL())+" β¦", true)}
1259 u.commerce = co
1260
1261 go func() {
1262 clientSecret, err := createPaymentIntent(items)
1263 u.post(func() {
1264 if u.commerce != co {
1265 return // the overlay was closed meanwhile
1266 }
1267 if err != nil {
1268 co.ta.setContent(fmt.Sprintf(
1269 "\n [red]could not create the payment:[-] %s\n\n [gray]is the store server running? (%s)\n Esc closes β the cart is kept[-]\n",
1270 esc(err.Error()), esc(ordersURL())))
1271 return
1272 }
1273 u.payWithElements(clientSecret, co.ta.setContent)
1274 })
1275 }()
1276}
1277
1278// createPaymentIntent POSTs the cart to the store server, which
1279// validates every line against the catalog and answers with the payment
1280// intent's client secret β identical to the cart wasm's fetch.
1281func createPaymentIntent(items []cartItem) (string, error) {
1282 body, err := json.Marshal(map[string]interface{}{"items": items})
1283 if err != nil {
1284 return "", err
1285 }
1286 client := &http.Client{Timeout: 15 * time.Second}
1287 resp, err := client.Post(ordersURL()+"/create-payment-intent", "application/json", bytes.NewReader(body))
1288 if err != nil {
1289 return "", err
1290 }
1291 defer resp.Body.Close() //nolint:errcheck // read-side close
1292 data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
1293 if err != nil {
1294 return "", err
1295 }
1296 var out struct {
1297 ClientSecret string `json:"clientSecret"`
1298 Error string `json:"error"`
1299 }
1300 if err := json.Unmarshal(data, &out); err != nil {
1301 return "", fmt.Errorf("bad response (%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
1302 }
1303 if out.Error != "" {
1304 return "", fmt.Errorf("%s", out.Error)
1305 }
1306 if out.ClientSecret == "" {
1307 return "", fmt.Errorf("no client secret in response (%d)", resp.StatusCode)
1308 }
1309 return out.ClientSecret, nil
1310}
1311
1312
1313// ===== pkg/tui/frame_dump_test.go =====
1314package tui
1315
1316import (
1317 "image/png"
1318 "os"
1319 "testing"
1320)
1321
1322func TestDumpFrame(t *testing.T) {
1323 out := os.Getenv("FRAMEOUT")
1324 if out == "" {
1325 t.Skip("set FRAMEOUT to dump a frame")
1326 }
1327 logo, err := loadImage(os.Getenv("FRAMELOGO"))
1328 if err != nil {
1329 t.Fatal(err)
1330 }
1331 bd := makeBackdrop(logo, 200, 100)
1332 fr := globeFrame(bd, 0.9, 2.3, -0.6)
1333 f, err := os.Create(out) //nolint:gosec
1334 if err != nil {
1335 t.Fatalf("create %s: %v", out, err)
1336 }
1337 defer f.Close() //nolint:errcheck,gosec
1338 if err := png.Encode(f, fr); err != nil {
1339 t.Fatal(err)
1340 }
1341}
1342
1343
1344// ===== pkg/tui/kit.go =====
1345// Package tui pkg/tui/kit.go β drawing primitives on tcell: a small
1346// color-tag markup ([fg:bg:flags], as tview popularized), line printing
1347// with alignment and wrapping, and bordered boxes. The TUI is written
1348// straight on tcell v3 so it runs wherever the 0magnet ecosystem's
1349// terminal stack does β natively today, in the browser via
1350// tuiwasm/xtcell tomorrow.
1351package tui
1352
1353import (
1354 "strings"
1355
1356 "github.com/gdamore/tcell/v3"
1357)
1358
1359type rect struct{ x, y, w, h int }
1360
1361// esc quotes user data so it never parses as a color tag: "[" doubles,
1362// and the parser reads "[[" back as one literal bracket.
1363func esc(s string) string {
1364 return strings.ReplaceAll(s, "[", "[[")
1365}
1366
1367// seg is a run of text in one style.
1368type seg struct {
1369 txt []rune
1370 st tcell.Style
1371}
1372
1373// namedColors are the palette names the markup uses; anything else goes
1374// to tcell.GetColor (which handles #rrggbb and the W3C names).
1375var namedColors = map[string]tcell.Color{
1376 "aqua": tcell.ColorAqua,
1377 "white": tcell.ColorWhite,
1378 "black": tcell.ColorBlack,
1379 "gray": tcell.GetColor("#808080"),
1380 "grey": tcell.GetColor("#808080"),
1381 "yellow": tcell.ColorYellow,
1382 "red": tcell.ColorRed,
1383 "green": tcell.ColorGreen,
1384 "orange": tcell.GetColor("#ffa500"),
1385}
1386
1387func markupColor(name string) (tcell.Color, bool) {
1388 if c, ok := namedColors[name]; ok {
1389 return c, true
1390 }
1391 c := tcell.GetColor(name)
1392 if c == tcell.ColorDefault && name != "default" {
1393 return c, false
1394 }
1395 return c, true
1396}
1397
1398// tagBody reports whether the text between brackets looks like a color
1399// tag: colors and flags only, at most two colons.
1400func tagBody(s string) bool {
1401 if s == "" {
1402 return false
1403 }
1404 colons := 0
1405 for _, r := range s {
1406 switch {
1407 case r == ':':
1408 colons++
1409 case r == '#' || r == '-':
1410 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
1411 default:
1412 return false
1413 }
1414 }
1415 return colons <= 2
1416}
1417
1418// applyTag folds one [fg:bg:flags] spec into a style. Empty parts keep,
1419// "-" resets to the base.
1420func applyTag(cur, base tcell.Style, body string) tcell.Style {
1421 parts := strings.SplitN(body, ":", 3)
1422 if len(parts) > 0 && parts[0] != "" {
1423 if parts[0] == "-" {
1424 cur = cur.Foreground(base.GetForeground())
1425 } else if c, ok := markupColor(parts[0]); ok {
1426 cur = cur.Foreground(c)
1427 }
1428 }
1429 if len(parts) > 1 && parts[1] != "" {
1430 if parts[1] == "-" {
1431 cur = cur.Background(base.GetBackground())
1432 } else if c, ok := markupColor(parts[1]); ok {
1433 cur = cur.Background(c)
1434 }
1435 }
1436 if len(parts) > 2 {
1437 if parts[2] == "-" || parts[2] == "" {
1438 cur = cur.Attributes(base.GetAttributes())
1439 } else {
1440 for _, f := range parts[2] {
1441 switch f {
1442 case 'b':
1443 cur = cur.Bold(true)
1444 case 'u':
1445 cur = cur.Underline(true)
1446 }
1447 }
1448 }
1449 }
1450 return cur
1451}
1452
1453// parseMarkup splits a tagged string into styled segments.
1454func parseMarkup(s string, base tcell.Style) []seg {
1455 var out []seg
1456 cur := base
1457 var run []rune
1458 flush := func() {
1459 if len(run) > 0 {
1460 out = append(out, seg{run, cur})
1461 run = nil
1462 }
1463 }
1464 r := []rune(s)
1465 for i := 0; i < len(r); i++ {
1466 if r[i] != '[' {
1467 run = append(run, r[i])
1468 continue
1469 }
1470 if i+1 < len(r) && r[i+1] == '[' { // escaped literal bracket
1471 run = append(run, '[')
1472 i++
1473 continue
1474 }
1475 end := -1
1476 for j := i + 1; j < len(r) && j < i+24; j++ {
1477 if r[j] == ']' {
1478 end = j
1479 break
1480 }
1481 }
1482 if end < 0 || !tagBody(string(r[i+1:end])) {
1483 run = append(run, r[i])
1484 continue
1485 }
1486 flush()
1487 cur = applyTag(cur, base, string(r[i+1:end]))
1488 i = end
1489 }
1490 flush()
1491 return out
1492}
1493
1494// markupWidth is the printed width of a tagged string.
1495func markupWidth(s string) int {
1496 n := 0
1497 for _, sg := range parseMarkup(s, tcell.StyleDefault) {
1498 n += len(sg.txt)
1499 }
1500 return n
1501}
1502
1503// printSegs draws segments on one row, clipped to maxW; returns cells drawn.
1504func printSegs(sc tcell.Screen, x, y, maxW int, segs []seg) int {
1505 n := 0
1506 for _, sg := range segs {
1507 for _, r := range sg.txt {
1508 if n >= maxW {
1509 return n
1510 }
1511 sc.SetContent(x+n, y, r, nil, sg.st)
1512 n++
1513 }
1514 }
1515 return n
1516}
1517
1518// printMarkup draws one tagged line, clipped to maxW.
1519// The width it returns mirrors printSegs, which callers of that do use.
1520func printMarkup(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style) int { //nolint:unparam
1521 return printSegs(sc, x, y, maxW, parseMarkup(s, base))
1522}
1523
1524// printMarkupCenter draws one tagged line centered in w columns.
1525func printMarkupCenter(sc tcell.Screen, x, y, w int, s string, base tcell.Style) {
1526 pad := (w - markupWidth(s)) / 2
1527 if pad < 0 {
1528 pad = 0
1529 }
1530 printMarkup(sc, x+pad, y, w-pad, s, base)
1531}
1532
1533// wrapSegs word-wraps parsed lines to width w.
1534func wrapSegs(s string, w int, base tcell.Style, wrap bool) [][]seg {
1535 if w < 1 {
1536 w = 1
1537 }
1538 var out [][]seg
1539 for _, line := range strings.Split(s, "\n") {
1540 segs := parseMarkup(line, base)
1541 if !wrap {
1542 out = append(out, segs)
1543 continue
1544 }
1545 // Flatten to styled runes, then break into rows at spaces.
1546 var flat []styledRune
1547 for _, sg := range segs {
1548 for _, r := range sg.txt {
1549 flat = append(flat, styledRune{r, sg.st})
1550 }
1551 }
1552 for len(flat) > w {
1553 cut := -1
1554 for i := w; i > 0; i-- {
1555 if flat[i].r == ' ' {
1556 cut = i
1557 break
1558 }
1559 }
1560 if cut <= 0 {
1561 cut = w
1562 }
1563 out = append(out, packSegs(flat[:cut]))
1564 for cut < len(flat) && flat[cut].r == ' ' {
1565 cut++
1566 }
1567 flat = flat[cut:]
1568 }
1569 out = append(out, packSegs(flat))
1570 }
1571 return out
1572}
1573
1574type styledRune struct {
1575 r rune
1576 st tcell.Style
1577}
1578
1579// keyRune reads the rune of a KeyRune event (v3 carries it as Str).
1580func keyRune(ev *tcell.EventKey) rune {
1581 if ev.Key() != tcell.KeyRune {
1582 return 0
1583 }
1584 rs := []rune(ev.Str())
1585 if len(rs) == 0 {
1586 return 0
1587 }
1588 return rs[0]
1589}
1590
1591func packSegs(flat []styledRune) []seg {
1592 var segs []seg
1593 for _, c := range flat {
1594 if n := len(segs); n > 0 && segs[n-1].st == c.st {
1595 segs[n-1].txt = append(segs[n-1].txt, c.r)
1596 } else {
1597 segs = append(segs, seg{[]rune{c.r}, c.st})
1598 }
1599 }
1600 return segs
1601}
1602
1603// fillRect paints a rectangle with spaces in the given style.
1604func fillRect(sc tcell.Screen, r rect, st tcell.Style) {
1605 for y := r.y; y < r.y+r.h; y++ {
1606 for x := r.x; x < r.x+r.w; x++ {
1607 sc.SetContent(x, y, ' ', nil, st)
1608 }
1609 }
1610}
1611
1612// drawRule draws a horizontal rule, the terminal stand-in for the 1px
1613// table borders that close the page's header and footer.
1614func drawRule(sc tcell.Screen, x, y, w int, st tcell.Style) {
1615 for i := 0; i < w; i++ {
1616 sc.SetContent(x+i, y, 'β', nil, st)
1617 }
1618}
1619
1620// drawBox fills and frames a rectangle, with an optional title in the
1621// top border.
1622func drawBox(sc tcell.Screen, r rect, title string, border, fill tcell.Style) {
1623 if r.w < 2 || r.h < 2 {
1624 return
1625 }
1626 fillRect(sc, r, fill)
1627 for x := r.x + 1; x < r.x+r.w-1; x++ {
1628 sc.SetContent(x, r.y, 'β', nil, border)
1629 sc.SetContent(x, r.y+r.h-1, 'β', nil, border)
1630 }
1631 for y := r.y + 1; y < r.y+r.h-1; y++ {
1632 sc.SetContent(r.x, y, 'β', nil, border)
1633 sc.SetContent(r.x+r.w-1, y, 'β', nil, border)
1634 }
1635 sc.SetContent(r.x, r.y, 'β', nil, border)
1636 sc.SetContent(r.x+r.w-1, r.y, 'β', nil, border)
1637 sc.SetContent(r.x, r.y+r.h-1, 'β', nil, border)
1638 sc.SetContent(r.x+r.w-1, r.y+r.h-1, 'β', nil, border)
1639 if title != "" {
1640 printMarkup(sc, r.x+2, r.y, r.w-4, title, fill)
1641 }
1642}
1643
1644// inner is the area inside a box's border.
1645func (r rect) inner() rect {
1646 return rect{r.x + 1, r.y + 1, r.w - 2, r.h - 2}
1647}
1648
1649func (r rect) contains(x, y int) bool {
1650 return x >= r.x && y >= r.y && x < r.x+r.w && y < r.y+r.h
1651}
1652
1653
1654// ===== pkg/tui/pay_js.go =====
1655//go:build js && wasm
1656
1657// Package tui pkg/tui/pay_js.go β the browser half of the payment seam.
1658// This is where the Stripe Payment Element mounts over the terminal,
1659// exactly as the website's cart wasm mounts it over the page
1660// (wasm/cart/checkout.go holds the DOM/Stripe.js plumbing to relocate
1661// here). Until that lands, the browser build reports the gap instead of
1662// failing to compile.
1663package tui
1664
1665func (u *ui) payWithElements(clientSecret string, set func(string)) {
1666 _ = clientSecret
1667 set("\n [green]the store server accepted the cart[-] and created the\n payment intent.\n\n" +
1668 " [yellow]the Stripe Payment Element mount is not wired up in this build\n" +
1669 " yet[-] β complete this order at [aqua]https://" + esc(u.sitedomain) + "[-]\n")
1670}
1671
1672
1673// ===== pkg/tui/pay_native.go =====
1674//go:build !js || !wasm
1675
1676// Package tui pkg/tui/pay_native.go β the native half of the payment
1677// seam. Card details can never be typed into a terminal (they would
1678// pass through this program, which PCI forbids); the browser build of
1679// this TUI supplies the other half in pay_js.go, mounting the Stripe
1680// Payment Element over the terminal exactly as the website's cart wasm
1681// mounts it over the page. Until then the native flow proves the whole
1682// pipeline β cart, shipping, server validation, payment intent β and
1683// says where the last step lives.
1684package tui
1685
1686// payWithElements takes over the checkout dialog once the store server
1687// has created the payment intent for this cart.
1688func (u *ui) payWithElements(clientSecret string, set func(string)) {
1689 preview := clientSecret
1690 if len(preview) > 12 {
1691 preview = preview[:12] + "β¦"
1692 }
1693 set("\n [green]the store server accepted the cart[-] and created the payment\n intent (" +
1694 esc(preview) + ").\n\n" +
1695 " [yellow]card entry needs the Stripe Payment Element[-], which a terminal\n" +
1696 " cannot host. In the browser build of this TUI it opens right here,\n" +
1697 " over the terminal β in a native terminal, complete this order at\n" +
1698 " [aqua]https://" + esc(u.sitedomain) + "[-]\n\n" +
1699 " [gray]Esc closes β the cart is kept[-]\n")
1700}
1701
1702
1703// ===== pkg/tui/remote.go =====
1704// Package tui pkg/tui/remote.go β the TUI as a store client. With
1705// STOREURL set (m2 tui --storeurl https://magnetosphere.net) nothing is
1706// read from disk: the catalog comes from /api/products, images from /i,
1707// the logo from /logo.jpg, and checkout from the same origin β exactly
1708// what the browser build of this TUI will do from the page the store
1709// serves it on. Without it, the TUI reads the deployment's own files as
1710// before.
1711package tui
1712
1713import (
1714 "encoding/json"
1715 "fmt"
1716 "io"
1717 "net/http"
1718 "net/url"
1719 "os"
1720 "strings"
1721 "sync"
1722 "time"
1723
1724 "github.com/0magnet/m2/pkg/product"
1725)
1726
1727// storeURL is the remote store, normalized; empty means local files.
1728func storeURL() string {
1729 return strings.TrimRight(f.Storeurl, "/")
1730}
1731
1732// FetchCatalog loads the product catalog from a store's /api/products.
1733func FetchCatalog(store string) (product.Products, error) {
1734 client := &http.Client{Timeout: 30 * time.Second}
1735 resp, err := client.Get(strings.TrimRight(store, "/") + "/api/products")
1736 if err != nil {
1737 return nil, err
1738 }
1739 defer resp.Body.Close() //nolint:errcheck // read-side close
1740 if resp.StatusCode != http.StatusOK {
1741 return nil, fmt.Errorf("%s from %s/api/products", resp.Status, store)
1742 }
1743 var prods product.Products
1744 if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&prods); err != nil {
1745 return nil, err
1746 }
1747 return prods, nil
1748}
1749
1750// FetchSite fills the site identity (name, tagline, telegram links) from
1751// a store's /api/site β the browser build has no MENV file to source.
1752func FetchSite(store string) error {
1753 client := &http.Client{Timeout: 15 * time.Second}
1754 resp, err := client.Get(strings.TrimRight(store, "/") + "/api/site")
1755 if err != nil {
1756 return err
1757 }
1758 defer resp.Body.Close() //nolint:errcheck // read-side close
1759 if resp.StatusCode != http.StatusOK {
1760 return fmt.Errorf("%s from %s/api/site", resp.Status, store)
1761 }
1762 var site struct {
1763 Sitename, Siteext, Sitelongname, Sitetagline string
1764 Tgcontact, Tgchannel string
1765 Teststripekey bool
1766 Sitemeta, Sitedomain string
1767 Siteprettyname, Siteprettynamecap string
1768 Siteprettynamecaps, SiteASCIILogo string
1769 Stripepk string
1770 }
1771 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&site); err != nil {
1772 return err
1773 }
1774 f.Sitename, f.Siteext = site.Sitename, site.Siteext
1775 f.Sitelongname, f.Sitetagline = site.Sitelongname, site.Sitetagline
1776 f.Tgcontact, f.Tgchannel = site.Tgcontact, site.Tgchannel
1777 f.Teststripekey = site.Teststripekey
1778 f.Sitemeta, f.Sitedomain = site.Sitemeta, site.Sitedomain
1779 f.Siteprettyname, f.Siteprettynamecap = site.Siteprettyname, site.Siteprettynamecap
1780 f.Siteprettynamecaps, f.SiteASCIILogo = site.Siteprettynamecaps, site.SiteASCIILogo
1781 if site.Stripepk != "" {
1782 f.StripePK = site.Stripepk
1783 }
1784 return nil
1785}
1786
1787// fetchContent reads a stock page (about/policy/links): from the store's
1788// /api/content in client mode, from the deployment's content/ otherwise.
1789func fetchContent(name string) string {
1790 s := storeURL()
1791 if s == "" {
1792 return contentFile("content/" + name + ".html")
1793 }
1794 client := &http.Client{Timeout: 15 * time.Second}
1795 resp, err := client.Get(s + "/api/content/" + url.PathEscape(name))
1796 if err != nil {
1797 return ""
1798 }
1799 defer resp.Body.Close() //nolint:errcheck // read-side close
1800 if resp.StatusCode != http.StatusOK {
1801 return ""
1802 }
1803 data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
1804 if err != nil {
1805 return ""
1806 }
1807 return string(data)
1808}
1809
1810// imageCache holds fetched remote images; the render worker re-reads an
1811// image at every pane size, and the network should pay only once.
1812var (
1813 imageCacheMu sync.Mutex
1814 imageCache = map[string][]byte{}
1815)
1816
1817// getImageData reads an image by path or URL.
1818func getImageData(path string) ([]byte, error) {
1819 if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
1820 return os.ReadFile(path) //nolint:gosec // paths come from the catalog, not the user
1821 }
1822 imageCacheMu.Lock()
1823 data, ok := imageCache[path]
1824 imageCacheMu.Unlock()
1825 if ok {
1826 return data, nil
1827 }
1828 client := &http.Client{Timeout: 20 * time.Second}
1829 // Fetching the URL it was asked for is the point of this function.
1830 resp, err := client.Get(path) //nolint:gosec
1831 if err != nil {
1832 return nil, err
1833 }
1834 defer resp.Body.Close() //nolint:errcheck // read-side close
1835 if resp.StatusCode != http.StatusOK {
1836 return nil, fmt.Errorf("%s", resp.Status)
1837 }
1838 data, err = io.ReadAll(io.LimitReader(resp.Body, 16<<20))
1839 if err != nil {
1840 return nil, err
1841 }
1842 imageCacheMu.Lock()
1843 if len(imageCache) > 64 {
1844 imageCache = map[string][]byte{}
1845 }
1846 imageCache[path] = data
1847 imageCacheMu.Unlock()
1848 return data, nil
1849}
1850
1851
1852// ===== pkg/tui/render.go =====
1853// Package tui pkg/tui/render.go β images, the globe, and text conversion.
1854// Everything renders to cells: product photos become pixel buffers
1855// painted as β half-blocks (or libcaca canvases blitted glyph by glyph),
1856// and tcell diffs frames so only changed cells reach the terminal.
1857package tui
1858
1859import (
1860 "bytes"
1861 "fmt"
1862 "image"
1863 "image/color"
1864 _ "image/gif"
1865 _ "image/jpeg"
1866 _ "image/png"
1867 "net/url"
1868 "os"
1869 "path/filepath"
1870 "regexp"
1871 "strings"
1872
1873 xhtml "html"
1874
1875 "github.com/gdamore/tcell/v3"
1876 "golang.org/x/image/draw"
1877
1878 "github.com/0magnet/chaosrack/pkg/geom"
1879 "github.com/0magnet/chaosrack/pkg/rasterview"
1880 "github.com/0magnet/img2txt-go/caca"
1881
1882 "github.com/0magnet/m2/pkg/product"
1883)
1884
1885// imgMode selects how product photos are painted.
1886type imgMode int
1887
1888const (
1889 modeHalfBlock imgMode = iota // truecolor, two pixels per cell via β
1890 modeCaca // libcaca ANSI art, as /logo renders it
1891)
1892
1893// artwork is a rendered product photo: a pixel buffer (half-block mode),
1894// a cell canvas (caca mode), or a message when there is nothing to show.
1895type artwork struct {
1896 rgba *image.RGBA
1897 cells *cellArt
1898 msg string
1899}
1900
1901// cellArt is a libcaca canvas as terminal cells.
1902type cellArt struct {
1903 w, h int
1904 ch []rune
1905 st []tcell.Style
1906}
1907
1908// imagePath resolves a product photo the way the templates do:
1909// img/<category>/<image1> (from the store's /i in client mode), falling
1910// back locally to img/<image1>. A nil product (nothing selected yet β
1911// an empty table's preview) has no image.
1912func imagePath(p *product.Product) string {
1913 if p == nil || p.Image1 == "" {
1914 return ""
1915 }
1916 if s := storeURL(); s != "" {
1917 return s + "/i/" + url.PathEscape(p.Category) + "/" + url.PathEscape(p.Image1)
1918 }
1919 for _, path := range []string{
1920 filepath.Join("img", p.Category, p.Image1),
1921 filepath.Join("img", p.Image1),
1922 } {
1923 if _, err := os.Stat(path); err == nil {
1924 return path
1925 }
1926 }
1927 return ""
1928}
1929
1930func loadImage(path string) (image.Image, error) {
1931 data, err := getImageData(path)
1932 if err != nil {
1933 return nil, err
1934 }
1935 img, _, err := image.Decode(bytes.NewReader(data))
1936 return img, err
1937}
1938
1939// scaleToFit scales img into a pixel buffer no larger than maxW x maxH,
1940// preserving aspect. Half-block pixels are roughly square.
1941func scaleToFit(img image.Image, maxW, maxH int) *image.RGBA {
1942 b := img.Bounds()
1943 pw, ph := b.Dx(), b.Dy()
1944 if pw*maxH > ph*maxW {
1945 ph = ph * maxW / pw
1946 pw = maxW
1947 } else {
1948 pw = pw * maxH / ph
1949 ph = maxH
1950 }
1951 if pw < 1 {
1952 pw = 1
1953 }
1954 if ph < 1 {
1955 ph = 1
1956 }
1957 dst := image.NewRGBA(image.Rect(0, 0, pw, ph))
1958 draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
1959 return dst
1960}
1961
1962// renderArt produces a product photo sized to fit cols x rows cells.
1963func renderArt(path string, cols, rows int, mode imgMode) artwork {
1964 if path == "" {
1965 return artwork{msg: "no image"}
1966 }
1967 if cols < 2 || rows < 2 {
1968 return artwork{msg: "pane too small"}
1969 }
1970 if mode == modeCaca {
1971 cells, err := renderCaca(path, cols, rows)
1972 if err != nil {
1973 return artwork{msg: err.Error()}
1974 }
1975 return artwork{cells: cells}
1976 }
1977 img, err := loadImage(path)
1978 if err != nil {
1979 return artwork{msg: err.Error()}
1980 }
1981 if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
1982 return artwork{msg: "empty image"}
1983 }
1984 return artwork{rgba: scaleToFit(img, cols, rows*2)}
1985}
1986
1987// renderCaca draws an image as libcaca ANSI art β the same renderer
1988// behind the /logo endpoint, via the pure-Go img2txt port β and reads
1989// the canvas cells straight into terminal cells.
1990func renderCaca(path string, cols, rows int) (*cellArt, error) {
1991 data, err := getImageData(path)
1992 if err != nil {
1993 return nil, err
1994 }
1995 im, err := caca.DecodeImage(bytes.NewReader(data))
1996 if err != nil {
1997 return nil, err
1998 }
1999 if im.W == 0 || im.H == 0 {
2000 return nil, fmt.Errorf("bad dimensions")
2001 }
2002 // img2txt's 6x10 font-cell aspect, constrained by both axes.
2003 lines := cols * im.H * 6 / im.W / 10
2004 if lines > rows {
2005 lines = rows
2006 cols = lines * im.W * 10 / im.H / 6
2007 }
2008 if lines < 1 || cols < 1 {
2009 return nil, fmt.Errorf("pane too small")
2010 }
2011 cv := caca.NewCanvas(cols, lines)
2012 cv.SetColorANSI(caca.Default, caca.Transparent)
2013 cv.Clear()
2014 im.Dither.SetAlgorithm("fstein")
2015 cv.DitherBitmap(0, 0, cols, lines, im.Dither, im.Pixels)
2016
2017 art := &cellArt{w: cv.Width, h: cv.Height,
2018 ch: make([]rune, cv.Width*cv.Height), st: make([]tcell.Style, cv.Width*cv.Height)}
2019 for i, ch := range cv.Chars {
2020 art.ch[i] = ch
2021 art.st[i] = tcell.StyleDefault.
2022 Foreground(rgb12(caca.AttrToRGB12Fg(cv.Attrs[i]))).
2023 Background(rgb12(caca.AttrToRGB12Bg(cv.Attrs[i])))
2024 }
2025 return art, nil
2026}
2027
2028// rgb12 expands libcaca's 12-bit 0xRGB into a tcell color.
2029func rgb12(v uint16) tcell.Color {
2030 return tcell.NewRGBColor(
2031 int32((v>>8&0xF)*17), int32((v>>4&0xF)*17), int32((v&0xF)*17))
2032}
2033
2034// drawArtwork paints a rendered photo centered in r.
2035func drawArtwork(sc tcell.Screen, r rect, art *artwork) {
2036 switch {
2037 case art == nil:
2038 case art.rgba != nil:
2039 rows := (art.rgba.Bounds().Dy() + 1) / 2
2040 yoff := (r.h - rows) / 2
2041 if yoff < 0 {
2042 yoff = 0
2043 }
2044 drawRGBA(sc, r.x, r.y+yoff, r.w, r.h-yoff, art.rgba)
2045 case art.cells != nil:
2046 xoff := (r.w - art.cells.w) / 2
2047 yoff := (r.h - art.cells.h) / 2
2048 if xoff < 0 {
2049 xoff = 0
2050 }
2051 if yoff < 0 {
2052 yoff = 0
2053 }
2054 for y := 0; y < art.cells.h && y < r.h; y++ {
2055 for x := 0; x < art.cells.w && x < r.w; x++ {
2056 i := y*art.cells.w + x
2057 sc.SetContent(r.x+xoff+x, r.y+yoff+y, art.cells.ch[i], nil, art.cells.st[i])
2058 }
2059 }
2060 case art.msg != "":
2061 printMarkupCenter(sc, r.x, r.y+r.h/2, r.w, "[gray]"+esc(art.msg)+"[-]", styleText)
2062 }
2063}
2064
2065// The home panel: a wireframe globe turning over the site logo, as the
2066// website's landing view renders its wasm globe over the svg logo.
2067
2068// makeBackdrop scales the logo to one panel size.
2069func makeBackdrop(logo image.Image, cols, rows int) *image.RGBA {
2070 if logo == nil || cols <= 0 || rows <= 0 {
2071 if cols < 1 {
2072 cols = 1
2073 }
2074 if rows < 1 {
2075 rows = 1
2076 }
2077 return image.NewRGBA(image.Rect(0, 0, cols, rows*2))
2078 }
2079 // Full brightness, as the website shows it: the svg behind the canvas
2080 // is not dimmed there, so its white stays white here too. The globe
2081 // reads on top by its own saturation, the way it does on the page.
2082 return scaleToFit(logo, cols, rows*2)
2083}
2084
2085// globeLines is the globe the website's attractor engine uploads to
2086// WebGL β same geometry, from the same package.
2087var globeLines = geom.Globe(18, 36, 60)
2088
2089// globeFrame composites one animation frame: the dimmed logo with the
2090// chaosrack globe drawn over it by chaosrack's own software renderer, in
2091// its default look β random pose, redβblue gradient along model z (see
2092// rasterview.DefaultGradient). BackDim is the one departure from the
2093// WebGL look: an unantialiased raster needs the depth cue.
2094func globeFrame(backdrop *image.RGBA, ax, ay, az float64) *image.RGBA {
2095 frame := image.NewRGBA(backdrop.Bounds())
2096 copy(frame.Pix, backdrop.Pix)
2097 view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: 3.2, BackDim: 0.5}
2098 view.Render(frame, globeLines.Vertices, globeLines.Indices, rasterview.DefaultGradient())
2099 return frame
2100}
2101
2102// drawRGBA paints a pixel buffer into terminal cells at x0,y0, two pixels
2103// per cell via β, centered in cols columns. Cells unchanged since the
2104// last frame cost nothing: tcell diffs them away.
2105func drawRGBA(sc tcell.Screen, x0, y0, cols, maxRows int, img *image.RGBA) {
2106 pw, ph := img.Bounds().Dx(), img.Bounds().Dy()
2107 xoff := 0
2108 if cols > pw {
2109 xoff = (cols - pw) / 2
2110 }
2111 for y := 0; y < ph; y += 2 {
2112 if y/2 >= maxRows {
2113 break
2114 }
2115 for x := 0; x < pw; x++ {
2116 if xoff+x >= cols {
2117 break
2118 }
2119 top := img.RGBAAt(x, y)
2120 var bot color.RGBA
2121 if y+1 < ph {
2122 bot = img.RGBAAt(x, y+1)
2123 }
2124 st := tcell.StyleDefault.
2125 Foreground(tcell.NewRGBColor(step16(top.R), step16(top.G), step16(top.B))).
2126 Background(tcell.NewRGBColor(step16(bot.R), step16(bot.G), step16(bot.B)))
2127 sc.SetContent(x0+xoff+x, y0+y/2, 'β', nil, st)
2128 }
2129 }
2130}
2131
2132var (
2133 tmplAction = regexp.MustCompile(`\{\{[^}]*\}\}`)
2134 tagAnchor = regexp.MustCompile(`(?is)<a\b[^>]*>(.*?)</a>`)
2135 tagBreak = regexp.MustCompile(`(?i)<br\s*/?>`)
2136 tagBlock = regexp.MustCompile(`(?i)</(p|h1|h2|h3|div|tr|li|ul|table|pre)>`)
2137 tagHeading = regexp.MustCompile(`(?is)<h[1-3][^>]*>(.*?)</h[1-3]>`)
2138 tagLi = regexp.MustCompile(`(?i)<li[^>]*>`)
2139 tagCell = regexp.MustCompile(`(?i)</t[dh]>`)
2140 tagAny = regexp.MustCompile(`(?s)<[^>]*>`)
2141 manyBlank = regexp.MustCompile(`\n{3,}`)
2142)
2143
2144// htmlToText renders a stock-page fragment (about/policy/links) as
2145// terminal text: links cyan like the site, headings bold, tags dropped.
2146func htmlToText(s, year string) string {
2147 s = strings.ReplaceAll(s, "{{.Page.Year}}", year)
2148 s = tmplAction.ReplaceAllString(s, "")
2149 s = strings.ReplaceAll(s, "[", "[[") // user HTML must not become tags
2150 s = tagHeading.ReplaceAllString(s, "\n[::b]$1[-:-:-]\n")
2151 s = tagAnchor.ReplaceAllString(s, "[aqua]$1[-]")
2152 s = tagBreak.ReplaceAllString(s, "\n")
2153 s = tagLi.ReplaceAllString(s, " * ")
2154 s = tagCell.ReplaceAllString(s, " ")
2155 s = tagBlock.ReplaceAllString(s, "\n")
2156 s = tagAny.ReplaceAllString(s, "")
2157 s = xhtml.UnescapeString(s)
2158 s = manyBlank.ReplaceAllString(s, "\n\n")
2159 return strings.TrimSpace(s) + "\n"
2160}
2161
2162// step16 snaps a channel to 16 levels, spanning the full range: 0x00 stays
2163// 0x00 and 0xFF stays 0xFF, so the logo's black and white are untouched and
2164// only the interpolated greys move, by at most 1/32 of the range.
2165//
2166// This is a rendering cost, not an aesthetic choice. The WebGL renderer
2167// caches rasterised glyphs under a key that includes both colors, so a
2168// truecolor half-block is a distinct glyph per color pair. A full-screen
2169// gradient at ~11k cells hands it ~11k keys a frame against an atlas of
2170// ~18k slots; it fills, clears, and re-rasterises everything, every frame.
2171// Quantising collapses the key space enough for the cache to hold, which
2172// measured out at roughly half the CPU and less than half the worst-case
2173// frame time.
2174func step16(v uint8) int32 { return int32(v&0xF0 | v>>4) }
2175
2176
2177// ===== pkg/tui/tui.go =====
2178// Package tui pkg/tui/tui.go β a terminal view of the web store, derived
2179// from the website itself: the same masthead, the catsubcats navigation
2180// tree, one panel at a time like the front page's :target panels, the
2181// category tables, product pages in template order, the stock content
2182// pages, and the footer bar. The landing view is the site's landing
2183// view: a wireframe globe turning over the logo.
2184//
2185// Written directly on tcell v3 with the small widget kit in widgets.go β
2186// the same terminal stack as the rest of the 0magnet ecosystem, so the
2187// browser build can ride tuiwasm/xtcell.
2188package tui
2189
2190import (
2191 "embed"
2192 "fmt"
2193 "image"
2194 "math"
2195 "math/rand"
2196 "os"
2197 "strconv"
2198 "strings"
2199 "sync"
2200 "time"
2201
2202 "github.com/gdamore/tcell/v3"
2203
2204 "github.com/0magnet/calvin"
2205
2206 "github.com/0magnet/m2/pkg/config"
2207 "github.com/0magnet/m2/pkg/product"
2208)
2209
2210//go:embed *.go
2211var Source embed.FS
2212
2213// f aliases the shared configuration, as in pkg/web.
2214var f = &config.F
2215
2216// site styles, from content/style.css
2217var (
2218 styleText = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack)
2219 styleLink = styleText.Foreground(tcell.ColorAqua) // a{color:cyan}
2220 styleSel = tcell.StyleDefault.Foreground(tcell.GetColor("#ffa500")).Background(tcell.ColorWhite) // a.cur
2221 styleThead = styleText.Foreground(tcell.GetColor("#a020f0")).Bold(true).Underline(true) // thead border: purple
2222 styleBorder = styleText.Foreground(tcell.ColorBlue)
2223 styleField = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.GetColor("#000087"))
2224 styleButton = tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.GetColor("#005f87"))
2225
2226 // The header and footer tables are closed by a 1px blue border on the
2227 // page; their cell edges are white. Terminal rules stand in for both.
2228 ruleBlue = styleText.Foreground(tcell.GetColor("#0000ff"))
2229 ruleWhite = styleText.Foreground(tcell.ColorWhite)
2230)
2231
2232// panel is one of the site's :target panels.
2233type panel interface {
2234 draw(u *ui, sc tcell.Screen, r rect)
2235 key(u *ui, ev *tcell.EventKey) bool
2236 mouse(u *ui, r rect, ev *tcell.EventMouse) bool
2237}
2238
2239// overlayW is a commerce overlay (cart / shipping / checkout).
2240type overlayW interface {
2241 draw(u *ui, sc tcell.Screen, r rect)
2242 key(u *ui, ev *tcell.EventKey) bool
2243 size() (w, h int)
2244 title() string
2245}
2246
2247// dropState is an open header dropdown.
2248type dropState struct {
2249 kind string // "cats" | "etc"
2250 lst *list
2251 targets []navTarget // cats only
2252 width int
2253}
2254
2255// artHolder carries one asynchronously rendered product photo.
2256type artHolder struct {
2257 key string // what art holds
2258 want string // what was last requested
2259 art *artwork
2260}
2261
2262type artReq struct {
2263 key string
2264 path string
2265 w, h int
2266 mode imgMode
2267 holder *artHolder
2268}
2269
2270type ui struct {
2271 screen tcell.Screen
2272 cat *catalogInfo
2273 quit bool
2274
2275 panels map[string]panel
2276 history []string
2277 fwd []string
2278
2279 drop *dropState
2280 commerce overlayW
2281 notice string
2282
2283 // postMu gates post against RunOn's shutdown closing the queue.
2284 postMu sync.Mutex
2285 finished bool
2286
2287 // header nav and footer summary click regions, rebuilt each draw
2288 navRegions []struct {
2289 x1, x2 int
2290 act string
2291 }
2292 footRegions []struct {
2293 x1, x2 int
2294 act string
2295 }
2296
2297 // image rendering
2298 mode imgMode
2299 reqCh chan artReq
2300
2301 // expanded holds which categories' branches are open in the
2302 // Categories dropdown, like the template's <details open>.
2303 expanded map[string]bool
2304
2305 // home animation. The ticker goroutine computes frame (a pixel
2306 // buffer) and the home panel paints it; both sides go through mu.
2307 // current names the visible panel; the goroutine reads it too.
2308 mu sync.Mutex
2309 current string
2310 logo image.Image
2311 homeW int
2312 homeH int
2313 frame *image.RGBA
2314 backdrop *image.RGBA
2315 bdW, bdH int
2316 // pose randomized at startup like chaosrack's randomizeOrientation,
2317 // then tumbled by a random rate on every axis.
2318 poseX, poseY, poseZ float64
2319 rateX, rateY, rateZ float64
2320 done chan struct{}
2321
2322 asciiLines []string
2323 caps string
2324
2325 // cart, as the footer's View Cart
2326 cartOrder []string
2327 cartQty map[string]int
2328 shipping *shippingLine
2329
2330 sitedomain string
2331 masthead [3]string
2332}
2333
2334// Run browses the store in a full-screen terminal UI until the user
2335// quits. It owns the screen; the browser build makes an xtcell screen
2336// itself and calls RunOn.
2337func Run(prods product.Products) error {
2338 sc, err := tcell.NewScreen()
2339 if err != nil {
2340 return err
2341 }
2342 if err := sc.Init(); err != nil {
2343 return err
2344 }
2345 return RunOn(sc, prods)
2346}
2347
2348// RunOn drives the store UI on an initialized screen until the user
2349// quits, then finalizes it.
2350func RunOn(sc tcell.Screen, prods product.Products) error {
2351 u := &ui{
2352 cat: newCatalogInfo(prods),
2353 panels: map[string]panel{},
2354 mode: modeHalfBlock,
2355 reqCh: make(chan artReq, 1),
2356 expanded: map[string]bool{},
2357 done: make(chan struct{}),
2358 cartQty: map[string]int{},
2359 }
2360 u.sitedomain = f.Sitename + f.Siteext
2361 if u.sitedomain == "" {
2362 u.sitedomain = "m2"
2363 }
2364 u.asciiLines = strings.Split(strings.TrimRight(calvin.AsciiFont(u.sitedomain), "\n"), "\n")
2365 // The page sets this line in blackboard bold, and the terminal cannot:
2366 // the double-struck capitals live outside the BMP and no monospace font
2367 // here carries them, so every one of them falls back to a face whose
2368 // glyph is nearly twice the cell it is given and collides with its
2369 // neighbor. Plain capitals are the same words, drawn on the grid.
2370 u.caps = strings.ToUpper(u.sitedomain)
2371 long := f.Sitelongname
2372 if long == "" {
2373 long = u.sitedomain
2374 }
2375 tag := ""
2376 if f.Sitetagline != "" {
2377 tag = "- " + esc(f.Sitetagline) + " -"
2378 }
2379 u.masthead = [3]string{"[::b]" + esc(long) + "[-:-:-]", "[gray]" + tag + "[-]", checkerboard(u.sitedomain)}
2380
2381 // A fresh random starting pose each run, as the website gives each
2382 // page load β and a random spin rate per axis, each with a floor so
2383 // all three visibly contribute, the vector normalized so the overall
2384 // speed matches auto-rotate's.
2385 u.poseX = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
2386 u.poseY = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
2387 u.poseZ = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
2388 randRate := func() float64 {
2389 r := 0.35 + 0.65*rand.Float64() //nolint:gosec
2390 if rand.Intn(2) == 0 { //nolint:gosec
2391 return -r
2392 }
2393 return r
2394 }
2395 u.rateX, u.rateY, u.rateZ = randRate(), randRate(), randRate()
2396 norm := math.Sqrt(u.rateX*u.rateX + u.rateY*u.rateY + u.rateZ*u.rateZ)
2397 const pace = 0.042 // auto-rotate's 0.3 rad/s at the 140ms ticker
2398 u.rateX, u.rateY, u.rateZ = u.rateX/norm*pace, u.rateY/norm*pace, u.rateZ/norm*pace
2399
2400 logos := []string{"logo.jpg", "logo.png"}
2401 if s := storeURL(); s != "" {
2402 logos = []string{s + "/logo.jpg", s + "/logo.png"}
2403 }
2404 for _, p := range logos {
2405 if img, err := loadImage(p); err == nil {
2406 u.logo = img
2407 break
2408 }
2409 }
2410
2411 u.screen = sc
2412 sc.SetStyle(styleText)
2413 sc.EnableMouse()
2414
2415 u.panels["home"] = &homePanel{}
2416 u.current = "home"
2417
2418 go u.renderWorker()
2419 go u.animateHome()
2420
2421 defer func() {
2422 // Stop accepting posts before the screen closes the event queue
2423 // under them β a ticker mid-post must not race the close.
2424 u.postMu.Lock()
2425 u.finished = true
2426 u.postMu.Unlock()
2427 sc.Fini()
2428 close(u.done)
2429 }()
2430 events := sc.EventQ()
2431 u.draw()
2432 for !u.quit {
2433 ev, ok := <-events
2434 if !ok {
2435 break
2436 }
2437 switch e := ev.(type) {
2438 case *tcell.EventInterrupt:
2439 if fn, ok := e.Data().(func()); ok && fn != nil {
2440 fn()
2441 }
2442 case *tcell.EventResize:
2443 sc.Sync()
2444 case *tcell.EventKey:
2445 // v3 reports key releases too where the terminal can; a
2446 // shortcut must fire once, on the press.
2447 if e.Pressed() {
2448 u.handleKey(e)
2449 }
2450 case *tcell.EventMouse:
2451 u.handleMouse(e)
2452 }
2453 u.draw()
2454 }
2455 return nil
2456}
2457
2458// post runs fn on the event loop and redraws β the worker and the
2459// animation ticker use it. Non-blocking and refused after shutdown, so
2460// a producer can never race the queue's close nor wedge against a loop
2461// that has already exited.
2462func (u *ui) post(fn func()) {
2463 u.postMu.Lock()
2464 defer u.postMu.Unlock()
2465 if u.finished {
2466 return
2467 }
2468 select {
2469 case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
2470 default:
2471 }
2472}
2473
2474// ---- drawing ----
2475
2476func (u *ui) draw() {
2477 sc := u.screen
2478 w, h := sc.Size()
2479 if w < 4 || h < 8 {
2480 sc.Show()
2481 return
2482 }
2483 sc.HideCursor()
2484 fillRect(sc, rect{0, 0, w, h}, styleText)
2485 u.drawHeader(sc, w)
2486 // Row 1 is the header table's bottom border, so the masthead starts
2487 // below it, as the page's title block sits under the nav rule.
2488 for i, line := range u.masthead {
2489 printMarkupCenter(sc, 0, 2+i, w, line, styleText)
2490 }
2491 content := rect{0, 5, w, h - 8}
2492 u.mu.Lock()
2493 cur := u.current
2494 u.mu.Unlock()
2495 if p := u.panels[cur]; p != nil {
2496 p.draw(u, sc, content)
2497 }
2498 u.drawFooter(sc, w, h)
2499 if u.commerce != nil {
2500 r := u.commerceRect()
2501 drawBox(sc, r, "[white] "+u.commerce.title()+" [-]", styleText.Foreground(tcell.ColorWhite), styleText)
2502 u.commerce.draw(u, sc, r.inner())
2503 }
2504 if u.drop != nil {
2505 r := u.dropRect()
2506 drawBox(sc, r, "[white] "+map[string]string{"cats": "Categories", "etc": "Etc..."}[u.drop.kind]+" [-]", styleBorder, styleText)
2507 u.drop.lst.draw(sc, r.inner())
2508 }
2509 if u.notice != "" {
2510 nw := markupWidth(u.notice) + 6
2511 if nw > w-4 {
2512 nw = w - 4
2513 }
2514 r := rect{(w - nw) / 2, h/2 - 2, nw, 5}
2515 drawBox(sc, r, "", styleText.Foreground(tcell.ColorWhite), styleText)
2516 printMarkupCenter(sc, r.x+1, r.y+2, r.w-2, u.notice, styleText)
2517 }
2518 sc.Show()
2519}
2520
2521// drawHeader lays the nav out as the website's header table does: three
2522// equal columns β Categories, Home, Etc... β each label centered in its
2523// third, with its dropdown opening beneath it. The current section is
2524// cyan and underlined and the rest are white, as the page renders its
2525// links, and the row is closed by the header table's blue bottom border.
2526func (u *ui) drawHeader(sc tcell.Screen, w int) {
2527 entries := []struct{ label, act string }{
2528 {"Categories", "cats"}, {"Home", "home"}, {"Etc...", "etc"},
2529 }
2530 active := u.navActive()
2531 u.navRegions = u.navRegions[:0]
2532 third := w / 3
2533 for i, e := range entries {
2534 x := i*third + (third-len(e.label))/2
2535 if x < 0 {
2536 x = 0
2537 }
2538 tag := "[white]"
2539 if e.act == active {
2540 tag = "[aqua::u]"
2541 }
2542 printMarkup(sc, x, 0, w-x, tag+e.label+"[-:-:-]", styleText)
2543 u.navRegions = append(u.navRegions, struct {
2544 x1, x2 int
2545 act string
2546 }{x, x + len(e.label), e.act})
2547 }
2548 drawRule(sc, 0, 1, w, ruleBlue)
2549}
2550
2551// navActive names the header entry for the page on screen, the way the
2552// site marks the section you are in: the catalog pages belong to
2553// Categories, the stock pages to Etc..., everything else to Home.
2554func (u *ui) navActive() string {
2555 u.mu.Lock()
2556 cur := u.current
2557 u.mu.Unlock()
2558 switch {
2559 case strings.HasPrefix(cur, "list:"), strings.HasPrefix(cur, "prod:"):
2560 return "cats"
2561 case strings.HasPrefix(cur, "content:"):
2562 return "etc"
2563 }
2564 return "home"
2565}
2566
2567// drawFooter is the website's fixed footer: the status flash, address
2568// and key hints on one row, then the footer table itself β View Cart on
2569// the left half and Add Shipping Info on the right, each centered in its
2570// cell, ruled above and divided down the middle as the page's is.
2571func (u *ui) drawFooter(sc tcell.Screen, w, h int) {
2572 status := "[#FF10F0:#009EFF] Open For Business [-:-]"
2573 if f.Teststripekey {
2574 status = "[yellow:red] Test Mode Active - No Orders Processed [-:-]"
2575 }
2576 arrows := ""
2577 if len(u.history) > 0 {
2578 arrows += "β"
2579 }
2580 if len(u.fwd) > 0 {
2581 arrows += "β"
2582 }
2583 if arrows != "" {
2584 arrows = "[gray]" + arrows + "[-] "
2585 }
2586 line := fmt.Sprintf(" %s %s[aqua]%s[-] [gray]β« back Β· f fwd Β· c cats Β· h home Β· e etc Β· b buy Β· v cart Β· i img Β· q quit[-]",
2587 status, arrows, u.pagePath())
2588 printMarkup(sc, 0, h-3, w, line, styleText)
2589
2590 // The footer table: a white rule for its top edge, then two equal
2591 // cells split by the divider the page draws between them.
2592 drawRule(sc, 0, h-2, w, ruleWhite)
2593 total, count := u.cartTotal()
2594 cart := fmt.Sprintf("β΄ View Cart Total: $%d.%02d", total/100, total%100)
2595 if count > 0 {
2596 cart = fmt.Sprintf("β΄ View Cart (%d) Total: $%d.%02d", count, total/100, total%100)
2597 }
2598 ship := "β΄ Add Shipping Info"
2599 u.footRegions = u.footRegions[:0]
2600 half := w / 2
2601 for i, e := range []struct{ label, act string }{{cart, "cart"}, {ship, "shipping"}} {
2602 x := i*half + (half-len([]rune(e.label)))/2
2603 if x < 0 {
2604 x = 0
2605 }
2606 printMarkup(sc, x, h-1, w-x, "[aqua]"+esc(e.label)+"[-]", styleText)
2607 u.footRegions = append(u.footRegions, struct {
2608 x1, x2 int
2609 act string
2610 }{x, x + len([]rune(e.label)), e.act})
2611 }
2612 sc.SetContent(half, h-1, 'β', nil, ruleWhite)
2613}
2614
2615func (u *ui) cartTotal() (cents, count int) {
2616 for partno, qty := range u.cartQty {
2617 if p := u.cat.find(partno); p != nil {
2618 cents += priceCents(p.Price) * qty
2619 count += qty
2620 }
2621 }
2622 if u.shipping != nil {
2623 cents += u.shipping.Cents
2624 }
2625 return cents, count
2626}
2627
2628func priceCents(s string) int {
2629 s = strings.TrimPrefix(s, "$")
2630 fl, err := strconv.ParseFloat(s, 64)
2631 if err != nil {
2632 return 0
2633 }
2634 return int(fl*100 + 0.5)
2635}
2636
2637// ---- input routing ----
2638
2639func (u *ui) handleKey(ev *tcell.EventKey) {
2640 if ev.Key() == tcell.KeyCtrlC {
2641 u.quit = true
2642 return
2643 }
2644 if u.notice != "" {
2645 u.notice = ""
2646 return
2647 }
2648 if u.drop != nil {
2649 if u.drop.lst.key(ev) {
2650 return
2651 }
2652 r := keyRune(ev)
2653 if ev.Key() == tcell.KeyEscape ||
2654 (r == 'c' && u.drop.kind == "cats") || (r == 'e' && u.drop.kind == "etc") {
2655 u.drop = nil
2656 return
2657 }
2658 }
2659 if u.commerce != nil {
2660 if u.commerce.key(u, ev) {
2661 return
2662 }
2663 if ev.Key() == tcell.KeyEscape {
2664 u.closeCommerce()
2665 return
2666 }
2667 }
2668 switch ev.Key() {
2669 case tcell.KeyEscape:
2670 u.back()
2671 return
2672 case tcell.KeyBackspace, tcell.KeyBackspace2:
2673 u.back()
2674 return
2675 }
2676 if ev.Key() == tcell.KeyRune {
2677 switch keyRune(ev) {
2678 case 'q':
2679 u.quit = true
2680 return
2681 case 'c':
2682 u.openCategories()
2683 return
2684 case 'h':
2685 u.show("home")
2686 return
2687 case 'e':
2688 u.openEtc()
2689 return
2690 case 'f':
2691 u.forward()
2692 return
2693 case 'v':
2694 u.toggleCart()
2695 return
2696 case 'i':
2697 if u.mode == modeHalfBlock {
2698 u.mode = modeCaca
2699 } else {
2700 u.mode = modeHalfBlock
2701 }
2702 return // the changed mode re-keys art requests at next draw
2703 }
2704 }
2705 if u.drop != nil || u.commerce != nil {
2706 return
2707 }
2708 u.mu.Lock()
2709 cur := u.current
2710 u.mu.Unlock()
2711 if p := u.panels[cur]; p != nil {
2712 p.key(u, ev)
2713 }
2714}
2715
2716func (u *ui) handleMouse(ev *tcell.EventMouse) {
2717 x, y := ev.Position()
2718 w, h := u.screen.Size()
2719 click := ev.Buttons()&tcell.Button1 != 0
2720 if u.notice != "" {
2721 if click {
2722 u.notice = ""
2723 }
2724 return
2725 }
2726 wasOpen := ""
2727 if u.drop != nil {
2728 r := u.dropRect().inner()
2729 if r.contains(x, y) {
2730 u.drop.lst.mouse(y-r.y, ev)
2731 return
2732 }
2733 if !click {
2734 return
2735 }
2736 // Close, and let the click land: clicking the other header label
2737 // switches menus in one click, as details dropdowns do β but a
2738 // click on this menu's own label must collapse it, not reopen it.
2739 wasOpen = u.drop.kind
2740 u.drop = nil
2741 }
2742 if click && y == 0 {
2743 for _, reg := range u.navRegions {
2744 if x >= reg.x1 && x < reg.x2 {
2745 switch reg.act {
2746 case "cats":
2747 if wasOpen != "cats" {
2748 u.openCategories()
2749 }
2750 case "home":
2751 u.show("home")
2752 case "etc":
2753 if wasOpen != "etc" {
2754 u.openEtc()
2755 }
2756 }
2757 return
2758 }
2759 }
2760 return
2761 }
2762 if click && y == h-1 {
2763 for _, reg := range u.footRegions {
2764 if x >= reg.x1 && x < reg.x2 {
2765 switch reg.act {
2766 case "cart":
2767 u.toggleCart()
2768 case "shipping":
2769 if _, open := u.commerce.(*shippingOverlay); open {
2770 u.closeCommerce()
2771 } else {
2772 u.openShipping()
2773 }
2774 }
2775 return
2776 }
2777 }
2778 return
2779 }
2780 if u.commerce != nil {
2781 r := u.commerceRect()
2782 if !r.contains(x, y) && click {
2783 u.closeCommerce()
2784 }
2785 return
2786 }
2787 content := rect{0, 5, w, h - 8}
2788 if content.contains(x, y) {
2789 u.mu.Lock()
2790 cur := u.current
2791 u.mu.Unlock()
2792 if p := u.panels[cur]; p != nil {
2793 p.mouse(u, content, ev)
2794 }
2795 }
2796}
2797
2798// ---- navigation ----
2799
2800func (u *ui) show(page string) {
2801 u.drop = nil
2802 u.closeCommerce()
2803 u.mu.Lock()
2804 if page == u.current {
2805 u.mu.Unlock()
2806 return
2807 }
2808 u.history = append(u.history, u.current)
2809 u.current = page
2810 u.mu.Unlock()
2811 u.fwd = nil
2812 u.ensurePanel(page)
2813}
2814
2815func (u *ui) back() {
2816 u.mu.Lock()
2817 if len(u.history) == 0 {
2818 u.mu.Unlock()
2819 return
2820 }
2821 page := u.history[len(u.history)-1]
2822 u.history = u.history[:len(u.history)-1]
2823 u.fwd = append(u.fwd, u.current)
2824 u.current = page
2825 u.mu.Unlock()
2826}
2827
2828func (u *ui) forward() {
2829 u.mu.Lock()
2830 if len(u.fwd) == 0 {
2831 u.mu.Unlock()
2832 return
2833 }
2834 page := u.fwd[len(u.fwd)-1]
2835 u.fwd = u.fwd[:len(u.fwd)-1]
2836 u.history = append(u.history, u.current)
2837 u.current = page
2838 u.mu.Unlock()
2839 u.ensurePanel(page)
2840}
2841
2842func (u *ui) ensurePanel(page string) {
2843 if _, ok := u.panels[page]; ok {
2844 return
2845 }
2846 switch {
2847 case strings.HasPrefix(page, "list:"):
2848 spec := strings.TrimPrefix(page, "list:")
2849 parts := strings.SplitN(spec, "|", 2)
2850 cat, sub := parts[0], ""
2851 if len(parts) == 2 {
2852 sub = parts[1]
2853 }
2854 u.panels[page] = newListPanel(u, page, cat, sub)
2855 case strings.HasPrefix(page, "prod:"):
2856 u.panels[page] = newProductPanel(u, strings.TrimPrefix(page, "prod:"))
2857 case strings.HasPrefix(page, "content:"):
2858 u.panels[page] = newContentPanel(strings.TrimPrefix(page, "content:"))
2859 }
2860}
2861
2862func listPageID(t navTarget) string {
2863 if t.cat == "" {
2864 return "list:"
2865 }
2866 if t.subcat == "" {
2867 return "list:" + t.cat
2868 }
2869 return "list:" + t.cat + "|" + t.subcat
2870}
2871
2872// pagePath renders the current panel as the URL path the website would
2873// give it β the footer shows it like an address bar.
2874func (u *ui) pagePath() string {
2875 u.mu.Lock()
2876 cur := u.current
2877 u.mu.Unlock()
2878 switch {
2879 case cur == "home":
2880 return "/"
2881 case strings.HasPrefix(cur, "content:"):
2882 return "/#" + strings.TrimPrefix(cur, "content:")
2883 case strings.HasPrefix(cur, "prod:"):
2884 return "/p/" + strings.TrimPrefix(cur, "prod:")
2885 case strings.HasPrefix(cur, "list:"):
2886 spec := strings.TrimPrefix(cur, "list:")
2887 if spec == "" {
2888 return "/cat"
2889 }
2890 return "/cat/" + strings.ReplaceAll(spec, "|", "/")
2891 }
2892 return "/"
2893}
2894
2895// ---- header dropdowns ----
2896
2897// dropRect anchors an open menu beneath its header label, like the
2898// website's details dropdowns.
2899func (u *ui) dropRect() rect {
2900 sw, sh := u.screen.Size()
2901 x := 1
2902 idx := 0
2903 if u.drop.kind == "etc" {
2904 idx = 2
2905 }
2906 if idx < len(u.navRegions) {
2907 x = u.navRegions[idx].x1 - 1
2908 }
2909 if x+u.drop.width > sw-1 {
2910 x = sw - 1 - u.drop.width
2911 }
2912 if x < 0 {
2913 x = 0
2914 }
2915 h := len(u.drop.lst.items) + 2
2916 if max := sh - 4; h > max {
2917 h = max
2918 }
2919 return rect{x, 2, u.drop.width, h}
2920}
2921
2922// openCategories drops down the catsubcats tree. Like the template's
2923// nested <details>, categories open and close: the current category
2924// starts open, Enter on a closed category opens it (Enter again
2925// navigates), β/space toggle, β closes.
2926func (u *ui) openCategories() {
2927 if u.drop != nil && u.drop.kind == "cats" {
2928 u.drop = nil
2929 return
2930 }
2931 if cat, _ := u.currentTarget(); cat != "" {
2932 u.expanded[cat] = true
2933 }
2934 // Width from the fully expanded tree, so the box doesn't resize as
2935 // branches open and close.
2936 width := 20
2937 for _, it := range u.cat.buildTree(nil) {
2938 if w := len([]rune(it.label)) + 4; w > width {
2939 width = w
2940 }
2941 }
2942 d := &dropState{kind: "cats", lst: &list{}, width: width}
2943 u.drop = d
2944 rebuild := func(selCat string) {
2945 items := u.cat.buildTree(u.expanded)
2946 d.lst.items = d.lst.items[:0]
2947 d.targets = d.targets[:0]
2948 sel := 0
2949 for i, it := range items {
2950 d.lst.items = append(d.lst.items, esc(it.label))
2951 d.targets = append(d.targets, it.target)
2952 if it.target.cat == selCat && it.target.subcat == "" {
2953 sel = i
2954 }
2955 }
2956 d.lst.sel = sel
2957 }
2958 rebuild("")
2959 itemAt := func(i int) (navTarget, bool) {
2960 if i < 0 || i >= len(d.targets) {
2961 return navTarget{}, false
2962 }
2963 return d.targets[i], true
2964 }
2965 hasSubs := func(t navTarget) bool {
2966 return t.cat != "" && t.subcat == "" && len(u.cat.subcatsByCat[t.cat]) > 0
2967 }
2968 d.lst.onPick = func(i int) {
2969 t, ok := itemAt(i)
2970 if !ok {
2971 return
2972 }
2973 if hasSubs(t) && !u.expanded[t.cat] {
2974 u.expanded[t.cat] = true
2975 rebuild(t.cat)
2976 return
2977 }
2978 u.show(listPageID(t))
2979 }
2980 d.lst.onKey = func(ev *tcell.EventKey, sel int) bool {
2981 t, ok := itemAt(sel)
2982 if !ok {
2983 return false
2984 }
2985 switch {
2986 case ev.Key() == tcell.KeyRight:
2987 if hasSubs(t) && !u.expanded[t.cat] {
2988 u.expanded[t.cat] = true
2989 rebuild(t.cat)
2990 }
2991 return true
2992 case ev.Key() == tcell.KeyLeft:
2993 if t.cat != "" && (u.expanded[t.cat] || t.subcat != "") {
2994 u.expanded[t.cat] = false
2995 rebuild(t.cat)
2996 }
2997 return true
2998 case keyRune(ev) == ' ':
2999 if t.cat == "" {
3000 return true
3001 }
3002 if t.subcat != "" {
3003 u.expanded[t.cat] = false
3004 } else if hasSubs(t) {
3005 u.expanded[t.cat] = !u.expanded[t.cat]
3006 }
3007 rebuild(t.cat)
3008 return true
3009 }
3010 return false
3011 }
3012}
3013
3014// currentTarget reports which category/subcategory panel is showing.
3015func (u *ui) currentTarget() (string, string) {
3016 u.mu.Lock()
3017 cur := u.current
3018 u.mu.Unlock()
3019 if !strings.HasPrefix(cur, "list:") {
3020 return "", ""
3021 }
3022 parts := strings.SplitN(strings.TrimPrefix(cur, "list:"), "|", 2)
3023 if len(parts) == 2 {
3024 return parts[0], parts[1]
3025 }
3026 return parts[0], ""
3027}
3028
3029// openEtc drops down the Etc... menu: About, Policy, Telegram, Contact, Links.
3030func (u *ui) openEtc() {
3031 if u.drop != nil && u.drop.kind == "etc" {
3032 u.drop = nil
3033 return
3034 }
3035 type entry struct {
3036 label string
3037 fn func()
3038 }
3039 var entries []entry
3040 add := func(label string, fn func()) { entries = append(entries, entry{label, fn}) }
3041 add("About", func() { u.show("content:about") })
3042 add("Policy", func() { u.show("content:policy") })
3043 if f.Tgchannel != "" {
3044 ch := f.Tgchannel
3045 add("Telegram", func() { u.drop = nil; u.notice = "[aqua]https://t.me/" + esc(ch) + "[-]" })
3046 }
3047 if f.Tgcontact != "" {
3048 ct := f.Tgcontact
3049 add("Contact", func() { u.drop = nil; u.notice = "[aqua]https://t.me/" + esc(ct) + "[-]" })
3050 }
3051 add("Links", func() { u.show("content:links") })
3052 lst := &list{}
3053 for _, e := range entries {
3054 lst.items = append(lst.items, esc(e.label))
3055 }
3056 lst.onPick = func(i int) { entries[i].fn() }
3057 u.drop = &dropState{kind: "etc", lst: lst, width: 30}
3058}
3059
3060// ---- the render worker ----
3061
3062func (u *ui) requestArt(h *artHolder, path string, w, ht int) {
3063 key := fmt.Sprintf("%s|%d|%d|%d", path, w, ht, u.mode)
3064 if h.key == key || h.want == key {
3065 return
3066 }
3067 h.want = key
3068 req := artReq{key: key, path: path, w: w, h: ht, mode: u.mode, holder: h}
3069 select {
3070 case u.reqCh <- req:
3071 default:
3072 select { // replace the stale pending request
3073 case <-u.reqCh:
3074 default:
3075 }
3076 u.reqCh <- req
3077 }
3078}
3079
3080func (u *ui) renderWorker() {
3081 cache := map[string]artwork{}
3082 for req := range u.reqCh {
3083 art, ok := cache[req.key]
3084 if !ok {
3085 art = renderArt(req.path, req.w, req.h, req.mode)
3086 if len(cache) > 128 {
3087 cache = map[string]artwork{}
3088 }
3089 cache[req.key] = art
3090 }
3091 r := req
3092 a := art
3093 u.post(func() {
3094 r.holder.key = r.key
3095 r.holder.art = &a
3096 })
3097 }
3098}
3099
3100// ---- the home panel ----
3101
3102type homePanel struct{}
3103
3104func (hp *homePanel) draw(u *ui, sc tcell.Screen, r rect) {
3105 u.mu.Lock()
3106 u.homeW, u.homeH = r.w, r.h
3107 frame := u.frame
3108 u.mu.Unlock()
3109 if frame == nil {
3110 return
3111 }
3112 rows := (frame.Bounds().Dy() + 1) / 2
3113 drawRGBA(sc, r.x, r.y, r.w, r.h, frame)
3114 if rows < r.h {
3115 ty := r.y + rows
3116 for i, l := range u.asciiLines {
3117 if ty+i >= r.y+r.h {
3118 break
3119 }
3120 printMarkupCenter(sc, r.x, ty+i, r.w, esc(l), styleText)
3121 }
3122 if ty+len(u.asciiLines)+1 < r.y+r.h {
3123 printMarkupCenter(sc, r.x, ty+len(u.asciiLines)+1, r.w, esc(u.caps), styleText)
3124 }
3125 }
3126}
3127
3128func (hp *homePanel) key(*ui, *tcell.EventKey) bool { return false }
3129func (hp *homePanel) mouse(*ui, rect, *tcell.EventMouse) bool { return false }
3130
3131// animateHome turns the globe over the logo while the home panel is
3132// shown, like the website's landing animation over the svg.
3133func (u *ui) animateHome() {
3134 ticker := time.NewTicker(140 * time.Millisecond)
3135 defer ticker.Stop()
3136 for {
3137 select {
3138 case <-u.done:
3139 return
3140 case <-ticker.C:
3141 }
3142 u.mu.Lock()
3143 cur, w, h := u.current, u.homeW, u.homeH
3144 u.mu.Unlock()
3145 if cur != "home" || w <= 0 || h <= 0 {
3146 continue
3147 }
3148 globeRows := h - len(u.asciiLines) - 2
3149 if globeRows < 4 {
3150 globeRows = h
3151 }
3152 if u.backdrop == nil || u.bdW != w || u.bdH != globeRows {
3153 u.backdrop = makeBackdrop(u.logo, w, globeRows)
3154 u.bdW, u.bdH = w, globeRows
3155 }
3156 u.poseX += u.rateX
3157 u.poseY += u.rateY
3158 u.poseZ += u.rateZ
3159 frame := globeFrame(u.backdrop, u.poseX, u.poseY, u.poseZ)
3160 u.mu.Lock()
3161 u.frame = frame
3162 u.mu.Unlock()
3163 u.post(nil) // just redraw
3164 }
3165}
3166
3167// ---- category / subcategory listings ----
3168
3169// listPanel renders a front.html category panel: the h2 heading, the
3170// subcategory line, and the product table, with the selected row's image
3171// beside it (the terminal's stand-in for the table's image column).
3172type listPanel struct {
3173 page, cat, sub string
3174 heading []string
3175 prods []*product.Product // panel's full set
3176 rows []*product.Product // filtered
3177 tbl *table
3178 art artHolder
3179 filterOn bool
3180 filter finput
3181}
3182
3183func newListPanel(u *ui, page, cat, sub string) *listPanel {
3184 lp := &listPanel{page: page, cat: cat, sub: sub}
3185 all := u.cat.products(cat, sub)
3186 for i := range all {
3187 lp.prods = append(lp.prods, &all[i])
3188 }
3189 title := "All Products"
3190 switch {
3191 case sub != "":
3192 title = cat + " β " + sub
3193 case cat != "":
3194 title = "Category: " + cat
3195 }
3196 lp.heading = []string{"[::b]" + esc(title) + "[-:-:-]"}
3197 if cat != "" && sub == "" {
3198 if subs := u.cat.subcatsByCat[cat]; len(subs) > 0 {
3199 var parts []string
3200 for _, s := range subs {
3201 parts = append(parts, fmt.Sprintf("[aqua]%s[-] (%d)", esc(s), u.cat.subcatCounts[cat][s]))
3202 }
3203 lp.heading = append(lp.heading, "Subcategories: "+strings.Join(parts, ", "))
3204 // front.html shows the remainder table only when there is one
3205 if len(lp.prods) > 0 {
3206 lp.heading = append(lp.heading, "[::b]Other Products in "+esc(cat)+":[-:-:-]")
3207 } else {
3208 lp.heading = append(lp.heading, "[gray]all "+esc(cat)+" products are in subcategories β open one from the Categories tree[-]")
3209 }
3210 }
3211 }
3212 lp.tbl = &table{cols: []tcol{
3213 {title: "Name"},
3214 {title: "Price", width: 9, alignRight: true},
3215 {title: "Stock", width: 6, alignRight: true},
3216 }}
3217 lp.tbl.onActivate = func(i int) {
3218 if i >= 0 && i < len(lp.rows) {
3219 u.show("prod:" + lp.rows[i].Partno)
3220 }
3221 }
3222 lp.fill("")
3223 return lp
3224}
3225
3226func (lp *listPanel) fill(query string) {
3227 lp.rows = lp.rows[:0]
3228 var rows []trow
3229 query = strings.ToLower(query)
3230 for _, p := range lp.prods {
3231 if query != "" && !strings.Contains(strings.ToLower(
3232 p.Partno+" "+p.Name+" "+p.Subcategory+" "+p.Description1), query) {
3233 continue
3234 }
3235 nameStyle := styleLink
3236 if p.Quantity == "0" {
3237 nameStyle = styleText.Foreground(tcell.GetColor("#808080"))
3238 }
3239 rows = append(rows, trow{cells: []tcell_{
3240 {p.Name, nameStyle},
3241 {"$" + p.Price, styleText},
3242 {p.Quantity, styleText},
3243 }})
3244 lp.rows = append(lp.rows, p)
3245 }
3246 lp.tbl.setRows(rows)
3247}
3248
3249func (lp *listPanel) selected() *product.Product {
3250 if lp.tbl.sel >= 0 && lp.tbl.sel < len(lp.rows) {
3251 return lp.rows[lp.tbl.sel]
3252 }
3253 return nil
3254}
3255
3256func (lp *listPanel) layout(r rect) (head, tblR, artR, filtR rect) {
3257 hh := len(lp.heading)
3258 head = rect{r.x + 1, r.y, r.w - 2, hh}
3259 body := rect{r.x, r.y + hh + 1, r.w, r.h - hh - 1}
3260 fh := 0
3261 if lp.filterOn {
3262 fh = 1
3263 }
3264 tw := body.w * 3 / 5
3265 tblR = rect{body.x + 1, body.y, tw - 2, body.h - fh}
3266 artR = rect{body.x + tw, body.y, body.w - tw, body.h - fh}
3267 filtR = rect{body.x + 1, body.y + body.h - 1, body.w - 2, 1}
3268 return
3269}
3270
3271func (lp *listPanel) draw(u *ui, sc tcell.Screen, r rect) {
3272 head, tblR, artR, filtR := lp.layout(r)
3273 for i, line := range lp.heading {
3274 printMarkup(sc, head.x, head.y+i, head.w, line, styleText)
3275 }
3276 lp.tbl.draw(sc, tblR)
3277 if p := lp.selected(); p != nil {
3278 u.requestArt(&lp.art, imagePath(p), artR.w, artR.h)
3279 }
3280 drawArtwork(sc, artR, lp.art.art)
3281 if lp.filterOn {
3282 printMarkup(sc, filtR.x, filtR.y, 3, "[aqua]/ [-]", styleText)
3283 curX, _ := lp.filter.drawValue(sc, filtR.x+2, filtR.y, filtR.w-2, true)
3284 sc.ShowCursor(curX, filtR.y)
3285 }
3286}
3287
3288func (lp *listPanel) key(u *ui, ev *tcell.EventKey) bool {
3289 if lp.filterOn {
3290 switch ev.Key() {
3291 case tcell.KeyEscape:
3292 lp.filterOn = false
3293 lp.filter.text = nil
3294 lp.filter.cur = 0
3295 lp.fill("")
3296 return true
3297 case tcell.KeyEnter:
3298 lp.filterOn = false
3299 return true
3300 }
3301 if lp.filter.key(ev) {
3302 lp.fill(string(lp.filter.text))
3303 return true
3304 }
3305 return ev.Key() == tcell.KeyRune
3306 }
3307 if ev.Key() == tcell.KeyRune {
3308 switch keyRune(ev) {
3309 case '/':
3310 lp.filterOn = true
3311 lp.filter = finput{width: 40}
3312 return true
3313 case 'b':
3314 if p := lp.selected(); p != nil {
3315 u.addToCart(p)
3316 }
3317 return true
3318 }
3319 }
3320 return lp.tbl.key(ev)
3321}
3322
3323func (lp *listPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
3324 _, tblR, _, _ := lp.layout(r)
3325 x, y := ev.Position()
3326 if x >= tblR.x+tblR.w && ev.Buttons()&tcell.Button1 == 0 {
3327 // wheel over the picture side still drives the table
3328 return lp.tbl.mouse(1, ev)
3329 }
3330 if tblR.contains(x, y) || ev.Buttons()&(tcell.WheelUp|tcell.WheelDown) != 0 {
3331 return lp.tbl.mouse(y-tblR.y, ev)
3332 }
3333 return false
3334}
3335
3336// ---- the product panel ----
3337
3338type productPanel struct {
3339 prod *product.Product
3340 ta *textArea
3341 art artHolder
3342}
3343
3344func newProductPanel(u *ui, partno string) panel {
3345 p := u.cat.find(partno)
3346 if p == nil {
3347 return newContentPanel("") // empty
3348 }
3349 return &productPanel{prod: p, ta: newTextArea(productLines(p), true)}
3350}
3351
3352func (pp *productPanel) draw(u *ui, sc tcell.Screen, r rect) {
3353 ih := r.h * 3 / 5
3354 imgR := rect{r.x, r.y, r.w, ih}
3355 txtR := rect{r.x + 1, r.y + ih + 1, r.w - 2, r.h - ih - 1}
3356 u.requestArt(&pp.art, imagePath(pp.prod), imgR.w, imgR.h)
3357 drawArtwork(sc, imgR, pp.art.art)
3358 pp.ta.draw(sc, txtR)
3359}
3360
3361func (pp *productPanel) key(u *ui, ev *tcell.EventKey) bool {
3362 if ev.Key() == tcell.KeyEnter || keyRune(ev) == 'b' {
3363 u.addToCart(pp.prod)
3364 return true
3365 }
3366 return pp.ta.key(ev)
3367}
3368
3369func (pp *productPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
3370 switch {
3371 case ev.Buttons()&tcell.WheelUp != 0:
3372 pp.ta.wheel(true)
3373 case ev.Buttons()&tcell.WheelDown != 0:
3374 pp.ta.wheel(false)
3375 default:
3376 return false
3377 }
3378 return true
3379}
3380
3381func (u *ui) addToCart(p *product.Product) {
3382 if p.Quantity == "0" {
3383 u.notice = esc(p.Name) + " is out of stock"
3384 return
3385 }
3386 if _, ok := u.cartQty[p.Partno]; !ok {
3387 u.cartOrder = append(u.cartOrder, p.Partno)
3388 }
3389 u.cartQty[p.Partno]++
3390}
3391
3392// ---- content pages: about, policy, links ----
3393
3394type contentPanel struct{ ta *textArea }
3395
3396// contentFile mirrors pkg/web: the deployment-local file when present,
3397// falling back to the committed .example.
3398func contentFile(path string) string {
3399 if data, err := os.ReadFile(path); err == nil { //nolint
3400 return string(data)
3401 }
3402 data, err := os.ReadFile(path + ".example") //nolint
3403 if err != nil {
3404 return ""
3405 }
3406 return string(data)
3407}
3408
3409func newContentPanel(name string) *contentPanel {
3410 raw := fetchContent(name)
3411 year := fmt.Sprintf("%d", time.Now().Year())
3412 text := "[gray]no content/" + esc(name) + ".html[-]"
3413 if raw != "" {
3414 text = htmlToText(raw, year)
3415 }
3416 ta := newTextArea(text, true)
3417 return &contentPanel{ta: ta}
3418}
3419
3420func (cp *contentPanel) draw(u *ui, sc tcell.Screen, r rect) {
3421 cp.ta.draw(sc, rect{r.x + 1, r.y, r.w - 2, r.h})
3422}
3423
3424func (cp *contentPanel) key(u *ui, ev *tcell.EventKey) bool {
3425 return cp.ta.key(ev)
3426}
3427
3428func (cp *contentPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
3429 switch {
3430 case ev.Buttons()&tcell.WheelUp != 0:
3431 cp.ta.wheel(true)
3432 case ev.Buttons()&tcell.WheelDown != 0:
3433 cp.ta.wheel(false)
3434 default:
3435 return false
3436 }
3437 return true
3438}
3439
3440
3441// ===== pkg/tui/widgets.go =====
3442// Package tui pkg/tui/widgets.go β the widget kit: a table, a list, a
3443// scrollable text area, and a form, written directly on tcell. Small on
3444// purpose: these are the site's shapes (the category table, the
3445// catsubcats dropdown, product pages, Add Shipping Info), not a general
3446// toolkit.
3447package tui
3448
3449import (
3450 "strings"
3451
3452 "github.com/gdamore/tcell/v3"
3453)
3454
3455// ---- textArea: scrollable, optionally word-wrapped markup text ----
3456
3457type textArea struct {
3458 content string
3459 base tcell.Style
3460 wrap bool
3461 scroll int
3462 lines [][]seg
3463 lastW int
3464}
3465
3466func newTextArea(content string, wrap bool) *textArea {
3467 return &textArea{content: content, base: styleText, wrap: wrap}
3468}
3469
3470func (t *textArea) setContent(s string) {
3471 t.content = s
3472 t.lastW = 0
3473 t.scroll = 0
3474}
3475
3476func (t *textArea) draw(sc tcell.Screen, r rect) {
3477 if r.w != t.lastW || t.lines == nil {
3478 t.lines = wrapSegs(t.content, r.w, t.base, t.wrap)
3479 t.lastW = r.w
3480 }
3481 if max := len(t.lines) - r.h; t.scroll > max {
3482 t.scroll = max
3483 }
3484 if t.scroll < 0 {
3485 t.scroll = 0
3486 }
3487 for i := 0; i < r.h; i++ {
3488 li := t.scroll + i
3489 if li >= len(t.lines) {
3490 break
3491 }
3492 printSegs(sc, r.x, r.y+i, r.w, t.lines[li])
3493 }
3494}
3495
3496func (t *textArea) key(ev *tcell.EventKey) bool {
3497 switch ev.Key() {
3498 case tcell.KeyUp:
3499 t.scroll--
3500 case tcell.KeyDown:
3501 t.scroll++
3502 case tcell.KeyPgUp:
3503 t.scroll -= 10
3504 case tcell.KeyPgDn:
3505 t.scroll += 10
3506 case tcell.KeyHome:
3507 t.scroll = 0
3508 case tcell.KeyEnd:
3509 t.scroll = len(t.lines)
3510 default:
3511 return false
3512 }
3513 return true
3514}
3515
3516func (t *textArea) wheel(up bool) {
3517 if up {
3518 t.scroll -= 3
3519 } else {
3520 t.scroll += 3
3521 }
3522}
3523
3524// ---- table: the site's product table ----
3525
3526type tcol struct {
3527 title string
3528 width int // 0 = expands
3529 alignRight bool
3530}
3531
3532type tcell_ struct {
3533 text string
3534 style tcell.Style
3535}
3536
3537type trow struct {
3538 cells []tcell_
3539}
3540
3541type table struct {
3542 cols []tcol
3543 rows []trow
3544 sel int
3545 offset int
3546 onSelect func(int)
3547 onActivate func(int)
3548}
3549
3550func (t *table) setRows(rows []trow) {
3551 t.rows = rows
3552 t.offset = 0
3553 if len(rows) == 0 {
3554 t.sel = -1
3555 return
3556 }
3557 t.sel = 0
3558 if t.onSelect != nil {
3559 t.onSelect(0)
3560 }
3561}
3562
3563// colWidths distributes r.w across the columns; width-0 columns share
3564// the remainder.
3565func (t *table) colWidths(w int) []int {
3566 ws := make([]int, len(t.cols))
3567 fixed, flex := 0, 0
3568 for i, c := range t.cols {
3569 ws[i] = c.width
3570 if c.width == 0 {
3571 flex++
3572 } else {
3573 fixed += c.width + 1
3574 }
3575 }
3576 if flex > 0 {
3577 share := (w - fixed - flex) / flex
3578 if share < 4 {
3579 share = 4
3580 }
3581 for i, c := range t.cols {
3582 if c.width == 0 {
3583 ws[i] = share
3584 }
3585 }
3586 }
3587 return ws
3588}
3589
3590func (t *table) draw(sc tcell.Screen, r rect) {
3591 if r.h < 2 {
3592 return
3593 }
3594 ws := t.colWidths(r.w)
3595 x := r.x
3596 for i, c := range t.cols {
3597 st := styleThead
3598 txt := c.title
3599 if len(txt) > ws[i] {
3600 txt = txt[:ws[i]]
3601 }
3602 pad := ws[i] - len(txt)
3603 if c.alignRight {
3604 printMarkup(sc, x+pad, r.y, ws[i], esc(txt), st)
3605 } else {
3606 printMarkup(sc, x, r.y, ws[i], esc(txt), st)
3607 }
3608 x += ws[i] + 1
3609 }
3610 visible := r.h - 1
3611 if t.sel >= 0 {
3612 if t.sel < t.offset {
3613 t.offset = t.sel
3614 }
3615 if t.sel >= t.offset+visible {
3616 t.offset = t.sel - visible + 1
3617 }
3618 }
3619 for row := 0; row < visible; row++ {
3620 ri := t.offset + row
3621 if ri >= len(t.rows) {
3622 break
3623 }
3624 y := r.y + 1 + row
3625 selected := ri == t.sel
3626 if selected {
3627 fillRect(sc, rect{r.x, y, r.w, 1}, styleSel)
3628 }
3629 x = r.x
3630 for i, c := range t.rows[ri].cells {
3631 if i >= len(ws) {
3632 break
3633 }
3634 st := c.style
3635 if selected {
3636 st = styleSel
3637 }
3638 txt := c.text
3639 if len(txt) > ws[i] {
3640 txt = txt[:ws[i]]
3641 }
3642 pad := 0
3643 if t.cols[i].alignRight {
3644 pad = ws[i] - len(txt)
3645 }
3646 printMarkup(sc, x+pad, y, ws[i]-pad, esc(txt), st)
3647 x += ws[i] + 1
3648 }
3649 }
3650}
3651
3652func (t *table) move(d int) {
3653 if len(t.rows) == 0 {
3654 return
3655 }
3656 t.sel += d
3657 if t.sel < 0 {
3658 t.sel = 0
3659 }
3660 if t.sel >= len(t.rows) {
3661 t.sel = len(t.rows) - 1
3662 }
3663 if t.onSelect != nil {
3664 t.onSelect(t.sel)
3665 }
3666}
3667
3668func (t *table) key(ev *tcell.EventKey) bool {
3669 switch ev.Key() {
3670 case tcell.KeyUp:
3671 t.move(-1)
3672 case tcell.KeyDown:
3673 t.move(1)
3674 case tcell.KeyPgUp:
3675 t.move(-10)
3676 case tcell.KeyPgDn:
3677 t.move(10)
3678 case tcell.KeyHome:
3679 t.move(-len(t.rows))
3680 case tcell.KeyEnd:
3681 t.move(len(t.rows))
3682 case tcell.KeyEnter:
3683 if t.sel >= 0 && t.onActivate != nil {
3684 t.onActivate(t.sel)
3685 }
3686 default:
3687 return false
3688 }
3689 return true
3690}
3691
3692// mouse handles a click or wheel at a position local to the table rect;
3693// clicking the selected row activates it, as clicking a link does.
3694func (t *table) mouse(localY int, ev *tcell.EventMouse) bool {
3695 btn := ev.Buttons()
3696 switch {
3697 case btn&tcell.WheelUp != 0:
3698 t.move(-3)
3699 case btn&tcell.WheelDown != 0:
3700 t.move(3)
3701 case btn&tcell.Button1 != 0:
3702 ri := t.offset + localY - 1 // row 0 is the header
3703 if localY < 1 || ri < 0 || ri >= len(t.rows) {
3704 return false
3705 }
3706 if ri == t.sel {
3707 if t.onActivate != nil {
3708 t.onActivate(ri)
3709 }
3710 } else {
3711 t.sel = ri
3712 if t.onSelect != nil {
3713 t.onSelect(ri)
3714 }
3715 }
3716 default:
3717 return false
3718 }
3719 return true
3720}
3721
3722// ---- list: the dropdown menus ----
3723
3724type list struct {
3725 items []string // markup labels
3726 sel int
3727 offset int
3728 onPick func(int)
3729 // onKey sees keys first β the categories tree uses it for its
3730 // expand/collapse keys.
3731 onKey func(ev *tcell.EventKey, sel int) bool
3732}
3733
3734func (l *list) draw(sc tcell.Screen, r rect) {
3735 if l.sel >= 0 {
3736 if l.sel < l.offset {
3737 l.offset = l.sel
3738 }
3739 if l.sel >= l.offset+r.h {
3740 l.offset = l.sel - r.h + 1
3741 }
3742 }
3743 for i := 0; i < r.h; i++ {
3744 li := l.offset + i
3745 if li >= len(l.items) {
3746 break
3747 }
3748 st := styleLink
3749 if li == l.sel {
3750 st = styleSel
3751 fillRect(sc, rect{r.x, r.y + i, r.w, 1}, styleSel)
3752 }
3753 printMarkup(sc, r.x, r.y+i, r.w, l.items[li], st)
3754 }
3755}
3756
3757func (l *list) key(ev *tcell.EventKey) bool {
3758 if l.onKey != nil && l.onKey(ev, l.sel) {
3759 return true
3760 }
3761 switch ev.Key() {
3762 case tcell.KeyUp:
3763 if l.sel > 0 {
3764 l.sel--
3765 }
3766 case tcell.KeyDown:
3767 if l.sel < len(l.items)-1 {
3768 l.sel++
3769 }
3770 case tcell.KeyHome:
3771 l.sel = 0
3772 case tcell.KeyEnd:
3773 l.sel = len(l.items) - 1
3774 case tcell.KeyEnter:
3775 if l.onPick != nil && l.sel >= 0 {
3776 l.onPick(l.sel)
3777 }
3778 default:
3779 return false
3780 }
3781 return true
3782}
3783
3784func (l *list) mouse(localY int, ev *tcell.EventMouse) bool {
3785 btn := ev.Buttons()
3786 switch {
3787 case btn&tcell.WheelUp != 0:
3788 if l.sel > 0 {
3789 l.sel--
3790 }
3791 case btn&tcell.WheelDown != 0:
3792 if l.sel < len(l.items)-1 {
3793 l.sel++
3794 }
3795 case btn&tcell.Button1 != 0:
3796 li := l.offset + localY
3797 if li < 0 || li >= len(l.items) {
3798 return false
3799 }
3800 l.sel = li
3801 if l.onPick != nil {
3802 l.onPick(li)
3803 }
3804 default:
3805 return false
3806 }
3807 return true
3808}
3809
3810// ---- form: Add Shipping Info ----
3811
3812type formItem interface {
3813 label() string
3814 // drawValue paints the value cell; the form places the cursor.
3815 drawValue(sc tcell.Screen, x, y, w int, focused bool) (curX int, showCur bool)
3816 key(ev *tcell.EventKey) bool
3817 value() string
3818}
3819
3820type finput struct {
3821 lbl string
3822 text []rune
3823 cur int
3824 width int
3825}
3826
3827func (f *finput) label() string { return f.lbl }
3828func (f *finput) value() string { return strings.TrimSpace(string(f.text)) }
3829
3830func (f *finput) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
3831 if f.width < w {
3832 w = f.width
3833 }
3834 st := styleField
3835 fillRect(sc, rect{x, y, w, 1}, st)
3836 start := 0
3837 if f.cur >= w {
3838 start = f.cur - w + 1
3839 }
3840 for i := 0; i < w && start+i < len(f.text); i++ {
3841 sc.SetContent(x+i, y, f.text[start+i], nil, st)
3842 }
3843 return x + f.cur - start, focused
3844}
3845
3846func (f *finput) key(ev *tcell.EventKey) bool {
3847 switch ev.Key() {
3848 case tcell.KeyRune:
3849 ins := []rune(ev.Str())
3850 f.text = append(f.text[:f.cur], append(ins, f.text[f.cur:]...)...)
3851 f.cur += len(ins)
3852 case tcell.KeyBackspace, tcell.KeyBackspace2:
3853 if f.cur > 0 {
3854 f.text = append(f.text[:f.cur-1], f.text[f.cur:]...)
3855 f.cur--
3856 }
3857 case tcell.KeyDelete:
3858 if f.cur < len(f.text) {
3859 f.text = append(f.text[:f.cur], f.text[f.cur+1:]...)
3860 }
3861 case tcell.KeyLeft:
3862 if f.cur > 0 {
3863 f.cur--
3864 }
3865 case tcell.KeyRight:
3866 if f.cur < len(f.text) {
3867 f.cur++
3868 }
3869 case tcell.KeyHome:
3870 f.cur = 0
3871 case tcell.KeyEnd:
3872 f.cur = len(f.text)
3873 default:
3874 return false
3875 }
3876 return true
3877}
3878
3879// fdropdown selects among options with β/β, or by typing a prefix (so
3880// "t","x" lands on TX).
3881type fdropdown struct {
3882 lbl string
3883 opts []string
3884 sel int
3885}
3886
3887func (f *fdropdown) label() string { return f.lbl }
3888func (f *fdropdown) value() string { return f.opts[f.sel] }
3889
3890func (f *fdropdown) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
3891 st := styleField
3892 label := f.opts[f.sel]
3893 if label == "" {
3894 label = "β"
3895 }
3896 txt := "β " + label + " βΈ"
3897 if len(txt) > w {
3898 txt = txt[:w]
3899 }
3900 fillRect(sc, rect{x, y, w, 1}, st)
3901 printMarkup(sc, x, y, w, esc(txt), st)
3902 return x, false
3903}
3904
3905func (f *fdropdown) key(ev *tcell.EventKey) bool {
3906 switch {
3907 case ev.Key() == tcell.KeyLeft:
3908 if f.sel > 0 {
3909 f.sel--
3910 }
3911 case ev.Key() == tcell.KeyRight:
3912 if f.sel < len(f.opts)-1 {
3913 f.sel++
3914 }
3915 case ev.Key() == tcell.KeyRune:
3916 want := strings.ToUpper(ev.Str())
3917 for i := 1; i <= len(f.opts); i++ {
3918 o := f.opts[(f.sel+i)%len(f.opts)]
3919 if strings.HasPrefix(strings.ToUpper(o), want) {
3920 f.sel = (f.sel + i) % len(f.opts)
3921 break
3922 }
3923 }
3924 default:
3925 return false
3926 }
3927 return true
3928}
3929
3930type fbutton struct {
3931 lbl string
3932 fn func()
3933}
3934
3935// form lays items out one per row with a button row below, inside a
3936// bordered box drawn by its owner.
3937type form struct {
3938 items []formItem
3939 buttons []fbutton
3940 focus int // 0..len(items)-1, then buttons
3941 cancel func()
3942 labelCol int
3943}
3944
3945func newForm(items []formItem, buttons []fbutton, cancel func()) *form {
3946 f := &form{items: items, buttons: buttons, cancel: cancel}
3947 for _, it := range items {
3948 if n := len(it.label()); n > f.labelCol {
3949 f.labelCol = n
3950 }
3951 }
3952 return f
3953}
3954
3955func (f *form) draw(sc tcell.Screen, r rect) {
3956 sc.HideCursor()
3957 for i, it := range f.items {
3958 y := r.y + i*2
3959 if y >= r.y+r.h-1 {
3960 break
3961 }
3962 printMarkup(sc, r.x, y, r.w, esc(it.label()), styleLink)
3963 curX, show := it.drawValue(sc, r.x+f.labelCol+1, y, r.w-f.labelCol-1, f.focus == i)
3964 if show && f.focus == i {
3965 sc.ShowCursor(curX, y)
3966 }
3967 }
3968 by := r.y + len(f.items)*2
3969 x := r.x + 2
3970 for i, b := range f.buttons {
3971 st := styleButton
3972 if f.focus == len(f.items)+i {
3973 st = styleSel
3974 }
3975 lbl := " " + b.lbl + " "
3976 printMarkup(sc, x, by, r.w, esc(lbl), st)
3977 x += len(lbl) + 2
3978 }
3979}
3980
3981func (f *form) key(ev *tcell.EventKey) bool {
3982 switch ev.Key() {
3983 case tcell.KeyEscape:
3984 if f.cancel != nil {
3985 f.cancel()
3986 }
3987 return true
3988 case tcell.KeyTab, tcell.KeyDown:
3989 f.focus = (f.focus + 1) % (len(f.items) + len(f.buttons))
3990 return true
3991 case tcell.KeyBacktab, tcell.KeyUp:
3992 f.focus--
3993 if f.focus < 0 {
3994 f.focus = len(f.items) + len(f.buttons) - 1
3995 }
3996 return true
3997 case tcell.KeyEnter:
3998 if f.focus >= len(f.items) {
3999 f.buttons[f.focus-len(f.items)].fn()
4000 } else {
4001 f.focus++
4002 }
4003 return true
4004 }
4005 if f.focus < len(f.items) {
4006 if f.items[f.focus].key(ev) {
4007 return true
4008 }
4009 }
4010 // Swallow stray runes so shortcuts never fire while a form is up.
4011 return ev.Key() == tcell.KeyRune
4012}
4013
4014
4015// ===== pkg/web/app.go =====
4016// Package web pkg/web/app.go β the fiber application, buildable for two hosts.
4017//
4018// Serve (native) and the in-tab site server (js/wasm β pkg/storepane's
4019// `serve` command, listening on the bottle vnet loopback) construct the same
4020// application from the same templates and handlers. AppOpts carries the only
4021// differences: where the request log goes, and the origin to lean on for
4022// everything a browser tab must not or cannot hold β the Stripe secret key,
4023// files on disk, and the toolchain that compiles wasm drop-ins.
4024package web
4025
4026import (
4027 "errors"
4028 "fmt"
4029 "io"
4030 "net/http"
4031 "os"
4032 "strings"
4033 "time"
4034
4035 "github.com/gofiber/fiber/v3"
4036 "github.com/gofiber/fiber/v3/middleware/static"
4037
4038 "github.com/0magnet/bottle"
4039)
4040
4041// AppOpts selects the host the app is being built for.
4042type AppOpts struct {
4043 // LogOutput receives the request log; nil keeps the native default
4044 // (stdout). The in-tab server points this at its shell's terminal, so
4045 // browsing the vnet site scrolls an access log like any server.
4046 LogOutput io.Writer
4047
4048 // ProxyOrigin, when non-empty, marks the in-tab role. Routes that need
4049 // the host machine (wasm compilation, source tarballs, images and fonts
4050 // on disk, the logo pipeline, CUPS printing) are not registered β a GET
4051 // that matches nothing is fetched from this origin instead β and the
4052 // payment endpoints forward there, so the tab renders the store but
4053 // never holds a secret key.
4054 ProxyOrigin string
4055}
4056
4057// NewApp builds the store's fiber application. It does not listen; Serve
4058// (native) and the in-tab server each own their listener.
4059func NewApp(o AppOpts) *fiber.App {
4060 initTMPL()
4061 logOut := o.LogOutput
4062 if logOut == nil {
4063 logOut = os.Stdout
4064 }
4065 inTab := o.ProxyOrigin != ""
4066
4067 r := fiber.New(fiber.Config{
4068 ErrorHandler: func(c fiber.Ctx, err error) error {
4069 code := fiber.StatusInternalServerError
4070 var e *fiber.Error
4071 if errors.As(err, &e) {
4072 code = e.Code
4073 }
4074 c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
4075 return c.Status(code).SendString(err.Error())
4076 },
4077 })
4078
4079 r.Use(func(c fiber.Ctx) error {
4080 start := time.Now()
4081 err := c.Next()
4082 status := c.Response().StatusCode()
4083 // Unmatched routes and handler errors are turned into their real
4084 // status by the app ErrorHandler after this middleware returns, so
4085 // c.Response() still reads 200 here. Recover the true code from the
4086 // error so 404s (and 5xx) are logged accurately rather than as 200.
4087 if err != nil {
4088 var fe *fiber.Error
4089 if errors.As(err, &fe) {
4090 status = fe.Code
4091 } else {
4092 status = fiber.StatusInternalServerError
4093 }
4094 }
4095 lat := time.Since(start)
4096 colors := c.App().Config().ColorScheme
4097 ip := fmt.Sprintf("%*s", 15, c.IP())
4098 ipsStr := strings.Join(c.IPs(), ", ")
4099 ips := fmt.Sprintf("%*s", 15, ipsStr)
4100 method := fmt.Sprintf("%-*s", 6, c.Method())
4101 statCol := statusColor(status, colors) + fmt.Sprintf("%3d", status) + colors.Reset
4102 methCol := methodColor(c.Method(), colors) + method + colors.Reset
4103 fmt.Fprintf(logOut, "%s | %s | %12s | %s | %s | %s | %s\n", time.Now().Format("2006-01-02 15:04:05"), statCol, lat, ip, ips, methCol, c.Path()) //nolint:errcheck,gosec
4104 return err
4105 })
4106
4107 if !inTab {
4108 // The desk page's OS layer and browser engine, served ahead of any
4109 // wasm module: jsfs/vnet must exist before Go captures globalThis.fs.
4110 jsAsset := func(body []byte) fiber.Handler {
4111 return func(c fiber.Ctx) error {
4112 c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
4113 c.Set(fiber.HeaderCacheControl, "public, max-age=3600")
4114 return c.Send(body)
4115 }
4116 }
4117 r.Get("/bottle/jsfs.js", jsAsset(bottle.JSFS()))
4118 r.Get("/bottle/vnet.js", jsAsset(bottle.VNetJS()))
4119 serveSourceCode(r)
4120 serveWASM(r)
4121 r.Get("/logo", logo)
4122 r.Get("/logo/:width", logo)
4123 r.Get("/logo/:width/:height", logo)
4124 r.Get("/logo.png", sendFile)
4125 r.Get("/logo.html", sendFile)
4126 r.Get("/mobilelogo.html", sendFile)
4127 r.Get("/logolarge.html", sendFile)
4128 r.Get("/favicon.ico", sendImage)
4129 if f.Siteimagesrc == "" {
4130 r.Use("/i", static.New("./img"))
4131 r.Use("/img", static.New("./img"))
4132 }
4133 r.Use("/font", static.New("./font"))
4134 r.Get("/stl/:filename", func(c fiber.Ctx) error {
4135 name := c.Params("filename")
4136 if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
4137 return c.SendStatus(fiber.StatusBadRequest)
4138 }
4139 return c.SendFile("./img/stl/" + name)
4140 })
4141 r.Get("/stl/base64/:filename", stlbase64)
4142 r.Get("/tui", tuipage)
4143 r.Get("/desk", deskpage)
4144 }
4145
4146 r.Get("/robots.txt", robots)
4147 r.Get("/site.webmanifest", func(c fiber.Ctx) error {
4148 return c.JSON(fiber.Map{
4149 "name": f.Sitelongname,
4150 "short_name": f.Sitename,
4151 "icons": []fiber.Map{
4152 {"src": f.Siteimagesrc + "/i/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
4153 {"src": f.Siteimagesrc + "/i/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"},
4154 },
4155 "theme_color": "#ffffff",
4156 "background_color": "#ffffff",
4157 "display": "standalone",
4158 })
4159 })
4160 r.Get("/api/products", apiproducts)
4161 r.Get("/api/site", apisite)
4162 r.Get("/api/content/:name", apicontent)
4163 r.Get("/sitemap", sitemap)
4164 r.Get("/sitemap.xml", sitemap)
4165 r.Get("/", homepage)
4166 r.Get("/p/:partno", productpage)
4167 r.Get("/post/:partno", handlecat)
4168 r.Get("/p", handlecat)
4169 r.Get("/cat", handlecat)
4170 r.Get("/cat/:cat", handlecat)
4171 r.Get("/cat/:cat/:subcat", handlecat)
4172 r.Get("/style.css", style)
4173 r.Get("/font.css", fontcss)
4174
4175 if !inTab {
4176 for _, register := range extraRoutes {
4177 register(r)
4178 }
4179 handleOrder(r)
4180 } else {
4181 // Money crosses back to the real server: the tab renders the cart,
4182 // the origin holds the Stripe key and the order book.
4183 r.Post("/create-payment-intent", proxyToOrigin(o.ProxyOrigin))
4184 r.Post("/submit-order", proxyToOrigin(o.ProxyOrigin))
4185 // Everything the tab does not carry β images, fonts, STL models,
4186 // the logo pipeline, /complete's Stripe lookup β reads through to
4187 // the origin. Registered last, so it is the 404 path.
4188 r.Use(proxyToOrigin(o.ProxyOrigin))
4189 }
4190 return r
4191}
4192
4193// proxyToOrigin relays the request to the real origin and copies the answer
4194// back. Under js/wasm net/http rides the browser's fetch, so a same-origin
4195// relay needs no credentials or CORS ceremony.
4196func proxyToOrigin(origin string) fiber.Handler {
4197 return func(c fiber.Ctx) error {
4198 method := c.Method()
4199 if method != fiber.MethodGet && method != fiber.MethodPost {
4200 return fiber.ErrNotFound
4201 }
4202 var body io.Reader
4203 if method == fiber.MethodPost {
4204 body = strings.NewReader(string(c.Body()))
4205 }
4206 req, err := http.NewRequest(method, origin+c.OriginalURL(), body)
4207 if err != nil {
4208 return fiber.ErrBadGateway
4209 }
4210 if ct := c.Get(fiber.HeaderContentType); ct != "" {
4211 req.Header.Set(fiber.HeaderContentType, ct)
4212 }
4213 resp, err := http.DefaultClient.Do(req)
4214 if err != nil {
4215 return fiber.ErrBadGateway
4216 }
4217 defer resp.Body.Close() //nolint:errcheck,gosec
4218 payload, err := io.ReadAll(resp.Body)
4219 if err != nil {
4220 return fiber.ErrBadGateway
4221 }
4222 if ct := resp.Header.Get("Content-Type"); ct != "" {
4223 c.Set(fiber.HeaderContentType, ct)
4224 }
4225 return c.Status(resp.StatusCode).Send(payload)
4226 }
4227}
4228
4229
4230// ===== pkg/web/catalog.go =====
4231// Package web pkg/web/catalog.go β the in-memory catalog the server sells from.
4232package web
4233
4234import (
4235 "log"
4236 "os"
4237 "sync"
4238 "time"
4239
4240 p "github.com/0magnet/m2/pkg/product"
4241)
4242
4243var (
4244 allproducts p.Products
4245 allproductsMu sync.RWMutex
4246)
4247
4248var lastModTime time.Time
4249
4250// LoadCatalog reads the products CSV named in the config into memory,
4251// logging any data-integrity warnings.
4252func LoadCatalog() error {
4253 fileInfo, err := os.Stat(f.ProductsCSV)
4254 if err != nil {
4255 return err
4256 }
4257 lastModTime = fileInfo.ModTime()
4258 prods := p.ReadCSV(f.ProductsCSV)
4259 if warnings := p.ValidateCSV(prods); len(warnings) > 0 {
4260 for _, w := range warnings {
4261 log.Println("CSV warning:", w)
4262 }
4263 }
4264 allproductsMu.Lock()
4265 allproducts = prods
4266 allproductsMu.Unlock()
4267 return nil
4268}
4269
4270// WatchCatalog polls the products CSV and reloads it when it changes.
4271// Run it in a goroutine; it never returns.
4272func WatchCatalog() {
4273 for {
4274 fileInfo, err := os.Stat(f.ProductsCSV)
4275 if err != nil {
4276 log.Println("Error getting file info:", err)
4277 time.Sleep(10 * time.Second)
4278 continue
4279 }
4280
4281 currentModTime := fileInfo.ModTime()
4282 if currentModTime != lastModTime {
4283 log.Println("CSV file has been modified!")
4284 if err := LoadCatalog(); err != nil {
4285 log.Println("Error reloading catalog:", err)
4286 }
4287 }
4288
4289 time.Sleep(10 * time.Second)
4290 }
4291}
4292
4293// SetCatalog replaces the in-memory catalog directly, for hosts that have no
4294// CSV on disk to load β the in-tab site server seeds this from /api/products,
4295// which is already the public projection (no cost, location or source info).
4296func SetCatalog(prods p.Products) {
4297 allproductsMu.Lock()
4298 allproducts = prods
4299 allproductsMu.Unlock()
4300}
4301
4302
4303// ===== pkg/web/order.go =====
4304// Package web pkg/web/order.go β checkout, order persistence, receipt printing.
4305package web
4306
4307import (
4308 "bytes"
4309 "encoding/json"
4310 "fmt"
4311 htmpl "html/template"
4312 "log"
4313 "os"
4314 "path/filepath"
4315 "regexp"
4316 "strconv"
4317 "strings"
4318 "time"
4319
4320 "github.com/bitfield/script"
4321 "github.com/gofiber/fiber/v3"
4322 "github.com/stripe/stripe-go/v81"
4323 "github.com/stripe/stripe-go/v81/paymentintent"
4324)
4325
4326// validPIID matches Stripe PaymentIntent IDs: "pi_" followed by alphanumeric chars.
4327// Also allows plain alphanumeric+underscore+hyphen for test order IDs.
4328var validPIID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
4329
4330func handleOrder(r *fiber.App) {
4331 r.Get("/checkout.css", func(c fiber.Ctx) error {
4332 c.Set("Content-Type", "text/css;charset=utf-8")
4333 _, err := c.Status(fiber.StatusOK).Write([]byte(h.CheckoutCSS()))
4334 return err
4335 })
4336
4337 r.Get("/complete", func(c fiber.Ctx) error {
4338 // Complete template
4339 completetmpl := htmpl.New("index")
4340 if _, err := completetmpl.Parse(h.CompletePage()); err != nil {
4341 msg := fmt.Sprintf("Error parsing complete page template: %v", err)
4342 log.Println(msg)
4343 return c.Status(fiber.StatusInternalServerError).SendString(msg)
4344 }
4345 if _, err := completetmpl.New("wasm").Parse(h.Wasm()); err != nil {
4346 log.Println("Error parsing wasm template:", err)
4347 msg := fmt.Sprintf("Error parsing wasm template: %v", err)
4348 log.Println(msg)
4349 return c.Status(fiber.StatusInternalServerError).SendString(msg)
4350 }
4351 h1 := htmlPageTemplateData
4352 /*
4353 proto := "http"
4354 if c.Secure() {
4355 proto += "s"
4356 }
4357 */
4358 proto := "https"
4359 h1.Canonical = proto + `://` + c.Hostname() + c.OriginalURL()
4360 h1.BaseURL = proto + `://` + c.Hostname()
4361 h1.RequestHost = c.Hostname()
4362 h1.Protocol = proto
4363 h1.Time = time.Now().Format(time.RFC3339Nano)
4364 h1.Year = fmt.Sprintf("%v", time.Now().Year())
4365 tmplData := map[string]interface{}{
4366 "Page": h1,
4367 }
4368 var result bytes.Buffer
4369 err := completetmpl.Execute(&result, tmplData)
4370 if err != nil {
4371 msg := fmt.Sprintf("Could not execute html template %v", err)
4372 log.Println(msg)
4373 return c.Status(fiber.StatusInternalServerError).SendString(msg)
4374 }
4375 c.Set("Content-Type", "text/html;charset=utf-8")
4376 return c.Status(fiber.StatusOK).Send(result.Bytes())
4377 })
4378
4379 r.Get("/order/:piid", func(c fiber.Ctx) error {
4380 piid := c.Params("piid")
4381 if !validPIID.MatchString(piid) {
4382 return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
4383 }
4384 order, err := script.File("orders/" + piid + ".json").Bytes()
4385 if err != nil {
4386 return c.Status(fiber.StatusNotFound).SendString("Order not found")
4387 }
4388 return c.Status(fiber.StatusOK).Send(order)
4389 })
4390
4391 r.Get("/order/:piid/html", func(c fiber.Ctx) error {
4392 piid := c.Params("piid")
4393 if !validPIID.MatchString(piid) {
4394 return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
4395 }
4396 order, err := script.File("orders/" + piid + ".json").Bytes()
4397 if err != nil {
4398 return c.Status(fiber.StatusNotFound).SendString("Order not found")
4399 }
4400 var m map[string]interface{}
4401 if err := json.Unmarshal(order, &m); err != nil {
4402 return c.Status(500).SendString("failed to unmarshal order json: " + err.Error())
4403 }
4404 receipt, err := buildReceipt(m, piid)
4405 if err != nil {
4406 return c.Status(500).SendString("failed to build receipt: " + err.Error())
4407 }
4408 return c.Status(200).SendString(string(receipt))
4409 })
4410
4411 r.Post("/create-payment-intent", func(c fiber.Ctx) error {
4412 rawBody := c.Body()
4413 if rawBody == nil {
4414 log.Printf("Failed to read raw request body")
4415 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to read request body"})
4416 }
4417
4418 var req struct {
4419 Items []item `json:"items"`
4420 }
4421 if err := json.Unmarshal(rawBody, &req); err != nil {
4422 log.Printf("Failed to parse JSON: %v", err)
4423 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
4424 }
4425
4426 if len(req.Items) == 0 {
4427 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
4428 }
4429
4430 // Validate each item's amount against the server-side product catalog.
4431 // Client sends ID as "partno X qty" for products, or "shipping-to|..." for shipping.
4432 total := int64(0)
4433 for _, it := range req.Items {
4434 if it.Amount <= 0 {
4435 log.Printf("Rejected item with non-positive amount: %s = %d", it.ID, it.Amount)
4436 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid item amount"})
4437 }
4438 if strings.HasPrefix(it.ID, "shipping-to|") {
4439 // Shipping line β accept the client-supplied amount
4440 total += it.Amount
4441 continue
4442 }
4443 // Extract partno and qty from "partno X qty"
4444 expectedAmt, err := validateItemAmount(it.ID, it.Amount)
4445 if err != nil {
4446 log.Printf("Item validation failed for %q: %v", it.ID, err)
4447 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
4448 }
4449 total += expectedAmt
4450 }
4451
4452 if total < 50 {
4453 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
4454 }
4455
4456 params := &stripe.PaymentIntentParams{
4457 Amount: stripe.Int64(total),
4458 Currency: stripe.String(string(stripe.CurrencyUSD)),
4459 }
4460 pi, err := paymentintent.New(params)
4461 if err != nil {
4462 log.Printf("Failed to create PaymentIntent: %v", err)
4463 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
4464 }
4465
4466 log.Printf("Created PaymentIntent %s for %d cents", pi.ID, total)
4467 return c.Status(fiber.StatusOK).JSON(fiber.Map{
4468 "clientSecret": pi.ClientSecret,
4469 "dpmCheckerLink": fmt.Sprintf("https://dashboard.stripe.com/settings/payment_methods/review?transaction_id=%s", pi.ID),
4470 })
4471 })
4472
4473 r.Post("/submit-order", func(c fiber.Ctx) error {
4474 var requestData struct {
4475 LocalStorageData map[string]interface{} `json:"localStorageData"`
4476 PaymentIntentID string `json:"paymentIntentId"`
4477 }
4478
4479 if err := c.Bind().Body(&requestData); err != nil {
4480 log.Println(err)
4481 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request data"})
4482 }
4483
4484 if !validPIID.MatchString(requestData.PaymentIntentID) {
4485 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payment intent ID"})
4486 }
4487
4488 log.Printf("Received payment intent ID: %s\n", requestData.PaymentIntentID)
4489
4490 paymentIntent, err := paymentintent.Get(requestData.PaymentIntentID, nil)
4491 if err != nil {
4492 log.Printf("Error retrieving payment intent: %v", err)
4493 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to verify payment"})
4494 }
4495 if paymentIntent.Status != stripe.PaymentIntentStatusSucceeded {
4496 log.Printf("Payment was not successful, status: %s", paymentIntent.Status)
4497 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Payment not successful"})
4498 }
4499
4500 ordersDir := "./orders"
4501 if err := os.MkdirAll(ordersDir, 0o750); err != nil {
4502 log.Printf("Error creating orders directory: %v", err)
4503 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
4504 }
4505
4506 filePath := filepath.Join(ordersDir, fmt.Sprintf("%s.json", requestData.PaymentIntentID))
4507
4508 // Idempotency: if the order file already exists, don't overwrite or reprint
4509 if _, err := os.Stat(filePath); err == nil {
4510 log.Printf("Order %s already exists, skipping duplicate submission", requestData.PaymentIntentID)
4511 return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order already submitted"})
4512 }
4513
4514 // Include the verified Stripe amount alongside the client-supplied data
4515 orderData := map[string]interface{}{
4516 "clientData": requestData.LocalStorageData,
4517 "verifiedCents": paymentIntent.Amount,
4518 "currency": string(paymentIntent.Currency),
4519 "stripeStatus": string(paymentIntent.Status),
4520 "submittedAt": time.Now().Format(time.RFC3339),
4521 }
4522
4523 data, err := json.MarshalIndent(orderData, "", " ")
4524 if err != nil {
4525 log.Printf("Error marshaling data to json: %v", err)
4526 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
4527 }
4528 if err := os.WriteFile(filePath, data, 0o600); err != nil {
4529 log.Printf("Error writing data to file: %v", err)
4530 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
4531 }
4532
4533 // ---- Print receipt via CUPS (non-blocking so your response is snappy)
4534 go func(pid string, local map[string]interface{}) {
4535 receipt, err := buildReceipt(local, pid)
4536 if err != nil {
4537 log.Printf("build receipt failed: %v", err)
4538 return
4539 }
4540 if err := sendToCUPS(receipt, "Order "+pid); err != nil {
4541 log.Printf("print failed: %v", err)
4542 _ = os.WriteFile(filepath.Join(ordersDir, pid+".print_failed"), []byte(err.Error()), 0o600) //nolint:errcheck // a best-effort marker that the receipt did not print; the failure is already logged
4543 }
4544 }(requestData.PaymentIntentID, requestData.LocalStorageData)
4545
4546 return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order submitted successfully"})
4547 })
4548
4549 /*
4550 r.Post("/reprint/:pid", func(c fiber.Ctx) error {
4551 pid := c.Params("pid")
4552 b, err := os.ReadFile(filepath.Join("./orders", pid+".json"))
4553 if err != nil { return c.Status(404).SendString("not found") }
4554 var m map[string]interface{}
4555 if err := json.Unmarshal(b, &m); err != nil { return c.Status(500).SendString(err.Error()) }
4556 receipt, err := buildReceipt(m, pid)
4557 if err != nil { return c.Status(500).SendString(err.Error()) }
4558 if err := sendToCUPS(receipt, "Order "+pid); err != nil {
4559 return c.Status(500).SendString(err.Error())
4560 }
4561 return c.SendStatus(204)
4562 })
4563 */
4564}
4565
4566func buildReceipt(local map[string]interface{}, paymentIntentID string) ([]byte, error) {
4567 // Pretty JSON body from what you already persisted
4568 body, err := json.MarshalIndent(local, "", " ")
4569 if err != nil {
4570 return nil, err
4571 }
4572 // Simple text receipt header
4573 ts := time.Now().Format("2006-01-02 15:04:05")
4574 hdr := fmt.Sprintf(
4575 "==================== ORDER ====================\n"+
4576 "PaymentIntent: %s\nTime: %s\n===============================================\n\n",
4577 paymentIntentID, ts,
4578 )
4579 // Footer (optional)
4580 ftr := "\n\n---------------------- END ---------------------\n"
4581 receipt := append([]byte(hdr), body...)
4582 receipt = append(receipt, []byte(ftr)...)
4583 return receipt, nil
4584}
4585
4586// serverPriceCents looks up a product's price from the server-side catalog by part number.
4587func serverPriceCents(partno string) (int64, error) {
4588 allproductsMu.RLock()
4589 prods := allproducts
4590 allproductsMu.RUnlock()
4591 for _, prod := range prods {
4592 if prod.Partno == partno {
4593 return parsePriceCents(prod.Price), nil
4594 }
4595 }
4596 return 0, fmt.Errorf("product %q not found in catalog", partno)
4597}
4598
4599// parsePriceCents converts a price string like "$1.23" or "1.23" to cents.
4600func parsePriceCents(s string) int64 {
4601 if s == "" {
4602 return 0
4603 }
4604 s = strings.TrimPrefix(s, "$")
4605 f, err := strconv.ParseFloat(s, 64)
4606 if err != nil {
4607 return 0
4608 }
4609 if f < 0 {
4610 return -int64(-f*100 + 0.5)
4611 }
4612 return int64(f*100 + 0.5)
4613}
4614
4615// validateItemAmount parses a client item ID ("partno X qty"), looks up the
4616// server-side price, computes the expected total, and returns it. If the
4617// client-supplied amount doesn't match, an error is returned.
4618func validateItemAmount(itemID string, clientAmount int64) (int64, error) {
4619 // Parse "partno X qty"
4620 parts := strings.SplitN(itemID, " X ", 2)
4621 if len(parts) != 2 {
4622 return 0, fmt.Errorf("unexpected item ID format: %q", itemID)
4623 }
4624 partno := parts[0]
4625 qty, err := strconv.Atoi(parts[1])
4626 if err != nil || qty <= 0 {
4627 return 0, fmt.Errorf("invalid quantity in item ID %q", itemID)
4628 }
4629
4630 unitCents, err := serverPriceCents(partno)
4631 if err != nil {
4632 return 0, err
4633 }
4634 expected := unitCents * int64(qty)
4635 if expected != clientAmount {
4636 return 0, fmt.Errorf("amount mismatch for %q: client sent %d cents, server expects %d cents", partno, clientAmount, expected)
4637 }
4638 return expected, nil
4639}
4640
4641// escape for inclusion inside *double quotes* in a bash command string
4642func bashEscapeDoubleQuoted(s string) string {
4643 s = strings.ReplaceAll(s, `\`, `\\`)
4644 s = strings.ReplaceAll(s, `"`, `\"`)
4645 s = strings.ReplaceAll(s, "$", `\$`)
4646 s = strings.ReplaceAll(s, "`", "\\`")
4647 return s
4648}
4649
4650func sendToCUPS(receipt []byte, title string) error {
4651 if title == "" {
4652 title = "Order"
4653 }
4654 var cmd strings.Builder
4655 cmd.WriteString("lp")
4656
4657 if f.PrinterName != "" {
4658 cmd.WriteString(` -d "`)
4659 cmd.WriteString(bashEscapeDoubleQuoted(f.PrinterName))
4660 cmd.WriteString(`"`)
4661 }
4662
4663 cmd.WriteString(` -t "`)
4664 cmd.WriteString(bashEscapeDoubleQuoted(title))
4665 cmd.WriteString(`"`)
4666
4667 if f.CupsOptions != "" {
4668 for _, opt := range strings.Split(f.CupsOptions, ",") {
4669 opt = strings.TrimSpace(opt)
4670 if opt == "" {
4671 continue
4672 }
4673 cmd.WriteString(` -o "`)
4674 cmd.WriteString(bashEscapeDoubleQuoted(opt))
4675 cmd.WriteString(`"`)
4676 }
4677 }
4678
4679 full := fmt.Sprintf(`bash -lc %q`, cmd.String())
4680
4681 _, err := script.Echo(string(receipt)).Exec(full).Stdout()
4682 if err != nil {
4683 return fmt.Errorf("lp failed: %v", err)
4684 }
4685 return nil
4686}
4687
4688
4689// ===== pkg/web/other.go =====
4690// Package web pkg/web/other.go β site-specific drop-in routes (not
4691// committed). Registers via the extraRoutes registry in server.go;
4692// deleting this file removes these routes with no other code changes.
4693// See other.go.example.
4694package web
4695
4696import (
4697 "bytes"
4698 "fmt"
4699 "log"
4700
4701 "github.com/gofiber/fiber/v3"
4702)
4703
4704func init() {
4705 extraRoutes = append(extraRoutes, handleOthers)
4706}
4707
4708func handleOthers(r *fiber.App) {
4709 r.Get("/coffee", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
4710 r.Get("/clock", clock)
4711 r.Get("/attractors", attractorspage)
4712 r.Get("/COVID", covidpage)
4713}
4714
4715func clock(c fiber.Ctx) error {
4716 c.Set("Content-Type", "text/html;charset=utf-8")
4717 _, err := c.Status(fiber.StatusOK).Write([]byte(mustReadFileToString("content/clock.html")))
4718 return err
4719}
4720
4721func covidpage(c fiber.Ctx) error {
4722 tmpl, err := mainTmpl()
4723 if err != nil {
4724 msg := fmt.Sprintf("Error parse html template: %v", err)
4725 log.Println(msg)
4726 return c.Status(fiber.StatusInternalServerError).SendString(msg)
4727 }
4728 tmpl0, err := tmpl.Clone()
4729 if err != nil {
4730 msg := fmt.Sprintf("Error cloning template: %v", err)
4731 log.Println(msg)
4732 return c.Status(fiber.StatusInternalServerError).SendString(msg)
4733 }
4734 _, err = tmpl0.New("main").Parse(mustReadFileToString("content/mementomori.html"))
4735 if err != nil {
4736 msg := fmt.Sprintf("Error parsing main template: %v", err)
4737 log.Println(msg)
4738 return c.Status(fiber.StatusInternalServerError).SendString(msg)
4739 }
4740 tmpl = tmpl0
4741 log.Println(c.Get("User-Agent"))
4742 c.Set("Content-Type", "text/html;charset=utf-8")
4743 h1 := pageMeta(c, htmlPageTemplateData)
4744 h1.Page = "hidden"
4745 h1.MetaDesc = "The COVID ΜΆvΜΆaΜΆcΜΆcΜΆiΜΆnΜΆeΜΆ bioweapon injection genocide and the new dark age of humanity"
4746 // h1.Mobile = strings.Contains(strings.ToLower(c.Get("User-Agent")), "mobile")
4747 tmplData := map[string]interface{}{
4748 "Page": h1,
4749 "Prods": allproducts,
4750 }
4751 var result bytes.Buffer
4752 err = tmpl.Execute(&result, tmplData)
4753 if err != nil {
4754 msg := fmt.Sprintf("Error executing template: %v", err)
4755 log.Println(msg)
4756 return c.Status(fiber.StatusInternalServerError).SendString(msg)
4757 }
4758 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
4759 return err
4760}
4761
4762// attractorspage renders a chromeless fullscreen page for the
4763// strange-attractor visualizer (no header, footer, cart, or store
4764// nav). Reuses the existing stl2 wasm β its URL-path dispatcher
4765// sees "/attractors" and falls into the default branch which
4766// invokes attractor.Run(). Loads the TinyGo or stdlib wasm based
4767// on f.UseTinygo.
4768func attractorspage(c fiber.Ctx) error {
4769 suffix := ".wasm"
4770 if f.UseTinygo {
4771 suffix = "-tiny.wasm"
4772 }
4773 wasmFile := "stl2" + suffix
4774 html := fmt.Sprintf(`<!DOCTYPE html>
4775<html lang="en">
4776<head>
4777<meta charset="utf-8">
4778<meta name="viewport" content="width=device-width, initial-scale=1">
4779<title>Strange Attractors β %s</title>
4780<meta name="description" content="Interactive 3D strange-attractor visualizer with mouse-drag rotation. Lorenz, Rossler, Chua, Aizawa, Sprott, Lissajous, Thomas, Halvorsen, Chen, Dadras, Rabinovich-Fabrikant, Burke-Shaw, Platonic solids, globe, sphere, torus, magnetosphere.">
4781<meta name="robots" content="index, follow">
4782<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;color:#fff;overflow:hidden;}#gocanvas{position:fixed;top:0;left:0;width:100%%;height:100%%;display:block;}</style>
4783<script src="%s"></script>
4784<script>
4785if (!WebAssembly.instantiateStreaming) {
4786 WebAssembly.instantiateStreaming = async (resp, importObject) => {
4787 const source = await (await resp).arrayBuffer();
4788 return await WebAssembly.instantiate(source, importObject);
4789 };
4790}
4791const go = new Go();
4792WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject).then((result) => {
4793 go.run(result.instance);
4794}).catch((err) => { console.error("Failed to run WASM:", err); });
4795</script>
4796</head>
4797<body>
4798<canvas id="gocanvas"></canvas>
4799</body>
4800</html>`, f.Sitelongname, f.WasmExecPath, wasmFile)
4801 c.Set("Content-Type", "text/html; charset=utf-8")
4802 return c.Status(fiber.StatusOK).SendString(html)
4803}
4804
4805
4806// ===== pkg/web/server.go =====
4807// Package web pkg/web/server.go β the store server: routes and page handlers.
4808package web
4809
4810import (
4811 "bytes"
4812 "encoding/base64"
4813 "fmt"
4814 "log"
4815 "os"
4816 "path/filepath"
4817 "regexp"
4818 "sort"
4819 "strconv"
4820 "strings"
4821 "sync"
4822
4823 "github.com/gofiber/fiber/v3"
4824
4825 "github.com/bitfield/script"
4826
4827 "github.com/0magnet/m2/pkg/config"
4828 p "github.com/0magnet/m2/pkg/product"
4829)
4830
4831// f aliases the shared configuration; the name keeps the handler code
4832// identical to its pre-refactor form at the repo root, and drop-in
4833// other.go files keep working unchanged.
4834var f = &config.F
4835
4836var collapseNewlines = regexp.MustCompile(`\n{2,}`)
4837
4838func methodColor(method string, colors fiber.Colors) string {
4839 switch method {
4840 case fiber.MethodGet:
4841 return colors.Cyan
4842 case fiber.MethodPost:
4843 return colors.Green
4844 case fiber.MethodPut:
4845 return colors.Yellow
4846 case fiber.MethodDelete:
4847 return colors.Red
4848 case fiber.MethodPatch:
4849 return colors.White
4850 case fiber.MethodHead:
4851 return colors.Magenta
4852 case fiber.MethodOptions:
4853 return colors.Blue
4854 default:
4855 return colors.Reset
4856 }
4857}
4858
4859func statusColor(code int, colors fiber.Colors) string {
4860 switch {
4861 case code >= fiber.StatusOK && code < fiber.StatusMultipleChoices:
4862 return colors.Green
4863 case code >= fiber.StatusMultipleChoices && code < fiber.StatusBadRequest:
4864 return colors.Blue
4865 case code >= fiber.StatusBadRequest && code < fiber.StatusInternalServerError:
4866 return colors.Yellow
4867 default:
4868 return colors.Red
4869 }
4870}
4871
4872// Serve runs the web store: templates, routes, wasm compilation, and the
4873// http listener. It blocks for the life of the server.
4874func Serve() {
4875 wg := new(sync.WaitGroup)
4876 wg.Add(1)
4877 r := NewApp(AppOpts{})
4878 go func() {
4879 err := r.Listen(fmt.Sprintf(":%d", f.WebPort))
4880 if err != nil {
4881 log.Println("Error serving http: ", err)
4882 }
4883 wg.Done()
4884 }()
4885 CompileWASM()
4886 wg.Wait()
4887}
4888
4889func sitemap(c fiber.Ctx) error {
4890 c.Type("xml", "utf-8")
4891 return c.SendString(generateSitemapXML())
4892}
4893
4894// apisite serves the site identity for store clients β the browser tui
4895// has no MENV file to source its masthead from.
4896func apisite(c fiber.Ctx) error {
4897 return c.JSON(fiber.Map{
4898 "Sitename": f.Sitename,
4899 "Siteext": f.Siteext,
4900 "Sitelongname": f.Sitelongname,
4901 "Sitetagline": f.Sitetagline,
4902 "Tgcontact": f.Tgcontact,
4903 "Tgchannel": f.Tgchannel,
4904 "Teststripekey": f.Teststripekey,
4905 // The identity fields below appear verbatim in every rendered page,
4906 // so serving them here reveals nothing new; they let the in-tab site
4907 // server render the same masthead and metadata the origin does.
4908 "Sitemeta": f.Sitemeta,
4909 "Sitedomain": f.Sitedomain,
4910 "Siteprettyname": f.Siteprettyname,
4911 "Siteprettynamecap": f.Siteprettynamecap,
4912 "Siteprettynamecaps": f.Siteprettynamecaps,
4913 "SiteASCIILogo": f.SiteASCIILogo,
4914 "Stripepk": f.StripePK,
4915 })
4916}
4917
4918// apicontent serves the stock-page fragments (about/policy/links) raw,
4919// for the tui to render as text.
4920func apicontent(c fiber.Ctx) error {
4921 name := c.Params("name")
4922 switch name {
4923 case "about", "policy", "links":
4924 default:
4925 return c.SendStatus(fiber.StatusNotFound)
4926 }
4927 c.Set("Content-Type", "text/html;charset=utf-8")
4928 return c.SendString(contentFile("content/" + name + ".html"))
4929}
4930
4931// tuipage serves the terminal storefront: the same store TUI `m2 tui`
4932// runs natively, compiled to wasm (the wasm/tui drop-in, listed in
4933// WASMSRC) and drawn into an xterm-go terminal filling the page.
4934func tuipage(c fiber.Ctx) error {
4935 // Prefer the tinygo build when it exists; the tui's Go build is the
4936 // usual case (tinygo's net/http shim does not compile it yet). The
4937 // wasm_exec.js runtime must match the binary's toolchain β under
4938 // USETINYGO the site default is tinygo's, which cannot start a
4939 // stdlib Go binary (LinkError on the gojs imports).
4940 suffix := ".wasm"
4941 execPath := f.WasmExecPathGo
4942 if f.UseTinygo {
4943 if _, err := os.Stat("tui-tiny.wasm"); err == nil {
4944 suffix = "-tiny.wasm"
4945 execPath = f.WasmExecPathTinyGo
4946 }
4947 }
4948 html := fmt.Sprintf(`<!DOCTYPE html>
4949<html lang="en">
4950<head>
4951<meta charset="utf-8">
4952<meta name="viewport" content="width=device-width, initial-scale=1">
4953<title>%s β terminal</title>
4954<meta name="description" content="%s as a terminal: browse the catalog, view products, and fill a cart in a TUI running in your browser.">
4955<meta name="robots" content="index, follow">
4956<link rel="stylesheet" href="/font.css">
4957<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;}#terminal{position:fixed;inset:0;}</style>
4958<script src="%s"></script>
4959<script>
4960if (!WebAssembly.instantiateStreaming) {
4961 WebAssembly.instantiateStreaming = async (resp, importObject) => {
4962 const source = await (await resp).arrayBuffer();
4963 return await WebAssembly.instantiate(source, importObject);
4964 };
4965}
4966const go = new Go();
4967WebAssembly.instantiateStreaming(fetch("/tui%s"), go.importObject).then((result) => {
4968 go.run(result.instance);
4969}).catch((err) => { console.error("Failed to run WASM:", err); });
4970</script>
4971</head>
4972<body>
4973<div id="terminal"></div>
4974</body>
4975</html>`, f.Sitedomain, f.Sitelongname, execPath, suffix)
4976 c.Set("Content-Type", "text/html; charset=utf-8")
4977 return c.Status(fiber.StatusOK).SendString(html)
4978}
4979
4980// deskpage serves the site as a desktop: the website in a maximized window
4981// and the storefront terminal in another, both launchable from the panel.
4982// Same drop-in mechanism as tuipage, against the 'wasm/desk' source.
4983func deskpage(c fiber.Ctx) error {
4984 suffix := ".wasm"
4985 execPath := f.WasmExecPathGo
4986 if f.UseTinygo {
4987 if _, err := os.Stat("desk-tiny.wasm"); err == nil {
4988 suffix = "-tiny.wasm"
4989 execPath = f.WasmExecPathTinyGo
4990 }
4991 }
4992 // ?bare / ?fsonly / ?nonet split the page's script layer for debugging:
4993 // bare = none of jsfs/vnet, fsonly = jsfs alone, nonet = no browser.
4994 //
4995 // netscrape is no longer among these. It used to be a JS engine served at
4996 // /netscrape.js; it is now a Go browser compiled INTO the desk wasm, so
4997 // there is no script to leave out β ?nonet is honored by the desk itself,
4998 // which reads it from location.search and does not open the window.
4999 scripts := `<script src="/bottle/jsfs.js"></script>
5000<script src="/bottle/vnet.js"></script>`
5001 switch {
5002 case c.Query("bare") != "":
5003 scripts = ""
5004 case c.Query("fsonly") != "":
5005 scripts = `<script src="/bottle/jsfs.js"></script>`
5006 }
5007 html := fmt.Sprintf(`<!DOCTYPE html>
5008<html lang="en">
5009<head>
5010<meta charset="utf-8">
5011<meta name="viewport" content="width=device-width, initial-scale=1">
5012<title>%s β desktop</title>
5013<meta name="description" content="%s as a desktop: the website and the storefront terminal, each in a window.">
5014<meta name="robots" content="index, follow">
5015<link rel="stylesheet" href="/font.css">
5016<style>
5017html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;
5018 font-family:mononokiregular,ui-monospace,monospace;color:#fff;}
5019/* Windows are positioned against this, so it is the element that fills the
5020 page rather than the body. */
5021#desktop{position:fixed;inset:0;}
5022#boot{padding:14px;color:#777;font-size:13px;}
5023</style>
5024%s
5025<script>
5026// Channel providers for the netscrape browser window the desk opens. The
5027// clearnet channel is the page's own fetch β enough for this origin and for
5028// anything CORS permits; there is no skysocks here. dmsg has no transport in
5029// this page at all, so it answers 502 rather than hanging.
5030globalThis.__m2Glue = {
5031 fetchClearnet: async function (exit, method, url, body, winId, headers) {
5032 try {
5033 const r = await fetch(url, { method: method || "GET", body: body || undefined, headers: headers || undefined });
5034 const buf = new Uint8Array(await r.arrayBuffer());
5035 const hs = {};
5036 r.headers.forEach(function (v, k) { hs[k] = v; });
5037 return { status: r.status, body: buf, headers: hs };
5038 } catch (e) {
5039 return { status: 502, body: new Uint8Array(), headers: {} };
5040 }
5041 },
5042 fetchDmsg: async function () { return { status: 502, body: new Uint8Array(), headers: {} }; },
5043};
5044
5045// netscrape asks globalThis.__netscrapeFetch(url) for every page and
5046// subresource, and expects a Response. Without one it falls back to a
5047// same-origin /fetch proxy this server does not have, so the transport is
5048// wired here rather than left to that default.
5049//
5050// Note the argument order: netscrape's own loader.js calls
5051// fetchClearnet(url, method, body), but __m2Glue predates it and takes
5052// (exit, method, url, ...). The glue is what the rest of this page uses, so
5053// the shim adapts to the glue rather than the other way round.
5054globalThis.__netscrapeFetch = function (url) {
5055 var u;
5056 try { u = new URL(url, location.href); } catch (e) { return fetch(url); }
5057 var path = (u.pathname || "/") + (u.search || "");
5058 var mesh = /\.(dmsg|skysocks|skynet)$/i.test(u.hostname) || /^[0-9a-f]{66}$/i.test(u.hostname);
5059 // vnet:<port> is the site running INSIDE this tab, on the page's virtual
5060 // loopback. It needs its own name: the desk's own origin is a real
5061 // 127.0.0.1:<port>, so addressing the in-tab server as 127.0.0.1 too sent
5062 // it to the page's ordinary fetch and out to the host, which answered
5063 // ERR_CONNECTION_REFUSED. Nothing outside this page can be reached at
5064 // "vnet", so the routing is unambiguous.
5065 var call;
5066 if (u.hostname === "vnet") {
5067 call = globalThis.vnet.httpFetch(parseInt(u.port || "80", 10), "GET", path, null);
5068 } else if (mesh) {
5069 call = globalThis.__m2Glue.fetchDmsg(u.hostname, "GET", path, null);
5070 } else {
5071 call = globalThis.__m2Glue.fetchClearnet(null, "GET", u.href, null, null, null);
5072 }
5073 return Promise.resolve(call).then(function (r) {
5074 var h = new Headers();
5075 if (r && r.headers) { for (var k in r.headers) { try { h.set(k, r.headers[k]); } catch (e) {} } }
5076 return new Response((r && r.body) || new Uint8Array(0), { status: (r && r.status) || 200, headers: h });
5077 });
5078};
5079</script>
5080<script src="%s"></script>
5081<script>
5082if (!WebAssembly.instantiateStreaming) {
5083 WebAssembly.instantiateStreaming = async (resp, importObject) => {
5084 const source = await (await resp).arrayBuffer();
5085 return await WebAssembly.instantiate(source, importObject);
5086 };
5087}
5088const go = new Go();
5089WebAssembly.instantiateStreaming(fetch("/desk%s"), go.importObject).then((result) => {
5090 const b = document.getElementById("boot"); if (b) b.remove();
5091 go.run(result.instance);
5092}).catch((err) => { console.error("Failed to run WASM:", err); });
5093</script>
5094</head>
5095<body>
5096<div id="desktop"><div id="boot">loadingβ¦</div></div>
5097</body>
5098</html>`, f.Sitedomain, f.Sitelongname, scripts, execPath, suffix)
5099 c.Set("Content-Type", "text/html; charset=utf-8")
5100 return c.Status(fiber.StatusOK).SendString(html)
5101}
5102
5103// apiproducts serves the catalog as JSON for store clients (the tui's
5104// --store mode, and the browser tui to come). The business-sensitive
5105// columns the HTML never renders stay private.
5106func apiproducts(c fiber.Ctx) error {
5107 allproductsMu.RLock()
5108 prods := make(p.Products, len(allproducts))
5109 copy(prods, allproducts)
5110 allproductsMu.RUnlock()
5111 for i := range prods {
5112 prods[i].Cost = ""
5113 prods[i].Location = ""
5114 prods[i].Sourceinfo = ""
5115 }
5116 return c.JSON(prods)
5117}
5118
5119// extraRoutes collects route registrars from optional drop-in files
5120// (see other.go.example). A drop-in appends its registrar from init();
5121// deleting the file removes its routes with no other code changes.
5122var extraRoutes []func(*fiber.App)
5123
5124func logo(c fiber.Ctx) error {
5125 tmpl, err := auxTmpl()
5126 if err != nil {
5127 msg := fmt.Sprintf("Error parsing html template: %v", err)
5128 log.Println(msg)
5129 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5130 }
5131 tmpl0, err := tmpl.Clone()
5132 if err != nil {
5133 msg := fmt.Sprintf("Error cloning template: %v", err)
5134 log.Println(msg)
5135 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5136 }
5137 _, err = tmpl0.New("main").Parse(h.Logo())
5138 if err != nil {
5139 msg := fmt.Sprintf("Error parsing product page template: %v", err)
5140 log.Println(msg)
5141 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5142 }
5143 tmpl = tmpl0
5144 c.Set("Content-Type", "text/html;charset=utf-8")
5145
5146 img2txtFlags := ""
5147 if w, err := strconv.Atoi(c.Params("width")); err == nil {
5148 img2txtFlags = fmt.Sprintf("--width=%d ", w)
5149 }
5150 if h, err := strconv.Atoi(c.Params("height")); err == nil {
5151 img2txtFlags = fmt.Sprintf("--height=%d ", h)
5152 }
5153
5154 logoHTMLslice, err := script.Exec(fmt.Sprintf("bash -c 'img2txt %s logo.jpg | ansifilter -H'", img2txtFlags)).Slice()
5155 if err != nil {
5156 log.Println("error: ", err)
5157 _, err = c.Status(fiber.StatusInternalServerError).Write([]byte(err.Error() + "/n" + strings.Join(logoHTMLslice, "\n")))
5158 return err
5159 }
5160 if len(logoHTMLslice) > 2 {
5161 logoHTMLslice = logoHTMLslice[:len(logoHTMLslice)-3]
5162 }
5163 if len(logoHTMLslice) > 18 {
5164 logoHTMLslice = logoHTMLslice[19:]
5165 }
5166
5167 var result bytes.Buffer
5168 h1 := pageMeta(c, htmlTemplateData{})
5169 h1.Page = "logo"
5170 h1.Title = "logo"
5171 tmplData := map[string]interface{}{
5172 "Content": strings.Join(logoHTMLslice, "\n"),
5173 }
5174 err = tmpl.Execute(&result, tmplData)
5175 if err != nil {
5176 log.Println("error: ", err)
5177 _, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
5178 return err
5179 }
5180 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
5181 return err
5182}
5183
5184func robots(c fiber.Ctx) error {
5185 c.Set("Content-Type", "text/plain;charset=utf-8")
5186 _, err := c.Status(fiber.StatusOK).Write([]byte(fmt.Sprintf("User-agent: *\n\nSitemap: https://%s/sitemap.xml", c.Hostname())))
5187 return err
5188}
5189
5190func style(c fiber.Ctx) error {
5191 c.Set("Content-Type", "text/css;charset=utf-8")
5192 _, err := c.Status(fiber.StatusOK).Write([]byte(h.StyleCSS()))
5193 return err
5194}
5195
5196// fontcss serves just the @font-face block, so the terminal page can have
5197// the site's face without pulling in the whole stylesheet. The TUI measures
5198// its cell from this font when it opens, which is why it is a separate,
5199// cacheable route rather than something inlined per page load.
5200func fontcss(c fiber.Ctx) error {
5201 c.Set("Content-Type", "text/css;charset=utf-8")
5202 c.Set("Cache-Control", "public, max-age=86400")
5203 _, err := c.Status(fiber.StatusOK).Write([]byte(h.FontCSS()))
5204 return err
5205}
5206
5207func serveWASM(r *fiber.App) {
5208 if f.WasmExecPath != "" {
5209 _, err := script.File(f.WasmExecPath).Bytes()
5210 if err != nil {
5211 log.Printf("Error reading %s: %v\n", f.WasmExecPath, err)
5212 } else { //the wasm exec must be present or none of the webassembly stuff will work ; provided by the golang installaton
5213 r.Get(f.WasmExecPathTinyGo, func(c fiber.Ctx) error {
5214 wasmExecData, err := script.File(f.WasmExecPathTinyGo).Bytes()
5215 if err != nil {
5216 log.Printf("Error reading %s: %v\n", f.WasmExecPathTinyGo, err)
5217 return c.SendStatus(fiber.StatusNotFound)
5218 }
5219 c.Set("Content-Type", "application/js")
5220 _, err = c.Status(fiber.StatusOK).Write(wasmExecData)
5221 return err
5222 })
5223
5224 r.Get(f.WasmExecPathGo, func(c fiber.Ctx) error {
5225 wasmExecData, err := script.File(f.WasmExecPathGo).Bytes()
5226 if err != nil {
5227 log.Printf("Error reading %s: %v\n", f.WasmExecPathGo, err)
5228 return c.SendStatus(fiber.StatusNotFound)
5229 }
5230 c.Set("Content-Type", "application/js")
5231 _, err = c.Status(fiber.StatusOK).Write(wasmExecData)
5232 return err
5233 })
5234
5235 // Register both variants per source: a drop-in tinygo cannot
5236 // compile still serves its Go build, and pages pick whichever
5237 // binary exists.
5238 for _, wasmSRC := range f.WasmSRC {
5239 base := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
5240 for _, suffix := range []string{".wasm", "-tiny.wasm"} {
5241 outputFile := base + suffix
5242 r.Get("/"+outputFile, func(c fiber.Ctx) error {
5243 // A binary this size must revalidate, not cache
5244 // blindly: a browser holding yesterday's wasm
5245 // makes a deploy look like nothing changed.
5246 fi, err := os.Stat(outputFile)
5247 if err != nil {
5248 return c.SendStatus(fiber.StatusInternalServerError)
5249 }
5250 etag := fmt.Sprintf(`"%x-%x"`, fi.ModTime().Unix(), fi.Size())
5251 c.Set("Cache-Control", "no-cache")
5252 c.Set("ETag", etag)
5253 if c.Get("If-None-Match") == etag {
5254 return c.SendStatus(fiber.StatusNotModified)
5255 }
5256 data, err := script.File(outputFile).Bytes()
5257 if err != nil {
5258 script.File(outputFile).Stdout() //nolint
5259 return c.SendStatus(fiber.StatusInternalServerError)
5260 }
5261 c.Set("Content-Type", "application/wasm")
5262 return c.Status(fiber.StatusOK).Send(data)
5263 })
5264 }
5265 }
5266 }
5267 }
5268}
5269
5270func sendFile(c fiber.Ctx) error {
5271 return c.SendFile("." + c.Path())
5272}
5273func sendImage(c fiber.Ctx) error {
5274 c.Set("Content-Type", "image/jpeg")
5275 return c.SendFile("./img" + c.Path())
5276}
5277
5278func stlbase64(c fiber.Ctx) error {
5279 name := c.Params("filename")
5280 if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
5281 return c.SendStatus(fiber.StatusBadRequest)
5282 }
5283 stlfile, err := script.File("img/stl/" + name).Bytes()
5284 if err != nil {
5285 return c.SendStatus(fiber.StatusNotFound)
5286 }
5287 _, err = c.Status(fiber.StatusOK).Write([]byte("data:model/stl;base64," + base64.StdEncoding.EncodeToString(stlfile)))
5288 return err
5289}
5290
5291type item struct {
5292 ID string
5293 Amount int64
5294}
5295
5296func cathtmlfunc(c fiber.Ctx) error {
5297 tmpl, err := mainTmpl()
5298 if err != nil {
5299 msg := fmt.Sprintf("Error parsing html template: %v", err)
5300 log.Println(msg)
5301 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5302 }
5303 tmpl0, err := tmpl.Clone()
5304 if err != nil {
5305 msg := fmt.Sprintf("Error cloning html template: %v", err)
5306 log.Println(msg)
5307 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5308 }
5309 _, err = tmpl0.New("main").Parse(h.CategoryPage())
5310 if err != nil {
5311 msg := fmt.Sprintf("Error parsing Category page template: %v", err)
5312 log.Println(msg)
5313 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5314 }
5315 tmpl = tmpl0
5316 var tmplData map[string]interface{}
5317 var result bytes.Buffer
5318 var categoryproducts p.Products
5319 c.Set("Content-Type", "text/html;charset=utf-8")
5320 h1 := pageMeta(c, htmlPageTemplateData)
5321 h1.Title = fmt.Sprintf("%s | %s", func() string {
5322 var str string
5323 if c.Params("partno") != "" {
5324 return "No product matching partno.: " + c.Params("partno") + " | Showing All Products"
5325 }
5326 if c.Params("cat") == "" {
5327 return "All Products"
5328 }
5329 str = fmt.Sprintf("Category: %s", c.Params("cat"))
5330 if c.Params("subcat") != "" {
5331 str += fmt.Sprintf("; Subcategory: %s", c.Params("subcat"))
5332 }
5333 return str
5334 }(), h1.Title)
5335 h1.Page = "category"
5336 if c.Params("cat") == "" && c.Params("subcat") == "" {
5337 tmplData = map[string]interface{}{
5338 "Products": allproducts,
5339 "Page": h1,
5340 "Category": c.Params("cat"),
5341 "Subcategory": c.Params("subcat"),
5342 "Prods": allproducts,
5343 "Product": c.Params("partno"),
5344 }
5345 } else {
5346
5347 for _, prod := range allproducts {
5348 if strings.EqualFold(prod.Category, c.Params("cat")) && (c.Params("subcat") == "" || strings.EqualFold(escapesubcat(prod.Subcategory), c.Params("subcat"))) {
5349 categoryproducts = append(categoryproducts, prod)
5350 }
5351 }
5352 tmplData = map[string]interface{}{
5353 "Products": categoryproducts,
5354 "Page": h1,
5355 "Category": c.Params("cat"),
5356 "Subcategory": c.Params("subcat"),
5357 "Prods": allproducts,
5358 }
5359 }
5360 err = tmpl.Execute(&result, tmplData)
5361 if err != nil {
5362 msg := fmt.Sprintf("Error execute html template: %v", err)
5363 log.Println(msg)
5364 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5365 }
5366 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
5367 return err
5368}
5369
5370func getcats() (cats []string) {
5371 var catsMap = make(map[string]int)
5372 for _, prod := range allproducts {
5373 catsMap[prod.Category]++
5374 }
5375 for cat := range catsMap {
5376 cats = append(cats, cat)
5377 }
5378 return cats
5379}
5380func contains(slice []string, str string) bool {
5381 for _, s := range slice {
5382 if s == str {
5383 return true
5384 }
5385 }
5386 return false
5387}
5388func getcategories(allproducts p.Products) (map[string]int, []string, map[string]map[string]int, map[string][]string) {
5389 categoryCounts := make(map[string]int)
5390 subcategoryCounts := make(map[string]map[string]int)
5391 subcategoriesByCategory := make(map[string][]string)
5392
5393 for _, prod := range allproducts {
5394 if prod.Category != "" {
5395 categoryCounts[prod.Category]++
5396 if prod.Subcategory != "" {
5397 if subcategoryCounts[prod.Category] == nil {
5398 subcategoryCounts[prod.Category] = make(map[string]int)
5399 }
5400 subcategoryCounts[prod.Category][prod.Subcategory]++
5401 if !contains(subcategoriesByCategory[prod.Category], prod.Subcategory) {
5402 subcategoriesByCategory[prod.Category] = append(subcategoriesByCategory[prod.Category], prod.Subcategory)
5403 }
5404 }
5405 }
5406 }
5407
5408 var sortableCategories []struct {
5409 Name string
5410 Count int
5411 }
5412 for cat, count := range categoryCounts {
5413 sortableCategories = append(sortableCategories, struct {
5414 Name string
5415 Count int
5416 }{Name: cat, Count: count})
5417 }
5418 sort.Slice(sortableCategories, func(i, j int) bool {
5419 return sortableCategories[i].Count > sortableCategories[j].Count
5420 })
5421 var sortedCategories []string
5422 for _, cat := range sortableCategories {
5423 sortedCategories = append(sortedCategories, cat.Name)
5424 var sortableSubcategories []struct {
5425 Name string
5426 Count int
5427 }
5428 for subcat, count := range subcategoryCounts[cat.Name] {
5429 sortableSubcategories = append(sortableSubcategories, struct {
5430 Name string
5431 Count int
5432 }{Name: subcat, Count: count})
5433 }
5434 sort.Slice(sortableSubcategories, func(i, j int) bool {
5435 return sortableSubcategories[i].Count > sortableSubcategories[j].Count
5436 })
5437 var sortedSubcategories []string
5438 for _, subcat := range sortableSubcategories {
5439 sortedSubcategories = append(sortedSubcategories, subcat.Name)
5440 }
5441 subcategoriesByCategory[cat.Name] = sortedSubcategories
5442 }
5443 return categoryCounts, sortedCategories, subcategoryCounts, subcategoriesByCategory
5444}
5445
5446func getsubcats(cat string) (subcats []string) {
5447 var subcatsMap = make(map[string]int)
5448 for _, prod := range allproducts {
5449 if cat == "" || strings.EqualFold(cat, prod.Category) {
5450 if prod.Subcategory != "" {
5451 subcatsMap[escapesubcat(prod.Subcategory)]++
5452 }
5453 }
5454 }
5455 for subcat := range subcatsMap {
5456 subcats = append(subcats, subcat)
5457 }
5458 return subcats
5459}
5460func escapesubcat(sc string) (esc string) {
5461 esc = strings.Replace(sc, "ΒΌ", "quarter-", -1)
5462 esc = strings.Replace(esc, "Β½", "half-", -1)
5463 esc = strings.Replace(esc, "1/16", "sixteenth-", -1)
5464 esc = strings.Replace(esc, "%", "-pct", -1)
5465 esc = strings.Replace(esc, " ", " ", -1)
5466 esc = strings.Replace(esc, " ", "-", -1)
5467 esc = strings.Replace(esc, "--", "-", -1)
5468 esc = strings.Replace(esc, "watt1", "watt-1", -1)
5469 esc = strings.Replace(esc, "watt5", "watt-5", -1)
5470 return esc
5471}
5472
5473func handlecat(c fiber.Ctx) error {
5474 if c.Params("cat") == "" && c.Params("subcat") == "" {
5475 return cathtmlfunc(c)
5476 }
5477 var catexists bool
5478 var subcatexists bool
5479 catexists = false
5480 for _, cat := range getcats() {
5481 if strings.EqualFold(cat, c.Params("cat")) {
5482 catexists = true
5483 break
5484 }
5485 }
5486 subcatexists = false
5487 if c.Params("subcat") != "" {
5488 for _, subcat := range getsubcats("") {
5489 if strings.EqualFold(escapesubcat(subcat), c.Params("subcat")) {
5490 subcatexists = true
5491 break
5492 }
5493 }
5494 }
5495 if c.Params("subcat") != "" && !subcatexists {
5496 log.Printf("subcategory %s does not match any existing subcategory\n", c.Params("subcat"))
5497 return c.Redirect().To("/cat/" + c.Params("cat"))
5498 }
5499 if !catexists {
5500 log.Printf("category %s does not match any existing category\n", c.Params("cat"))
5501 return c.Redirect().To("/cat")
5502 }
5503 if catexists || (catexists && subcatexists) {
5504 return cathtmlfunc(c)
5505 }
5506 return c.SendStatus(fiber.StatusNotFound)
5507}
5508
5509func homepage(c fiber.Ctx) error {
5510 tmpl, err := mainTmpl()
5511 if err != nil {
5512 msg := fmt.Sprintf("Could not parsing html template: %v", err)
5513 log.Println(msg)
5514 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5515 }
5516 tmpl0, err := tmpl.Clone()
5517 if err != nil {
5518 msg := fmt.Sprintf("Error cloning template: %v", err)
5519 log.Println(msg)
5520 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5521 }
5522 _, err = tmpl0.New("main").Parse(h.FrontPage())
5523 if err != nil {
5524 msg := fmt.Sprintf("Error parsing Front Page template: %v", err)
5525 log.Println(msg)
5526 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5527 }
5528 _, err = tmpl0.New("about").Parse(h.AboutPage())
5529 if err != nil {
5530 msg := fmt.Sprintf("Error parsing About Page template: %v", err)
5531 log.Println(msg)
5532 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5533 }
5534 _, err = tmpl0.New("policy").Parse(h.PolicyPage())
5535 if err != nil {
5536 msg := fmt.Sprintf("Error parsing Policy Page template: %v", err)
5537 log.Println(msg)
5538 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5539 }
5540 _, err = tmpl0.New("links").Parse(h.LinksPage())
5541 if err != nil {
5542 msg := fmt.Sprintf("Error parsing Links Page template: %v", err)
5543 log.Println(msg)
5544 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5545 }
5546 tmpl = tmpl0
5547 log.Println(c.Get("User-Agent"))
5548 c.Set("Content-Type", "text/html;charset=utf-8")
5549 h1 := pageMeta(c, htmlPageTemplateData)
5550 tmplData := map[string]interface{}{
5551 "Page": h1,
5552 "Prods": allproducts,
5553 }
5554 var result bytes.Buffer
5555 err = tmpl.Execute(&result, tmplData)
5556 if err != nil {
5557 msg := fmt.Sprintf("Error executing template: %v", err)
5558 log.Println(msg)
5559 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5560 }
5561 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
5562 return err
5563}
5564
5565func productpage(c fiber.Ctx) error {
5566 tmpl, err := mainTmpl()
5567 if err != nil {
5568 msg := fmt.Sprintf("Error parsing html template: %v", err)
5569 log.Println(msg)
5570 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5571 }
5572 tmpl0, err := tmpl.Clone()
5573 if err != nil {
5574 msg := fmt.Sprintf("Error cloning template: %v", err)
5575 log.Println(msg)
5576 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5577 }
5578 _, err = tmpl0.New("main").Parse(h.ProductPage())
5579 if err != nil {
5580 msg := fmt.Sprintf("Error parsing product page template: %v", err)
5581 log.Println(msg)
5582 return c.Status(fiber.StatusInternalServerError).SendString(msg)
5583 }
5584 tmpl = tmpl0
5585 c.Set("Content-Type", "text/html;charset=utf-8")
5586 for _, prod := range allproducts {
5587 if strings.EqualFold(prod.Partno, c.Params("partno")) {
5588 var result bytes.Buffer
5589 h1 := pageMeta(c, htmlPageTemplateData)
5590 h1.Page = "product"
5591 h1.Title = fmt.Sprintf("%s | %s", prod.Name, h1.Title)
5592 tmplData := map[string]interface{}{
5593 "Prod": prod,
5594 "Page": h1,
5595 "Prods": allproducts,
5596 }
5597 err := tmpl.Execute(&result, tmplData)
5598 if err != nil {
5599 log.Println("error: ", err)
5600 _, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
5601 return err
5602 }
5603 _, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
5604 return err
5605 }
5606 }
5607 log.Printf("product %s does not match any existing product\n", c.Params("partno"))
5608 return c.Status(fiber.StatusNotFound).Redirect().To("/cat")
5609}
5610
5611
5612// ===== pkg/web/sourcecode.go =====
5613// Package web pkg/web/sourcecode.go β the store serves its own source.
5614package web
5615
5616import (
5617 "bytes"
5618 "embed"
5619 "fmt"
5620 "io/fs"
5621 "os"
5622 "strings"
5623
5624 "github.com/alecthomas/chroma/v2"
5625 "github.com/alecthomas/chroma/v2/formatters/html"
5626 "github.com/alecthomas/chroma/v2/lexers"
5627 "github.com/alecthomas/chroma/v2/styles"
5628 "github.com/gofiber/fiber/v3"
5629
5630 "github.com/0magnet/m2/pkg/config"
5631 "github.com/0magnet/m2/pkg/product"
5632 "github.com/0magnet/m2/pkg/tui"
5633)
5634
5635//go:embed *.go
5636var srcWeb embed.FS
5637
5638type srcEntry struct {
5639 Prefix string
5640 FS fs.FS
5641}
5642
5643// goSources holds each package's embedded Go files for /sourcecode/go. A Go
5644// package cannot embed files outside its own directory, so since the code
5645// moved out of the repo root every package embeds its own *.go and is listed
5646// here. The web package's own embed picks up the other.go drop-in when a
5647// deployment builds with one.
5648var goSources = []srcEntry{
5649 {"pkg/config", config.Source},
5650 {"pkg/product", product.Source},
5651 {"pkg/tui", tui.Source},
5652 {"pkg/web", srcWeb},
5653}
5654
5655// RegisterSource adds a package's embedded sources to /sourcecode/go β used
5656// by the commands package, which web cannot import without a cycle.
5657func RegisterSource(prefix string, fsys fs.FS) {
5658 goSources = append(goSources, srcEntry{prefix, fsys})
5659}
5660
5661var sourcesWasm []fs.FS
5662var sourceCore = os.DirFS("ui")
5663var sourceHtml = os.DirFS("htmpl")
5664var sourceContent = os.DirFS("content")
5665
5666func serveSourceCode(r *fiber.App) {
5667 for _, wasmSRC := range f.WasmSRC {
5668 sourcesWasm = append(sourcesWasm, os.DirFS(wasmSRC))
5669 }
5670 r.Get("/sourcecode", func(c fiber.Ctx) error {
5671 ret := `<!doctype html>
5672<html lang='en'>
5673<head>
5674<link rel="stylesheet" href="/style.css" type="text/css">
5675</head>
5676<body class='grid-container' style='background-color:black;color:white;'>
5677<a href='/sourcecode/go'>GO</a><br><br>
5678
5679<a href='/sourcecode/html'>HTML</a><br><br>
5680
5681<a href='/sourcecode/content'>Content</a><br><br>
5682
5683<a href='/sourcecode/core'>C.O.R.E.</a><br><br>
5684
5685<a href='/sourcecode/wasm'>WASM</a><br><br>
5686
5687</body>
5688</html>
5689`
5690 c.Set("Content-Type", "text/html;charset=utf-8")
5691 _, err := c.Status(fiber.StatusOK).Write([]byte(ret))
5692 return err
5693 })
5694
5695 r.Get("/sourcecode/html", sourcecodehtml)
5696 r.Get("/sourcecode/content", sourcecodecontent)
5697 r.Get("/sourcecode/go", sourcecodego)
5698 r.Get("/sourcecode/core", sourcecodecore)
5699 r.Get("/sourcecode/wasm", func(c fiber.Ctx) error {
5700 ret := `<!doctype html>
5701<html lang='en'>
5702<head>
5703<link rel="stylesheet" href="/style.css" type="text/css">
5704</head>
5705<body class='grid-container' style='background-color:black;color:white;'>
5706`
5707 for _, wasmSRC := range f.WasmSRC {
5708 pathNameSlc := strings.Split(wasmSRC, "/")
5709 pathName := pathNameSlc[len(pathNameSlc)-1]
5710 ret += `<a href='/sourcecode/wasm/` + pathName + `'>` + pathName + `</a><br>
5711 `
5712 }
5713 ret += `</body></html>
5714 `
5715 c.Set("Content-Type", "text/html;charset=utf-8")
5716 _, err := c.Status(fiber.StatusOK).Write([]byte(ret))
5717 return err
5718 })
5719
5720 for i, wasmSRC := range f.WasmSRC {
5721 pathNameSlc := strings.Split(wasmSRC, "/")
5722 pathName := pathNameSlc[len(pathNameSlc)-1]
5723 r.Get("/sourcecode/wasm/"+pathName, func(c fiber.Ctx) error {
5724 return sourcecode(c, sourcesWasm[i], "dracula", "go")
5725 })
5726 }
5727}
5728
5729func sourcecodehtml(c fiber.Ctx) error {
5730 return sourcecode(c, sourceHtml, "monokai", "html")
5731}
5732func sourcecodecontent(c fiber.Ctx) error {
5733 return sourcecode(c, sourceContent, "monokai", "html")
5734}
5735
5736func sourcecodego(c fiber.Ctx) error {
5737 var builder strings.Builder
5738 for _, src := range goSources {
5739 if err := collectSource(&builder, src.FS, src.Prefix, "go"); err != nil {
5740 return err
5741 }
5742 }
5743 return renderSource(c, builder.String(), "monokai", "go")
5744}
5745
5746func sourcecodecore(c fiber.Ctx) error {
5747 return sourcecode(c, sourceCore, "solarized-dark256", "go")
5748}
5749
5750func sourcecode(c fiber.Ctx, fsys fs.FS, styleName string, lang string) error {
5751 var builder strings.Builder
5752 if err := collectSource(&builder, fsys, "", lang); err != nil {
5753 return err
5754 }
5755 return renderSource(c, builder.String(), styleName, lang)
5756}
5757
5758// collectSource concatenates every .<lang> file in fsys into builder, each
5759// under a banner naming it (with prefix, so files from different packages
5760// keep their repo paths).
5761func collectSource(builder *strings.Builder, fsys fs.FS, prefix string, lang string) error {
5762 return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
5763 if err != nil {
5764 return err
5765 }
5766 if !d.IsDir() && strings.HasSuffix(path, "."+lang) {
5767 content, err := fs.ReadFile(fsys, path)
5768 if err != nil {
5769 return err
5770 }
5771 name := path
5772 if prefix != "" {
5773 name = prefix + "/" + path
5774 }
5775 builder.WriteString(fmt.Sprintf("// ===== %s =====\n", name))
5776 builder.Write(content)
5777 builder.WriteString("\n\n")
5778 }
5779 return nil
5780 })
5781}
5782
5783func renderSource(c fiber.Ctx, source string, styleName string, lang string) error {
5784 c.Set("Content-Type", "text/html;charset=utf-8")
5785 var buf bytes.Buffer
5786
5787 // Pick lexer & style
5788 lexer := lexers.Get(lang)
5789 if lexer == nil {
5790 lexer = lexers.Fallback
5791 }
5792 lexer = chroma.Coalesce(lexer)
5793
5794 style := styles.Get(styleName)
5795 if style == nil {
5796 style = styles.Fallback
5797 }
5798
5799 // Formatter with line numbers & CSS classes
5800 formatter := html.New(
5801 html.WithLineNumbers(true),
5802 html.WithClasses(true),
5803 )
5804
5805 iterator, err := lexer.Tokenise(nil, source)
5806 if err != nil {
5807 return err
5808 }
5809
5810 // Optional: include CSS in output
5811 var css bytes.Buffer
5812 _ = formatter.WriteCSS(&css, style) //nolint:errcheck // the stylesheet is decoration; the highlighted source is still readable without it
5813 buf.WriteString("<style>")
5814 buf.Write(css.Bytes())
5815 buf.WriteString("</style>")
5816
5817 if err := formatter.Format(&buf, style, iterator); err != nil {
5818 return err
5819 }
5820
5821 _, err = c.Status(fiber.StatusOK).Write(buf.Bytes())
5822 return err
5823}
5824
5825
5826// ===== pkg/web/tmpl.go =====
5827// Package web pkg/web/tmpl.go β template plumbing.
5828package web
5829
5830import (
5831 "bytes"
5832 "fmt"
5833 htmpl "html/template"
5834 "log"
5835 "os"
5836 "path/filepath"
5837 "sort"
5838 "strconv"
5839 "strings"
5840 ttmpl "text/template"
5841 "time"
5842
5843 "github.com/gofiber/fiber/v3"
5844
5845 p "github.com/0magnet/m2/pkg/product"
5846)
5847
5848/*
5849//go:embed htmpl/*
5850var templatesFS embed.FS
5851
5852//go:embed content/*
5853var contentFS embed.FS
5854*/
5855/*
5856var (
5857 templatesFS = os.DirFS("htmpl")
5858 contentFS = os.DirFS("content")
5859)
5860*/
5861/*
5862func mustReadEmbeddedFileToString(path string, fs embed.FS) string {
5863 return string(mustReadEmbeddedFileToBytes(path, fs))
5864}
5865
5866func mustReadEmbeddedFileToBytes(path string, fs embed.FS) []byte {
5867 data, err := fs.ReadFile(path)
5868 if err != nil {
5869 panic(err)
5870 }
5871 return data
5872}
5873*/
5874
5875func mustReadFileToString(path string) string {
5876 return string(mustReadFileToBytes(path))
5877}
5878
5879// optionalReadFileToString returns "" when the file does not exist.
5880// Used for deployment-local assets (e.g. content/font.css) that are
5881// not part of the source repo.
5882func optionalReadFileToString(path string) string {
5883 data, err := os.ReadFile(path) //nolint
5884 if err != nil {
5885 return ""
5886 }
5887 return string(data)
5888}
5889
5890// contentFile returns the deployment-local file when present, falling
5891// back to the committed <path>.example. Lets site operators override
5892// stock pages (about, policy, links) without touching tracked files.
5893func contentFile(path string) string {
5894 if s := optionalReadFileToString(path); s != "" {
5895 return s
5896 }
5897 return mustReadFileToString(path + ".example")
5898}
5899
5900func mustReadFileToBytes(path string) []byte {
5901 data, err := os.ReadFile(path) //nolint
5902 if err != nil {
5903 panic(err)
5904 }
5905 return data
5906}
5907
5908type htmlTemplate struct {
5909 Empty func() string
5910 Head func() string
5911 Logo func() string
5912 Header func() string
5913 Categories func() string
5914 CatSubcats func() string
5915 Footer func() string
5916 MainPage func() string
5917 AuxPage func() string
5918 FrontPage func() string
5919 CategoryPage func() string
5920 CategoryPageMD func() string
5921 ProductPage func() string
5922 ProductPageMD func() string
5923 Schema func() string
5924 Cart func() string
5925 XMLSitemap func() string
5926 Wasm func() string
5927 AboutPage func() string
5928 PolicyPage func() string
5929 LinksPage func() string
5930 CheckoutPage func() string
5931 CompletePage func() string
5932 CheckoutCSS func() string
5933 StyleCSS func() string
5934 FontCSS func() string
5935}
5936
5937var h = htmlTemplate{
5938 Empty: func() string { return mustReadFileToString("htmpl/empty.html") },
5939 Head: func() string { return mustReadFileToString("htmpl/head.html") },
5940 Logo: func() string { return mustReadFileToString("htmpl/logo.html") },
5941 Header: func() string { return mustReadFileToString("htmpl/header.html") },
5942 Categories: func() string { return mustReadFileToString("htmpl/categories.html") },
5943 CatSubcats: func() string { return mustReadFileToString("htmpl/catsubcats.html") },
5944 Footer: func() string { return mustReadFileToString("htmpl/footer.html") },
5945 MainPage: func() string { return mustReadFileToString("htmpl/main.html") },
5946 AuxPage: func() string { return mustReadFileToString("htmpl/auxpage.html") },
5947 FrontPage: func() string { return mustReadFileToString("htmpl/front.html") },
5948 CategoryPage: func() string { return mustReadFileToString("htmpl/category.html") },
5949 CategoryPageMD: func() string { return mustReadFileToString("htmpl/category.md") },
5950 ProductPage: func() string { return mustReadFileToString("htmpl/product.html") },
5951 ProductPageMD: func() string { return mustReadFileToString("htmpl/product.md") },
5952 Schema: func() string { return mustReadFileToString("htmpl/schema.html") },
5953 Cart: func() string { return mustReadFileToString("htmpl/cart.html") },
5954 XMLSitemap: func() string { return mustReadFileToString("htmpl/sitemap.xml") },
5955 Wasm: func() string { return mustReadFileToString("htmpl/wasm.html") },
5956 CompletePage: func() string { return mustReadFileToString("htmpl/complete.html") },
5957 AboutPage: func() string { return contentFile("content/about.html") },
5958 PolicyPage: func() string { return contentFile("content/policy.html") },
5959 LinksPage: func() string { return contentFile("content/links.html") },
5960 CheckoutPage: func() string { return mustReadFileToString("content/checkout.html") },
5961 CheckoutCSS: func() string { return mustReadFileToString("content/checkout.css") },
5962 StyleCSS: func() string {
5963 return fontCSS() + mustReadFileToString("content/style.css")
5964 },
5965 // The terminal page wants the face on its own, without the stylesheet.
5966 FontCSS: func() string { return fontCSS() },
5967}
5968
5969var htmlPageTemplateData htmlTemplateData
5970
5971var funcs = htmpl.FuncMap{
5972 "replace": replace, "mul": mul, "div": div, "safeHTML": safeHTML,
5973 "safeJS": safeJS, "stripProtocol": stripProtocol, "add": add, "sub": sub,
5974 "toFloat": toFloat, "equalsIgnoreCase": equalsIgnoreCase,
5975 "getsubcats": getsubcats, "escapesubcat": escapesubcat,
5976 "sortsubcats": sortsubcats, "repeat": repeat, "subcatlink": subcatlink,
5977}
5978
5979func mainTmpl() (tmpl *htmpl.Template, err error) {
5980 tmpl = htmpl.New("index").Funcs(funcs)
5981 if _, err := tmpl.Parse(h.MainPage()); err != nil {
5982 log.Println("Error parsing index template:", err)
5983 return tmpl, err
5984 }
5985
5986 partials := []struct {
5987 Name string
5988 Content string
5989 }{
5990 {"head", h.Head()},
5991 {"schema", h.Schema()},
5992 {"header", h.Header()},
5993 {"catsubcats", h.CatSubcats()},
5994 {"categories", h.Categories()},
5995 {"footer", h.Footer()},
5996 {"cart", h.Cart()},
5997 {"wasm", h.Wasm()},
5998 }
5999
6000 for _, p := range partials {
6001 if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
6002 log.Printf("Error parsing %s template: %v", p.Name, err)
6003 return tmpl, err
6004 }
6005 }
6006 return tmpl, err
6007}
6008
6009func auxTmpl() (tmpl *htmpl.Template, err error) {
6010 tmpl = htmpl.New("index").Funcs(funcs)
6011 if _, err := tmpl.Parse(h.AuxPage()); err != nil {
6012 log.Println("Error parsing index template:", err)
6013 return tmpl, err
6014 }
6015
6016 partials := []struct {
6017 Name string
6018 Content string
6019 }{
6020 {"head", h.Head()},
6021 {"schema", h.Empty()},
6022 {"wasm", h.Empty()},
6023 }
6024
6025 for _, p := range partials {
6026 if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
6027 log.Printf("Error parsing %s template: %v", p.Name, err)
6028 return tmpl, err
6029 }
6030 }
6031 return tmpl, err
6032}
6033
6034func pageMeta(c fiber.Ctx, base htmlTemplateData) htmlTemplateData {
6035 h := base
6036 host := string(c.Request().Host())
6037 /*
6038 proto := "http"
6039 if c.Secure() {
6040 proto += "s"
6041 }
6042 */
6043 proto := "https"
6044 h.Canonical = proto + "://" + host + c.OriginalURL()
6045 h.BaseURL = proto + "://" + host
6046 h.RequestHost = host
6047 h.Protocol = proto
6048 h.CatsCounts, h.Cats, h.SubCatsCounts, h.SubCatsByCat = getcategories(allproducts)
6049 h.LenAllProducts = len(allproducts)
6050 h.Time = time.Now().Format(time.RFC3339Nano)
6051 h.Year = fmt.Sprintf("%v", time.Now().Year())
6052 h.MetaDesc = f.Sitemeta
6053 h.KeyWords = strings.Replace(f.Sitelongname, " ", ", ", -1)
6054 return h
6055}
6056
6057func initTMPL() {
6058 htmlPageTemplateData = htmlTemplateData{
6059 TestMode: f.Teststripekey,
6060 Title: f.Sitelongname,
6061 StripePK: f.StripePK,
6062 SiteName: f.Sitedomain,
6063 SiteTagLine: f.Sitetagline,
6064 SiteName1: htmpl.HTML(checkerBoard(f.Sitedomain)), //nolint
6065 SiteLongName: f.Sitelongname,
6066 SiteASCIILogo: htmpl.HTML(f.SiteASCIILogo), //nolint
6067 SitePrettyName: f.Siteprettyname,
6068 SitePrettyNameCap: f.Siteprettynamecap,
6069 SitePrettyNameCaps: f.Siteprettynamecaps,
6070 TelegramContact: f.Tgcontact,
6071 TelegramChannel: f.Tgchannel,
6072 WasmExecPath: f.WasmExecPath,
6073 WasmExecRel: f.WasmExecPath,
6074 Cats: getcats(),
6075 LenAllProducts: len(allproducts),
6076 ImgSRC: func() (ret string) {
6077 ret = f.Siteimagesrc
6078 if ret == "" {
6079 ret = "/i"
6080 }
6081 return ret
6082 }(),
6083 Page: "front",
6084 Time: time.Now().Format(time.RFC3339Nano),
6085 Year: fmt.Sprintf("%v", time.Now().Year()),
6086 }
6087 htmlPageTemplateData.CatsCounts, htmlPageTemplateData.Cats, htmlPageTemplateData.SubCatsCounts, htmlPageTemplateData.SubCatsByCat = getcategories(allproducts)
6088 htmlPageTemplateData.WasmBinary = wasmBinary()
6089
6090}
6091
6092func wasmBinary() (ret []string) {
6093 if len(f.WasmSRC) == 0 {
6094 return ret
6095 }
6096 if f.UseTinygo {
6097 for _, wasmSRC := range f.WasmSRC {
6098 outputFile := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC)) + "-tiny.wasm"
6099 ret = append(ret, outputFile)
6100 }
6101 return ret
6102 }
6103 for _, wasmSRC := range f.WasmSRC {
6104 outputFile := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC)) + ".wasm"
6105 ret = append(ret, outputFile)
6106 }
6107 return ret
6108}
6109
6110type xmlTemplateData struct {
6111 BaseURL string
6112 Cats []string
6113 SubCatsByCat map[string][]string
6114 Products p.Products
6115 Update string
6116}
6117
6118func generateSitemapXML() string {
6119 xmlSitemapTemplateData := xmlTemplateData{
6120 BaseURL: "https://" + f.Sitedomain,
6121 Products: allproducts,
6122 Update: time.Now().Format("2006-01-02"),
6123 }
6124 _, xmlSitemapTemplateData.Cats, _, xmlSitemapTemplateData.SubCatsByCat = getcategories(allproducts)
6125 var err1 error
6126 xtmpl, err1 := ttmpl.New("index").Funcs(ttmpl.FuncMap{"getsubcats": getsubcats}).Parse(h.XMLSitemap())
6127 if err1 != nil {
6128 log.Println("Error parsing index template:", err1)
6129 }
6130 var result bytes.Buffer
6131 err1 = xtmpl.Execute(&result, xmlSitemapTemplateData)
6132 if err1 != nil {
6133 log.Println("error: ", err1)
6134 }
6135 return result.String()
6136}
6137
6138func toFloat(s string) float64 {
6139 if s == "" {
6140 return 0.0
6141 }
6142 f, err := strconv.ParseFloat(s, 64)
6143 if err != nil {
6144 return 0.0
6145 }
6146 return f
6147}
6148
6149func checkerBoard(input string) string {
6150 var result strings.Builder
6151 for i, char := range input {
6152 // Wrap every other letter with the specified HTML
6153 if i%2 == 0 {
6154 result.WriteString(fmt.Sprintf("<span class='nv'>%c</span>", char))
6155 } else {
6156 result.WriteRune(char)
6157 }
6158 }
6159 return result.String()
6160}
6161
6162type htmlTemplateData struct {
6163 Title string
6164 MetaDesc string
6165 Canonical string
6166 BaseURL string
6167 ImgSRC string // url where images are hosted
6168 OrdersURL string // url where checkout is served from
6169 SiteName string
6170 SiteTagLine string
6171 SiteName1 htmpl.HTML //checkerboard - alternate swap text & bg color
6172 SiteLongName string
6173 SitePrettyName string //ππππππ₯π π€π‘πππ£π.πππ₯
6174 SitePrettyNameCap string //ππππππ₯π π€π‘πππ£π.πππ₯
6175 SitePrettyNameCaps string //ππΈπΎβπΌπππββπΌβπΌ.βπΌπ
6176 SiteASCIILogo htmpl.HTML
6177 TelegramContact string
6178 TelegramChannel string
6179 Protocol string
6180 RequestHost string
6181 KeyWords string
6182 Style htmpl.HTML
6183 Heading htmpl.HTML
6184 StripePK string
6185 Cats []string
6186 CatsCounts map[string]int
6187 SubCatsCounts map[string]map[string]int
6188 SubCatsByCat map[string][]string
6189 LenAllProducts int
6190 Mobile bool
6191 Gocanvas htmpl.HTML
6192 WasmBinary []string
6193 WasmExecPath string
6194 WasmExecRel string
6195 StyleFontFace htmpl.CSS
6196 Message htmpl.HTML
6197 Page string
6198 Year string
6199 Time string
6200 AboutHTML htmpl.HTML
6201 LinksHTML htmpl.HTML
6202 PolicyHTML htmpl.HTML
6203 TestMode bool
6204}
6205
6206func equalsIgnoreCase(a, b string) bool {
6207 return strings.EqualFold(strings.Join(strings.Fields(a), ""), strings.Join(strings.Fields(b), ""))
6208}
6209
6210func replace(s, o, n string) string {
6211 return strings.ReplaceAll(s, o, n)
6212}
6213func mul(a, b float64) float64 {
6214 return a * b
6215}
6216func div(a, b float64) float64 {
6217 return a / b
6218}
6219func add(a, b int) int {
6220 return a + b
6221}
6222func sub(a, b int) int {
6223 return a - b
6224}
6225func safeHTML(s string) htmpl.HTML {
6226 return htmpl.HTML(s) //nolint
6227}
6228func safeJS(s string) htmpl.JS {
6229 return htmpl.JS(s) //nolint
6230}
6231func stripProtocol(s string) string {
6232 return strings.Replace(strings.Replace(s, "https://", "", -1), "http://", "", -1)
6233}
6234func repeat(s string, count int) string {
6235 var result string
6236 for i := 0; i < count; i++ {
6237 result += s
6238 }
6239 return result
6240}
6241func sortsubcats(subcats []string, counts map[string]map[string]int) []string {
6242 sort.Slice(subcats, func(i, j int) bool {
6243 catI, catJ := subcats[i], subcats[j]
6244 countI, countJ := counts[catI]["count"], counts[catJ]["count"]
6245 return countI > countJ
6246 })
6247 return subcats
6248}
6249
6250func subcatlink(subcategory string) string {
6251 s := subcategory
6252 s = strings.ReplaceAll(s, "ΒΌ", "quarter-")
6253 s = strings.ReplaceAll(s, "Β½", "half-")
6254 s = strings.ReplaceAll(s, "1/16", "sixteenth-")
6255 s = strings.ReplaceAll(s, "%", "-pct")
6256 s = strings.ReplaceAll(s, " ", " ")
6257 s = strings.ReplaceAll(s, "watt1", "watt-1")
6258 s = strings.ReplaceAll(s, "watt5", "watt-5")
6259 s = strings.ReplaceAll(s, " ", "-")
6260 s = strings.ReplaceAll(s, "--", "-")
6261 return s
6262}
6263
6264// fontCSS is the deployment's @font-face block, read from disk. It is a
6265// plain function rather than a method on h because h's own initializer
6266// needs it, and a var cannot refer to itself.
6267func fontCSS() string { return optionalReadFileToString("content/font.css") }
6268
6269
6270// ===== pkg/web/wasmbuild.go =====
6271// Package web pkg/web/wasmbuild.go β compiling the drop-in wasm apps.
6272package web
6273
6274import (
6275 "encoding/json"
6276 "fmt"
6277 "io"
6278 "log"
6279 "net/http"
6280 "os/exec"
6281 "path/filepath"
6282 "regexp"
6283 "strconv"
6284 "strings"
6285 "time"
6286
6287 "github.com/bitfield/script"
6288 "github.com/briandowns/spinner"
6289
6290 "github.com/0magnet/m2/pkg/config"
6291)
6292
6293func CompileWASM() {
6294 s := spinner.New(spinner.CharSets[14], 25*time.Millisecond)
6295 s.Suffix = " Compiling wasm..."
6296 // Each build runs on the system's default Go first; only if that fails does
6297 // it retry on a pinned Go toolchain. The pinned version is normally resolved
6298 // dynamically β tinygo trails the latest Go release, so on failure we read
6299 // the Go range tinygo supports from its own error and pin the newest matching
6300 // Go patch (see resolveCompatibleToolchain). That keeps the site on the newest
6301 // Go whenever tinygo supports it and self-heals across Go bumps with no
6302 // hardcoded version. GOTOOLCHAINFALLBACK in the MENV config is an OPTIONAL
6303 // offline backstop, used only if the dynamic lookup can't reach the release
6304 // list; leave it empty for fully dynamic behavior.
6305 offlineFallback := config.ScriptExecString("${GOTOOLCHAINFALLBACK}")
6306 for _, wasmSRC := range f.WasmSRC {
6307 ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
6308 outputFile := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC)) + ".wasm"
6309 mk := func(tc string) string {
6310 return fmt.Sprintf("cd %s || exit 1 ; time GOOS=js GOARCH=wasm %s%s -o %s %s -ldflags=\"-s -w\" %s && cd %s && du %s", wasmSRC, tc, f.Gobuild, ascend+outputFile, ldflags(wasmSRC), ".", ascend, outputFile)
6311 }
6312 buildWasmWithFallback(mk, offlineFallback, s, true)
6313 }
6314 if !f.UseTinygo {
6315 return
6316 }
6317 for _, wasmSRC := range f.WasmSRC {
6318 ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
6319 outputFile := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC)) + "-tiny.wasm"
6320 mk := func(tc string) string {
6321 return fmt.Sprintf("cd %s || exit 1 ; time GOOS=js GOARCH=wasm %s%s -o %s %s %s && cd %s && du %s", wasmSRC, tc, f.Tinygobuild, ascend+outputFile, ldflags(wasmSRC), ".", ascend, outputFile)
6322 }
6323 // The Go build above succeeded and serves; a drop-in that tinygo
6324 // cannot compile (e.g. one using net/http, which tinygo's shim
6325 // does not build) must not take the whole store down.
6326 buildWasmWithFallback(mk, offlineFallback, s, false)
6327 }
6328}
6329
6330// runBash runs an inner bash script and returns its combined stdout+stderr, so
6331// the caller can both log it and parse compiler errors out of it.
6332func runBash(inner string) (string, error) {
6333 // The command is built by this package, not taken from a request.
6334 out, err := exec.Command("bash", "-c", inner).CombinedOutput() //nolint:gosec
6335 return string(out), err
6336}
6337
6338// buildWasmWithFallback builds wasm on the system Go first (mkCmd("")). On
6339// failure it resolves a Go toolchain tinygo can use β dynamically from tinygo's
6340// own error output, or the offlineFallback if the release list is unreachable β
6341// and retries with GOTOOLCHAIN pinned to it. When required, a build that still
6342// fails is fatal; otherwise it is logged and skipped (the pages fall back to
6343// whichever binary exists). mkCmd receives the string to place before the
6344// build command: "" for the default, or "GOTOOLCHAIN=β¦ ".
6345func buildWasmWithFallback(mkCmd func(toolchainPrefix string) string, offlineFallback string, s *spinner.Spinner, required bool) {
6346 log.Println("Compiling wasm with:")
6347 log.Println(mkCmd(""))
6348 s.Start()
6349 out, err := runBash(mkCmd(""))
6350 s.Stop()
6351 log.Print(out)
6352 if err == nil {
6353 log.Println("Compiled wasm!")
6354 return
6355 }
6356 tc := resolveCompatibleToolchain(out)
6357 if tc == "" {
6358 tc = offlineFallback
6359 }
6360 if tc == "" {
6361 if required {
6362 log.Fatalf("wasm build failed and no compatible Go toolchain could be resolved: %v", err)
6363 }
6364 log.Printf("optional wasm build failed (no compatible toolchain): %v", err)
6365 return
6366 }
6367 log.Printf("wasm build failed on the default Go; retrying with GOTOOLCHAIN=%s", tc)
6368 s.Start()
6369 out, err = runBash(mkCmd("GOTOOLCHAIN=" + tc + " "))
6370 s.Stop()
6371 log.Print(out)
6372 if err != nil {
6373 if required {
6374 log.Fatal(err)
6375 }
6376 log.Printf("optional wasm build failed: %v", err)
6377 return
6378 }
6379 log.Printf("Compiled wasm (Go toolchain %s)!", tc)
6380}
6381
6382// resolveCompatibleToolchain reads tinygo's supported Go range from a failed
6383// build's output (e.g. "requires go version 1.19 through 1.26, got go1.27") and
6384// returns the newest released Go patch of that ceiling minor (e.g. "go1.26.7"),
6385// or "" if the range can't be parsed or the release list can't be fetched.
6386func resolveCompatibleToolchain(buildOutput string) string {
6387 m := regexp.MustCompile(`through (\d+)\.(\d+)`).FindStringSubmatch(buildOutput)
6388 if m == nil {
6389 return ""
6390 }
6391 minor := m[1] + "." + m[2]
6392
6393 client := &http.Client{Timeout: 20 * time.Second}
6394 resp, err := client.Get("https://go.dev/dl/?mode=json&include=all")
6395 if err != nil {
6396 return ""
6397 }
6398 defer func() { _ = resp.Body.Close() }() //nolint:errcheck,gosec
6399 body, err := io.ReadAll(resp.Body)
6400 if err != nil {
6401 return ""
6402 }
6403 var rels []struct {
6404 Version string `json:"version"`
6405 }
6406 if err := json.Unmarshal(body, &rels); err != nil {
6407 return ""
6408 }
6409
6410 // Newest patch of go<minor> (require a patch number: GOTOOLCHAIN rejects a
6411 // bare "go1.26" β it must be a full toolchain version like go1.26.7).
6412 re := regexp.MustCompile(`^go` + regexp.QuoteMeta(minor) + `\.(\d+)$`)
6413 best, bestPatch := "", -1
6414 for _, r := range rels {
6415 mm := re.FindStringSubmatch(r.Version)
6416 if mm == nil {
6417 continue
6418 }
6419 // A version that does not parse yields 0, which loses the comparison
6420 // below, so an unparsable tag is skipped rather than erroring.
6421 if p, _ := strconv.Atoi(mm[1]); p > bestPatch { //nolint:errcheck
6422 bestPatch, best = p, r.Version
6423 }
6424 }
6425 return best
6426}
6427
6428func ldflags(s string) (ss string) {
6429 checkFiles, err := script.FindFiles(s).Slice()
6430 if err != nil {
6431 log.Fatal(err)
6432 }
6433 if f.LDFlagsX != "" {
6434 for _, s := range checkFiles {
6435 res, err := script.File(s).Match(strings.Split(f.LDFlagsX, "=")[0]).String()
6436 if err != nil {
6437 log.Fatal(err)
6438 }
6439 if res != "" {
6440 ss += fmt.Sprintf(` -X 'main.%s' `, f.LDFlagsX)
6441 break
6442 }
6443 }
6444 }
6445 for _, s := range checkFiles {
6446 res, err := script.File(s).Match("wasmName").String()
6447 if err != nil {
6448 log.Fatal(err)
6449 }
6450 if res != "" {
6451 ss += fmt.Sprintf(` -X 'main.wasmName=%s' `, strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))+".wasm")
6452 break
6453 }
6454 }
6455 if ss != "" {
6456 ss = `-ldflags="` + ss + `"`
6457 }
6458 return ss
6459}
6460
6461
6462// ===== cmd/m2/commands/gen.go =====
6463// Package commands cmd/m2/commands/gen.go β the `m2 gen` config template.
6464package commands
6465
6466import (
6467 "fmt"
6468
6469 "github.com/spf13/cobra"
6470)
6471
6472var genCmd = &cobra.Command{
6473 Use: "gen",
6474 Short: "generate conf template",
6475 Long: "generate conf template",
6476 Run: func(_ *cobra.Command, _ []string) {
6477 fmt.Print(envfiletemplate)
6478 },
6479}
6480
6481const envfiletemplate = `#########################################################################
6482# M2 CONFIG
6483#
6484# Copy to <yoursite>.conf and run with: MENV=<yoursite>.conf m2 run
6485# This file is sourced by bash; use shell syntax.
6486# Comment a value with # to use the built-in default.
6487#########################################################################
6488
6489### Stripe Configuration ################################################
6490
6491#-- Live and test API keys - REQUIRED for checkout
6492# https://dashboard.stripe.com/apikeys
6493STRIPELIVEPK='pk_live_...'
6494STRIPELIVESK='sk_live_...'
6495STRIPETESTPK='pk_test_...'
6496STRIPETESTSK='sk_test_...'
6497
6498#-- Use the test keys instead of the live keys
6499TESTSTRIPEKEY=true
6500
6501### Product Data ########################################################
6502
6503#-- Products CSV path (see products.example.csv for the schema)
6504PRODUCTSCSV='products.csv'
6505
6506### Site Identity #######################################################
6507
6508#-- Image subdomain, no trailing slash (ex. 'https://img.example.com')
6509# empty = serve images from ./img
6510SITEIMAGESRC=''
6511
6512#-- Orders subdomain, no trailing slash (ex. 'https://pay.example.com')
6513SITEORDERSURL=''
6514
6515#-- Website (Host) Name - domain minus extension (ex. 'example')
6516SITENAME='example'
6517
6518#-- Website Domain Extension (ex. '.com' '.net')
6519SITEEXT='.com'
6520
6521#-- Site Long Name (ex. 'example electronic surplus')
6522SITELONGNAME='example web store'
6523
6524#-- Site Tag Line
6525SITETAGLINE='an example web store'
6526
6527#-- Site Meta Description (SEO)
6528SITEMETA='an example web store selling example things'
6529
6530#-- Telegram contact + channel; username only, no 'https://t.me/'
6531TGCONTACT=''
6532TGCHANNEL=''
6533
6534### Web Server ##########################################################
6535
6536#-- Port to serve http on
6537WEBPORT='9883'
6538
6539
6540### WebAssembly #########################################################
6541
6542#-- Compile wasm with tinygo (smaller output) in addition to go
6543USETINYGO=true
6544
6545#-- wasm source directories, relative paths
6546# 'wasm/cart' powers checkout β keep it. 'wasm/tui' is the terminal
6547# storefront served at /tui. Additional entries are drop-in
6548# wasm apps: any dir under wasm/ with a main package (a self-contained
6549# module with its own vendor/ also works). Empty () disables all wasm.
6550WASMSRC=('wasm/cart' 'wasm/tui' 'wasm/desk')
6551
6552### Receipt Printing (CUPS) #############################################
6553
6554#-- CUPS printer name (default: system default)
6555PRINTERNAME=''
6556
6557#-- CUPS options, comma separated (ex. 'media=Custom.80x200mm,fit-to-page')
6558CUPSOPTIONS=''
6559
6560#-- timeout for lp command
6561LPTIMEOUT='10s'
6562
6563### Terminal UI ##########################################################
6564
6565#-- m2 tui client mode: browse this store over http instead of the
6566# local products csv / img / content files (ex. 'https://example.com')
6567STOREURL=''
6568`
6569
6570
6571// ===== cmd/m2/commands/root.go =====
6572// Package commands cmd/m2/commands/root.go β the m2 CLI.
6573package commands
6574
6575import (
6576 "embed"
6577 "log"
6578
6579 "github.com/spf13/cobra"
6580 "github.com/stripe/stripe-go/v81"
6581
6582 "github.com/0magnet/calvin"
6583 cc "github.com/0magnet/coloredcobra"
6584
6585 "github.com/0magnet/m2/pkg/config"
6586 "github.com/0magnet/m2/pkg/web"
6587)
6588
6589//go:embed *.go
6590var source embed.FS
6591
6592// f aliases the shared configuration, as in pkg/web.
6593var f = &config.F
6594
6595func init() {
6596 stripe.EnableTelemetry = false
6597 RootCmd.CompletionOptions.DisableDefaultCmd = true
6598 RootCmd.AddCommand(
6599 runCmd,
6600 genCmd,
6601 wasmCmd,
6602 tuiCmd,
6603 )
6604 var helpflag bool
6605 RootCmd.SetUsageTemplate(help)
6606 RootCmd.PersistentFlags().BoolVarP(&helpflag, "help", "h", false, "help for "+RootCmd.Use)
6607 RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
6608 RootCmd.PersistentFlags().MarkHidden("help") //nolint
6609
6610 web.RegisterSource("cmd/m2/commands", source)
6611}
6612
6613func init() {
6614 runCmd.Flags().SortFlags = false
6615 config.AddStringFlag([]*cobra.Command{runCmd}, &f.ProductsCSV, "products csv file")
6616 config.AddBoolFlag([]*cobra.Command{runCmd, wasmCmd}, &f.Teststripekey, "use stripe test api keys instead of live key")
6617 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripeliveSK, "stripe live api sk")
6618 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripelivePK, "stripe live api pk")
6619 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestSK, "stripe test api sk")
6620 config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestPK, "stripe test api pk")
6621 config.AddIntFlag([]*cobra.Command{runCmd}, &f.WebPort, "port to serve on")
6622 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteimagesrc, "domain for images - leave blank to serve images")
6623 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteordersurl, "domain for orders - leave blank for same domain")
6624 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitename, "site name")
6625 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteext, "site domain extension")
6626 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitelongname, "site long name")
6627 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitetagline, "site tag line")
6628 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitemeta, "site meta")
6629 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgcontact, "telegram contact")
6630 config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgchannel, "telegram channel")
6631 config.AddBoolFlag([]*cobra.Command{runCmd}, &f.UseTinygo, "use tinygo instead of go to compile wasm")
6632 config.AddStringSliceFlag([]*cobra.Command{runCmd, wasmCmd}, &f.WasmSRC, "wasm source code files RELATIVE PATHS without '..'")
6633 config.AddStringFlag([]*cobra.Command{runCmd}, &f.PrinterName, "CUPS printer name (default: system default)")
6634 config.AddStringFlag([]*cobra.Command{runCmd}, &f.CupsOptions, "e.g. 'media=Custom.80x200mm,fit-to-page'")
6635 config.AddDurationFlag([]*cobra.Command{runCmd}, &f.LpTimeout, "timeout for lp command")
6636 config.AddStringFlag([]*cobra.Command{tuiCmd}, &f.Storeurl, "browse this store over http (ex. 'https://magnetosphere.net') instead of local files")
6637}
6638
6639// RootCmd is the top-level m2 command.
6640var RootCmd = &cobra.Command{
6641 Use: "m2",
6642 Short: "web store server",
6643 Long: calvin.AsciiFont("magnetosphere.net") + "\n" + "web store server",
6644}
6645
6646// Execute executes the root cli command
6647func Execute() {
6648 cc.Init(&cc.Config{
6649 RootCmd: RootCmd,
6650 Headings: cc.HiBlue + cc.Bold,
6651 Commands: cc.HiBlue + cc.Bold,
6652 CmdShortDescr: cc.HiBlue,
6653 Example: cc.HiBlue + cc.Italic,
6654 ExecName: cc.HiBlue + cc.Bold,
6655 Flags: cc.HiBlue + cc.Bold,
6656 FlagsDescr: cc.HiBlue,
6657 NoExtraNewlines: true,
6658 NoBottomNewline: true,
6659 })
6660 if err := RootCmd.Execute(); err != nil {
6661 log.Fatal("Failed to execute command: ", err)
6662 }
6663}
6664
6665const help = "\r\n" +
6666 " {{if .HasAvailableSubCommands}}{{end}} {{if gt (len .Aliases) 0}}\r\n\r\n" +
6667 "{{.NameAndAliases}}{{end}}{{if .HasAvailableSubCommands}}\r\n\r\n" +
6668 "Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand)}}\r\n " +
6669 "{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}\r\n\r\n" +
6670 "Flags:\r\n" +
6671 "{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}\r\n\r\n" +
6672 "Global Flags:\r\n" +
6673 "{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}\r\n\r\n"
6674
6675
6676// ===== cmd/m2/commands/run.go =====
6677// Package commands cmd/m2/commands/run.go β the `m2 run` web server command.
6678package commands
6679
6680import (
6681 "fmt"
6682 "log"
6683 "os"
6684 "strings"
6685
6686 "github.com/spf13/cobra"
6687 "golang.org/x/text/cases"
6688 "golang.org/x/text/language"
6689
6690 "github.com/0magnet/calvin"
6691
6692 "github.com/0magnet/m2/pkg/config"
6693 "github.com/0magnet/m2/pkg/web"
6694)
6695
6696var runCmd = &cobra.Command{
6697 Use: "run",
6698 Short: "run the web application",
6699 Long: calvin.AsciiFont("magnetosphere.net") + "\n" + func() string {
6700 helptext := `Run the web application
6701Generate a config file first
6702
6703Config defaults file may also be specified with:
6704MENV=m2.conf m2 run
6705OR
6706MENV=/path/to/m2.conf m2 run
6707print the MENV file template with:
6708m2 gen`
6709 if config.MENV == "" {
6710 return helptext
6711 }
6712 if _, err := os.Stat(config.MENV); err == nil {
6713 return `Run the web application
6714
6715menv file detected: ` + config.MENV
6716 }
6717 return helptext
6718 }(),
6719 Run: func(_ *cobra.Command, _ []string) {
6720 f.Sitedomain = f.Sitename + f.Siteext
6721 log.Println(" Initializing " + f.Sitedomain)
6722 fmt.Println(calvin.BlackboardBold(f.Sitedomain))
6723 fmt.Println(calvin.AsciiFont(f.Sitedomain))
6724 config.InitStripe()
6725 f.Siteprettyname = calvin.BlackboardBold(f.Sitedomain) //"ππππππ₯π π€π‘πππ£π.πππ₯"
6726 c := cases.Title(language.English)
6727 f.Siteprettynamecap = calvin.BlackboardBold(c.String(f.Sitedomain)) //"ππππππ₯π π€π‘πππ£π.πππ₯"
6728 f.Siteprettynamecaps = calvin.BlackboardBold(strings.ToUpper(f.Sitedomain)) //"ππΈπΎβπΌπππββπΌβπΌ.βπΌπ"
6729 f.SiteASCIILogo = strings.Replace(strings.Replace(calvin.AsciiFont(f.Sitedomain), " ", " ", -1), "\n", "<br>\n", -1)
6730
6731 if f.UseTinygo {
6732 f.WasmExecPath = f.WasmExecPathTinyGo
6733 f.Buildwasmwith = f.Tinygobuild
6734 }
6735 if len(f.WasmSRC) == 0 {
6736 f.WasmExecPath = ""
6737 f.Buildwasmwith = ""
6738 }
6739 log.Println("Checking for products CSV")
6740 log.Println("Reading products CSV")
6741 if err := web.LoadCatalog(); err != nil {
6742 log.Fatal("Error getting file info:", err)
6743 }
6744 go web.WatchCatalog()
6745 web.Serve()
6746 },
6747}
6748
6749
6750// ===== cmd/m2/commands/tui.go =====
6751// Package commands cmd/m2/commands/tui.go β the `m2 tui` terminal storefront.
6752package commands
6753
6754import (
6755 "log"
6756
6757 "github.com/spf13/cobra"
6758
6759 "github.com/0magnet/m2/pkg/product"
6760 "github.com/0magnet/m2/pkg/tui"
6761)
6762
6763var tuiCmd = &cobra.Command{
6764 Use: "tui",
6765 Short: "browse the store in the terminal",
6766 Long: `Browse the store in a terminal UI.
6767
6768Reads the same config as 'run':
6769MENV=m2.conf m2 tui
6770
6771As a client of a running store (catalog, images, and checkout over
6772http; no local files needed):
6773m2 tui --storeurl https://magnetosphere.net`,
6774 Run: func(_ *cobra.Command, _ []string) {
6775 var prods product.Products
6776 if f.Storeurl != "" {
6777 var err error
6778 prods, err = tui.FetchCatalog(f.Storeurl)
6779 if err != nil {
6780 log.Fatalf("could not fetch the catalog from %s: %v", f.Storeurl, err)
6781 }
6782 } else {
6783 if f.ProductsCSV == "" {
6784 f.ProductsCSV = "products.csv"
6785 }
6786 prods = product.ReadCSV(f.ProductsCSV)
6787 }
6788 if len(prods) == 0 {
6789 log.Fatal("no products in the catalog")
6790 }
6791 if err := tui.Run(prods); err != nil {
6792 log.Fatal(err)
6793 }
6794 },
6795}
6796
6797
6798// ===== cmd/m2/commands/wasm.go =====
6799// Package commands cmd/m2/commands/wasm.go β the `m2 wasm` compile command.
6800package commands
6801
6802import (
6803 "log"
6804
6805 "github.com/spf13/cobra"
6806
6807 "github.com/0magnet/m2/pkg/config"
6808 "github.com/0magnet/m2/pkg/web"
6809)
6810
6811var wasmCmd = &cobra.Command{
6812 Use: "wasm",
6813 Short: "compile wasm",
6814 Long: "compile wasm",
6815 Run: func(_ *cobra.Command, _ []string) {
6816 if len(f.WasmSRC) == 0 {
6817 log.Fatal("No wasm source code specified")
6818 }
6819 config.InitStripe()
6820 web.CompileWASM()
6821 },
6822}
6823
6824