diff --git a/net/smsgw/smstmpl/smstmpl.go b/net/smsgw/smstmpl/smstmpl.go deleted file mode 100644 index 0e6cfa7..0000000 --- a/net/smsgw/smstmpl/smstmpl.go +++ /dev/null @@ -1,76 +0,0 @@ -package smstmpl - -import ( - "fmt" - "maps" - "regexp" - "slices" - "strings" - - "github.com/therootcompany/golib/net/smsgw/smscsv" -) - -var reUnmatchedVars = regexp.MustCompile(`(\{[^}]+\})`) - -func RenderAll(messages []smscsv.Message) ([]smscsv.Message, error) { - for i, message := range messages { - rowIndex := i + 1 - - message.SetText(ReplaceVar(message.Template(), "Name", message.Name())) - keyIter := maps.Keys(message.Vars) - keys := slices.Sorted(keyIter) - for _, key := range keys { - val := message.Vars[key] - message.SetText(ReplaceVar(message.Text(), key, val)) - } - - if tmpls := reUnmatchedVars.FindAllString(message.Text(), -1); len(tmpls) != 0 { - return nil, &smscsv.CSVWarn{ - Index: rowIndex, - Code: "UnmatchedVars", - Message: fmt.Sprintf( - "failing due to row %d (%s): leftover template variable(s): %s", - rowIndex, message.Name(), strings.Join(tmpls, " "), - ), - // Record: rec, - } - } - - messages[i] = message - } - - // TODO XXX AJ makes sure the copy retains its Text - return messages, nil -} - -func ReplaceVar(text, key, val string) string { - if val != "" { - // No special treatment: - // "Hey {+Name}," => "Hey Doe," - // "Bob,{Name}" => "Bob,Doe" - // "{Name-},Joe" => "Doe,Joe" - // "Hi {-Name-}, Joe" => "Hi Doe, Joe" - var reHasVar = regexp.MustCompile(fmt.Sprintf(`\{\+?%s-?\}`, regexp.QuoteMeta(key))) - return reHasVar.ReplaceAllString(text, val) - } - - var metaKey = regexp.QuoteMeta(key) - - // "Hey {+Name}," => "Hey ," - var reEatNone = regexp.MustCompile(fmt.Sprintf(`\{\+%s\}`, metaKey)) - text = reEatNone.ReplaceAllString(text, val) - - // "Bob,{Name};" => "Bob;" - var reEatOneLeft = regexp.MustCompile(fmt.Sprintf(`.?\{%s\}`, metaKey)) - text = reEatOneLeft.ReplaceAllString(text, val) - - // ",{Name-};Joe" => ",Joe" - var reEatOneRight = regexp.MustCompile(fmt.Sprintf(`\{%s-\}.?`, metaKey)) - text = reEatOneRight.ReplaceAllString(text, val) - - // "Hi {-Name-}, Joe" => "Hi Joe" - var reEatOneBoth = regexp.MustCompile(fmt.Sprintf(`.?\{-%s-\}.?`, metaKey)) - text = reEatOneBoth.ReplaceAllString(text, val) - - return text -} diff --git a/text/textvars/README.md b/text/textvars/README.md new file mode 100644 index 0000000..4344f0a --- /dev/null +++ b/text/textvars/README.md @@ -0,0 +1,66 @@ +# [textvars](https://github.com/therootcompany/golib/tree/main/text/textvars) + +[![Go Reference](https://pkg.go.dev/badge/github.com/therootcompany/golib/text/textvars.svg)](https://pkg.go.dev/github.com/therootcompany/golib/text/textvars) + +Text replacement functions that handle the empty string / trailing comma problem in a sane way: \ +(cuts the character to the left when empty) + +Example: Leading space: + +```go +textvars.ReplaceVar(`Hey {Name}!`, "Name", "Joe") +// "Hey Joe!" + +textvars.ReplaceVar(`Hey {Name}!`, "Name", "") +// "Hey!" 👍 + +strings.ReplaceAll(`Hey {Name}!`, "{Name}", "") +// "Hey !" 🫤 +``` + +Example: Leading comma: + +```go +textvars.ReplaceVar(`Apples,{Fruit},Bananas`, "Fruit", "Oranges") +// "Apples,Oranges,Bananas" + +textvars.ReplaceVar(`Apples,{Fruit},Bananas`, "Fruit", "") +// "Apples,Bananas" 👍 + +strings.ReplaceAll(`Apples,{Fruit},Bananas`, "{Fruit}", "") +// "Apples,,Bananas" 🫤 +``` + +Example: Multiple Vars + +```go +tmpl := `{#}. {Name}` +vars := map[string]string{ + "#": "1", + "Name": "Joe", +} +text, err := textvars.ReplaceVars(tmpl, vars) +// "1. Joe" +// errors if any {...} are left over +``` + +**Note**: This is the sort of thing that's it's probably better to copy and paste rather than to have as a dependency, but I wanted to have it for myself as a convenience in my own repo of tools, so here it is. + +## Other Uses + +It seemed like an okay idea at the time, so I also baked in some other uses: + +| Syntax | Example | "Joe" | Empty ("") | Comment | +| ---------- | -------------- | ----------- | ---------- | ----------------------------- | +| `{Name}` | `Hey {Name}!` | `Hey Joe!` | `Hey!` | cuts left character if empty | +| `{Name-}` | `1,{Name-},3` | `1,Joe,3` | `1,3` | cuts right character if empty | +| `{-Name-}` | `Hey! {Name}!` | `Hey! Joe!` | `Hey!` | cuts left and right if empty | +| `{+Name}` | `Name:{+Name}` | `Name:Joe` | `Name:` | keeps left character always | + +However, I haven't actually had the use case for those yet and you probably won't either... so don't use what you don't need. 🙃 + +I DO NOT plan on making a robust template system. I was only interested in solving the _leading space_ / _trailing comma_ problem for [sendsms](https://github.com/therootcompany/golib/tree/main/cmd/sendsms). + +# Legal + +CC0-1.0 (Public Domain) diff --git a/text/textvars/go.mod b/text/textvars/go.mod new file mode 100644 index 0000000..f262964 --- /dev/null +++ b/text/textvars/go.mod @@ -0,0 +1,3 @@ +module github.com/therootcompany/golib/text/textvars + +go 1.25.4 diff --git a/text/textvars/textvars.go b/text/textvars/textvars.go new file mode 100644 index 0000000..413c1bd --- /dev/null +++ b/text/textvars/textvars.go @@ -0,0 +1,72 @@ +// Authored in 2026 by AJ ONeal (https://therootcompany.com) +// +// To the extent possible under law, the author(s) have dedicated all copyright +// and related and neighboring rights to this software to the public domain +// worldwide. This software is distributed without any warranty. +// +// You should have received a copy of the CC0 Public Domain Dedication along with +// this software. If not, see . +// +// SPDX-License-Identifier: CC0-1.0 + +package textvars + +import ( + "fmt" + "maps" + "regexp" + "slices" + "strings" +) + +var reUnmatchedVars = regexp.MustCompile(`(\{[^}]+\})`) + +func GetPlaceholders(tmpl string) []string { + return reUnmatchedVars.FindAllString(tmpl, -1) +} + +func ReplaceVars(text string, vars map[string]string) (string, error) { + keyIter := maps.Keys(vars) + keys := slices.Sorted(keyIter) + for _, key := range keys { + val := vars[key] + text = ReplaceVar(text, key, val) + } + + if tmpls := GetPlaceholders(text); len(tmpls) != 0 { + return "", fmt.Errorf("leftover template variable(s): %s", strings.Join(tmpls, " ")) + } + + return text, nil +} + +func ReplaceVar(text, key, val string) string { + if val != "" { + // No special treatment: + // "Hey {+Name}," => "Hey Doe," + // "Bob,{Name}" => "Bob,Doe" + // "{Name-},Joe" => "Doe,Joe" + // "Hi {-Name-}, Joe" => "Hi Doe, Joe" + var reHasVar = regexp.MustCompile(fmt.Sprintf(`\{\+?%s-?\}`, regexp.QuoteMeta(key))) + return reHasVar.ReplaceAllString(text, val) + } + + var metaKey = regexp.QuoteMeta(key) + + // "Hey {+Name}," => "Hey ," + text = strings.ReplaceAll(text, `{+`+key+`}`, val) + + // "Bob,{Name};" => "Bob;" + var reEatOneLeft = regexp.MustCompile(fmt.Sprintf(`.?\{%s\}`, metaKey)) + text = reEatOneLeft.ReplaceAllString(text, val) + + // ",{Name-};Joe" => ",Joe" + var reEatOneRight = regexp.MustCompile(fmt.Sprintf(`\{%s-\}.?`, metaKey)) + text = reEatOneRight.ReplaceAllString(text, val) + + // "Hi {-Name-}, Joe" => "Hi Joe" + var reEatOneBoth = regexp.MustCompile(fmt.Sprintf(`.?\{-%s-\}.?`, metaKey)) + text = reEatOneBoth.ReplaceAllString(text, val) + + return text +}