pat/internal/forms/builder.go
2025-09-04 11:47:20 +02:00

482 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package forms
import (
"bufio"
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"net/textproto"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/la5nta/wl2k-go/fbb"
"github.com/la5nta/pat/internal/debug"
"github.com/la5nta/pat/internal/editor"
)
// Message represents a concrete message compiled from a template
type Message struct {
To string `json:"msg_to"`
Cc string `json:"msg_cc"`
Subject string `json:"msg_subject"`
Body string `json:"msg_body"`
Attachments []*fbb.File `json:"-"`
submitted time.Time
}
type messageBuilder struct {
Interactive bool
LineReader func() string
InReplyToMsg *fbb.Message
Template Template
FormValues map[string]string
PromptResponses map[string]string
FormsMgr *Manager
}
// build returns message subject, body, and attachments for the given template and variable map
func (b messageBuilder) build() (Message, error) {
b.setDefaultFormValues()
msg, err := b.scanAndBuild(b.Template.Path)
if err != nil {
return Message{}, err
}
msg.Attachments = b.buildAttachments()
return msg, nil
}
// TODO: What are these "default form vars"? It looks to be a subset of the
// official insertion tags, but there is no mention of these special vars in
// the forms documentation. Consider doing insertion tag replacement with the
// {var ...} pattern instead of this.
func (b messageBuilder) setDefaultFormValues() {
if b.InReplyToMsg != nil {
b.FormValues["msgisreply"] = "True"
// Here be dragons.
// Templates using this has a strange `Def: MsgOrignalBody=<var MsgOriginalBody>`.
// Maybe to force the inclusion of the original body in the XML? But
// why is it referenced as a variable and not the officially supported
// tag (i.e. `Def: MsgOriginalBody=<MsgOriginalBody>`)?
if _, ok := b.FormValues["msgoriginalbody"]; !ok {
b.FormValues["msgoriginalbody"], _ = b.InReplyToMsg.Body()
}
} else {
b.FormValues["msgisreply"] = "False"
}
for _, key := range []string{"msgsender"} {
if _, ok := b.FormValues[key]; !ok {
b.FormValues[key] = b.FormsMgr.config.MyCall
}
}
// some defaults that we can't set yet. Winlink doesn't seem to care about these
// Set only if they're not set by form values.
for _, key := range []string{"msgto", "msgcc", "msgsubject", "msgbody", "msgp2p", "txtstr"} {
if _, ok := b.FormValues[key]; !ok {
b.FormValues[key] = ""
}
}
for _, key := range []string{"msgisforward", "msgisacknowledgement"} {
if _, ok := b.FormValues[key]; !ok {
b.FormValues[key] = "False"
}
}
// TODO: Implement sequences
for _, key := range []string{"msgseqnum"} {
if _, ok := b.FormValues[key]; !ok {
b.FormValues[key] = "0"
}
}
}
func (b messageBuilder) buildXML() []byte {
type Variable struct {
XMLName xml.Name
Value string `xml:",chardata"`
}
filename := func(path string) string {
// Avoid "." for empty paths
if path == "" {
return ""
}
return filepath.Base(path)
}
form := struct {
XMLName xml.Name `xml:"RMS_Express_Form"`
XMLFileVersion string `xml:"form_parameters>xml_file_version"`
RMSExpressVersion string `xml:"form_parameters>rms_express_version"`
SubmissionDatetime string `xml:"form_parameters>submission_datetime"`
SendersCallsign string `xml:"form_parameters>senders_callsign"`
GridSquare string `xml:"form_parameters>grid_square"`
DisplayForm string `xml:"form_parameters>display_form"`
ReplyTemplate string `xml:"form_parameters>reply_template"`
Variables []Variable `xml:"variables>name"`
}{
XMLFileVersion: "1.0",
RMSExpressVersion: b.FormsMgr.config.AppVersion,
SubmissionDatetime: now().UTC().Format("20060102150405"),
SendersCallsign: b.FormsMgr.config.MyCall,
GridSquare: b.FormsMgr.config.LocatorProvider.Locator(),
DisplayForm: filename(b.Template.DisplayFormPath),
ReplyTemplate: filename(b.Template.ReplyTemplatePath),
}
for k, v := range b.FormValues {
// Trim leading and trailing whitespace. Winlink Express does
// this, judging from the produced XML attachments.
v = strings.TrimSpace(v)
form.Variables = append(form.Variables, Variable{xml.Name{Local: k}, v})
}
// Sort vars by name to make sure the output is deterministic.
sort.Slice(form.Variables, func(i, j int) bool {
a, b := form.Variables[i], form.Variables[j]
return a.XMLName.Local < b.XMLName.Local
})
data, err := xml.MarshalIndent(form, "", " ")
if err != nil {
panic(err)
}
return append([]byte(xml.Header), data...)
}
func (b messageBuilder) buildAttachments() []*fbb.File {
var attachments []*fbb.File
// Add optional text attachments defined by some forms as form values
// pairs in the format attached_textN/attached_fileN (N=0 is omitted).
for k := range b.FormValues {
if !strings.HasPrefix(k, "attached_text") {
continue
}
if strings.TrimSpace(b.FormValues[k]) == "" {
// Some forms set this key as empty, meaning no real attachment.
debug.Printf("Ignoring empty text attachment %q: %q", k, b.FormValues[k])
continue
}
textKey := k
text := b.FormValues[textKey]
nameKey := strings.Replace(k, "attached_text", "attached_file", 1)
name := strings.TrimSpace(b.FormValues[nameKey])
if name == "" {
debug.Printf("%s defined, but corresponding filename element %q is not set", textKey, nameKey)
name = "FormData.txt" // Fallback (better than nothing)
}
attachments = append(attachments, fbb.NewFile(name, []byte(text)))
delete(b.FormValues, nameKey)
delete(b.FormValues, textKey)
}
// Add XML if a viewer is defined for this template
if b.Template.DisplayFormPath != "" {
filename := xmlName(b.Template)
attachments = append(attachments, fbb.NewFile(filename, b.buildXML()))
}
return attachments
}
// scanAndBuild scans the template at the given path, applies placeholder substition and builds the message.
//
// If b,Interactive is true, the user is prompted for undefined placeholders via stdio.
func (b messageBuilder) scanAndBuild(path string) (Message, error) {
f, err := os.Open(path)
if err != nil {
return Message{}, err
}
defer f.Close()
replaceInsertionTags := insertionTagReplacer(b.FormsMgr, b.InReplyToMsg, path, "<", ">")
refreshInsertionTags := func() {
replaceInsertionTags = insertionTagReplacer(b.FormsMgr, b.InReplyToMsg, path, "<", ">")
}
replaceVars := variableReplacer("<", ">", b.FormValues)
addFormValue := func(k, v string) {
b.FormValues[strings.ToLower(k)] = v
replaceVars = variableReplacer("<", ">", b.FormValues) // Refresh variableReplacer (rebuild regular expressions)
debug.Printf("Defined %q=%q", k, v)
}
scanner := bufio.NewScanner(newTrimBomReader(f))
msg := Message{submitted: now()}
var inBody bool
for scanner.Scan() {
lineTmpl := scanner.Text()
// Insertion tags and variables
lineTmpl = replaceInsertionTags(lineTmpl)
lineTmpl = replaceVars(lineTmpl)
// Prompt responses already provided (from text template editor in frontend)
for search, replace := range b.PromptResponses {
lineTmpl = strings.Replace(lineTmpl, search, replace, 1)
}
// Prompts (mostly found in text templates)
if b.Interactive {
lineTmpl = promptAsks(lineTmpl, func(a Ask) string {
var ans string
if a.Multiline {
fmt.Println(a.Prompt + " (Press ENTER to start external editor)")
b.LineReader()
var err error
ans, err = editor.EditText("")
if err != nil {
log.Fatalf("Failed to start text editor: %v", err)
}
} else {
fmt.Print(a.Prompt + " ")
ans = b.LineReader()
}
if a.Uppercase {
ans = strings.ToUpper(ans)
}
return ans
})
lineTmpl = promptSelects(lineTmpl, func(s Select) Option {
for {
fmt.Println(s.Prompt)
for i, opt := range s.Options {
fmt.Printf(" %d\t%s\n", i, opt.Item)
}
fmt.Printf("select 0-%d: ", len(s.Options)-1)
idx, err := strconv.Atoi(b.LineReader())
if err == nil && idx < len(s.Options) {
return s.Options[idx]
}
}
})
// Fallback prompt for undefined form variables.
// Typically these are defined by the associated HTML form, but since
// this is CLI land we'll just prompt for the variable value.
lineTmpl = promptVars(lineTmpl, func(key string) string {
fmt.Println(lineTmpl)
fmt.Printf("%s: ", key)
value := b.LineReader()
addFormValue(key, value)
return value
})
}
if inBody {
msg.Body += lineTmpl + "\n"
continue // No control fields in body
}
// Control fields
switch key, value, _ := strings.Cut(lineTmpl, ":"); textproto.CanonicalMIMEHeaderKey(key) {
case "Msg":
// The message body starts here. No more control fields after this.
msg.Body += value
inBody = true
case "Form", "Replytemplate":
// Handled elsewhere
continue
case "Def", "Define":
// Def: variable=value Define the value of a variable.
key, value, ok := strings.Cut(value, "=")
if !ok {
debug.Printf("Def: without key-value pair: %q", value)
continue
}
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
addFormValue(key, value)
case "Subject", "Subj":
// Set the subject of the message
msg.Subject = strings.TrimSpace(value)
case "To":
// Specify to whom the message is being sent
msg.To = strings.TrimSpace(value)
case "Cc":
// Specify carbon copy addresses
msg.Cc = strings.TrimSpace(value)
case "Readonly":
// Yes/No Specify whether user can edit.
// TODO: Disable editing of body in composer?
case "Seqinc":
value = strings.TrimSpace(value)
if value == "" {
value = "1"
}
incr, err := strconv.ParseInt(value, 10, 64)
if err != nil {
log.Printf("WARNING: failed to parse Seqinc value (%q): %v", value, err)
}
if _, err := b.FormsMgr.sequence.Incr(incr); err != nil {
return Message{}, err
}
refreshInsertionTags()
case "Seqset":
value = strings.TrimSpace(value)
num, err := strconv.ParseInt(value, 10, 64)
if err != nil {
log.Printf("WARNING: failed to parse Seqset value (%q): %v", value, err)
}
if _, err := b.FormsMgr.sequence.Set(num); err != nil {
return Message{}, err
}
refreshInsertionTags()
default:
if strings.TrimSpace(lineTmpl) != "" {
log.Printf("skipping unknown template line: '%q'", lineTmpl)
}
}
}
if b.InReplyToMsg != nil {
var buf bytes.Buffer
io.Copy(&buf, strings.NewReader(msg.Body))
writeMessageCitation(&buf, b.InReplyToMsg)
msg.Body = buf.String()
}
return msg, nil
}
func writeMessageCitation(w io.Writer, inReplyToMsg *fbb.Message) {
fmt.Fprintf(w, "--- %s %s wrote: ---\n", inReplyToMsg.Date(), inReplyToMsg.From().Addr)
body, _ := inReplyToMsg.Body()
scanner := bufio.NewScanner(strings.NewReader(body))
for scanner.Scan() {
fmt.Fprintf(w, ">%s\n", scanner.Text())
}
}
// VariableReplacer returns a function that replaces the given key-value pairs.
func variableReplacer(tagStart, tagEnd string, vars map[string]string) func(string) string {
return placeholderReplacer(tagStart+"Var ", tagEnd, vars)
}
// InsertionTagReplacer returns a function that replaces the fixed set of insertion tags with their corresponding values.
func insertionTagReplacer(m *Manager, inReplyToMsg *fbb.Message, templatePath string, tagStart, tagEnd string) func(string) string {
now := now()
validPos := "NO"
nowPos, err := m.gpsPos()
if err != nil {
debug.Printf("GPSd error: %v", err)
} else {
validPos = "YES"
debug.Printf("GPSd position: %s", positionFmt(signedDecimal, nowPos))
}
internetAvailable := "NO"
if isInternetAvailable() {
internetAvailable = "YES"
}
seqNum, err := m.sequence.Load()
if err != nil {
debug.Printf("Error loading sequence number: %v", err)
}
// This list is based on RMSE_FORMS/insertion_tags.zip (copy in docs/) as well as searching Standard Forms's templates.
tags := map[string]string{
"MsgSender": m.config.MyCall,
"Callsign": m.config.MyCall,
"ProgramVersion": m.config.AppVersion,
"DateTime": formatDateTime(now),
"UDateTime": formatDateTimeUTC(now),
"Date": formatDate(now),
"UDate": formatDateUTC(now),
"UDTG": formatUDTG(now),
"Time": formatTime(now),
"UTime": formatTimeUTC(now),
"Day": formatDay(now, location),
"UDay": formatDay(now, time.UTC),
"GPS": positionFmt(degreeMinute, nowPos),
"GPSValid": validPos,
"GPS_DECIMAL": positionFmt(decimal, nowPos),
"GPS_SIGNED_DECIMAL": positionFmt(signedDecimal, nowPos),
"GridSquare": positionFmt(gridSquare, nowPos),
"Latitude": fmt.Sprintf("%.4f", nowPos.Lat),
"Longitude": fmt.Sprintf("%.4f", nowPos.Lon),
// No docs found for these, but they are referenced by a couple of templates in Standard Forms.
// By reading the embedded javascript, they appear to be signed decimal.
"GPSLatitude": fmt.Sprintf("%.4f", nowPos.Lat),
"GPSLongitude": fmt.Sprintf("%.4f", nowPos.Lon),
"InternetAvailable": internetAvailable,
"MsgIsReply": strings.Title(strconv.FormatBool(inReplyToMsg != nil)),
"MsgIsForward": "False",
"MsgIsAcknowledgement": "False",
"SeqNum": fmt.Sprintf(m.config.SequenceFormat, seqNum),
"FormFolder": path.Join("/api/forms/", filepath.Dir(m.rel(templatePath))),
// TODO (other insertion tags found in Standard Forms):
// MsgTo
// MsgCc
// MsgSubject
// MsgP2P
// Sender (only in 'ARC Forms/Disaster Receipt 6409-B Reply.0')
// Speed (only in 'GENERAL Forms/GPS Position Report.txt' - but not included in produced message body)
// course (only in 'GENERAL Forms/GPS Position Report.txt' - but not included in produced message body)
// decimal_separator
}
if inReplyToMsg != nil {
tags["MsgOriginalSubject"] = inReplyToMsg.Subject()
tags["MsgOriginalSender"] = inReplyToMsg.From().Addr
tags["MsgOriginalBody"], _ = inReplyToMsg.Body()
tags["MsgOriginalID"] = inReplyToMsg.MID()
tags["MsgOriginalDate"] = formatDateTime(inReplyToMsg.Date())
// The documentation is not clear on these. Examples does not match the description.
tags["MsgOriginalUtcDate"] = formatDateUTC(inReplyToMsg.Date())
tags["MsgOriginalUtcTime"] = formatTimeUTC(inReplyToMsg.Date())
tags["MsgOriginalLocalDate"] = formatDate(inReplyToMsg.Date())
tags["MsgOriginalLocalTime"] = formatTime(inReplyToMsg.Date())
tags["MsgOriginalDTG"] = formatUDTG(inReplyToMsg.Date()) // Assuming UTC (as per example).
tags["MsgOriginalSize"] = fmt.Sprint(inReplyToMsg.BodySize()) // Assuming body size.
tags["MsgOriginalAttachmentCount"] = fmt.Sprint(len(inReplyToMsg.Files()))
for _, f := range inReplyToMsg.Files() {
if strings.HasPrefix(f.Name(), "RMS_Express_Form_") && strings.HasSuffix(f.Name(), ".xml") {
tags["MsgOriginalXML"] = string(f.Data())
}
}
}
return placeholderReplacer(tagStart, tagEnd, tags)
}
// xmlName returns the user-visible filename for the message attachment that holds the form instance values
func xmlName(t Template) string {
attachmentName := filepath.Base(t.DisplayFormPath)
attachmentName = strings.TrimSuffix(attachmentName, filepath.Ext(attachmentName))
attachmentName = "RMS_Express_Form_" + attachmentName + ".xml"
if len(attachmentName) > 255 {
attachmentName = strings.TrimPrefix(attachmentName, "RMS_Express_Form_")
}
return attachmentName
}
func isInternetAvailable() bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://www.google.com", nil)
resp, err := http.DefaultClient.Do(req)
debug.Printf("Internet available: %v (%v)", err == nil, err)
if err != nil {
return false
}
// Be nice, read the response body and close it.
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return true
}