Move commands under cmd/
This commit is contained in:
93
cmd/bdf-preview/main.go
Normal file
93
cmd/bdf-preview/main.go
Normal file
@@ -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))
|
||||
}
|
||||
42
cmd/bdf-sample/main.go
Normal file
42
cmd/bdf-sample/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
199
cmd/label-tool/main.go
Normal file
199
cmd/label-tool/main.go
Normal file
@@ -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))
|
||||
}
|
||||
41
cmd/ql-info/main.go
Normal file
41
cmd/ql-info/main.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
88
cmd/ql-print/main.go
Normal file
88
cmd/ql-print/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
68
cmd/sklad/base.tmpl
Normal file
68
cmd/sklad/base.tmpl
Normal file
@@ -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>
|
||||
99
cmd/sklad/container.tmpl
Normal file
99
cmd/sklad/container.tmpl
Normal file
@@ -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 }}
|
||||
225
cmd/sklad/db.go
Normal file
225
cmd/sklad/db.go
Normal file
@@ -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
|
||||
}
|
||||
13
cmd/sklad/label.tmpl
Normal file
13
cmd/sklad/label.tmpl
Normal file
@@ -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 }}
|
||||
17
cmd/sklad/login.tmpl
Normal file
17
cmd/sklad/login.tmpl
Normal file
@@ -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 }}
|
||||
271
cmd/sklad/main.go
Normal file
271
cmd/sklad/main.go
Normal file
@@ -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))
|
||||
}
|
||||
38
cmd/sklad/search.tmpl
Normal file
38
cmd/sklad/search.tmpl
Normal file
@@ -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 }}
|
||||
43
cmd/sklad/series.tmpl
Normal file
43
cmd/sklad/series.tmpl
Normal file
@@ -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 }}
|
||||
66
cmd/sklad/session.go
Normal file
66
cmd/sklad/session.go
Normal file
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user