2024-01-01 13:12:31 +01:00
|
|
|
|
package forms
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bufio"
|
2024-12-14 06:47:10 +01:00
|
|
|
|
"bytes"
|
2024-12-19 16:26:39 +01:00
|
|
|
|
"context"
|
2024-01-01 13:12:31 +01:00
|
|
|
|
"encoding/xml"
|
|
|
|
|
|
"fmt"
|
2024-12-19 23:52:06 +01:00
|
|
|
|
"io"
|
2024-01-01 13:12:31 +01:00
|
|
|
|
"log"
|
2024-12-19 23:26:20 +01:00
|
|
|
|
"net/http"
|
2024-01-01 13:12:31 +01:00
|
|
|
|
"net/textproto"
|
|
|
|
|
|
"os"
|
2025-05-26 11:37:58 +02:00
|
|
|
|
"path"
|
2024-01-01 13:12:31 +01:00
|
|
|
|
"path/filepath"
|
2024-01-06 13:59:51 +01:00
|
|
|
|
"sort"
|
2024-01-01 13:12:31 +01:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/la5nta/wl2k-go/fbb"
|
2024-02-13 22:45:12 +01:00
|
|
|
|
|
|
|
|
|
|
"github.com/la5nta/pat/internal/debug"
|
2024-12-13 13:00:00 +01:00
|
|
|
|
"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 {
|
2025-06-14 07:50:42 +02:00
|
|
|
|
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()
|
2024-02-13 22:45:12 +01:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-12-14 06:47:10 +01:00
|
|
|
|
// 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() {
|
2024-12-14 06:47:10 +01:00
|
|
|
|
if b.InReplyToMsg != nil {
|
2024-01-01 13:38:55 +01:00
|
|
|
|
b.FormValues["msgisreply"] = "True"
|
2024-12-14 06:47:10 +01:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
|
|
2024-02-13 22:45:12 +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
|
|
|
|
|
2024-01-06 13:59:51 +01:00
|
|
|
|
func (b messageBuilder) buildXML() []byte {
|
2024-01-07 12:46:43 +01:00
|
|
|
|
type Variable struct {
|
|
|
|
|
|
XMLName xml.Name
|
|
|
|
|
|
Value string `xml:",chardata"`
|
|
|
|
|
|
}
|
2024-02-13 22:45:12 +01:00
|
|
|
|
|
|
|
|
|
|
filename := func(path string) string {
|
|
|
|
|
|
// Avoid "." for empty paths
|
|
|
|
|
|
if path == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return filepath.Base(path)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-01-07 12:46:43 +01:00
|
|
|
|
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,
|
2025-08-31 18:55:35 +02:00
|
|
|
|
GridSquare: b.FormsMgr.config.LocatorProvider.Locator(),
|
2024-02-15 20:10:00 +01:00
|
|
|
|
DisplayForm: filename(b.Template.DisplayFormPath),
|
|
|
|
|
|
ReplyTemplate: filename(b.Template.ReplyTemplatePath),
|
2024-01-07 12:48:28 +01:00
|
|
|
|
}
|
2024-01-07 12:46:43 +01:00
|
|
|
|
for k, v := range b.FormValues {
|
2024-02-25 19:40:29 +01:00
|
|
|
|
// Trim leading and trailing whitespace. Winlink Express does
|
|
|
|
|
|
// this, judging from the produced XML attachments.
|
|
|
|
|
|
v = strings.TrimSpace(v)
|
2024-01-07 12:46:43 +01:00
|
|
|
|
form.Variables = append(form.Variables, Variable{xml.Name{Local: k}, v})
|
2024-01-06 13:59:51 +01:00
|
|
|
|
}
|
2024-01-07 12:46:43 +01:00
|
|
|
|
// 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)
|
2024-01-06 13:59:51 +01:00
|
|
|
|
}
|
2024-01-07 12:46:43 +01:00
|
|
|
|
return append([]byte(xml.Header), data...)
|
2024-01-06 13:59:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (b messageBuilder) buildAttachments() []*fbb.File {
|
|
|
|
|
|
var attachments []*fbb.File
|
2024-02-25 06:38:12 +01:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2025-01-21 19:40:56 +01:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2024-02-25 06:38:12 +01:00
|
|
|
|
textKey := k
|
|
|
|
|
|
text := b.FormValues[textKey]
|
|
|
|
|
|
nameKey := strings.Replace(k, "attached_text", "attached_file", 1)
|
2025-01-21 19:40:56 +01:00
|
|
|
|
name := strings.TrimSpace(b.FormValues[nameKey])
|
|
|
|
|
|
if name == "" {
|
2024-02-25 06:38:12 +01:00
|
|
|
|
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)
|
2024-01-06 13:59:51 +01:00
|
|
|
|
}
|
|
|
|
|
|
// Add XML if a viewer is defined for this template
|
2024-02-15 20:10:00 +01:00
|
|
|
|
if b.Template.DisplayFormPath != "" {
|
2024-02-13 22:45:12 +01:00
|
|
|
|
filename := xmlName(b.Template)
|
2024-01-06 13:59:51 +01:00
|
|
|
|
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()
|
|
|
|
|
|
|
2025-05-26 11:37:58 +02:00
|
|
|
|
replaceInsertionTags := insertionTagReplacer(b.FormsMgr, b.InReplyToMsg, path, "<", ">")
|
2024-12-19 16:26:39 +01:00
|
|
|
|
refreshInsertionTags := func() {
|
2025-05-26 11:37:58 +02:00
|
|
|
|
replaceInsertionTags = insertionTagReplacer(b.FormsMgr, b.InReplyToMsg, path, "<", ">")
|
2024-12-19 16:26:39 +01:00
|
|
|
|
}
|
2024-01-03 20:35:45 +01:00
|
|
|
|
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
|
|
|
|
|
2024-12-13 18:33:08 +01:00
|
|
|
|
scanner := bufio.NewScanner(newTrimBomReader(f))
|
2024-01-01 13:12:31 +01:00
|
|
|
|
|
2024-01-06 07:55:33 +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)
|
|
|
|
|
|
|
2025-05-28 21:07:03 +02:00
|
|
|
|
// 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 {
|
2024-12-13 13:00:00 +01:00
|
|
|
|
var ans string
|
|
|
|
|
|
if a.Multiline {
|
|
|
|
|
|
fmt.Println(a.Prompt + " (Press ENTER to start external editor)")
|
2025-06-14 07:50:42 +02:00
|
|
|
|
b.LineReader()
|
2024-12-13 13:00:00 +01:00
|
|
|
|
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 + " ")
|
2025-06-14 07:50:42 +02:00
|
|
|
|
ans = b.LineReader()
|
2024-12-13 13:00:00 +01:00
|
|
|
|
}
|
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)
|
2025-06-14 07:50:42 +02:00
|
|
|
|
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)
|
2025-06-14 07:50:42 +02:00
|
|
|
|
value := b.LineReader()
|
2024-01-03 20:35:45 +01:00
|
|
|
|
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
|
2025-07-05 13:32:15 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2024-01-03 20:35:45 +01:00
|
|
|
|
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":
|
2024-12-19 16:26:39 +01:00
|
|
|
|
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) != "" {
|
2024-12-13 18:33:08 +01:00
|
|
|
|
log.Printf("skipping unknown template line: '%q'", lineTmpl)
|
2024-01-01 13:12:31 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-12-14 06:47:10 +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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-12-14 06:47:10 +01:00
|
|
|
|
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.
|
2025-05-26 11:37:58 +02:00
|
|
|
|
func insertionTagReplacer(m *Manager, inReplyToMsg *fbb.Message, templatePath string, tagStart, tagEnd string) func(string) string {
|
2024-01-06 07:55:33 +01:00
|
|
|
|
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))
|
|
|
|
|
|
}
|
2024-12-19 23:26:20 +01:00
|
|
|
|
|
|
|
|
|
|
internetAvailable := "NO"
|
|
|
|
|
|
if isInternetAvailable() {
|
|
|
|
|
|
internetAvailable = "YES"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-12-19 16:26:39 +01:00
|
|
|
|
seqNum, err := m.sequence.Load()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
debug.Printf("Error loading sequence number: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-01-06 09:37:31 +01:00
|
|
|
|
// This list is based on RMSE_FORMS/insertion_tags.zip (copy in docs/) as well as searching Standard Forms's templates.
|
2024-12-14 06:47:10 +01:00
|
|
|
|
tags := map[string]string{
|
2024-01-01 13:12:31 +01:00
|
|
|
|
"MsgSender": m.config.MyCall,
|
|
|
|
|
|
"Callsign": m.config.MyCall,
|
2024-01-07 12:55:33 +01:00
|
|
|
|
"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),
|
2024-01-06 09:37:31 +01:00
|
|
|
|
"Day": formatDay(now, location),
|
|
|
|
|
|
"UDay": formatDay(now, time.UTC),
|
2024-01-01 13:12:31 +01:00
|
|
|
|
|
|
|
|
|
|
"GPS": positionFmt(degreeMinute, nowPos),
|
2024-02-25 18:09:06 +01:00
|
|
|
|
"GPSValid": validPos,
|
2024-01-01 13:12:31 +01:00
|
|
|
|
"GPS_DECIMAL": positionFmt(decimal, nowPos),
|
|
|
|
|
|
"GPS_SIGNED_DECIMAL": positionFmt(signedDecimal, nowPos),
|
2024-01-04 22:35:44 +01:00
|
|
|
|
"GridSquare": positionFmt(gridSquare, nowPos),
|
2024-01-01 13:12:31 +01:00
|
|
|
|
"Latitude": fmt.Sprintf("%.4f", nowPos.Lat),
|
|
|
|
|
|
"Longitude": fmt.Sprintf("%.4f", nowPos.Lon),
|
2024-01-06 09:37:31 +01:00
|
|
|
|
// 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
|
|
|
|
|
2024-12-19 23:26:20 +01:00
|
|
|
|
"InternetAvailable": internetAvailable,
|
2024-12-19 16:26:39 +01:00
|
|
|
|
|
2024-12-14 06:47:10 +01:00
|
|
|
|
"MsgIsReply": strings.Title(strconv.FormatBool(inReplyToMsg != nil)),
|
|
|
|
|
|
"MsgIsForward": "False",
|
|
|
|
|
|
"MsgIsAcknowledgement": "False",
|
2024-12-19 23:26:20 +01:00
|
|
|
|
|
2024-12-19 16:26:39 +01:00
|
|
|
|
"SeqNum": fmt.Sprintf(m.config.SequenceFormat, seqNum),
|
|
|
|
|
|
|
2025-05-26 11:37:58 +02:00
|
|
|
|
"FormFolder": path.Join("/api/forms/", filepath.Dir(m.rel(templatePath))),
|
|
|
|
|
|
|
2024-02-13 22:45:12 +01:00
|
|
|
|
// TODO (other insertion tags found in Standard Forms):
|
2024-01-06 09:37:31 +01:00
|
|
|
|
// MsgTo
|
|
|
|
|
|
// MsgCc
|
2024-01-01 13:12:31 +01:00
|
|
|
|
// MsgSubject
|
2024-01-06 09:37:31 +01:00
|
|
|
|
// 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
|
2024-12-14 06:47:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
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())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-02-13 22:45:12 +01:00
|
|
|
|
|
2024-12-14 06:47:10 +01:00
|
|
|
|
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
|
2024-02-13 22:45:12 +01:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2024-12-19 23:26:20 +01:00
|
|
|
|
|
|
|
|
|
|
func isInternetAvailable() bool {
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
req, _ := http.NewRequestWithContext(ctx, "HEAD", "https://www.google.com", nil)
|
2024-12-19 23:52:06 +01:00
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
2024-12-19 23:26:20 +01:00
|
|
|
|
debug.Printf("Internet available: %v (%v)", err == nil, err)
|
2024-12-19 23:52:06 +01:00
|
|
|
|
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
|
2024-12-19 23:26:20 +01:00
|
|
|
|
}
|