diff options
author | Přemysl Janouch <p@janouch.name> | 2019-04-14 22:30:40 +0200 |
---|---|---|
committer | Přemysl Janouch <p@janouch.name> | 2019-04-14 22:30:40 +0200 |
commit | 1331f3b5642f521236fcb1ec21ee43d5b76c0b91 (patch) | |
tree | 1213c2f2014cf0eff5a4a881b02cbebf91a604b8 /cmd | |
parent | 7d9410c6b3a724e3670941f7ec2d00e7966d0b1a (diff) | |
download | sklad-1331f3b5642f521236fcb1ec21ee43d5b76c0b91.tar.gz sklad-1331f3b5642f521236fcb1ec21ee43d5b76c0b91.tar.xz sklad-1331f3b5642f521236fcb1ec21ee43d5b76c0b91.zip |
Move commands under cmd/
Diffstat (limited to 'cmd')
-rw-r--r-- | cmd/bdf-preview/main.go | 93 | ||||
-rw-r--r-- | cmd/bdf-sample/main.go | 42 | ||||
-rw-r--r-- | cmd/label-tool/main.go | 199 | ||||
-rw-r--r-- | cmd/ql-info/main.go | 41 | ||||
-rw-r--r-- | cmd/ql-print/main.go | 88 | ||||
-rw-r--r-- | cmd/sklad/base.tmpl | 68 | ||||
-rw-r--r-- | cmd/sklad/container.tmpl | 99 | ||||
-rw-r--r-- | cmd/sklad/db.go | 225 | ||||
-rw-r--r-- | cmd/sklad/label.tmpl | 13 | ||||
-rw-r--r-- | cmd/sklad/login.tmpl | 17 | ||||
-rw-r--r-- | cmd/sklad/main.go | 271 | ||||
-rw-r--r-- | cmd/sklad/search.tmpl | 38 | ||||
-rw-r--r-- | cmd/sklad/series.tmpl | 43 | ||||
-rw-r--r-- | cmd/sklad/session.go | 66 |
14 files changed, 1303 insertions, 0 deletions
diff --git a/cmd/bdf-preview/main.go b/cmd/bdf-preview/main.go new file mode 100644 index 0000000..b1f2045 --- /dev/null +++ b/cmd/bdf-preview/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "html/template" + "image" + "image/draw" + "image/png" + "log" + "net/http" + "os" + + "janouch.name/sklad/bdf" +) + +type fontItem struct { + Font *bdf.Font + Preview image.Image +} + +var fonts = map[string]fontItem{} + +var tmpl = template.Must(template.New("list").Parse(` + <!DOCTYPE html> + <html><body> + <table border='1' cellpadding='3' style='border-collapse: collapse'> + <tr> + <th>Name</th> + <th>Preview</th> + <tr> +{{range $k, $v := . }} + <tr> + <td>{{$k}}</td> + <td><img src='?name={{$k}}'></td> + </tr> +{{end}} + </table> + </body></html> +`)) + +func handle(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 500) + return + } + + name := r.FormValue("name") + if name == "" { + w.Header().Set("Content-Type", "text/html") + tmpl.Execute(w, fonts) + return + } + + item, ok := fonts[name] + if !ok { + http.Error(w, "No such font.", 400) + return + } + + w.Header().Set("Content-Type", "image/png") + if err := png.Encode(w, item.Preview); err != nil { + http.Error(w, err.Error(), 500) + return + } +} + +func main() { + for _, filename := range os.Args[1:] { + fi, err := os.Open(filename) + if err != nil { + log.Fatalln(err) + } + font, err := bdf.NewFromBDF(fi) + if err != nil { + log.Fatalf("%s: %s\n", filename, err) + } + if err := fi.Close(); err != nil { + log.Fatalln(err) + } + + r, _ := font.BoundString(font.Name) + super := r.Inset(-3) + + img := image.NewRGBA(super) + draw.Draw(img, super, image.White, image.ZP, draw.Src) + font.DrawString(img, image.ZP, font.Name) + + fonts[filename] = fontItem{Font: font, Preview: img} + } + + log.Println("Starting server") + http.HandleFunc("/", handle) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/cmd/bdf-sample/main.go b/cmd/bdf-sample/main.go new file mode 100644 index 0000000..1850118 --- /dev/null +++ b/cmd/bdf-sample/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "image" + "image/draw" + "image/png" + "log" + "os" + + "janouch.name/sklad/bdf" +) + +func main() { + fi, err := os.Open(os.Args[1]) + if err != nil { + log.Fatalln(err) + } + defer fi.Close() + font, err := bdf.NewFromBDF(fi) + if err != nil { + log.Fatalln(err) + } + + r, _ := font.BoundString(font.Name) + super := r.Inset(-20) + + img := image.NewRGBA(super) + draw.Draw(img, super, image.White, image.ZP, draw.Src) + font.DrawString(img, image.ZP, font.Name) + + fo, err := os.Create("out.png") + if err != nil { + log.Fatalln(err) + } + if err := png.Encode(fo, img); err != nil { + fo.Close() + log.Fatal(err) + } + if err := fo.Close(); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/label-tool/main.go b/cmd/label-tool/main.go new file mode 100644 index 0000000..39a840c --- /dev/null +++ b/cmd/label-tool/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "errors" + "html/template" + "image" + "image/png" + "log" + "net/http" + "os" + "strconv" + + "janouch.name/sklad/bdf" + "janouch.name/sklad/imgutil" + "janouch.name/sklad/label" + "janouch.name/sklad/ql" +) + +var font *bdf.Font + +var tmpl = template.Must(template.New("form").Parse(` +<!DOCTYPE html> +<html><body> +<h1>PT-CBP label printing tool</h1> +<table><tr> +<td valign=top> + <img border=1 src='?img&scale={{.Scale}}&text={{.Text}}'> +</td> +<td valign=top> + <fieldset> + {{ if .Printer }} + + <p>Printer: {{ .Printer.Manufacturer }} {{ .Printer.Model }} + <p>Tape: + {{ if .Printer.LastStatus }} + {{ .Printer.LastStatus.MediaWidthMM }} mm × + {{ .Printer.LastStatus.MediaLengthMM }} mm + + {{ if .MediaInfo }} + (offset: {{ .MediaInfo.SideMarginPins }} pt, + print area: {{ .MediaInfo.PrintAreaPins }} pt) + {{ else }} + (unknown media) + {{ end }} + + {{ if .Printer.LastStatus.Errors }} + {{ range .Printer.LastStatus.Errors }} + <p>Error: {{ . }} + {{ end }} + {{ end }} + + {{ end }} + {{ if .InitErr }} + {{ .InitErr }} + {{ end }} + + {{ else }} + <p>Error: {{ .PrinterErr }} + {{ end }} + </fieldset> + <fieldset> + <p>Font: {{ .Font.Name }} + </fieldset> + <form><fieldset> + <p><label for=text>Text:</label> + <input id=text name=text value='{{.Text}}'> + <label for=scale>Scale:</label> + <input id=scale name=scale value='{{.Scale}}' size=1> + <p><input type=submit value='Update'> + <input type=submit name=print value='Update and Print'> + </fieldset></form> +</td> +</tr></table> +</body></html> +`)) + +func getPrinter() (*ql.Printer, error) { + printer, err := ql.Open() + if err != nil { + return nil, err + } + if printer == nil { + return nil, errors.New("no suitable printer found") + } + return printer, nil +} + +func getStatus(printer *ql.Printer) error { + if err := printer.Initialize(); err != nil { + return err + } + if err := printer.UpdateStatus(); err != nil { + return err + } + return nil +} + +func handle(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), 500) + return + } + + var ( + initErr error + mediaInfo *ql.MediaInfo + ) + printer, printerErr := getPrinter() + if printerErr == nil { + defer printer.Close() + printer.StatusNotify = func(status *ql.Status) { + log.Printf("\x1b[1mreceived status\x1b[m\n%s", status) + } + + if initErr = getStatus(printer); initErr == nil { + mediaInfo = ql.GetMediaInfo( + printer.LastStatus.MediaWidthMM(), + printer.LastStatus.MediaLengthMM(), + ) + } + } + + var params = struct { + Printer *ql.Printer + PrinterErr error + InitErr error + MediaInfo *ql.MediaInfo + Font *bdf.Font + Text string + Scale int + }{ + Printer: printer, + PrinterErr: printerErr, + InitErr: initErr, + MediaInfo: mediaInfo, + Font: font, + Text: r.FormValue("text"), + } + + var err error + params.Scale, err = strconv.Atoi(r.FormValue("scale")) + if err != nil { + params.Scale = 3 + } + + var img image.Image + if mediaInfo != nil { + img = &imgutil.LeftRotate{Image: label.GenLabelForHeight( + font, params.Text, mediaInfo.PrintAreaPins, params.Scale)} + if r.FormValue("print") != "" { + if err := printer.Print(img); err != nil { + log.Println("print error:", err) + } + } + } + + if _, ok := r.Form["img"]; !ok { + w.Header().Set("Content-Type", "text/html") + tmpl.Execute(w, ¶ms) + return + } + + if mediaInfo == nil { + http.Error(w, "unknown media", 500) + return + } + + w.Header().Set("Content-Type", "image/png") + if err := png.Encode(w, img); err != nil { + http.Error(w, err.Error(), 500) + return + } +} + +func main() { + if len(os.Args) != 3 { + log.Fatalf("usage: %s ADDRESS BDF-FILE\n", os.Args[0]) + } + + address, bdfPath := os.Args[1], os.Args[2] + + var err error + fi, err := os.Open(bdfPath) + if err != nil { + log.Fatalln(err) + } + + font, err = bdf.NewFromBDF(fi) + if err := fi.Close(); err != nil { + log.Fatalln(err) + } + if err != nil { + log.Fatalln(err) + } + + log.Println("starting server") + http.HandleFunc("/", handle) + log.Fatalln(http.ListenAndServe(address, nil)) +} diff --git a/cmd/ql-info/main.go b/cmd/ql-info/main.go new file mode 100644 index 0000000..8d53d63 --- /dev/null +++ b/cmd/ql-info/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + "log" + + "janouch.name/sklad/ql" +) + +func main() { + printer, err := ql.Open() + if err != nil { + log.Fatalln(err) + } + if printer == nil { + log.Fatalln("no suitable printer found") + } + + defer printer.Close() + + fmt.Printf("\x1b[1m%s %s\x1b[m\n", printer.Manufacturer, printer.Model) + if err := printer.Initialize(); err != nil { + log.Fatalln(err) + } + if err := printer.UpdateStatus(); err != nil { + log.Fatalln(err) + } + + status := printer.LastStatus + fmt.Print(status) + + fmt.Println("\x1b[1mMedia information\x1b[m") + if mi := ql.GetMediaInfo( + status.MediaWidthMM(), status.MediaLengthMM()); mi != nil { + fmt.Println("side margin pins:", mi.SideMarginPins) + fmt.Println("print area pins:", mi.PrintAreaPins) + fmt.Println("print area length:", mi.PrintAreaLength) + } else { + fmt.Println("unknown media") + } +} diff --git a/cmd/ql-print/main.go b/cmd/ql-print/main.go new file mode 100644 index 0000000..d0c986d --- /dev/null +++ b/cmd/ql-print/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "flag" + "fmt" + "log" + "os" + + "janouch.name/sklad/imgutil" + "janouch.name/sklad/ql" +) + +var scale = flag.Int("scale", 1, "integer upscaling") +var rotate = flag.Bool("rotate", false, "print sideways") + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s IMAGE\n", os.Args[0]) + flag.PrintDefaults() + } + + flag.Parse() + if flag.NArg() != 1 { + flag.Usage() + os.Exit(1) + } + + // Open the picture. + f, err := os.Open(flag.Arg(0)) + if err != nil { + log.Fatalln(err) + } + defer f.Close() + + // Load and eventually transform the picture. + img, _, err := image.Decode(f) + if err != nil { + log.Fatalln(err) + } + if *scale > 1 { + img = &imgutil.Scale{Image: img, Scale: *scale} + } + if *rotate { + img = &imgutil.LeftRotate{Image: img} + } + + // Open and initialize the printer. + p, err := ql.Open() + if err != nil { + log.Fatalln(err) + } + if p == nil { + log.Fatalln("no suitable printer found") + } + if err := p.Initialize(); err != nil { + log.Fatalln(err) + } + if err := p.UpdateStatus(); err != nil { + log.Fatalln(err) + } + + // Check the picture against the media in the printer. + mi := ql.GetMediaInfo( + p.LastStatus.MediaWidthMM(), + p.LastStatus.MediaLengthMM(), + ) + if mi == nil { + log.Fatalln("unknown media") + } + + bounds := img.Bounds() + dx, dy := bounds.Dx(), bounds.Dy() + if dx > mi.PrintAreaPins { + log.Fatalln("the image is too wide,", dx, ">", mi.PrintAreaPins, "pt") + } + if dy > mi.PrintAreaLength && mi.PrintAreaLength != 0 { + log.Fatalln("the image is too high,", dy, ">", mi.PrintAreaLength, "pt") + } + + if err := p.Print(img); err != nil { + log.Fatalln(err) + } +} diff --git a/cmd/sklad/base.tmpl b/cmd/sklad/base.tmpl new file mode 100644 index 0000000..d92a818 --- /dev/null +++ b/cmd/sklad/base.tmpl @@ -0,0 +1,68 @@ +<!DOCTYPE html> +<html> +<head> + <title>{{ template "Title" . }} - sklad</title> + <meta http-equiv=Content-Type content="text/html; charset=utf-8"> + <meta name=viewport content="width=device-width, initial-scale=1"> + <style> + html, body { min-height: 100vh; } + body { padding: 1rem; box-sizing: border-box; + margin: 0 auto; max-width: 50rem; + border-left: 1px solid #ccc; border-right: 1px solid #ccc; + font-family: sans-serif; } + + section { border: 1px outset #ccc; padding: 0 .5rem; margin: 1rem 0; } + section > p { margin: 0 0 .5rem 0; } + + header, footer { display: flex; justify-content: space-between; + align-items: center; flex-wrap: wrap; padding-top: .5em; } + header { margin: 0 -.5rem; padding: .5rem .5rem 0 .5rem; + background: linear-gradient(0deg, transparent, #f8f8f8); } + body > header { margin: -1rem -1rem 0 -1rem; padding: 1rem 1rem 0 1rem; + background: linear-gradient(0deg, transparent, #eeeeee); } + + header *, + footer * { display: inline-block; } + header > *, + footer > * { margin: 0 0 .5rem 0; } + header > *:not(:last-child), + footer > *:not(:last-child) { margin-right: .5rem; } + + header > h2, + header > h3 { flex-grow: 1; } + + /* Don't ask me why this is an improvement on mobile browsers. */ + input[type=submit], input[type=text], input[type=password], + select, textarea { border: 1px inset #ccc; padding: .25rem; } + input[type=submit] { border-style: outset; } + select { border-style: solid; } + + a { color: inherit; } + textarea { padding: .5rem; box-sizing: border-box; width: 100%; + font-family: inherit; resize: vertical; } + select { max-width: 15rem; } + </style> +</head> +<body> + +<header> + <h1>sklad</h1> + +{{ block "HeaderControls" . }} + <a href=/>Obaly</a> + <a href=/series>Řady</a> + + <form method=get action=/search> + <input type=text name=q autofocus><input type=submit value="Hledat"> + </form> + + <form method=post action=/logout> + <input type=submit value="Odhlásit"> + </form> +{{ end }} + +</header> + +{{ template "Content" . }} +</body> +</html> diff --git a/cmd/sklad/container.tmpl b/cmd/sklad/container.tmpl new file mode 100644 index 0000000..4bacae8 --- /dev/null +++ b/cmd/sklad/container.tmpl @@ -0,0 +1,99 @@ +{{ define "Title" }}{{/* +*/}}{{ if .Container }}{{ .Container.Id }}{{ else }}Obaly{{ end }}{{ end }} +{{ define "Content" }} + +{{ if .Container }} + +<section> +<header> + <h2>{{ .Container.Id }}</h2> + <form method=post action="/label?id={{ .Container.Id }}"> + <input type=submit value="Vytisknout štítek"> + </form> + <form method=post action="/?id={{ .Container.Id }}&remove"> + <input type=submit value="Odstranit"> + </form> +</header> + +<form method=post action="/?id={{ .Container.Id }}"> +<textarea name=description rows=5> +{{ .Container.Description }} +</textarea> +<footer> + <div> + <label for=series>Řada:</label> + <select name=series id=series> +{{ range $prefix, $desc := .AllSeries }} + <option value="{{ $prefix }}" + {{ if eq $prefix $.Container.Series }}selected{{ end }} + >{{ $prefix }} — {{ $desc }}</option> +{{ end }} + </select> + </div> + <div> + <label for=parent>Nadobal:</label> + <input type=text name=parent id=parent value="{{ .Container.Parent }}"> + </div> + <input type=submit value="Uložit"> +</footer> +</form> +</section> + +<h2>Podobaly</h3> + +{{ else }} +<section> +<header> + <h2>Nový obal</h2> +</header> +<form method=post action="/"> +<textarea name=description rows=5 + placeholder="Popis obalu nebo jeho obsahu"></textarea> +<footer> + <div> + <label for=series>Řada:</label> + <select name=series id=series> +{{ range $prefix, $desc := .AllSeries }} + <option value="{{ $prefix }}" + >{{ $prefix }} — {{ $desc }}</option> +{{ end }} + </select> + </div> + <div> + <label for=parent>Nadobal:</label> + <input type=text name=parent id=parent value=""> + </div> + <input type=submit value="Uložit"> +</footer> +</form> +</section> + +<h2>Obaly nejvyšší úrovně</h2> +{{ end }} + +{{ range .Children }} +<section> +<header> + <h3><a href="/?id={{ .Id }}">{{ .Id }}</a></h3> + <form method=post action="/label?id={{ .Id }}"> + <input type=submit value="Vytisknout štítek"> + </form> + <form method=post action="/?id={{ .Id }}&remove"> + <input type=submit value="Odstranit"> + </form> +</header> +{{ if .Description }} +<p>{{ .Description }} +{{ end }} +{{ if .Children }} +<p> +{{ range .Children }} +<a href="/?id={{ .Id }}">{{ .Id }}</a> +{{ end }} +{{ end }} +</section> +{{ else }} +<p>Obal je prázdný. +{{ end }} + +{{ end }} diff --git a/cmd/sklad/db.go b/cmd/sklad/db.go new file mode 100644 index 0000000..def18a5 --- /dev/null +++ b/cmd/sklad/db.go @@ -0,0 +1,225 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + "janouch.name/sklad/bdf" +) + +type Series struct { + Prefix string // PK: prefix + Description string // what kind of containers this is for +} + +type ContainerId string + +type Container struct { + Series string // PK: what series does this belong to + Number uint // PK: order within the series + Parent ContainerId // the container we're in, if any, otherwise "" + Description string // description and/or contents of this container +} + +func (c *Container) Id() ContainerId { + return ContainerId(fmt.Sprintf("%s%s%d", db.Prefix, c.Series, c.Number)) +} + +func (c *Container) Children() []*Container { + // TODO: Sort this by Id, or maybe even return a map[string]*Container, + // text/template would sort that automatically. + return indexChildren[c.Id()] +} + +func (c *Container) Path() (result []ContainerId) { + for c != nil && c.Parent != "" { + c = indexContainer[c.Parent] + result = append(result, c.Id()) + } + return +} + +type Database struct { + Password string // password for web users + Prefix string // prefix for all container IDs + Series []*Series // all known series + Containers []*Container // all known containers + + BDFPath string // path to bitmap font file + BDFScale int // integer scaling for the bitmap font +} + +var ( + dbPath string + db Database + dbLast Database + dbLog *os.File + + indexSeries = map[string]*Series{} + indexContainer = map[ContainerId]*Container{} + indexChildren = map[ContainerId][]*Container{} + + labelFont *bdf.Font +) + +// TODO: Some functions to add, remove and change things in the database. +// Indexes must be kept valid, just like any invariants. + +func dbSearchSeries(query string) (result []*Series) { + query = strings.ToLower(query) + added := map[string]bool{} + for _, s := range db.Series { + if query == strings.ToLower(s.Prefix) { + result = append(result, s) + added[s.Prefix] = true + } + } + for _, s := range db.Series { + if strings.Contains( + strings.ToLower(s.Description), query) && !added[s.Prefix] { + result = append(result, s) + } + } + return +} + +func dbSearchContainers(query string) (result []*Container) { + query = strings.ToLower(query) + added := map[ContainerId]bool{} + for id, c := range indexContainer { + if query == strings.ToLower(string(id)) { + result = append(result, c) + added[id] = true + } + } + for id, c := range indexContainer { + if strings.Contains( + strings.ToLower(c.Description), query) && !added[id] { + result = append(result, c) + } + } + return +} + +func dbCommit() error { + // Write a timestamp. + e := json.NewEncoder(dbLog) + e.SetIndent("", " ") + if err := e.Encode(time.Now().Format(time.RFC3339)); err != nil { + return err + } + + // Back up the current database contents. + if err := e.Encode(&dbLast); err != nil { + return err + } + if err := dbLog.Sync(); err != nil { + return err + } + + // Atomically replace the current database file. + tempPath := dbPath + ".new" + temp, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + defer temp.Close() + + e = json.NewEncoder(temp) + e.SetIndent("", " ") + if err := e.Encode(&db); err != nil { + return err + } + + if err := os.Rename(tempPath, dbPath); err != nil { + return err + } + + dbLast = db + return nil +} + +// loadDatabase loads the database from a simple JSON file. We do not use +// any SQL stuff or even external KV storage because there is no real need +// for our trivial use case, with our general amount of data. +func loadDatabase() error { + dbFile, err := os.Open(dbPath) + if err != nil { + return err + } + if err := json.NewDecoder(dbFile).Decode(&db); err != nil { + return err + } + + // Further validate the database. + if db.Prefix == "" { + return errors.New("misconfigured prefix") + } + + // Construct indexes for primary keys, validate against duplicates. + for _, pv := range db.Series { + if _, ok := indexSeries[pv.Prefix]; ok { + return fmt.Errorf("duplicate series: %s", pv.Prefix) + } + indexSeries[pv.Prefix] = pv + } + for _, pv := range db.Containers { + id := pv.Id() + if _, ok := indexContainer[id]; ok { + return fmt.Errorf("duplicate container: %s", id) + } + indexContainer[id] = pv + } + + // Construct an index that goes from parent containers to their children. + for _, pv := range db.Containers { + if pv.Parent != "" { + if _, ok := indexContainer[pv.Parent]; !ok { + return fmt.Errorf("container %s has a nonexistent parent %s", + pv.Id(), pv.Parent) + } + } + indexChildren[pv.Parent] = append(indexChildren[pv.Parent], pv) + } + + // Validate that no container is a parent of itself on any level. + // This could probably be optimized but it would stop being obvious. + for _, pv := range db.Containers { + parents := map[ContainerId]bool{pv.Id(): true} + for pv.Parent != "" { + if parents[pv.Parent] { + return fmt.Errorf("%s contains itself", pv.Parent) + } + parents[pv.Parent] = true + pv = indexContainer[pv.Parent] + } + } + + // Prepare label printing. + if db.BDFScale <= 0 { + db.BDFScale = 1 + } + + if f, err := os.Open(db.BDFPath); err != nil { + return fmt.Errorf("cannot load label font: %s", err) + } else { + defer f.Close() + if labelFont, err = bdf.NewFromBDF(f); err != nil { + return fmt.Errorf("cannot load label font: %s", err) + } + } + + // Open database log file for appending. + if dbLog, err = os.OpenFile(dbPath+".log", + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err != nil { + return err + } + + // Remember the current state of the database. + dbLast = db + return nil +} diff --git a/cmd/sklad/label.tmpl b/cmd/sklad/label.tmpl new file mode 100644 index 0000000..da17c58 --- /dev/null +++ b/cmd/sklad/label.tmpl @@ -0,0 +1,13 @@ +{{ define "Title" }}Tisk štítku{{ end }} +{{ define "Content" }} +<h2>Tisk štítku pro {{ .Id }}</h2> + +{{ if .UnknownId }} +<p>Neznámý obal. +{{ else if .Error }} +<p>Tisk selhal: {{ .Error }} +{{ else }} +<p>Tisk proběhl úspěšně. +{{ end }} + +{{ end }} diff --git a/cmd/sklad/login.tmpl b/cmd/sklad/login.tmpl new file mode 100644 index 0000000..c34ab53 --- /dev/null +++ b/cmd/sklad/login.tmpl @@ -0,0 +1,17 @@ +{{ define "Title" }}Přihlášení{{ end }} +{{ define "HeaderControls" }}<!-- text/template requires content -->{{ end }} +{{ define "Content" }} + +<h2>Přihlášení</h2> + +<form method=post> +<label for=password>Heslo:</label> +<input type=password name=password id=password autofocus +><input type=submit value="Přihlásit"> +</form> + +{{ if .IncorrectPassword }} +<p>Bylo zadáno nesprávné heslo. +{{ end }} + +{{ end }} diff --git a/cmd/sklad/main.go b/cmd/sklad/main.go new file mode 100644 index 0000000..32dd68b --- /dev/null +++ b/cmd/sklad/main.go @@ -0,0 +1,271 @@ +package main + +import ( + "errors" + "html/template" + "io" + "log" + "math/rand" + "net/http" + "os" + "path/filepath" + "time" + + "janouch.name/sklad/imgutil" + "janouch.name/sklad/label" + "janouch.name/sklad/ql" +) + +var templates = map[string]*template.Template{} + +func executeTemplate(name string, w io.Writer, data interface{}) { + if err := templates[name].Execute(w, data); err != nil { + panic(err) + } +} + +func wrap(inner func(http.ResponseWriter, *http.Request)) func( + http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if r.Method == http.MethodGet { + w.Header().Set("Cache-Control", "no-store") + } + inner(w, r) + } +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + redirect := r.FormValue("redirect") + if redirect == "" { + redirect = "/" + } + + session := sessionGet(w, r) + if session.LoggedIn { + http.Redirect(w, r, redirect, http.StatusSeeOther) + return + } + + params := struct { + IncorrectPassword bool + }{} + + switch r.Method { + case http.MethodGet: + // We're just going to render the template. + case http.MethodPost: + if r.FormValue("password") == db.Password { + session.LoggedIn = true + http.Redirect(w, r, redirect, http.StatusSeeOther) + return + } + params.IncorrectPassword = true + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + executeTemplate("login.tmpl", w, ¶ms) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + session := r.Context().Value(sessionContextKey{}).(*Session) + session.LoggedIn = false + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func handleContainer(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + // TODO + } + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + allSeries := map[string]string{} + for _, s := range indexSeries { + allSeries[s.Prefix] = s.Description + } + + var container *Container + children := []*Container{} + + if id := ContainerId(r.FormValue("id")); id == "" { + children = indexChildren[""] + } else if c, ok := indexContainer[id]; ok { + children = c.Children() + container = c + } + + params := struct { + Container *Container + Children []*Container + AllSeries map[string]string + }{ + Container: container, + Children: children, + AllSeries: allSeries, + } + + executeTemplate("container.tmpl", w, ¶ms) +} + +func handleSeries(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + // TODO + } + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + allSeries := map[string]string{} + for _, s := range indexSeries { + allSeries[s.Prefix] = s.Description + } + + prefix := r.FormValue("prefix") + description := "" + + if prefix == "" { + } else if series, ok := indexSeries[prefix]; ok { + description = series.Description + } + + params := struct { + Prefix string + Description string + AllSeries map[string]string + }{ + Prefix: prefix, + Description: description, + AllSeries: allSeries, + } + + executeTemplate("series.tmpl", w, ¶ms) +} + +func handleSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + query := r.FormValue("q") + params := struct { + Query string + Series []*Series + Containers []*Container + }{ + Query: query, + Series: dbSearchSeries(query), + Containers: dbSearchContainers(query), + } + + executeTemplate("search.tmpl", w, ¶ms) +} + +func printLabel(id string) error { + printer, err := ql.Open() + if err != nil { + return err + } + if printer == nil { + return errors.New("no suitable printer found") + } + defer printer.Close() + + /* + printer.StatusNotify = func(status *ql.Status) { + log.Printf("\x1b[1mreceived status\x1b[m\n%+v\n%s", + status[:], status) + } + */ + + if err := printer.Initialize(); err != nil { + return err + } + if err := printer.UpdateStatus(); err != nil { + return err + } + + mediaInfo := ql.GetMediaInfo( + printer.LastStatus.MediaWidthMM(), + printer.LastStatus.MediaLengthMM(), + ) + if mediaInfo == nil { + return errors.New("unknown media") + } + + return printer.Print(&imgutil.LeftRotate{Image: label.GenLabelForHeight( + labelFont, id, mediaInfo.PrintAreaPins, db.BDFScale)}) +} + +func handleLabel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + params := struct { + Id string + UnknownId bool + Error error + }{ + Id: r.FormValue("id"), + } + + if c := indexContainer[ContainerId(params.Id)]; c == nil { + params.UnknownId = true + } else { + params.Error = printLabel(params.Id) + } + + executeTemplate("label.tmpl", w, ¶ms) +} + +func main() { + // Randomize the RNG for session string generation. + rand.Seed(time.Now().UnixNano()) + + if len(os.Args) != 3 { + log.Fatalf("Usage: %s ADDRESS DATABASE-FILE\n", os.Args[0]) + } + + var address string + address, dbPath = os.Args[1], os.Args[2] + + // Load database. + if err := loadDatabase(); err != nil { + log.Fatalln(err) + } + + // Load HTML templates from the current working directory. + m, err := filepath.Glob("*.tmpl") + if err != nil { + log.Fatalln(err) + } + for _, name := range m { + templates[name] = template.Must(template.ParseFiles("base.tmpl", name)) + } + + http.HandleFunc("/login", wrap(handleLogin)) + http.HandleFunc("/logout", sessionWrap(wrap(handleLogout))) + + http.HandleFunc("/", sessionWrap(wrap(handleContainer))) + http.HandleFunc("/series", sessionWrap(wrap(handleSeries))) + http.HandleFunc("/search", sessionWrap(wrap(handleSearch))) + http.HandleFunc("/label", sessionWrap(wrap(handleLabel))) + + log.Fatalln(http.ListenAndServe(address, nil)) +} diff --git a/cmd/sklad/search.tmpl b/cmd/sklad/search.tmpl new file mode 100644 index 0000000..cf704cf --- /dev/null +++ b/cmd/sklad/search.tmpl @@ -0,0 +1,38 @@ +{{ define "Title" }}„{{ .Query }}“ — Vyhledávání{{ end }} +{{ define "Content" }} + +<h2>Vyhledávání: „{{ .Query }}“<h2> + +<h3>Řady</h3> + +{{ range .Series }} +<section> +<header> + <h3><a href="/series?prefix={{ .Prefix }}">{{ .Prefix }}</a></h3> + <p>{{ .Description }} +</header> +</section> +{{ else }} +<p>Neodpovídají žádné řady. +{{ end }} + +<h3>Obaly</h3> + +{{ range .Containers }} +<section> +<header> + <h3><a href="/?id={{ .Id }}">{{ .Id }}</a> +{{ range .Path }} + <small>« <a href="/?id={{ . }}">{{ . }}</a></small> +{{ end }} + </h3> +</header> +{{ if .Description }} +<p>{{ .Description }} +{{ end }} +</section> +{{ else }} +<p>Neodpovídají žádné obaly. +{{ end }} + +{{ end }} diff --git a/cmd/sklad/series.tmpl b/cmd/sklad/series.tmpl new file mode 100644 index 0000000..4956e3a --- /dev/null +++ b/cmd/sklad/series.tmpl @@ -0,0 +1,43 @@ +{{ define "Title" }}{{ or .Prefix "Řady" }}{{ end }} +{{ define "Content" }} + +{{ if .Prefix }} +<h2>{{ .Prefix }}</h2> + +{{ if .Description }} +<p>{{ .Description }} +{{ end }} +{{ else }} + +<section> +<form method=post action="/series"> +<header> + <h3>Nová řada</h3> + <input type=text name=prefix placeholder="Prefix řady"> + <input type=text name=description placeholder="Popis řady" + ><input type=submit value="Uložit"> + </form> +</header> +</form> +</section> + +{{ range $prefix, $desc := .AllSeries }} +<section> +<header> + <h3><a href="/series?prefix={{ $prefix }}">{{ $prefix }}</a></h3> + <form method=post action="/series?prefix={{ $prefix }}"> + <input type=text name=description value="{{ $desc }}" + ><input type=submit value="Uložit"> + </form> + <form method=post action="/series?prefix={{ $prefix }}&remove"> + <input type=submit value="Odstranit"> + </form> +</header> +</section> +{{ else }} +<p>Nejsou žádné řady. +{{ end }} + +{{ end }} + +{{ end }} diff --git a/cmd/sklad/session.go b/cmd/sklad/session.go new file mode 100644 index 0000000..02fe0b0 --- /dev/null +++ b/cmd/sklad/session.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "encoding/hex" + "math/rand" + "net/http" + "net/url" +) + +// session storage indexed by a random UUID +var sessions = map[string]*Session{} + +type Session struct { + LoggedIn bool // may access the DB +} + +type sessionContextKey struct{} + +func sessionGenId() string { + u := make([]byte, 16) + if _, err := rand.Read(u); err != nil { + panic("cannot generate random bytes") + } + return hex.EncodeToString(u) +} + +// TODO: We don't want to keep an unlimited amount of cookies in the storage. +// - The essential question is: how do we avoid DoS? +// - Which cookies are worth keeping? +// - Definitely logged-in users, only one person should know the password. +// - Evict by FIFO? LRU? +func sessionGet(w http.ResponseWriter, r *http.Request) (session *Session) { + if c, _ := r.Cookie("sessionid"); c != nil { + session, _ = sessions[c.Value] + } + if session == nil { + id := sessionGenId() + session = &Session{LoggedIn: false} + sessions[id] = session + http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: id}) + } + return +} + +func sessionWrap(inner func(http.ResponseWriter, *http.Request)) func( + http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + // We might also try no-cache with an ETag for the whole database, + // though I don't expect any substantial improvements of anything. + w.Header().Set("Cache-Control", "no-store") + + redirect := "/login" + if r.RequestURI != "/" && r.Method == http.MethodGet { + redirect += "?redirect=" + url.QueryEscape(r.RequestURI) + } + + session := sessionGet(w, r) + if !session.LoggedIn { + http.Redirect(w, r, redirect, http.StatusSeeOther) + return + } + inner(w, r.WithContext( + context.WithValue(r.Context(), sessionContextKey{}, session))) + } +} |