From 149107272f8d406560fa7bdb6dbaefdd460dfd33 Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Sun, 31 Dec 2023 13:50:08 +0100 Subject: [PATCH 01/28] Refactor variable substitution Variable substitution is essentially the same as tag insertion, so the two features can share the same underlying implementation. --- internal/forms/forms.go | 72 +++++++----------------------- internal/forms/placeholder.go | 41 +++++++++++++++++ internal/forms/placeholder_test.go | 43 ++++++++++++++++++ 3 files changed, 101 insertions(+), 55 deletions(-) create mode 100644 internal/forms/placeholder.go create mode 100644 internal/forms/placeholder_test.go diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 86e3cdda..1f55095a 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -24,7 +24,6 @@ import ( "os" "path" "path/filepath" - "regexp" "sort" "strconv" "strings" @@ -256,7 +255,7 @@ func (m *Manager) GetFormTemplateHandler(w http.ResponseWriter, r *http.Request) return } - responseText, err := m.fillFormTemplate(formPath, "/api/form?"+r.URL.Query().Encode(), nil, make(map[string]string)) + responseText, err := m.fillFormTemplate(formPath, "/api/form?"+r.URL.Query().Encode(), nil) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("problem filling form template file %s %s: can't open template %s. Err: %s", r.Method, r.URL.Path, formPath, err) @@ -493,7 +492,7 @@ func (m *Manager) RenderForm(contentUnsanitized []byte, composeReply bool) (stri tmplPath = form.ViewerURI } - return m.fillFormTemplate(tmplPath, "/api/form?composereply=true&formPath="+m.rel(tmplPath), regexp.MustCompile(`{[vV][aA][rR]\s+(\w+)\s*}`), formVars) + return m.fillFormTemplate(tmplPath, "/api/form?composereply=true&formPath="+m.rel(tmplPath), formVars) } // ComposeForm combines all data needed for the whole form-based message: subject, body, and attachment @@ -833,7 +832,7 @@ func posToGridSquare(pos gpsd.Position) string { return gridsquare } -func (m *Manager) fillFormTemplate(tmplPath string, formDestURL string, placeholderRegEx *regexp.Regexp, formVars map[string]string) (string, error) { +func (m *Manager) fillFormTemplate(tmplPath string, formDestURL string, formVars map[string]string) (string, error) { fUnsanitized, err := os.Open(tmplPath) if err != nil { return "", err @@ -853,6 +852,7 @@ func (m *Manager) fillFormTemplate(tmplPath string, formDestURL string, placehol } replaceInsertionTags := m.insertionTagReplacer("{", "}") + replaceVars := variableReplacer("{", "}", formVars) var buf bytes.Buffer scanner := bufio.NewScanner(bytes.NewReader(sanitizedFileContent)) @@ -862,9 +862,7 @@ func (m *Manager) fillFormTemplate(tmplPath string, formDestURL string, placehol // 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 = replaceInsertionTags(l) - if placeholderRegEx != nil { - l = fillPlaceholders(l, placeholderRegEx, formVars) - } + l = replaceVars(l) buf.WriteString(l + "\n") } return buf.String(), nil @@ -969,7 +967,7 @@ func (b formMessageBuilder) initFormValues() { // 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"} { + for _, key := range []string{"msgto", "msgcc", "msgsubject", "msgbody", "msgp2p", "txtstr"} { if _, ok := b.FormValues[key]; !ok { b.FormValues[key] = "" } @@ -995,7 +993,7 @@ func (b formMessageBuilder) scanTmplBuildMessage(tmplPath string) (MessageForm, } defer infile.Close() - placeholderRegEx := regexp.MustCompile(`(?i)`) + replaceVars := variableReplacer("<", ">", b.FormValues) replaceInsertionTags := b.FormsMgr.insertionTagReplacer("<", ">") scanner := bufio.NewScanner(infile) @@ -1004,11 +1002,9 @@ func (b formMessageBuilder) scanTmplBuildMessage(tmplPath string) (MessageForm, for scanner.Scan() { lineTmpl := scanner.Text() - // Insertion tags + // Insertion tags and variables lineTmpl = replaceInsertionTags(lineTmpl) - - // Variables - lineTmpl = fillPlaceholders(lineTmpl, placeholderRegEx, b.FormValues) + lineTmpl = replaceVars(lineTmpl) // Prompts (mostly found in text templates) if b.Interactive { @@ -1088,6 +1084,12 @@ func (b formMessageBuilder) scanTmplBuildMessage(tmplPath string) (MessageForm, return msgForm, nil } +// 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 (m *Manager) insertionTagReplacer(tagStart, tagEnd string) func(string) string { now := time.Now() validPos := "NO" @@ -1098,7 +1100,7 @@ func (m *Manager) insertionTagReplacer(tagStart, tagEnd string) func(string) str validPos = "YES" debug.Printf("GPSd position: %s", gpsFmt(signedDecimal, nowPos)) } - tags := map[string]string{ + return placeholderReplacer(tagStart, tagEnd, map[string]string{ "MsgSender": m.config.MyCall, "Callsign": m.config.MyCall, "ProgramVersion": "Pat " + m.config.AppVersion, @@ -1131,32 +1133,7 @@ func (m *Manager) insertionTagReplacer(tagStart, tagEnd string) func(string) str // 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 { @@ -1167,21 +1144,6 @@ func xmlEscape(s string) string { return buf.String() } -func fillPlaceholders(s string, re *regexp.Regexp, values map[string]string) string { - if _, ok := values["txtstr"]; !ok { - values["txtstr"] = "" - } - result := s - matches := re.FindAllStringSubmatch(s, -1) - for _, match := range matches { - value, ok := values[strings.ToLower(match[1])] - if ok { - result = strings.ReplaceAll(result, match[0], value) - } - } - return result -} - func (m *Manager) cleanupOldFormData() { m.postedFormData.mu.Lock() defer m.postedFormData.mu.Unlock() diff --git a/internal/forms/placeholder.go b/internal/forms/placeholder.go new file mode 100644 index 00000000..07bd98b1 --- /dev/null +++ b/internal/forms/placeholder.go @@ -0,0 +1,41 @@ +package forms + +import ( + "regexp" + + "github.com/la5nta/pat/internal/debug" +) + +// placeholderReplacer returns a function that performs a case-insensitive search and replace. +// The placeholders are expected to be encapsulated with prefix and suffix. +// Any whitespace between prefix/suffix and key is ignored. +func placeholderReplacer(prefix, suffix string, fields map[string]string) func(string) string { + const ( + space = `\s*` + caseInsensitiveFlag = `(?i)` + ) + // compileRegexp compiles a case insensitive regular expression matching the given key. + prefix, suffix = regexp.QuoteMeta(prefix)+space, space+regexp.QuoteMeta(suffix) + compileRegexp := func(key string) *regexp.Regexp { + return regexp.MustCompile(caseInsensitiveFlag + prefix + regexp.QuoteMeta(key) + suffix) + } + // Build a map from regexp to replacement values for all tags. + regexps := make(map[*regexp.Regexp]string, len(fields)) + for key, newValue := range fields { + regexps[compileRegexp(key)] = 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 := caseInsensitiveFlag + prefix + `[\w_-]+` + suffix + if matches := regexp.MustCompile(re).FindAllString(str, -1); len(matches) > 0 { + debug.Printf("Unhandled placeholder: %v", matches) + } + } + return str + } +} diff --git a/internal/forms/placeholder_test.go b/internal/forms/placeholder_test.go new file mode 100644 index 00000000..e2e964bc --- /dev/null +++ b/internal/forms/placeholder_test.go @@ -0,0 +1,43 @@ +package forms + +import ( + "fmt" + "testing" +) + +func TestPlaceholderReplacer(t *testing.T) { + tests := []struct { + In string + Replacer func(string) string + Expect string + }{ + { + In: "", + Replacer: placeholderReplacer("<", ">", map[string]string{"mykey": "foobar"}), + Expect: "foobar", + }, + { + In: "", + Replacer: placeholderReplacer("<", ">", map[string]string{"MyKey": "foobar"}), + Expect: "foobar", + }, + { + In: "< mykey \t>", + Replacer: placeholderReplacer("<", ">", map[string]string{"MyKey": "foobar"}), + Expect: "foobar", + }, + { + In: "", + Replacer: placeholderReplacer("", map[string]string{"mykey": "foobar"}), + Expect: "foobar", + }, + } + for i, tt := range tests { + t.Run(fmt.Sprint(i), func(t *testing.T) { + if got := tt.Replacer(tt.In); got != tt.Expect { + t.Errorf("Expected %q, got %q", tt.Expect, got) + } + + }) + } +} From 8002ec66c9e7e6b31469cb6cb229e4c635b58fc5 Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Sun, 31 Dec 2023 15:38:34 +0100 Subject: [PATCH 02/28] Cleanup (read form file) --- go.mod | 1 - go.sum | 2 - internal/forms/forms.go | 95 ++++++++++++----------------------------- internal/forms/io.go | 26 +++++++++++ 4 files changed, 54 insertions(+), 70 deletions(-) create mode 100644 internal/forms/io.go diff --git a/go.mod b/go.mod index 7fbce5a8..06a98bf2 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.19 require ( github.com/adrg/xdg v0.3.3 github.com/bndr/gotabulate v1.1.3-0.20170315142410-bc555436bfd5 - github.com/dimchansky/utfbom v1.1.1 github.com/fsnotify/fsnotify v1.4.9 github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 github.com/gorilla/mux v1.8.0 diff --git a/go.sum b/go.sum index c4aca25f..f0487495 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= -github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 h1:f0n1xnMSmBLzVfsMMvriDyA75NB/oBgILX2GcHXIQzY= diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 1f55095a..4e299c9a 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -31,7 +31,6 @@ import ( "time" "unicode/utf8" - "github.com/dimchansky/utfbom" "github.com/la5nta/pat/cfg" "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/directories" @@ -414,29 +413,23 @@ func getXMLAttachmentNameForForm(f Form, isReply bool) string { } // RenderForm finds the associated form and returns the filled-in form in HTML given the contents of a form attachment -func (m *Manager) RenderForm(contentUnsanitized []byte, composeReply bool) (string, error) { +func (m *Manager) RenderForm(data []byte, composeReply bool) (string, error) { type Node struct { XMLName xml.Name Content []byte `xml:",innerxml"` Nodes []Node `xml:",any"` } - sr := utfbom.SkipOnly(bytes.NewReader(contentUnsanitized)) - - contentData, err := io.ReadAll(sr) - if err != nil { - return "", fmt.Errorf("error reading sanitized form xml: %w", err) - } - - if !utf8.Valid(contentData) { - log.Println("Warning: unsupported string encoding in form XML, expected utf-8") + data = trimBom(data) + if !utf8.Valid(data) { + log.Println("Warning: unsupported string encoding in form XML, expected UTF-8") } var n1 Node formParams := make(map[string]string) formVars := make(map[string]string) - if err := xml.Unmarshal(contentData, &n1); err != nil { + if err := xml.Unmarshal(data, &n1); err != nil { return "", err } @@ -550,34 +543,21 @@ func (m *Manager) buildFormFolder() (FormFolder, error) { } func (m *Manager) innerRecursiveBuildFormFolder(rootPath string) (FormFolder, error) { - rootFile, err := os.Open(rootPath) + rootPath = filepath.Clean(rootPath) + entries, err := os.ReadDir(rootPath) if err != nil { return FormFolder{}, err } - defer rootFile.Close() - rootFileInfo, _ := os.Stat(rootPath) - - if !rootFileInfo.IsDir() { - return FormFolder{}, errors.New(rootPath + " is not a directory") - } folder := FormFolder{ - Name: rootFileInfo.Name(), - Path: rootFile.Name(), + Name: filepath.Base(rootPath), + Path: rootPath, Forms: []Form{}, Folders: []FormFolder{}, } - - infos, err := rootFile.Readdir(0) - if err != nil { - return folder, err - } - _ = rootFile.Close() - - formCnt := 0 - for _, info := range infos { - if info.IsDir() { - subfolder, err := m.innerRecursiveBuildFormFolder(filepath.Join(rootPath, info.Name())) + for _, entry := range entries { + if entry.IsDir() { + subfolder, err := m.innerRecursiveBuildFormFolder(filepath.Join(rootPath, entry.Name())) if err != nil { return folder, err } @@ -585,17 +565,16 @@ func (m *Manager) innerRecursiveBuildFormFolder(rootPath string) (FormFolder, er folder.FormCount += subfolder.FormCount continue } - if !strings.EqualFold(filepath.Ext(info.Name()), txtFileExt) { + if !strings.EqualFold(filepath.Ext(entry.Name()), txtFileExt) { continue } - path := filepath.Join(rootPath, info.Name()) + path := filepath.Join(rootPath, entry.Name()) frm, err := buildFormFromTxt(path) if err != nil { debug.Printf("failed to load form file %q: %v", path, err) continue } if frm.InitialURI != "" || frm.ViewerURI != "" { - formCnt++ folder.Forms = append(folder.Forms, frm) folder.FormCount++ } @@ -833,48 +812,29 @@ func posToGridSquare(pos gpsd.Position) string { } func (m *Manager) fillFormTemplate(tmplPath string, formDestURL string, formVars map[string]string) (string, error) { - fUnsanitized, err := os.Open(tmplPath) + data, err := readFile(tmplPath) if err != nil { return "", err } - defer fUnsanitized.Close() - - // skipping over UTF-8 byte-ordering mark EFBBEF, some 3rd party templates use it - // (e.g. Sonoma county's ICS213_v2.1_SonomaACS_TwoWay_Initial_Viewer.html) - f := utfbom.SkipOnly(fUnsanitized) - sanitizedFileContent, err := io.ReadAll(f) - if err != nil { - return "", fmt.Errorf("error reading file %s", tmplPath) - } - if !utf8.Valid(sanitizedFileContent) { - log.Printf("Warning: unsupported string encoding in template %s, expected utf-8", tmplPath) - } + // Set the "form server" URL + data = strings.ReplaceAll(data, "http://{FormServer}:{FormPort}", formDestURL) + data = strings.ReplaceAll(data, "http://localhost:8001", formDestURL) // Some Canada BC forms are hardcoded to this URL - replaceInsertionTags := m.insertionTagReplacer("{", "}") - replaceVars := variableReplacer("{", "}", formVars) + // Substitute insertion tags and variables + data = m.insertionTagReplacer("{", "}")(data) + data = variableReplacer("{", "}", formVars)(data) - var buf bytes.Buffer - scanner := bufio.NewScanner(bytes.NewReader(sanitizedFileContent)) - for scanner.Scan() { - l := scanner.Text() - 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 = replaceInsertionTags(l) - l = replaceVars(l) - buf.WriteString(l + "\n") - } - return buf.String(), nil + return data, nil } func (m *Manager) getFormsVersion() string { - data, err := os.ReadFile(m.abs("Standard_Forms_Version.dat")) + str, err := readFile(m.abs("Standard_Forms_Version.dat")) if err != nil { debug.Printf("failed to open version file: %v", err) return "unknown" } - return string(bytes.TrimSpace(data)) + return strings.TrimSpace(str) } type formMessageBuilder struct { @@ -987,15 +947,16 @@ func (b formMessageBuilder) initFormValues() { } func (b formMessageBuilder) scanTmplBuildMessage(tmplPath string) (MessageForm, error) { - infile, err := os.Open(tmplPath) + f, err := os.Open(tmplPath) if err != nil { return MessageForm{}, err } - defer infile.Close() + defer f.Close() replaceVars := variableReplacer("<", ">", b.FormValues) replaceInsertionTags := b.FormsMgr.insertionTagReplacer("<", ">") - scanner := bufio.NewScanner(infile) + + scanner := bufio.NewScanner(f) var msgForm MessageForm var inBody bool diff --git a/internal/forms/io.go b/internal/forms/io.go new file mode 100644 index 00000000..41f5e7a6 --- /dev/null +++ b/internal/forms/io.go @@ -0,0 +1,26 @@ +package forms + +import ( + "bytes" + "log" + "os" + "unicode/utf8" +) + +func readFile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + // skipping over UTF-8 byte-ordering mark EFBBEF, some 3rd party templates use it + // (e.g. Sonoma county's ICS213_v2.1_SonomaACS_TwoWay_Initial_Viewer.html) + data = trimBom(data) + if !utf8.Valid(data) { + log.Printf("Warning: unsupported string encoding in file %q, expected UTF-8", path) + } + return string(data), nil +} + +func trimBom(p []byte) []byte { + return bytes.TrimLeftFunc(p, func(r rune) bool { return r == '\uFEFF' }) +} From f62fdf0c689cae45923b48664a61b2765857d4f9 Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Mon, 1 Jan 2024 08:52:42 +0100 Subject: [PATCH 03/28] More refactoring (split source files) --- internal/forms/forms.go | 126 ++----------------------------------- internal/forms/position.go | 77 +++++++++++++++++++++++ internal/forms/unzip.go | 56 +++++++++++++++++ 3 files changed, 137 insertions(+), 122 deletions(-) create mode 100644 internal/forms/position.go create mode 100644 internal/forms/unzip.go diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 4e299c9a..51000afb 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -7,7 +7,6 @@ package forms import ( - "archive/zip" "bufio" "bytes" "context" @@ -18,7 +17,6 @@ import ( "io" "io/ioutil" "log" - "math" "net/http" "net/textproto" "os" @@ -36,7 +34,6 @@ import ( "github.com/la5nta/pat/internal/directories" "github.com/la5nta/pat/internal/gpsd" "github.com/la5nta/wl2k-go/fbb" - "github.com/pd0mz/go-maidenhead" ) const formsVersionInfoURL = "https://api.getpat.io/v1/forms/standard-templates/latest" @@ -351,53 +348,6 @@ func (m *Manager) downloadAndUnzipForms(ctx context.Context, downloadLink string return nil } -func unzip(srcArchivePath, dstRoot string) error { - // Closure to address file descriptors issue with all the deferred .Close() methods - extractAndWriteFile := func(zf *zip.File) error { - if zf.FileInfo().IsDir() { - return nil - } - destPath := filepath.Join(dstRoot, zf.Name) - - // Check for ZipSlip (Directory traversal) - if !strings.HasPrefix(destPath, filepath.Clean(dstRoot)+string(os.PathSeparator)) { - return fmt.Errorf("illegal file path: %s", destPath) - } - - // Ensure target directory exists - if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { - return fmt.Errorf("can't create target directory: %w", err) - } - - // Write file - src, err := zf.Open() - if err != nil { - return err - } - defer src.Close() - dst, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zf.Mode()) - if err != nil { - return err - } - defer dst.Close() - _, err = io.Copy(dst, src) - return err - } - - r, err := zip.OpenReader(srcArchivePath) - if err != nil { - return err - } - defer r.Close() - for _, f := range r.File { - err := extractAndWriteFile(f) - if err != nil { - return err - } - } - return nil -} - // getXMLAttachmentNameForForm returns the user-visible filename for the message attachment that holds the form instance values func getXMLAttachmentNameForForm(f Form, isReply bool) string { attachmentName := filepath.Base(f.ViewerURI) @@ -743,74 +693,6 @@ func (m *Manager) gpsPos() (gpsd.Position, error) { return conn.NextPosTimeout(3 * time.Second) } -type gpsStyle int - -const ( - // documentation: https://www.winlink.org/sites/default/files/RMSE_FORMS/insertion_tags.zip - signedDecimal gpsStyle = iota // 41.1234 -73.4567 - decimal // 46.3795N 121.5835W - degreeMinute // 46-22.77N 121-35.01W -) - -func gpsFmt(style gpsStyle, pos gpsd.Position) string { - var ( - northing string - easting string - latDegrees int - latMinutes float64 - lonDegrees int - lonMinutes float64 - ) - - noPos := gpsd.Position{} - if pos == noPos { - return "(Not available)" - } - switch style { - case degreeMinute: - { - latDegrees = int(math.Trunc(math.Abs(pos.Lat))) - latMinutes = (math.Abs(pos.Lat) - float64(latDegrees)) * 60 - lonDegrees = int(math.Trunc(math.Abs(pos.Lon))) - lonMinutes = (math.Abs(pos.Lon) - float64(lonDegrees)) * 60 - } - fallthrough - case decimal: - { - if pos.Lat >= 0 { - northing = "N" - } else { - northing = "S" - } - if pos.Lon >= 0 { - easting = "E" - } else { - easting = "W" - } - } - } - - switch style { - case signedDecimal: - return fmt.Sprintf("%.4f %.4f", pos.Lat, pos.Lon) - case decimal: - return fmt.Sprintf("%.4f%s %.4f%s", math.Abs(pos.Lat), northing, math.Abs(pos.Lon), easting) - case degreeMinute: - return fmt.Sprintf("%02d-%05.2f%s %03d-%05.2f%s", latDegrees, latMinutes, northing, lonDegrees, lonMinutes, easting) - default: - return "(Not available)" - } -} - -func posToGridSquare(pos gpsd.Position) string { - point := maidenhead.NewPoint(pos.Lat, pos.Lon) - gridsquare, err := point.GridSquare() - if err != nil { - return "" - } - return gridsquare -} - func (m *Manager) fillFormTemplate(tmplPath string, formDestURL string, formVars map[string]string) (string, error) { data, err := readFile(tmplPath) if err != nil { @@ -1059,7 +941,7 @@ func (m *Manager) insertionTagReplacer(tagStart, tagEnd string) func(string) str debug.Printf("GPSd error: %v", err) } else { validPos = "YES" - debug.Printf("GPSd position: %s", gpsFmt(signedDecimal, nowPos)) + debug.Printf("GPSd position: %s", positionFmt(signedDecimal, nowPos)) } return placeholderReplacer(tagStart, tagEnd, map[string]string{ "MsgSender": m.config.MyCall, @@ -1074,9 +956,9 @@ func (m *Manager) insertionTagReplacer(tagStart, tagEnd string) func(string) str "Time": formatTime(now), "UTime": formatTimeUTC(now), - "GPS": gpsFmt(degreeMinute, nowPos), - "GPS_DECIMAL": gpsFmt(decimal, nowPos), - "GPS_SIGNED_DECIMAL": gpsFmt(signedDecimal, nowPos), + "GPS": positionFmt(degreeMinute, nowPos), + "GPS_DECIMAL": positionFmt(decimal, nowPos), + "GPS_SIGNED_DECIMAL": positionFmt(signedDecimal, nowPos), "Latitude": fmt.Sprintf("%.4f", nowPos.Lat), "Longitude": fmt.Sprintf("%.4f", nowPos.Lon), "GridSquare": posToGridSquare(nowPos), diff --git a/internal/forms/position.go b/internal/forms/position.go new file mode 100644 index 00000000..8db3b1f5 --- /dev/null +++ b/internal/forms/position.go @@ -0,0 +1,77 @@ +package forms + +import ( + "fmt" + "math" + + "github.com/la5nta/pat/internal/gpsd" + "github.com/pd0mz/go-maidenhead" +) + +type gpsStyle int + +const ( + // documentation: https://www.winlink.org/sites/default/files/RMSE_FORMS/insertion_tags.zip + signedDecimal gpsStyle = iota // 41.1234 -73.4567 + decimal // 46.3795N 121.5835W + degreeMinute // 46-22.77N 121-35.01W +) + +func positionFmt(style gpsStyle, pos gpsd.Position) string { + var ( + northing string + easting string + latDegrees int + latMinutes float64 + lonDegrees int + lonMinutes float64 + ) + + noPos := gpsd.Position{} + if pos == noPos { + return "(Not available)" + } + switch style { + case degreeMinute: + { + latDegrees = int(math.Trunc(math.Abs(pos.Lat))) + latMinutes = (math.Abs(pos.Lat) - float64(latDegrees)) * 60 + lonDegrees = int(math.Trunc(math.Abs(pos.Lon))) + lonMinutes = (math.Abs(pos.Lon) - float64(lonDegrees)) * 60 + } + fallthrough + case decimal: + { + if pos.Lat >= 0 { + northing = "N" + } else { + northing = "S" + } + if pos.Lon >= 0 { + easting = "E" + } else { + easting = "W" + } + } + } + + switch style { + case signedDecimal: + return fmt.Sprintf("%.4f %.4f", pos.Lat, pos.Lon) + case decimal: + return fmt.Sprintf("%.4f%s %.4f%s", math.Abs(pos.Lat), northing, math.Abs(pos.Lon), easting) + case degreeMinute: + return fmt.Sprintf("%02d-%05.2f%s %03d-%05.2f%s", latDegrees, latMinutes, northing, lonDegrees, lonMinutes, easting) + default: + panic("invalid style") + } +} + +func posToGridSquare(pos gpsd.Position) string { + point := maidenhead.NewPoint(pos.Lat, pos.Lon) + gridsquare, err := point.GridSquare() + if err != nil { + return "" + } + return gridsquare +} diff --git a/internal/forms/unzip.go b/internal/forms/unzip.go new file mode 100644 index 00000000..9b50320f --- /dev/null +++ b/internal/forms/unzip.go @@ -0,0 +1,56 @@ +package forms + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func unzip(srcArchivePath, dstRoot string) error { + // Closure to address file descriptors issue with all the deferred .Close() methods + extractAndWriteFile := func(zf *zip.File) error { + if zf.FileInfo().IsDir() { + return nil + } + destPath := filepath.Join(dstRoot, zf.Name) + + // Check for ZipSlip (Directory traversal) + if !strings.HasPrefix(destPath, filepath.Clean(dstRoot)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", destPath) + } + + // Ensure target directory exists + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return fmt.Errorf("can't create target directory: %w", err) + } + + // Write file + src, err := zf.Open() + if err != nil { + return err + } + defer src.Close() + dst, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zf.Mode()) + if err != nil { + return err + } + defer dst.Close() + _, err = io.Copy(dst, src) + return err + } + + r, err := zip.OpenReader(srcArchivePath) + if err != nil { + return err + } + defer r.Close() + for _, f := range r.File { + if err := extractAndWriteFile(f); err != nil { + return err + } + } + return r.Close() +} From 5b966989ed9bf4ebd001c1ae1ac6b7dd654eebb2 Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Mon, 1 Jan 2024 08:53:45 +0100 Subject: [PATCH 04/28] Distinguish Template from Form (HTML) Forms is an extension of the Templates feature in RMS Express. Templates are the .txt files. Some templates support HTML forms, indicated by the Template Control Field `Form`. --- cli_composer.go | 2 +- internal/forms/forms.go | 143 ++++++++++++++++++++-------------------- 2 files changed, 71 insertions(+), 74 deletions(-) diff --git a/cli_composer.go b/cli_composer.go index 030b5d20..acdb7259 100644 --- a/cli_composer.go +++ b/cli_composer.go @@ -283,7 +283,7 @@ func composeFormReport(ctx context.Context, args []string) { msg := composeMessageHeader(nil) - formMsg, err := formsMgr.ComposeForm(tmplPathArg, msg.Subject()) + formMsg, err := formsMgr.ComposeTemplate(tmplPathArg, msg.Subject()) if err != nil { log.Printf("failed to compose message for template: %v", err) return diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 51000afb..6711f364 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -58,7 +58,7 @@ type Manager struct { // different form authoring sessions separate from each other. postedFormData struct { mu sync.RWMutex - m map[string]MessageForm + m map[string]Message } } @@ -73,8 +73,8 @@ type Config struct { GPSd cfg.GPSdConfig } -// Form holds information about a Winlink form template -type Form struct { +// Template holds information about a Winlink form template +type Template struct { Name string `json:"name"` TxtFileURI string `json:"txt_file_uri"` InitialURI string `json:"initial_uri"` @@ -90,12 +90,12 @@ type FormFolder struct { Path string `json:"path"` Version string `json:"version"` FormCount int `json:"form_count"` - Forms []Form `json:"forms"` + Forms []Template `json:"forms"` Folders []FormFolder `json:"folders"` } -// MessageForm represents a concrete form-based message -type MessageForm struct { +// 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"` @@ -103,13 +103,13 @@ type MessageForm struct { attachmentXML string attachmentTXT string - targetForm Form + targetForm Template isReply bool submitted time.Time } // Attachments returns the attachments generated by the filled-in form -func (m MessageForm) Attachments() []*fbb.File { +func (m Message) Attachments() []*fbb.File { var files []*fbb.File if xml := m.attachmentXML; xml != "" { name := getXMLAttachmentNameForForm(m.targetForm, m.isReply) @@ -135,7 +135,7 @@ func NewManager(conf Config) *Manager { retval := &Manager{ config: conf, } - retval.postedFormData.m = make(map[string]MessageForm) + retval.postedFormData.m = make(map[string]Message) return retval } @@ -192,7 +192,7 @@ func (m *Manager) PostFormDataHandler(w http.ResponseWriter, r *http.Request) { fields[strings.TrimSpace(strings.ToLower(key))] = values[0] } - formMsg, err := formMessageBuilder{ + msg, err := messageBuilder{ Template: form, FormValues: fields, Interactive: false, @@ -205,7 +205,7 @@ func (m *Manager) PostFormDataHandler(w http.ResponseWriter, r *http.Request) { } m.postedFormData.mu.Lock() - m.postedFormData.m[formInstanceKey.Value] = formMsg + m.postedFormData.m[formInstanceKey.Value] = msg m.postedFormData.mu.Unlock() m.cleanupOldFormData() @@ -229,7 +229,7 @@ func (m *Manager) GetFormDataHandler(w http.ResponseWriter, r *http.Request) { } // GetPostedFormData is similar to GetFormDataHandler, but used when posting the form-based message to the outbox -func (m *Manager) GetPostedFormData(key string) (MessageForm, bool) { +func (m *Manager) GetPostedFormData(key string) (Message, bool) { m.postedFormData.mu.RLock() defer m.postedFormData.mu.RUnlock() v, ok := m.postedFormData.m[key] @@ -349,10 +349,10 @@ func (m *Manager) downloadAndUnzipForms(ctx context.Context, downloadLink string } // getXMLAttachmentNameForForm returns the user-visible filename for the message attachment that holds the form instance values -func getXMLAttachmentNameForForm(f Form, isReply bool) string { - attachmentName := filepath.Base(f.ViewerURI) +func getXMLAttachmentNameForForm(t Template, isReply bool) string { + attachmentName := filepath.Base(t.ViewerURI) if isReply { - attachmentName = filepath.Base(f.ReplyViewerURI) + attachmentName = filepath.Base(t.ReplyViewerURI) } attachmentName = strings.TrimSuffix(attachmentName, filepath.Ext(attachmentName)) attachmentName = "RMS_Express_Form_" + attachmentName + ".xml" @@ -438,52 +438,49 @@ func (m *Manager) RenderForm(data []byte, composeReply bool) (string, error) { return m.fillFormTemplate(tmplPath, "/api/form?composereply=true&formPath="+m.rel(tmplPath), formVars) } -// ComposeForm combines all data needed for the whole form-based message: subject, body, and attachment -func (m *Manager) ComposeForm(tmplPath string, subject string) (MessageForm, error) { - form, err := buildFormFromTxt(tmplPath) +// ComposeTemplate composes a message from a template (tmplPath) by prompting the user through stdio. +// +// It combines all data needed for the whole template-based message: subject, body, and attachments. +func (m *Manager) ComposeTemplate(tmplPath string, subject string) (Message, error) { + template, err := readTemplate(tmplPath) if err != nil { - return MessageForm{}, err + return Message{}, err } formValues := map[string]string{ "subjectline": subject, "templateversion": m.getFormsVersion(), } - fmt.Printf("Form '%s', version: %s", form.TxtFileURI, formValues["templateversion"]) - formMsg, err := formMessageBuilder{ - Template: form, + fmt.Printf("Form '%s', version: %s", template.TxtFileURI, formValues["templateversion"]) + return messageBuilder{ + Template: template, FormValues: formValues, Interactive: true, IsReply: false, FormsMgr: m, }.build() - if err != nil { - return MessageForm{}, err - } - - return formMsg, nil } -func (f Form) matchesName(nameToMatch string) bool { - return f.InitialURI == nameToMatch || - strings.EqualFold(f.InitialURI, nameToMatch+htmlFileExt) || - f.ViewerURI == nameToMatch || - strings.EqualFold(f.ViewerURI, nameToMatch+htmlFileExt) || - f.ReplyInitialURI == nameToMatch || - f.ReplyInitialURI == nameToMatch+".0" || - f.ReplyViewerURI == nameToMatch || - f.ReplyViewerURI == nameToMatch+".0" || - f.TxtFileURI == nameToMatch || - strings.EqualFold(f.TxtFileURI, nameToMatch+txtFileExt) +func (t Template) matchesName(nameToMatch string) bool { + return t.InitialURI == nameToMatch || + strings.EqualFold(t.InitialURI, nameToMatch+htmlFileExt) || + t.ViewerURI == nameToMatch || + strings.EqualFold(t.ViewerURI, nameToMatch+htmlFileExt) || + t.ReplyInitialURI == nameToMatch || + t.ReplyInitialURI == nameToMatch+".0" || + t.ReplyViewerURI == nameToMatch || + t.ReplyViewerURI == nameToMatch+".0" || + t.TxtFileURI == nameToMatch || + strings.EqualFold(t.TxtFileURI, nameToMatch+txtFileExt) } -func (f Form) containsName(partialName string) bool { - return strings.Contains(f.InitialURI, partialName) || - strings.Contains(f.ViewerURI, partialName) || - strings.Contains(f.ReplyInitialURI, partialName) || - strings.Contains(f.ReplyViewerURI, partialName) || - strings.Contains(f.ReplyTxtFileURI, partialName) || - strings.Contains(f.TxtFileURI, partialName) +func (t Template) containsName(partialName string) bool { + return strings.Contains(t.InitialURI, partialName) || + strings.Contains(t.ViewerURI, partialName) || + strings.Contains(t.ReplyInitialURI, partialName) || + strings.Contains(t.ReplyViewerURI, partialName) || + strings.Contains(t.ReplyTxtFileURI, partialName) || + strings.Contains(t.TxtFileURI, partialName) } func (m *Manager) buildFormFolder() (FormFolder, error) { @@ -502,7 +499,7 @@ func (m *Manager) innerRecursiveBuildFormFolder(rootPath string) (FormFolder, er folder := FormFolder{ Name: filepath.Base(rootPath), Path: rootPath, - Forms: []Form{}, + Forms: []Template{}, Folders: []FormFolder{}, } for _, entry := range entries { @@ -519,13 +516,13 @@ func (m *Manager) innerRecursiveBuildFormFolder(rootPath string) (FormFolder, er continue } path := filepath.Join(rootPath, entry.Name()) - frm, err := buildFormFromTxt(path) + tmpl, err := readTemplate(path) if err != nil { debug.Printf("failed to load form file %q: %v", path, err) continue } - if frm.InitialURI != "" || frm.ViewerURI != "" { - folder.Forms = append(folder.Forms, frm) + if tmpl.InitialURI != "" || tmpl.ViewerURI != "" { + folder.Forms = append(folder.Forms, tmpl) folder.FormCount++ } } @@ -587,19 +584,19 @@ func resolveFileReference(basePath string, referencePath string) (string, bool) return path, false } -func buildFormFromTxt(path string) (Form, error) { +func readTemplate(path string) (Template, error) { f, err := os.Open(path) if err != nil { - return Form{}, err + return Template{}, err } defer f.Close() - form := Form{ + template := Template{ Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), TxtFileURI: path, } scanner := bufio.NewScanner(f) - baseURI := filepath.Dir(form.TxtFileURI) + baseURI := filepath.Dir(template.TxtFileURI) for scanner.Scan() { switch key, value, _ := strings.Cut(scanner.Text(), ":"); key { case "Form": @@ -614,12 +611,12 @@ func buildFormFromTxt(path string) (Form, error) { var ok bool files[i], ok = resolveFileReference(baseURI, path) if !ok { - debug.Printf("%s: failed to resolve referenced file %q", form.TxtFileURI, path) + debug.Printf("%s: failed to resolve referenced file %q", template.TxtFileURI, path) } } - form.InitialURI = files[0] + template.InitialURI = files[0] if len(files) > 1 { - form.ViewerURI = files[1] + template.ViewerURI = files[1] } case "ReplyTemplate": path := strings.TrimSpace(value) @@ -630,23 +627,23 @@ func buildFormFromTxt(path string) (Form, error) { var ok bool path, ok = resolveFileReference(baseURI, path) if !ok { - debug.Printf("%s: failed to resolve referenced reply template file %q", form.TxtFileURI, path) + debug.Printf("%s: failed to resolve referenced reply template file %q", template.TxtFileURI, path) continue } - tmpForm, err := buildFormFromTxt(path) + replyTemplate, err := readTemplate(path) if err != nil { - debug.Printf("%s: failed to load referenced reply template: %v", form.TxtFileURI, err) + debug.Printf("%s: failed to load referenced reply template: %v", template.TxtFileURI, err) } - form.ReplyTxtFileURI = path - form.ReplyInitialURI = tmpForm.InitialURI - form.ReplyViewerURI = tmpForm.ViewerURI + template.ReplyTxtFileURI = path + template.ReplyInitialURI = replyTemplate.InitialURI + template.ReplyViewerURI = replyTemplate.ViewerURI } } - return form, err + return template, err } -func findFormFromURI(formName string, folder FormFolder) (Form, error) { - form := Form{Name: "unknown"} +func findFormFromURI(formName string, folder FormFolder) (Template, error) { + form := Template{Name: "unknown"} for _, subFolder := range folder.Folders { form, err := findFormFromURI(formName, subFolder) if err == nil { @@ -719,16 +716,16 @@ func (m *Manager) getFormsVersion() string { return strings.TrimSpace(str) } -type formMessageBuilder struct { +type messageBuilder struct { Interactive bool IsReply bool - Template Form + Template Template FormValues map[string]string FormsMgr *Manager } // build returns message subject, body, and attachments for the given template and variable map -func (b formMessageBuilder) build() (MessageForm, error) { +func (b messageBuilder) build() (Message, error) { tmplPath := b.Template.TxtFileURI if b.IsReply && b.Template.ReplyTxtFileURI != "" { tmplPath = b.Template.ReplyTxtFileURI @@ -738,7 +735,7 @@ func (b formMessageBuilder) build() (MessageForm, error) { msgForm, err := b.scanTmplBuildMessage(tmplPath) if err != nil { - return MessageForm{}, err + return Message{}, err } //TODO: Should any of these be set in scanTmplBuildMessage? msgForm.targetForm = b.Template @@ -795,7 +792,7 @@ func (b formMessageBuilder) build() (MessageForm, error) { return msgForm, nil } -func (b formMessageBuilder) initFormValues() { +func (b messageBuilder) initFormValues() { if b.IsReply { b.FormValues["msgisreply"] = "True" } else { @@ -828,10 +825,10 @@ func (b formMessageBuilder) initFormValues() { } } -func (b formMessageBuilder) scanTmplBuildMessage(tmplPath string) (MessageForm, error) { +func (b messageBuilder) scanTmplBuildMessage(tmplPath string) (Message, error) { f, err := os.Open(tmplPath) if err != nil { - return MessageForm{}, err + return Message{}, err } defer f.Close() @@ -840,7 +837,7 @@ func (b formMessageBuilder) scanTmplBuildMessage(tmplPath string) (MessageForm, scanner := bufio.NewScanner(f) - var msgForm MessageForm + var msgForm Message var inBody bool for scanner.Scan() { lineTmpl := scanner.Text() From 461b7a6002a13ccd089b269938de879c493fabc1 Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Mon, 1 Jan 2024 09:40:33 +0100 Subject: [PATCH 05/28] Improve form variable prompting in CLI composer Forms-enabled templates is not written with a CLI user in mind. By echoing the template line before prompting for a , it will hopefully be possible to understand the meaning of the variable (by context). Also apply a hack to refresh the regular expressions used by varReplacer, to avoid prompting for the same variable multiple times. --- internal/forms/forms.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 6711f364..56e78d64 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -451,7 +451,7 @@ func (m *Manager) ComposeTemplate(tmplPath string, subject string) (Message, err "subjectline": subject, "templateversion": m.getFormsVersion(), } - fmt.Printf("Form '%s', version: %s", template.TxtFileURI, formValues["templateversion"]) + fmt.Printf("Form '%s', version: %s\n", template.TxtFileURI, formValues["templateversion"]) return messageBuilder{ Template: template, FormValues: formValues, @@ -870,9 +870,11 @@ func (b messageBuilder) scanTmplBuildMessage(tmplPath string) (Message, error) { // 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.FormsMgr.config.LineReader() b.FormValues[strings.ToLower(key)] = value + replaceVars = variableReplacer("<", ">", b.FormValues) // Refresh variableReplacer to avoid prompting the same again return value }) } From 300a993a612e3a42bb0433a9aac5fae842243761 Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Mon, 1 Jan 2024 13:12:31 +0100 Subject: [PATCH 06/28] More refactoring (split source files) --- internal/forms/forms.go | 428 +------------------------------------ internal/forms/message.go | 320 +++++++++++++++++++++++++++ internal/forms/template.go | 131 ++++++++++++ 3 files changed, 452 insertions(+), 427 deletions(-) create mode 100644 internal/forms/message.go create mode 100644 internal/forms/template.go diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 56e78d64..31986738 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -7,7 +7,6 @@ package forms import ( - "bufio" "bytes" "context" "encoding/json" @@ -18,7 +17,6 @@ import ( "io/ioutil" "log" "net/http" - "net/textproto" "os" "path" "path/filepath" @@ -33,7 +31,6 @@ import ( "github.com/la5nta/pat/internal/debug" "github.com/la5nta/pat/internal/directories" "github.com/la5nta/pat/internal/gpsd" - "github.com/la5nta/wl2k-go/fbb" ) const formsVersionInfoURL = "https://api.getpat.io/v1/forms/standard-templates/latest" @@ -73,17 +70,6 @@ type Config struct { GPSd cfg.GPSdConfig } -// Template holds information about a Winlink form template -type Template struct { - Name string `json:"name"` - TxtFileURI string `json:"txt_file_uri"` - InitialURI string `json:"initial_uri"` - ViewerURI string `json:"viewer_uri"` - ReplyTxtFileURI string `json:"reply_txt_file_uri"` - ReplyInitialURI string `json:"reply_initial_uri"` - ReplyViewerURI string `json:"reply_viewer_uri"` -} - // FormFolder is a folder with forms. A tree structure with Form leaves and sub-Folder branches type FormFolder struct { Name string `json:"name"` @@ -94,33 +80,6 @@ type FormFolder struct { Folders []FormFolder `json:"folders"` } -// 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"` - - attachmentXML string - attachmentTXT string - targetForm Template - isReply bool - submitted time.Time -} - -// Attachments returns the attachments generated by the filled-in form -func (m Message) Attachments() []*fbb.File { - var files []*fbb.File - if xml := m.attachmentXML; xml != "" { - name := getXMLAttachmentNameForForm(m.targetForm, m.isReply) - files = append(files, fbb.NewFile(name, []byte(xml))) - } - if txt := m.attachmentTXT; txt != "" { - files = append(files, fbb.NewFile("FormData.txt", []byte(txt))) - } - return files -} - // UpdateResponse is the API response format for the upgrade forms endpoint type UpdateResponse struct { NewestVersion string `json:"newestVersion"` @@ -348,20 +307,6 @@ func (m *Manager) downloadAndUnzipForms(ctx context.Context, downloadLink string return nil } -// getXMLAttachmentNameForForm returns the user-visible filename for the message attachment that holds the form instance values -func getXMLAttachmentNameForForm(t Template, isReply bool) string { - attachmentName := filepath.Base(t.ViewerURI) - if isReply { - attachmentName = filepath.Base(t.ReplyViewerURI) - } - 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 -} - // RenderForm finds the associated form and returns the filled-in form in HTML given the contents of a form attachment func (m *Manager) RenderForm(data []byte, composeReply bool) (string, error) { type Node struct { @@ -461,28 +406,6 @@ func (m *Manager) ComposeTemplate(tmplPath string, subject string) (Message, err }.build() } -func (t Template) matchesName(nameToMatch string) bool { - return t.InitialURI == nameToMatch || - strings.EqualFold(t.InitialURI, nameToMatch+htmlFileExt) || - t.ViewerURI == nameToMatch || - strings.EqualFold(t.ViewerURI, nameToMatch+htmlFileExt) || - t.ReplyInitialURI == nameToMatch || - t.ReplyInitialURI == nameToMatch+".0" || - t.ReplyViewerURI == nameToMatch || - t.ReplyViewerURI == nameToMatch+".0" || - t.TxtFileURI == nameToMatch || - strings.EqualFold(t.TxtFileURI, nameToMatch+txtFileExt) -} - -func (t Template) containsName(partialName string) bool { - return strings.Contains(t.InitialURI, partialName) || - strings.Contains(t.ViewerURI, partialName) || - strings.Contains(t.ReplyInitialURI, partialName) || - strings.Contains(t.ReplyViewerURI, partialName) || - strings.Contains(t.ReplyTxtFileURI, partialName) || - strings.Contains(t.TxtFileURI, partialName) -} - func (m *Manager) buildFormFolder() (FormFolder, error) { formFolder, err := m.innerRecursiveBuildFormFolder(m.config.FormsPath) formFolder.Version = m.getFormsVersion() @@ -555,93 +478,6 @@ func (m *Manager) rel(path string) string { return rel } -// resolveFileReference searches for files referenced in .txt files. -// -// If found the returned path is relative to FormsPath and bool is true, otherwise the given path is returned unmodified. -func resolveFileReference(basePath string, referencePath string) (string, bool) { - path := filepath.Join(basePath, referencePath) - if !directories.IsInPath(basePath, path) { - debug.Printf("%q escapes template's base path (%q)", referencePath, basePath) - return "", false - } - if _, err := os.Stat(path); err == nil { - return path, true - } - // Fallback to case-insenstive search. - // Some HTML files references in the .txt files has a different caseness than the actual filename on disk. - //TODO: Walk basePath tree instead - absPathTemplateFolder := filepath.Dir(path) - entries, err := os.ReadDir(absPathTemplateFolder) - if err != nil { - return path, false - } - for _, entry := range entries { - name := entry.Name() - if strings.EqualFold(filepath.Base(path), name) { - return filepath.Join(absPathTemplateFolder, name), true - } - } - return path, false -} - -func readTemplate(path string) (Template, error) { - f, err := os.Open(path) - if err != nil { - return Template{}, err - } - defer f.Close() - - template := Template{ - Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), - TxtFileURI: path, - } - scanner := bufio.NewScanner(f) - baseURI := filepath.Dir(template.TxtFileURI) - for scanner.Scan() { - switch key, value, _ := strings.Cut(scanner.Text(), ":"); key { - case "Form": - // Form: , - files := strings.Split(value, ",") - // Extend to absolute paths and add missing html extension - for i, path := range files { - path = strings.TrimSpace(path) - if ext := filepath.Ext(path); ext == "" { - path += htmlFileExt - } - var ok bool - files[i], ok = resolveFileReference(baseURI, path) - if !ok { - debug.Printf("%s: failed to resolve referenced file %q", template.TxtFileURI, path) - } - } - template.InitialURI = files[0] - if len(files) > 1 { - template.ViewerURI = files[1] - } - case "ReplyTemplate": - path := strings.TrimSpace(value) - // Some are missing .txt - if filepath.Ext(path) == "" { - path += txtFileExt - } - var ok bool - path, ok = resolveFileReference(baseURI, path) - if !ok { - debug.Printf("%s: failed to resolve referenced reply template file %q", template.TxtFileURI, path) - continue - } - replyTemplate, err := readTemplate(path) - if err != nil { - debug.Printf("%s: failed to load referenced reply template: %v", template.TxtFileURI, err) - } - template.ReplyTxtFileURI = path - template.ReplyInitialURI = replyTemplate.InitialURI - template.ReplyViewerURI = replyTemplate.ViewerURI - } - } - return template, err -} - func findFormFromURI(formName string, folder FormFolder) (Template, error) { form := Template{Name: "unknown"} for _, subFolder := range folder.Folders { @@ -701,7 +537,7 @@ func (m *Manager) fillFormTemplate(tmplPath string, formDestURL string, formVars data = strings.ReplaceAll(data, "http://localhost:8001", formDestURL) // Some Canada BC forms are hardcoded to this URL // Substitute insertion tags and variables - data = m.insertionTagReplacer("{", "}")(data) + data = insertionTagReplacer(m, "{", "}")(data) data = variableReplacer("{", "}", formVars)(data) return data, nil @@ -716,268 +552,6 @@ func (m *Manager) getFormsVersion() string { return strings.TrimSpace(str) } -type messageBuilder struct { - Interactive bool - IsReply bool - Template Template - FormValues 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) { - tmplPath := b.Template.TxtFileURI - if b.IsReply && b.Template.ReplyTxtFileURI != "" { - tmplPath = b.Template.ReplyTxtFileURI - } - - b.initFormValues() - - msgForm, err := b.scanTmplBuildMessage(tmplPath) - if err != nil { - return Message{}, err - } - //TODO: Should any of these be set in scanTmplBuildMessage? - msgForm.targetForm = b.Template - msgForm.isReply = b.IsReply - msgForm.submitted = time.Now() - - // Add TXT attachment if defined by the form - if v, ok := b.FormValues["attached_text"]; ok { - delete(b.FormValues, "attached_text") - msgForm.attachmentTXT = v - } - - formVarsAsXML := "" - for varKey, varVal := range b.FormValues { - formVarsAsXML += fmt.Sprintf(" <%s>%s\n", xmlEscape(varKey), xmlEscape(varVal), xmlEscape(varKey)) - } - - // Add XML if a viewer is defined for this form - if b.Template.ViewerURI != "" { - viewer := b.Template.ViewerURI - if b.IsReply && b.Template.ReplyViewerURI != "" { - viewer = b.Template.ReplyViewerURI - } - msgForm.attachmentXML = fmt.Sprintf(`%s - - %s - %s - %s - %s - %s - %s - %s - - -%s - - -`, - xml.Header, - "1.0", - b.FormsMgr.config.AppVersion, - time.Now().UTC().Format("20060102150405"), - b.FormsMgr.config.MyCall, - b.FormsMgr.config.Locator, - filepath.Base(viewer), - filepath.Base(b.Template.ReplyTxtFileURI), - formVarsAsXML) - } - - msgForm.To = strings.TrimSpace(msgForm.To) - msgForm.Cc = strings.TrimSpace(msgForm.Cc) - msgForm.Subject = strings.TrimSpace(msgForm.Subject) - msgForm.Body = strings.TrimSpace(msgForm.Body) - return msgForm, nil -} - -func (b messageBuilder) initFormValues() { - if b.IsReply { - b.FormValues["msgisreply"] = "True" - } 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) scanTmplBuildMessage(tmplPath string) (Message, error) { - f, err := os.Open(tmplPath) - if err != nil { - return Message{}, err - } - defer f.Close() - - replaceVars := variableReplacer("<", ">", b.FormValues) - replaceInsertionTags := b.FormsMgr.insertionTagReplacer("<", ">") - - scanner := bufio.NewScanner(f) - - var msgForm Message - var inBody bool - for scanner.Scan() { - lineTmpl := scanner.Text() - - // Insertion tags and variables - lineTmpl = replaceInsertionTags(lineTmpl) - lineTmpl = replaceVars(lineTmpl) - - // Prompts (mostly found in text templates) - if b.Interactive { - 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.Println(lineTmpl) - fmt.Printf("%s: ", key) - value := b.FormsMgr.config.LineReader() - b.FormValues[strings.ToLower(key)] = value - replaceVars = variableReplacer("<", ">", b.FormValues) // Refresh variableReplacer to avoid prompting the same again - return value - }) - } - - 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 strings.TrimSpace(lineTmpl) != "" { - log.Printf("skipping unknown template line: '%s'", lineTmpl) - } - } - } - return msgForm, nil -} - -// 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 (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", positionFmt(signedDecimal, nowPos)) - } - return placeholderReplacer(tagStart, tagEnd, 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": positionFmt(degreeMinute, nowPos), - "GPS_DECIMAL": positionFmt(decimal, nowPos), - "GPS_SIGNED_DECIMAL": positionFmt(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 - }) -} - func xmlEscape(s string) string { var buf bytes.Buffer if err := xml.EscapeText(&buf, []byte(s)); err != nil { diff --git a/internal/forms/message.go b/internal/forms/message.go new file mode 100644 index 00000000..865c2d6c --- /dev/null +++ b/internal/forms/message.go @@ -0,0 +1,320 @@ +package forms + +import ( + "bufio" + "encoding/xml" + "fmt" + "log" + "net/textproto" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/la5nta/pat/internal/debug" + "github.com/la5nta/wl2k-go/fbb" +) + +// 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"` + + attachmentXML string + attachmentTXT string + template Template + isReply bool + submitted time.Time +} + +// Attachments returns the attachments generated by the filled-in form +func (m Message) Attachments() []*fbb.File { + var files []*fbb.File + if xml := m.attachmentXML; xml != "" { + name := getXMLAttachmentNameForForm(m.template, m.isReply) + files = append(files, fbb.NewFile(name, []byte(xml))) + } + if txt := m.attachmentTXT; txt != "" { + files = append(files, fbb.NewFile("FormData.txt", []byte(txt))) + } + return files +} + +// getXMLAttachmentNameForForm returns the user-visible filename for the message attachment that holds the form instance values +func getXMLAttachmentNameForForm(t Template, isReply bool) string { + attachmentName := filepath.Base(t.ViewerURI) + if isReply { + attachmentName = filepath.Base(t.ReplyViewerURI) + } + 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 +} + +type messageBuilder struct { + Interactive bool + IsReply bool + Template Template + FormValues 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) { + tmplPath := b.Template.TxtFileURI + if b.IsReply && b.Template.ReplyTxtFileURI != "" { + tmplPath = b.Template.ReplyTxtFileURI + } + + b.initFormValues() + + msgForm, err := b.scanTmplBuildMessage(tmplPath) + if err != nil { + return Message{}, err + } + + // Add TXT attachment if defined by the form + if v, ok := b.FormValues["attached_text"]; ok { + delete(b.FormValues, "attached_text") + msgForm.attachmentTXT = v + } + + formVarsAsXML := "" + for varKey, varVal := range b.FormValues { + formVarsAsXML += fmt.Sprintf(" <%s>%s\n", xmlEscape(varKey), xmlEscape(varVal), xmlEscape(varKey)) + } + + // Add XML if a viewer is defined for this form + if b.Template.ViewerURI != "" { + viewer := b.Template.ViewerURI + if b.IsReply && b.Template.ReplyViewerURI != "" { + viewer = b.Template.ReplyViewerURI + } + msgForm.attachmentXML = fmt.Sprintf(`%s + + %s + %s + %s + %s + %s + %s + %s + + +%s + + +`, + xml.Header, + "1.0", + b.FormsMgr.config.AppVersion, + time.Now().UTC().Format("20060102150405"), + b.FormsMgr.config.MyCall, + b.FormsMgr.config.Locator, + filepath.Base(viewer), + filepath.Base(b.Template.ReplyTxtFileURI), + formVarsAsXML) + } + + msgForm.To = strings.TrimSpace(msgForm.To) + msgForm.Cc = strings.TrimSpace(msgForm.Cc) + msgForm.Subject = strings.TrimSpace(msgForm.Subject) + msgForm.Body = strings.TrimSpace(msgForm.Body) + return msgForm, nil +} + +func (b messageBuilder) initFormValues() { + if b.IsReply { + b.FormValues["msgisreply"] = "True" + } 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) scanTmplBuildMessage(tmplPath string) (Message, error) { + f, err := os.Open(tmplPath) + if err != nil { + return Message{}, err + } + defer f.Close() + + replaceVars := variableReplacer("<", ">", b.FormValues) + replaceInsertionTags := insertionTagReplacer(b.FormsMgr, "<", ">") + + scanner := bufio.NewScanner(f) + + msg := Message{ + template: b.Template, + isReply: b.IsReply, + submitted: time.Now(), + } + var inBody bool + for scanner.Scan() { + lineTmpl := scanner.Text() + + // Insertion tags and variables + lineTmpl = replaceInsertionTags(lineTmpl) + lineTmpl = replaceVars(lineTmpl) + + // Prompts (mostly found in text templates) + if b.Interactive { + 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.Println(lineTmpl) + fmt.Printf("%s: ", key) + value := b.FormsMgr.config.LineReader() + b.FormValues[strings.ToLower(key)] = value + replaceVars = variableReplacer("<", ">", b.FormValues) // Refresh variableReplacer to avoid prompting the same again + 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.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 + 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": + //TODO: Handle sequences + default: + if strings.TrimSpace(lineTmpl) != "" { + log.Printf("skipping unknown template line: '%s'", lineTmpl) + } + } + } + return msg, nil +} + +// 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, 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", positionFmt(signedDecimal, nowPos)) + } + return placeholderReplacer(tagStart, tagEnd, 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": positionFmt(degreeMinute, nowPos), + "GPS_DECIMAL": positionFmt(decimal, nowPos), + "GPS_SIGNED_DECIMAL": positionFmt(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 + }) +} diff --git a/internal/forms/template.go b/internal/forms/template.go new file mode 100644 index 00000000..90af2487 --- /dev/null +++ b/internal/forms/template.go @@ -0,0 +1,131 @@ +package forms + +import ( + "bufio" + "os" + "path/filepath" + "strings" + + "github.com/la5nta/pat/internal/debug" + "github.com/la5nta/pat/internal/directories" +) + +// Template holds information about a Winlink form template +type Template struct { + Name string `json:"name"` + TxtFileURI string `json:"txt_file_uri"` + InitialURI string `json:"initial_uri"` + ViewerURI string `json:"viewer_uri"` + ReplyTxtFileURI string `json:"reply_txt_file_uri"` + ReplyInitialURI string `json:"reply_initial_uri"` + ReplyViewerURI string `json:"reply_viewer_uri"` +} + +func readTemplate(path string) (Template, error) { + f, err := os.Open(path) + if err != nil { + return Template{}, err + } + defer f.Close() + + template := Template{ + Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), + TxtFileURI: path, + } + scanner := bufio.NewScanner(f) + baseURI := filepath.Dir(template.TxtFileURI) + for scanner.Scan() { + switch key, value, _ := strings.Cut(scanner.Text(), ":"); key { + case "Form": + // Form: , + files := strings.Split(value, ",") + // Extend to absolute paths and add missing html extension + for i, path := range files { + path = strings.TrimSpace(path) + if ext := filepath.Ext(path); ext == "" { + path += htmlFileExt + } + var ok bool + files[i], ok = resolveFileReference(baseURI, path) + if !ok { + debug.Printf("%s: failed to resolve referenced file %q", template.TxtFileURI, path) + } + } + template.InitialURI = files[0] + if len(files) > 1 { + template.ViewerURI = files[1] + } + case "ReplyTemplate": + path := strings.TrimSpace(value) + // Some are missing .txt + if filepath.Ext(path) == "" { + path += txtFileExt + } + var ok bool + path, ok = resolveFileReference(baseURI, path) + if !ok { + debug.Printf("%s: failed to resolve referenced reply template file %q", template.TxtFileURI, path) + continue + } + replyTemplate, err := readTemplate(path) + if err != nil { + debug.Printf("%s: failed to load referenced reply template: %v", template.TxtFileURI, err) + } + template.ReplyTxtFileURI = path + template.ReplyInitialURI = replyTemplate.InitialURI + template.ReplyViewerURI = replyTemplate.ViewerURI + } + } + return template, err +} + +func (t Template) matchesName(nameToMatch string) bool { + return t.InitialURI == nameToMatch || + strings.EqualFold(t.InitialURI, nameToMatch+htmlFileExt) || + t.ViewerURI == nameToMatch || + strings.EqualFold(t.ViewerURI, nameToMatch+htmlFileExt) || + t.ReplyInitialURI == nameToMatch || + t.ReplyInitialURI == nameToMatch+".0" || + t.ReplyViewerURI == nameToMatch || + t.ReplyViewerURI == nameToMatch+".0" || + t.TxtFileURI == nameToMatch || + strings.EqualFold(t.TxtFileURI, nameToMatch+txtFileExt) +} + +func (t Template) containsName(partialName string) bool { + return strings.Contains(t.InitialURI, partialName) || + strings.Contains(t.ViewerURI, partialName) || + strings.Contains(t.ReplyInitialURI, partialName) || + strings.Contains(t.ReplyViewerURI, partialName) || + strings.Contains(t.ReplyTxtFileURI, partialName) || + strings.Contains(t.TxtFileURI, partialName) +} + +// resolveFileReference searches for files referenced in .txt files. +// +// If found the returned path is relative to FormsPath and bool is true, otherwise the given path is returned unmodified. +func resolveFileReference(basePath string, referencePath string) (string, bool) { + path := filepath.Join(basePath, referencePath) + if !directories.IsInPath(basePath, path) { + debug.Printf("%q escapes template's base path (%q)", referencePath, basePath) + return "", false + } + if _, err := os.Stat(path); err == nil { + return path, true + } + // Fallback to case-insenstive search. + // Some HTML files references in the .txt files has a different caseness than the actual filename on disk. + //TODO: Walk basePath tree instead + absPathTemplateFolder := filepath.Dir(path) + entries, err := os.ReadDir(absPathTemplateFolder) + if err != nil { + return path, false + } + for _, entry := range entries { + name := entry.Name() + if strings.EqualFold(filepath.Base(path), name) { + return filepath.Join(absPathTemplateFolder, name), true + } + } + return path, false +} From 8c55e0ac3206aea8bb849c1621e8f95a17d39351 Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Mon, 1 Jan 2024 13:38:55 +0100 Subject: [PATCH 07/28] Refactor message builder --- cli_composer.go | 2 +- http.go | 2 +- internal/forms/{message.go => builder.go} | 182 ++++++++++------------ 3 files changed, 86 insertions(+), 100 deletions(-) rename internal/forms/{message.go => builder.go} (79%) diff --git a/cli_composer.go b/cli_composer.go index acdb7259..bfee28bb 100644 --- a/cli_composer.go +++ b/cli_composer.go @@ -307,7 +307,7 @@ func composeFormReport(ctx context.Context, args []string) { msg.SetBody(formMsg.Body) - for _, f := range formMsg.Attachments() { + for _, f := range formMsg.Attachments { msg.AddFile(f) } diff --git a/http.go b/http.go index 591f5969..22163f2b 100644 --- a/http.go +++ b/http.go @@ -290,7 +290,7 @@ func postOutboundMessageHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "form instance key not valid", http.StatusBadRequest) return } - for _, f := range formData.Attachments() { + for _, f := range formData.Attachments { msg.AddFile(f) } } diff --git a/internal/forms/message.go b/internal/forms/builder.go similarity index 79% rename from internal/forms/message.go rename to internal/forms/builder.go index 865c2d6c..cefee927 100644 --- a/internal/forms/message.go +++ b/internal/forms/builder.go @@ -18,43 +18,13 @@ import ( // 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"` + To string `json:"msg_to"` + Cc string `json:"msg_cc"` + Subject string `json:"msg_subject"` + Body string `json:"msg_body"` + Attachments []*fbb.File `json:"-"` - attachmentXML string - attachmentTXT string - template Template - isReply bool - submitted time.Time -} - -// Attachments returns the attachments generated by the filled-in form -func (m Message) Attachments() []*fbb.File { - var files []*fbb.File - if xml := m.attachmentXML; xml != "" { - name := getXMLAttachmentNameForForm(m.template, m.isReply) - files = append(files, fbb.NewFile(name, []byte(xml))) - } - if txt := m.attachmentTXT; txt != "" { - files = append(files, fbb.NewFile("FormData.txt", []byte(txt))) - } - return files -} - -// getXMLAttachmentNameForForm returns the user-visible filename for the message attachment that holds the form instance values -func getXMLAttachmentNameForForm(t Template, isReply bool) string { - attachmentName := filepath.Base(t.ViewerURI) - if isReply { - attachmentName = filepath.Base(t.ReplyViewerURI) - } - 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 + submitted time.Time } type messageBuilder struct { @@ -67,36 +37,77 @@ type messageBuilder struct { // build returns message subject, body, and attachments for the given template and variable map func (b messageBuilder) build() (Message, error) { - tmplPath := b.Template.TxtFileURI + b.setDefaultFormValues() + msg, err := b.scanAndBuild(b.templatePath()) + if err != nil { + return Message{}, err + } + msg.Attachments = b.buildAttachments() + return msg, nil +} + +func (b messageBuilder) templatePath() string { + path := b.Template.TxtFileURI if b.IsReply && b.Template.ReplyTxtFileURI != "" { - tmplPath = b.Template.ReplyTxtFileURI + path = b.Template.ReplyTxtFileURI } + return path +} - b.initFormValues() +func (b messageBuilder) setDefaultFormValues() { + if b.IsReply { + b.FormValues["msgisreply"] = "True" + } else { + b.FormValues["msgisreply"] = "False" + } + for _, key := range []string{"msgsender"} { + if _, ok := b.FormValues[key]; !ok { + b.FormValues[key] = b.FormsMgr.config.MyCall + } + } - msgForm, err := b.scanTmplBuildMessage(tmplPath) - if err != nil { - return Message{}, err + // 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" + } } - // Add TXT attachment if defined by the form - if v, ok := b.FormValues["attached_text"]; ok { - delete(b.FormValues, "attached_text") - msgForm.attachmentTXT = v + //TODO: Implement sequences + for _, key := range []string{"msgseqnum"} { + if _, ok := b.FormValues[key]; !ok { + b.FormValues[key] = "0" + } } +} - formVarsAsXML := "" - for varKey, varVal := range b.FormValues { - formVarsAsXML += fmt.Sprintf(" <%s>%s\n", xmlEscape(varKey), xmlEscape(varVal), xmlEscape(varKey)) +func (b messageBuilder) buildAttachments() []*fbb.File { + var attachments []*fbb.File + + // Add FormData.txt attachment if defined by the form + if v, ok := b.FormValues["attached_text"]; ok { + delete(b.FormValues, "attached_text") // Should not be included in the XML. + attachments = append(attachments, fbb.NewFile("FormData.txt", []byte(v))) } - // Add XML if a viewer is defined for this form + // Add XML if a viewer is defined for this template if b.Template.ViewerURI != "" { viewer := b.Template.ViewerURI if b.IsReply && b.Template.ReplyViewerURI != "" { viewer = b.Template.ReplyViewerURI } - msgForm.attachmentXML = fmt.Sprintf(`%s + formVarsAsXML := "" + for varKey, varVal := range b.FormValues { + formVarsAsXML += fmt.Sprintf(" <%s>%s\n", xmlEscape(varKey), xmlEscape(varVal), xmlEscape(varKey)) + } + filename := xmlName(b.Template, b.IsReply) + attachments = append(attachments, fbb.NewFile(filename, []byte(fmt.Sprintf(`%s %s %s @@ -119,51 +130,16 @@ func (b messageBuilder) build() (Message, error) { b.FormsMgr.config.Locator, filepath.Base(viewer), filepath.Base(b.Template.ReplyTxtFileURI), - formVarsAsXML) + formVarsAsXML)))) } - - msgForm.To = strings.TrimSpace(msgForm.To) - msgForm.Cc = strings.TrimSpace(msgForm.Cc) - msgForm.Subject = strings.TrimSpace(msgForm.Subject) - msgForm.Body = strings.TrimSpace(msgForm.Body) - return msgForm, nil + return attachments } -func (b messageBuilder) initFormValues() { - if b.IsReply { - b.FormValues["msgisreply"] = "True" - } 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) scanTmplBuildMessage(tmplPath string) (Message, error) { - f, err := os.Open(tmplPath) +// 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 } @@ -174,11 +150,7 @@ func (b messageBuilder) scanTmplBuildMessage(tmplPath string) (Message, error) { scanner := bufio.NewScanner(f) - msg := Message{ - template: b.Template, - isReply: b.IsReply, - submitted: time.Now(), - } + msg := Message{submitted: time.Now()} var inBody bool for scanner.Scan() { lineTmpl := scanner.Text() @@ -318,3 +290,17 @@ func insertionTagReplacer(m *Manager, tagStart, tagEnd string) func(string) stri // decimal_separator }) } + +// xmlName returns the user-visible filename for the message attachment that holds the form instance values +func xmlName(t Template, isReply bool) string { + attachmentName := filepath.Base(t.ViewerURI) + if isReply { + attachmentName = filepath.Base(t.ReplyViewerURI) + } + 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 +} From 99b19907da50f683e8a0a27acfc9e532d243b80d Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Wed, 3 Jan 2024 20:35:45 +0100 Subject: [PATCH 08/28] Fix resolve of Def: bug introduced in refactoring --- internal/forms/builder.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/internal/forms/builder.go b/internal/forms/builder.go index cefee927..3088f291 100644 --- a/internal/forms/builder.go +++ b/internal/forms/builder.go @@ -145,8 +145,13 @@ func (b messageBuilder) scanAndBuild(path string) (Message, error) { } defer f.Close() - replaceVars := variableReplacer("<", ">", b.FormValues) replaceInsertionTags := insertionTagReplacer(b.FormsMgr, "<", ">") + 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(f) @@ -186,8 +191,7 @@ func (b messageBuilder) scanAndBuild(path string) (Message, error) { fmt.Println(lineTmpl) fmt.Printf("%s: ", key) value := b.FormsMgr.config.LineReader() - b.FormValues[strings.ToLower(key)] = value - replaceVars = variableReplacer("<", ">", b.FormValues) // Refresh variableReplacer to avoid prompting the same again + addFormValue(key, value) return value }) } @@ -213,9 +217,8 @@ func (b messageBuilder) scanAndBuild(path string) (Message, error) { 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) + 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) From f565b4e000517b49b4c60d66a918ac71e03793aa Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Thu, 4 Jan 2024 21:19:35 +0100 Subject: [PATCH 09/28] Handle (uppercase prompt) --- internal/forms/builder.go | 5 +++++ internal/forms/prompt.go | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/forms/builder.go b/internal/forms/builder.go index 3088f291..fa720dbe 100644 --- a/internal/forms/builder.go +++ b/internal/forms/builder.go @@ -169,6 +169,10 @@ func (b messageBuilder) scanAndBuild(path string) (Message, error) { lineTmpl = promptAsks(lineTmpl, func(a Ask) string { //TODO: Handle a.Multiline as we do message body fmt.Printf(a.Prompt + " ") + ans := b.FormsMgr.config.LineReader() + if a.Uppercase { + ans = strings.ToUpper(ans) + } return b.FormsMgr.config.LineReader() }) lineTmpl = promptSelects(lineTmpl, func(s Select) Option { @@ -258,6 +262,7 @@ func insertionTagReplacer(m *Manager, tagStart, tagEnd string) func(string) stri validPos = "YES" debug.Printf("GPSd position: %s", positionFmt(signedDecimal, nowPos)) } + // documentation: https://www.winlink.org/sites/default/files/RMSE_FORMS/insertion_tags.zip return placeholderReplacer(tagStart, tagEnd, map[string]string{ "MsgSender": m.config.MyCall, "Callsign": m.config.MyCall, diff --git a/internal/forms/prompt.go b/internal/forms/prompt.go index 6c1f3dc0..a58caf39 100644 --- a/internal/forms/prompt.go +++ b/internal/forms/prompt.go @@ -13,6 +13,7 @@ type Select struct { type Ask struct { Prompt string Multiline bool + Uppercase bool } type Option struct { @@ -28,7 +29,11 @@ func promptAsks(str string, promptFn func(Ask) string) string { 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")} + a := Ask{ + Prompt: prompt, + Multiline: strings.EqualFold(options, "MU"), + Uppercase: strings.EqualFold(options, "UP"), + } ans := promptFn(a) str = strings.Replace(str, replace, ans, 1) } From d2bdc6a1f627a844a99be9ee5feaa3d7f1b42bbc Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Thu, 4 Jan 2024 22:35:44 +0100 Subject: [PATCH 10/28] Refactor gridSquare position formatting --- internal/forms/builder.go | 2 +- internal/forms/builder_test.go | 23 +++++++++++++++++++++++ internal/forms/forms.go | 5 +++++ internal/forms/position.go | 20 ++++++++++---------- 4 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 internal/forms/builder_test.go diff --git a/internal/forms/builder.go b/internal/forms/builder.go index fa720dbe..b67ca845 100644 --- a/internal/forms/builder.go +++ b/internal/forms/builder.go @@ -279,9 +279,9 @@ func insertionTagReplacer(m *Manager, tagStart, tagEnd string) func(string) stri "GPS": positionFmt(degreeMinute, nowPos), "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), - "GridSquare": posToGridSquare(nowPos), "GPSValid": fmt.Sprintf("%s ", validPos), //TODO (other insertion tags found in Standard Forms): diff --git a/internal/forms/builder_test.go b/internal/forms/builder_test.go new file mode 100644 index 00000000..d4773228 --- /dev/null +++ b/internal/forms/builder_test.go @@ -0,0 +1,23 @@ +package forms + +import ( + "testing" + + "github.com/la5nta/pat/cfg" +) + +func TestInsertionTagReplacer(t *testing.T) { + m := &Manager{config: Config{ + MyCall: "LA5NTA", + GPSd: cfg.GPSdConfig{Addr: gpsMockAddr}, + }} + tests := map[string]string{ + "": "JO29PJ", + } + for in, expect := range tests { + if out := insertionTagReplacer(m, "<", ">")(in); out != expect { + t.Errorf("%s: Expected %q, got %q", in, expect, out) + } + + } +} diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 31986738..c0b9d226 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -503,12 +503,17 @@ func findFormFromURI(formName string, folder FormFolder) (Template, error) { return form, errors.New("form not found") } +const gpsMockAddr = "mock" // Hack for unit testing + // gpsPos returns the current GPS Position func (m *Manager) gpsPos() (gpsd.Position, error) { addr := m.config.GPSd.Addr if addr == "" { return gpsd.Position{}, errors.New("GPSd: not configured.") } + if addr == gpsMockAddr { + return gpsd.Position{Lat: 59.41378, Lon: 5.268}, nil + } if !m.config.GPSd.AllowForms { return gpsd.Position{}, errors.New("GPSd: allow_forms is disabled. GPS position will not be available in form templates.") } diff --git a/internal/forms/position.go b/internal/forms/position.go index 8db3b1f5..95136232 100644 --- a/internal/forms/position.go +++ b/internal/forms/position.go @@ -15,9 +15,12 @@ const ( signedDecimal gpsStyle = iota // 41.1234 -73.4567 decimal // 46.3795N 121.5835W degreeMinute // 46-22.77N 121-35.01W + gridSquare // JO29PJ ) func positionFmt(style gpsStyle, pos gpsd.Position) string { + const notAvailable = "(Not available)" + var ( northing string easting string @@ -29,9 +32,15 @@ func positionFmt(style gpsStyle, pos gpsd.Position) string { noPos := gpsd.Position{} if pos == noPos { - return "(Not available)" + return notAvailable } switch style { + case gridSquare: + str, err := maidenhead.NewPoint(pos.Lat, pos.Lon).GridSquare() + if err != nil { + return notAvailable + } + return str case degreeMinute: { latDegrees = int(math.Trunc(math.Abs(pos.Lat))) @@ -66,12 +75,3 @@ func positionFmt(style gpsStyle, pos gpsd.Position) string { panic("invalid style") } } - -func posToGridSquare(pos gpsd.Position) string { - point := maidenhead.NewPoint(pos.Lat, pos.Lon) - gridsquare, err := point.GridSquare() - if err != nil { - return "" - } - return gridsquare -} From 7ea6cd729bc6d13f162a53836490fd9604b05af2 Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Sat, 6 Jan 2024 07:55:33 +0100 Subject: [PATCH 11/28] Customizable time.Now and location for testing --- internal/forms/builder.go | 6 +++--- internal/forms/date.go | 13 ++++++++++--- internal/forms/date_test.go | 3 ++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/internal/forms/builder.go b/internal/forms/builder.go index b67ca845..c00ce16c 100644 --- a/internal/forms/builder.go +++ b/internal/forms/builder.go @@ -125,7 +125,7 @@ func (b messageBuilder) buildAttachments() []*fbb.File { xml.Header, "1.0", b.FormsMgr.config.AppVersion, - time.Now().UTC().Format("20060102150405"), + now().UTC().Format("20060102150405"), b.FormsMgr.config.MyCall, b.FormsMgr.config.Locator, filepath.Base(viewer), @@ -155,7 +155,7 @@ func (b messageBuilder) scanAndBuild(path string) (Message, error) { scanner := bufio.NewScanner(f) - msg := Message{submitted: time.Now()} + msg := Message{submitted: now()} var inBody bool for scanner.Scan() { lineTmpl := scanner.Text() @@ -253,7 +253,7 @@ func variableReplacer(tagStart, tagEnd string, vars map[string]string) func(stri // InsertionTagReplacer returns a function that replaces the fixed set of insertion tags with their corresponding values. func insertionTagReplacer(m *Manager, tagStart, tagEnd string) func(string) string { - now := time.Now() + now := now() validPos := "NO" nowPos, err := m.gpsPos() if err != nil { diff --git a/internal/forms/date.go b/internal/forms/date.go index af0af8e4..a23100f4 100644 --- a/internal/forms/date.go +++ b/internal/forms/date.go @@ -5,10 +5,17 @@ import ( "time" ) -func formatDateTime(t time.Time) string { return t.Format("2006-01-02 15:04:05") } +var ( + // Now is a customizable time.Now() alias to make testing easier + now = time.Now + // Location defines the local timezone + location = time.Local +) + +func formatDateTime(t time.Time) string { return t.In(location).Format("2006-01-02 15:04:05") } func formatDateTimeUTC(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05Z07:00") } -func formatDate(t time.Time) string { return t.Format("2006-01-02") } -func formatTime(t time.Time) string { return t.Format("15:04:05") } +func formatDate(t time.Time) string { return t.In(location).Format("2006-01-02") } +func formatTime(t time.Time) string { return t.In(location).Format("15:04:05") } func formatDateUTC(t time.Time) string { return t.UTC().Format("2006-01-02Z07:00") } func formatTimeUTC(t time.Time) string { return t.UTC().Format("15:04:05Z07:00") } func formatUDTG(t time.Time) string { return strings.ToUpper(t.UTC().Format("021504Z07:00 Jan 2006")) } diff --git a/internal/forms/date_test.go b/internal/forms/date_test.go index c5434da9..b5e9be7a 100644 --- a/internal/forms/date_test.go +++ b/internal/forms/date_test.go @@ -6,7 +6,8 @@ import ( ) func TestDateFormat(t *testing.T) { - now := time.Date(2023, 12, 31, 23, 59, 59, 0, time.FixedZone("UTC-4", -4*60*60)) + location = time.FixedZone("UTC-4", -4*60*60) + now := time.Date(2023, 12, 31, 23, 59, 59, 0, location) tests := []struct { fn func(t time.Time) string From 91505ba7605d4df1f7b42c7958e296eac4d47654 Mon Sep 17 00:00:00 2001 From: Martin Hebnes Pedersen Date: Sat, 6 Jan 2024 09:13:36 +0100 Subject: [PATCH 12/28] Add more insertion tags tests --- internal/forms/builder.go | 4 +++- internal/forms/builder_test.go | 37 +++++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/internal/forms/builder.go b/internal/forms/builder.go index c00ce16c..e952225c 100644 --- a/internal/forms/builder.go +++ b/internal/forms/builder.go @@ -282,7 +282,9 @@ func insertionTagReplacer(m *Manager, tagStart, tagEnd string) func(string) stri "GridSquare": positionFmt(gridSquare, nowPos), "Latitude": fmt.Sprintf("%.4f", nowPos.Lat), "Longitude": fmt.Sprintf("%.4f", nowPos.Lon), - "GPSValid": fmt.Sprintf("%s ", validPos), + //TODO: Why a trailing space here? + // Some forms also adds a whitespace in their declaration, so we end up with two trailing spaces.. + "GPSValid": fmt.Sprintf("%s ", validPos), //TODO (other insertion tags found in Standard Forms): // SeqNum diff --git a/internal/forms/builder_test.go b/internal/forms/builder_test.go index d4773228..8aa406c8 100644 --- a/internal/forms/builder_test.go +++ b/internal/forms/builder_test.go @@ -2,22 +2,45 @@ package forms import ( "testing" + "time" "github.com/la5nta/pat/cfg" ) func TestInsertionTagReplacer(t *testing.T) { m := &Manager{config: Config{ - MyCall: "LA5NTA", - GPSd: cfg.GPSdConfig{Addr: gpsMockAddr}, + MyCall: "LA5NTA", + AppVersion: "v1.0.0", + GPSd: cfg.GPSdConfig{Addr: gpsMockAddr}, }} + location = time.FixedZone("UTC+1", 1*60*60) + now = func() time.Time { return time.Date(1988, 3, 21, 00, 00, 00, 00, location).In(time.UTC) } tests := map[string]string{ - "": "JO29PJ", + "": "Pat v1.0.0", + "": "LA5NTA", + "": "LA5NTA", + + "": "1988-03-21 00:00:00", + "": "1988-03-20 23:00:00Z", + "": "1988-03-21", + "": "1988-03-20Z", + "": "202300Z MAR 1988", + "