aboutsummaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorPřemysl Janouch <p@janouch.name>2019-04-14 22:30:40 +0200
committerPřemysl Janouch <p@janouch.name>2019-04-14 22:30:40 +0200
commit1331f3b5642f521236fcb1ec21ee43d5b76c0b91 (patch)
tree1213c2f2014cf0eff5a4a881b02cbebf91a604b8 /cmd
parent7d9410c6b3a724e3670941f7ec2d00e7966d0b1a (diff)
downloadsklad-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.go93
-rw-r--r--cmd/bdf-sample/main.go42
-rw-r--r--cmd/label-tool/main.go199
-rw-r--r--cmd/ql-info/main.go41
-rw-r--r--cmd/ql-print/main.go88
-rw-r--r--cmd/sklad/base.tmpl68
-rw-r--r--cmd/sklad/container.tmpl99
-rw-r--r--cmd/sklad/db.go225
-rw-r--r--cmd/sklad/label.tmpl13
-rw-r--r--cmd/sklad/login.tmpl17
-rw-r--r--cmd/sklad/main.go271
-rw-r--r--cmd/sklad/search.tmpl38
-rw-r--r--cmd/sklad/series.tmpl43
-rw-r--r--cmd/sklad/session.go66
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&amp;scale={{.Scale}}&amp;text={{.Text}}'>
+</td>
+<td valign=top>
+ <fieldset>
+ {{ if .Printer }}
+
+ <p>Printer: {{ .Printer.Manufacturer }} {{ .Printer.Model }}
+ <p>Tape:
+ {{ if .Printer.LastStatus }}
+ {{ .Printer.LastStatus.MediaWidthMM }} mm &times;
+ {{ .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, &params)
+ 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 }}&amp;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 }} &mdash; {{ $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 }} &mdash; {{ $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 }}&amp;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, &params)
+}
+
+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, &params)
+}
+
+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, &params)
+}
+
+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, &params)
+}
+
+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, &params)
+}
+
+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" }}&bdquo;{{ .Query }}&ldquo; &mdash; Vyhledávání{{ end }}
+{{ define "Content" }}
+
+<h2>Vyhledávání: &bdquo;{{ .Query }}&ldquo;<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>&laquo; <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 }}&amp;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)))
+ }
+}