mesh/merge-gate head 6d7117a361b8 merged into trunk 43e0daf6947d — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head 6d7117a361b8 merged into trunk 43e0daf6947d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
401 lines
9.3 KiB
Go
401 lines
9.3 KiB
Go
package main
|
|
|
|
import (
|
|
"html"
|
|
"regexp"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// The printer's admin pages are small, tolerant HTML (XHTML 1.0 with unclosed inputs). Only what the module
|
|
// reads is taken out of them: the title, the links, and the forms with their fields.
|
|
|
|
// Link is an anchor of a page.
|
|
type Link struct {
|
|
Href string `json:"href"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// Field is a named control of a form. A password field's value is never kept (ADR 0467 clause 4).
|
|
type Field struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Value string `json:"value"`
|
|
Checked *bool `json:"checked,omitempty"`
|
|
Label string `json:"label"`
|
|
Options []Option `json:"options,omitempty"`
|
|
id string
|
|
own string // the label of this control alone: <label for>, a wrapping <label>, or a radio button's text after it
|
|
row string // what the control's table row (or <dt>) calls it
|
|
}
|
|
|
|
// Option is a choice of a select (Value and Text) or a button of a radio group (Value and Label).
|
|
type Option struct {
|
|
Value string `json:"value"`
|
|
Label string `json:"label,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
// Form is a form of a page with its named fields.
|
|
type Form struct {
|
|
Action string `json:"action"`
|
|
Method string `json:"method"`
|
|
Fields []Field `json:"fields"`
|
|
}
|
|
|
|
// Page is what a page of the admin site holds.
|
|
type Page struct {
|
|
Title string `json:"title"`
|
|
Links []Link `json:"links"`
|
|
Forms []Form `json:"forms"`
|
|
}
|
|
|
|
var attrRE = regexp.MustCompile(`([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?`)
|
|
|
|
// attrs reads a tag's attributes, names in lower case and values unescaped.
|
|
func attrs(s string) map[string]string {
|
|
out := map[string]string{}
|
|
for _, m := range attrRE.FindAllStringSubmatch(s, -1) {
|
|
v := m[2] + m[3] + m[4]
|
|
out[strings.ToLower(m[1])] = html.UnescapeString(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func squash(s string) string { return strings.Join(strings.Fields(html.UnescapeString(s)), " ") }
|
|
|
|
// latin1 turns a body that is not UTF-8 into UTF-8, reading each byte as the character of the same number:
|
|
// the printer's pages say iso-8859-1.
|
|
func latin1(b []byte) string {
|
|
if utf8.Valid(b) {
|
|
return string(b)
|
|
}
|
|
r := make([]rune, len(b))
|
|
for i, c := range b {
|
|
r[i] = rune(c)
|
|
}
|
|
return string(r)
|
|
}
|
|
|
|
// tagEnd is the index of the '>' that closes the tag starting at s[0], skipping quoted values, or -1.
|
|
func tagEnd(s string) int {
|
|
var q byte
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
switch {
|
|
case q != 0:
|
|
if c == q {
|
|
q = 0
|
|
}
|
|
case c == '"' || c == '\'':
|
|
q = c
|
|
case c == '>':
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// ParsePage reads a page. Unknown markup is skipped, and what is not closed is closed at the end.
|
|
func ParsePage(body []byte) Page {
|
|
s := latin1(body)
|
|
var p Page
|
|
var (
|
|
inTitle bool
|
|
title strings.Builder
|
|
link *Link
|
|
formOpen bool
|
|
form Form
|
|
labelFor string
|
|
inLabel bool
|
|
label strings.Builder
|
|
labelIDs = map[string]string{}
|
|
wrapped []int // fields inside a label that names none, by index in form.Fields
|
|
selIdx = -1
|
|
inOption bool
|
|
optValue string
|
|
optHasV bool
|
|
optSel bool
|
|
optText strings.Builder
|
|
inTextA bool
|
|
textA strings.Builder
|
|
textAIdx = -1
|
|
// Where a control stands in its row: Brother pages lay a setting out as <tr><th>name</th><td>control</td></tr>
|
|
// or <dt>name</dt><dd>control</dd>; the first cell names the row.
|
|
cellN int
|
|
cellOpen bool
|
|
cellText strings.Builder
|
|
rowFirst string
|
|
lastText string
|
|
tailIdx = -1
|
|
)
|
|
finishOption := func() {
|
|
if !inOption || selIdx < 0 {
|
|
inOption = false
|
|
return
|
|
}
|
|
v := optValue
|
|
if !optHasV {
|
|
v = squash(optText.String())
|
|
}
|
|
f := &form.Fields[selIdx]
|
|
if len(f.Options) == 0 {
|
|
f.Value = v // a browser shows the first option when none is selected
|
|
}
|
|
f.Options = append(f.Options, Option{Value: v, Text: squash(optText.String())})
|
|
if optSel {
|
|
f.Value = v
|
|
}
|
|
inOption = false
|
|
}
|
|
closeForm := func() {
|
|
finishOption()
|
|
selIdx = -1
|
|
if formOpen {
|
|
p.Forms = append(p.Forms, form)
|
|
}
|
|
formOpen = false
|
|
form = Form{}
|
|
}
|
|
addField := func(f Field) {
|
|
f.row = rowFirst
|
|
if (cellOpen && cellN == 1) || f.row == "" {
|
|
f.row = lastText
|
|
}
|
|
lastText = "" // text before a control names that control alone
|
|
form.Fields = append(form.Fields, f)
|
|
if inLabel && labelFor == "" {
|
|
wrapped = append(wrapped, len(form.Fields)-1)
|
|
}
|
|
}
|
|
text := func(t string) {
|
|
switch {
|
|
case inTitle:
|
|
title.WriteString(t)
|
|
case inOption:
|
|
optText.WriteString(t)
|
|
case inTextA:
|
|
textA.WriteString(t)
|
|
}
|
|
if !inTitle && !inOption && !inTextA && link == nil {
|
|
if q := squash(t); q != "" {
|
|
switch {
|
|
case tailIdx >= 0:
|
|
// the text right after a radio button is that button's, not the next field's
|
|
if form.Fields[tailIdx].own == "" {
|
|
form.Fields[tailIdx].own = q
|
|
}
|
|
case !inLabel:
|
|
lastText = q
|
|
}
|
|
}
|
|
if cellOpen && cellN == 1 {
|
|
cellText.WriteString(t)
|
|
}
|
|
}
|
|
if link != nil {
|
|
link.Text += t
|
|
}
|
|
if inLabel {
|
|
label.WriteString(t)
|
|
}
|
|
}
|
|
i := 0
|
|
for i < len(s) {
|
|
lt := strings.IndexByte(s[i:], '<')
|
|
if lt < 0 {
|
|
text(s[i:])
|
|
break
|
|
}
|
|
text(s[i : i+lt])
|
|
i += lt
|
|
switch {
|
|
case strings.HasPrefix(s[i:], "<!--"):
|
|
end := strings.Index(s[i:], "-->")
|
|
if end < 0 {
|
|
i = len(s)
|
|
} else {
|
|
i += end + 3
|
|
}
|
|
continue
|
|
case strings.HasPrefix(s[i:], "<!") || strings.HasPrefix(s[i:], "<?"):
|
|
end := strings.IndexByte(s[i:], '>')
|
|
if end < 0 {
|
|
i = len(s)
|
|
} else {
|
|
i += end + 1
|
|
}
|
|
continue
|
|
}
|
|
end := tagEnd(s[i:])
|
|
if end < 0 {
|
|
break
|
|
}
|
|
tailIdx = -1
|
|
raw := s[i+1 : i+end]
|
|
i += end + 1
|
|
closing := strings.HasPrefix(raw, "/")
|
|
raw = strings.TrimSuffix(strings.TrimPrefix(raw, "/"), "/")
|
|
name := raw
|
|
rest := ""
|
|
if sp := strings.IndexAny(raw, " \t\r\n"); sp >= 0 {
|
|
name, rest = raw[:sp], raw[sp:]
|
|
}
|
|
name = strings.ToLower(name)
|
|
if !closing && (name == "script" || name == "style") {
|
|
if e := strings.Index(strings.ToLower(s[i:]), "</"+name); e >= 0 {
|
|
i += e
|
|
} else {
|
|
i = len(s)
|
|
}
|
|
continue
|
|
}
|
|
a := map[string]string{}
|
|
if !closing {
|
|
a = attrs(rest)
|
|
}
|
|
switch name {
|
|
case "title":
|
|
inTitle = !closing
|
|
case "tr":
|
|
cellN, cellOpen, rowFirst, lastText = 0, false, "", ""
|
|
cellText.Reset()
|
|
case "td", "th", "dt", "dd":
|
|
if cellOpen && cellN == 1 {
|
|
rowFirst = squash(cellText.String())
|
|
}
|
|
cellOpen = false
|
|
if name == "dt" && !closing {
|
|
cellN, rowFirst, lastText = 0, "", ""
|
|
}
|
|
if !closing {
|
|
cellN++
|
|
if name == "dd" && cellN < 2 {
|
|
cellN = 2
|
|
}
|
|
cellOpen = true
|
|
cellText.Reset()
|
|
}
|
|
case "a":
|
|
if closing {
|
|
if link != nil {
|
|
link.Text = squash(link.Text)
|
|
p.Links = append(p.Links, *link)
|
|
}
|
|
link = nil
|
|
} else if href, ok := a["href"]; ok {
|
|
link = &Link{Href: href}
|
|
}
|
|
case "form":
|
|
closeForm()
|
|
lastText = ""
|
|
if !closing {
|
|
formOpen = true
|
|
form = Form{Action: a["action"], Method: strings.ToLower(a["method"])}
|
|
if form.Method == "" {
|
|
form.Method = "get"
|
|
}
|
|
}
|
|
case "label":
|
|
if closing {
|
|
t := squash(label.String())
|
|
if labelFor != "" {
|
|
labelIDs[labelFor] = t
|
|
}
|
|
for _, k := range wrapped {
|
|
form.Fields[k].own = t
|
|
}
|
|
inLabel, labelFor, wrapped = false, "", nil
|
|
label.Reset()
|
|
} else {
|
|
inLabel, labelFor = true, a["for"]
|
|
label.Reset()
|
|
}
|
|
case "input":
|
|
if closing || a["name"] == "" {
|
|
break
|
|
}
|
|
typ := strings.ToLower(a["type"])
|
|
if typ == "" {
|
|
typ = "text"
|
|
}
|
|
if typ == "submit" || typ == "button" || typ == "image" || typ == "reset" {
|
|
break
|
|
}
|
|
f := Field{Name: a["name"], Type: typ, Value: a["value"], id: a["id"]}
|
|
if typ == "password" {
|
|
f.Value = ""
|
|
}
|
|
if typ == "checkbox" || typ == "radio" {
|
|
_, on := a["checked"]
|
|
f.Checked = &on
|
|
}
|
|
addField(f)
|
|
if typ == "radio" {
|
|
tailIdx = len(form.Fields) - 1
|
|
}
|
|
case "select":
|
|
finishOption()
|
|
selIdx = -1
|
|
if !closing && a["name"] != "" {
|
|
addField(Field{Name: a["name"], Type: "select", id: a["id"]})
|
|
selIdx = len(form.Fields) - 1
|
|
}
|
|
case "option":
|
|
finishOption()
|
|
if !closing && selIdx >= 0 {
|
|
inOption = true
|
|
optValue, optHasV = a["value"], false
|
|
if _, ok := a["value"]; ok {
|
|
optHasV = true
|
|
}
|
|
_, optSel = a["selected"]
|
|
optText.Reset()
|
|
}
|
|
case "textarea":
|
|
if closing {
|
|
if inTextA && textAIdx >= 0 {
|
|
form.Fields[textAIdx].Value = html.UnescapeString(textA.String())
|
|
}
|
|
inTextA = false
|
|
} else if a["name"] != "" {
|
|
addField(Field{Name: a["name"], Type: "textarea", id: a["id"]})
|
|
inTextA, textAIdx = true, len(form.Fields)-1
|
|
textA.Reset()
|
|
}
|
|
}
|
|
}
|
|
closeForm()
|
|
p.Title = squash(title.String())
|
|
for fi := range p.Forms {
|
|
var fields []Field
|
|
groups := map[string]int{}
|
|
for k := range p.Forms[fi].Fields {
|
|
f := p.Forms[fi].Fields[k]
|
|
if f.own == "" && f.id != "" {
|
|
f.own = labelIDs[f.id]
|
|
}
|
|
if f.Type != "radio" {
|
|
f.Label = f.own
|
|
if f.Label == "" && f.Type != "hidden" {
|
|
f.Label = f.row
|
|
}
|
|
fields = append(fields, f)
|
|
continue
|
|
}
|
|
g, ok := groups[f.Name]
|
|
if !ok {
|
|
g = len(fields)
|
|
groups[f.Name] = g
|
|
fields = append(fields, Field{Name: f.Name, Type: "radio", Label: f.row})
|
|
}
|
|
fields[g].Options = append(fields[g].Options, Option{Value: f.Value, Label: f.own})
|
|
if f.Checked != nil && *f.Checked {
|
|
fields[g].Value = f.Value
|
|
}
|
|
}
|
|
p.Forms[fi].Fields = fields
|
|
}
|
|
return p
|
|
}
|