Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Message Templates (templates without HTML Forms) #438

Merged
merged 2 commits into from
Jan 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/debug/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ func init() {
enabled, _ = strconv.ParseBool(os.Getenv(EnvVar))
}

func Enabled() bool { return enabled }

func Printf(format string, v ...interface{}) {
if !enabled {
return
Expand Down
220 changes: 152 additions & 68 deletions internal/forms/forms.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"log"
"math"
"net/http"
"net/textproto"
"os"
"path"
"path/filepath"
Expand Down Expand Up @@ -505,7 +506,6 @@ func (m *Manager) ComposeForm(tmplPath string, subject string) (MessageForm, err
formValues := map[string]string{
"subjectline": subject,
"templateversion": m.getFormsVersion(),
"msgsender": m.config.MyCall,
}
fmt.Printf("Form '%s', version: %s", form.TxtFileURI, formValues["templateversion"])
formMsg, err := formMessageBuilder{
Expand Down Expand Up @@ -852,15 +852,7 @@ func (m *Manager) fillFormTemplate(tmplPath string, formDestURL string, placehol
log.Printf("Warning: unsupported string encoding in template %s, expected utf-8", tmplPath)
}

now := time.Now()
validPos := "NO"
nowPos, err := m.gpsPos()
if err != nil {
debug.Printf("GPSd error: %v", err)
} else {
validPos = "YES"
debug.Printf("GPSd position: %s", gpsFmt(signedDecimal, nowPos))
}
replaceInsertionTags := m.insertionTagReplacer("{", "}")

var buf bytes.Buffer
scanner := bufio.NewScanner(bytes.NewReader(sanitizedFileContent))
Expand All @@ -869,27 +861,7 @@ func (m *Manager) fillFormTemplate(tmplPath string, formDestURL string, placehol
l = strings.ReplaceAll(l, "http://{FormServer}:{FormPort}", formDestURL)
// some Canada BC forms don't use the {FormServer} placeholder, it's OK, can deal with it here
l = strings.ReplaceAll(l, "http://localhost:8001", formDestURL)
l = strings.ReplaceAll(l, "{MsgSender}", m.config.MyCall)
l = strings.ReplaceAll(l, "{Callsign}", m.config.MyCall)
l = strings.ReplaceAll(l, "{ProgramVersion}", "Pat "+m.config.AppVersion)
l = strings.ReplaceAll(l, "{DateTime}", formatDateTime(now))
l = strings.ReplaceAll(l, "{UDateTime}", formatDateTimeUTC(now))
l = strings.ReplaceAll(l, "{Date}", formatDate(now))
l = strings.ReplaceAll(l, "{UDate}", formatDateUTC(now))
l = strings.ReplaceAll(l, "{UDTG}", formatUDTG(now))
l = strings.ReplaceAll(l, "{Time}", formatTime(now))
l = strings.ReplaceAll(l, "{UTime}", formatTimeUTC(now))
l = strings.ReplaceAll(l, "{GPS}", gpsFmt(degreeMinute, nowPos))
l = strings.ReplaceAll(l, "{GPS_DECIMAL}", gpsFmt(decimal, nowPos))
l = strings.ReplaceAll(l, "{GPS_SIGNED_DECIMAL}", gpsFmt(signedDecimal, nowPos))
// Lots of undocumented tags found in the Winlink check in form.
// Note also various ways of capitalizing. Perhaps best to do case insenstive string replacements....
l = strings.ReplaceAll(l, "{Latitude}", fmt.Sprintf("%.4f", nowPos.Lat))
l = strings.ReplaceAll(l, "{latitude}", fmt.Sprintf("%.4f", nowPos.Lat))
l = strings.ReplaceAll(l, "{Longitude}", fmt.Sprintf("%.4f", nowPos.Lon))
l = strings.ReplaceAll(l, "{longitude}", fmt.Sprintf("%.4f", nowPos.Lon))
l = strings.ReplaceAll(l, "{GridSquare}", posToGridSquare(nowPos))
l = strings.ReplaceAll(l, "{GPSValid}", fmt.Sprintf("%s ", validPos))
l = replaceInsertionTags(l)
if placeholderRegEx != nil {
l = fillPlaceholders(l, placeholderRegEx, formVars)
}
Expand Down Expand Up @@ -989,8 +961,11 @@ func (b formMessageBuilder) initFormValues() {
} else {
b.FormValues["msgisreply"] = "False"
}

b.FormValues["msgsender"] = b.FormsMgr.config.MyCall
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.
Expand All @@ -1004,8 +979,12 @@ func (b formMessageBuilder) initFormValues() {
b.FormValues[key] = "False"
}
}
if _, ok := b.FormValues["msgseqnum"]; !ok {
b.FormValues["msgseqnum"] = "0"

//TODO: Implement sequences
for _, key := range []string{"msgseqnum"} {
if _, ok := b.FormValues[key]; !ok {
b.FormValues[key] = "0"
}
}
}

Expand All @@ -1016,65 +995,170 @@ func (b formMessageBuilder) scanTmplBuildMessage(tmplPath string) (MessageForm,
}
defer infile.Close()

placeholderRegEx := regexp.MustCompile(`<[vV][aA][rR]\s+(\w+)\s*>`)
placeholderRegEx := regexp.MustCompile(`(?i)<Var\s+(\w+)\s*>`)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated cleanup here. (?i) makes the regular expression case-insensitive 🙂

replaceInsertionTags := b.FormsMgr.insertionTagReplacer("<", ">")
scanner := bufio.NewScanner(infile)

var msgForm MessageForm
var inBody bool
for scanner.Scan() {
lineTmpl := scanner.Text()

// Insertion tags
lineTmpl = replaceInsertionTags(lineTmpl)

// Variables
lineTmpl = fillPlaceholders(lineTmpl, placeholderRegEx, b.FormValues)
lineTmpl = strings.ReplaceAll(lineTmpl, "<MsgSender>", b.FormsMgr.config.MyCall)
lineTmpl = strings.ReplaceAll(lineTmpl, "<ProgramVersion>", "Pat "+b.FormsMgr.config.AppVersion)
if strings.HasPrefix(lineTmpl, "Form:") {
continue
}
if strings.HasPrefix(lineTmpl, "ReplyTemplate:") {
continue
}
if strings.HasPrefix(lineTmpl, "Msg:") {
lineTmpl = strings.TrimSpace(strings.TrimPrefix(lineTmpl, "Msg:"))
inBody = true
}

// Prompts (mostly found in text templates)
if b.Interactive {
matches := placeholderRegEx.FindAllStringSubmatch(lineTmpl, -1)
fmt.Println(lineTmpl)
for i := range matches {
varName := matches[i][1]
varNameLower := strings.ToLower(varName)
if b.FormValues[varNameLower] != "" {
continue
}
fmt.Print(varName + ": ")
b.FormValues[varNameLower] = "blank"
val := b.FormsMgr.config.LineReader()
if val != "" {
b.FormValues[varNameLower] = val
lineTmpl = promptAsks(lineTmpl, func(a Ask) string {
//TODO: Handle a.Multiline as we do message body
fmt.Printf(a.Prompt + " ")
return b.FormsMgr.config.LineReader()
})
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.FormsMgr.config.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.Printf("%s: ", key)
value := b.FormsMgr.config.LineReader()
b.FormValues[strings.ToLower(key)] = value
return value
})
}

lineTmpl = fillPlaceholders(lineTmpl, placeholderRegEx, b.FormValues)
switch key, value, _ := strings.Cut(lineTmpl, ":"); key {
case "Subject":
if inBody {
msgForm.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.
msgForm.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.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value)
b.FormValues[key] = value
debug.Printf("Defined %q=%q", key, value)
case "Subject", "Subj":
// Set the subject of the message
msgForm.Subject = strings.TrimSpace(value)
case "To":
// Specify to whom the message is being sent
msgForm.To = strings.TrimSpace(value)
case "Cc":
// Specify carbon copy addresses
msgForm.Cc = strings.TrimSpace(value)
case "Readonly":
// Yes/No – Specify whether user can edit.
// TODO: Disable editing of body in composer?
case "Seqinc":
//TODO: Handle sequences
default:
if inBody {
msgForm.Body += lineTmpl + "\n"
} else {
if strings.TrimSpace(lineTmpl) != "" {
log.Printf("skipping unknown template line: '%s'", lineTmpl)
}
}
}
return msgForm, nil
}

func (m *Manager) insertionTagReplacer(tagStart, tagEnd string) func(string) string {
now := time.Now()
validPos := "NO"
nowPos, err := m.gpsPos()
if err != nil {
debug.Printf("GPSd error: %v", err)
} else {
validPos = "YES"
debug.Printf("GPSd position: %s", gpsFmt(signedDecimal, nowPos))
}
tags := map[string]string{
"MsgSender": m.config.MyCall,
"Callsign": m.config.MyCall,
"ProgramVersion": "Pat " + m.config.AppVersion,

"DateTime": formatDateTime(now),
"UDateTime": formatDateTimeUTC(now),
"Date": formatDate(now),
"UDate": formatDateUTC(now),
"UDTG": formatUDTG(now),
"Time": formatTime(now),
"UTime": formatTimeUTC(now),

"GPS": gpsFmt(degreeMinute, nowPos),
"GPS_DECIMAL": gpsFmt(decimal, nowPos),
"GPS_SIGNED_DECIMAL": gpsFmt(signedDecimal, nowPos),
"Latitude": fmt.Sprintf("%.4f", nowPos.Lat),
"Longitude": fmt.Sprintf("%.4f", nowPos.Lon),
"GridSquare": posToGridSquare(nowPos),
"GPSValid": fmt.Sprintf("%s ", validPos),

//TODO (other insertion tags found in Standard Forms):
// SeqNum
// FormFolder
// GPSLatitude
// GPSLongitude
// InternetAvailable
// MsgP2P
// MsgSubject
// Sender
// Speed
// course
// decimal_separator
}

// compileRegexp compiles a case insensitive regular expression matching the given tag.
compileRegexp := func(tag string) *regexp.Regexp {
tag = tagStart + tag + tagEnd
return regexp.MustCompile(`(?i)` + regexp.QuoteMeta(tag))
}
// Build a map from regexp to replacement values of for all tags.
regexps := make(map[*regexp.Regexp]string, len(tags))
for tag, newValue := range tags {
regexps[compileRegexp(tag)] = newValue
}
// Return a function for applying the replacements.
return func(str string) string {
for re, newValue := range regexps {
str = re.ReplaceAllLiteralString(str, newValue)
}
if debug.Enabled() {
// Log remaining insertion tags
re := regexp.QuoteMeta(tagStart) + `[\w_-]+` + regexp.QuoteMeta(tagEnd)
if matches := regexp.MustCompile(re).FindAllString(str, -1); len(matches) > 0 {
debug.Printf("Unhandled insertion tags: %v", matches)
}
}
return str
}
}

func xmlEscape(s string) string {
var buf bytes.Buffer
if err := xml.EscapeText(&buf, []byte(s)); err != nil {
Expand Down
72 changes: 72 additions & 0 deletions internal/forms/prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package forms

import (
"regexp"
"strings"
)

type Select struct {
Prompt string
Options []Option
}

type Ask struct {
Prompt string
Multiline bool
}

type Option struct {
Item string
Value string
}

func promptAsks(str string, promptFn func(Ask) string) string {
re := regexp.MustCompile(`(?i)<Ask\s+([^,]+)(,[^>]+)?>`)
for {
tokens := re.FindAllStringSubmatch(str, -1)
if len(tokens) == 0 {
return str
}
replace, prompt, options := tokens[0][0], tokens[0][1], strings.TrimPrefix(tokens[0][2], ",")
a := Ask{Prompt: prompt, Multiline: strings.EqualFold(options, "MU")}
ans := promptFn(a)
str = strings.Replace(str, replace, ans, 1)
}
}

func promptSelects(str string, promptFn func(Select) Option) string {
re := regexp.MustCompile(`(?i)<Select\s+([^,]+)(,[^>]+)?>`)
for {
tokens := re.FindAllStringSubmatch(str, -1)
if len(tokens) == 0 {
return str
}
replace, prompt, options := tokens[0][0], tokens[0][1], strings.Split(strings.TrimPrefix(tokens[0][2], ","), ",")
s := Select{Prompt: prompt}
for _, opt := range options {
item, value, ok := strings.Cut(opt, "=")
if !ok {
value = item
}
s.Options = append(s.Options, Option{Item: item, Value: value})
}
ans := promptFn(s)
str = strings.Replace(str, replace, ans.Value, 1)
}
}

func promptVars(str string, promptFn func(string) string) string {
re := regexp.MustCompile(`(?i)<Var\s+(\w+)\s*>`)
for {
tokens := re.FindAllStringSubmatch(str, -1)
if len(tokens) == 0 {
return str
}
replace, key := tokens[0][0], tokens[0][1]
ans := promptFn(key)
if ans == "" {
ans = "blank"
}
str = strings.Replace(str, replace, ans, 1)
}
}
Loading