pat/internal/forms/builder.go

483 lines
16 KiB
Go
Raw Permalink Normal View History

2024-01-01 13:12:31 +01:00
package forms
import (
"bufio"
"bytes"
"context"
2024-01-01 13:12:31 +01:00
"encoding/xml"
"fmt"
"io"
2024-01-01 13:12:31 +01:00
"log"
"net/http"
2024-01-01 13:12:31 +01:00
"net/textproto"
"os"
"path"
2024-01-01 13:12:31 +01:00
"path/filepath"
"sort"
2024-01-01 13:12:31 +01:00
"strconv"
"strings"
"time"
"github.com/la5nta/wl2k-go/fbb"
"github.com/la5nta/pat/internal/debug"
"github.com/la5nta/pat/internal/editor"
2024-01-01 13:12:31 +01:00
)
// Message represents a concrete message compiled from a template
type Message struct {
2024-01-01 13:38:55 +01:00
To string `json:"msg_to"`
Cc string `json:"msg_cc"`
Subject string `json:"msg_subject"`
Body string `json:"msg_body"`
Attachments []*fbb.File `json:"-"`
2024-01-01 13:12:31 +01:00
2024-01-01 13:38:55 +01:00
submitted time.Time
2024-01-01 13:12:31 +01:00
}
type messageBuilder struct {
Interactive bool
LineReader func() string
2025-05-30 07:07:43 +02:00
InReplyToMsg *fbb.Message
Template Template
FormValues map[string]string
PromptResponses map[string]string
FormsMgr *Manager
2024-01-01 13:12:31 +01:00
}
// build returns message subject, body, and attachments for the given template and variable map
func (b messageBuilder) build() (Message, error) {
2024-01-01 13:38:55 +01:00
b.setDefaultFormValues()
msg, err := b.scanAndBuild(b.Template.Path)
2024-01-01 13:38:55 +01:00
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.
2024-01-01 13:38:55 +01:00
func (b messageBuilder) setDefaultFormValues() {
if b.InReplyToMsg != nil {
2024-01-01 13:38:55 +01:00
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()
}
2024-01-01 13:38:55 +01:00
} else {
b.FormValues["msgisreply"] = "False"
}
for _, key := range []string{"msgsender"} {
if _, ok := b.FormValues[key]; !ok {
b.FormValues[key] = b.FormsMgr.config.MyCall
}
}
2024-01-01 13:12:31 +01:00
2024-01-01 13:38:55 +01:00
// 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"
}
2024-01-01 13:12:31 +01:00
}
// TODO: Implement sequences
2024-01-01 13:38:55 +01:00
for _, key := range []string{"msgseqnum"} {
if _, ok := b.FormValues[key]; !ok {
b.FormValues[key] = "0"
}
2024-01-01 13:12:31 +01:00
}
2024-01-01 13:38:55 +01:00
}
2024-01-01 13:12:31 +01:00
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(),
2024-02-15 20:10:00 +01:00
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
2024-02-15 20:10:00 +01:00
if b.Template.DisplayFormPath != "" {
filename := xmlName(b.Template)
attachments = append(attachments, fbb.NewFile(filename, b.buildXML()))
2024-01-01 13:12:31 +01:00
}
2024-01-01 13:38:55 +01:00
return attachments
2024-01-01 13:12:31 +01:00
}
2024-01-01 13:38:55 +01:00
// 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)
2024-01-01 13:12:31 +01:00
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)
}
2024-01-01 13:12:31 +01:00
scanner := bufio.NewScanner(newTrimBomReader(f))
2024-01-01 13:12:31 +01:00
msg := Message{submitted: now()}
2024-01-01 13:12:31 +01:00
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)
}
2024-01-01 13:12:31 +01:00
// 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 {
2025-09-03 18:51:27 +02:00
fmt.Print(a.Prompt + " ")
ans = b.LineReader()
}
2024-01-04 21:19:35 +01:00
if a.Uppercase {
ans = strings.ToUpper(ans)
}
2024-02-25 06:46:23 +01:00
return ans
2024-01-01 13:12:31 +01:00
})
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())
2024-01-01 13:12:31 +01:00
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)
2024-01-01 13:12:31 +01:00
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":
2024-01-01 13:12:31 +01:00
// 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)
2024-01-01 13:12:31 +01:00
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()
2024-01-01 13:12:31 +01:00
default:
if strings.TrimSpace(lineTmpl) != "" {
log.Printf("skipping unknown template line: '%q'", lineTmpl)
2024-01-01 13:12:31 +01:00
}
}
}
if b.InReplyToMsg != nil {
var buf bytes.Buffer
io.Copy(&buf, strings.NewReader(msg.Body))
writeMessageCitation(&buf, b.InReplyToMsg)
msg.Body = buf.String()
}
2024-01-01 13:12:31 +01:00
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())
}
}
2024-01-01 13:12:31 +01:00
// 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()
2024-01-01 13:12:31 +01:00
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{
2024-01-01 13:12:31 +01:00
"MsgSender": m.config.MyCall,
"Callsign": m.config.MyCall,
"ProgramVersion": m.config.AppVersion,
2024-01-01 13:12:31 +01:00
"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),
2024-01-01 13:12:31 +01:00
"GPS": positionFmt(degreeMinute, nowPos),
"GPSValid": validPos,
2024-01-01 13:12:31 +01:00
"GPS_DECIMAL": positionFmt(decimal, nowPos),
"GPS_SIGNED_DECIMAL": positionFmt(signedDecimal, nowPos),
"GridSquare": positionFmt(gridSquare, nowPos),
2024-01-01 13:12:31 +01:00
"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),
2024-01-01 13:12:31 +01:00
"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
2024-01-01 13:12:31 +01:00
// 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)
2024-01-01 13:12:31 +01:00
// 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)
2024-01-01 13:12:31 +01:00
}
2024-01-01 13:38:55 +01:00
// xmlName returns the user-visible filename for the message attachment that holds the form instance values
func xmlName(t Template) string {
2024-02-15 20:10:00 +01:00
attachmentName := filepath.Base(t.DisplayFormPath)
2024-01-01 13:38:55 +01:00
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
}