diff --git a/filters.go b/filters.go index ddae2da..2a7d859 100644 --- a/filters.go +++ b/filters.go @@ -10,10 +10,9 @@ import ( "time" "unicode/utf8" - "github.com/flosch/pongo2/v4" - "github.com/extemporalgenome/slug" "github.com/flosch/go-humanize" + "github.com/flosch/pongo2" "github.com/russross/blackfriday/v2" ) diff --git a/filters_test.go b/filters_test.go index 6d146f5..c5d9d20 100644 --- a/filters_test.go +++ b/filters_test.go @@ -4,9 +4,8 @@ import ( "testing" "time" - . "gopkg.in/check.v1" - "github.com/flosch/pongo2" + . "gopkg.in/check.v1" ) // Hook up gocheck into the "go test" runner. diff --git a/go.mod b/go.mod index 138d558..3a471d0 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/flosch/pongo2-addons +module github.com/idc77/pongo2-addons go 1.14 @@ -6,10 +6,11 @@ require ( github.com/extemporalgenome/slug v0.0.0-20150414033109-0320c85e32e0 github.com/flosch/go-humanize v0.0.0-20140728123800-3ba51eabe506 github.com/flosch/pongo2 v0.0.0-20200529170236-5abacdfa4915 - github.com/flosch/pongo2/v4 v4.0.2 // indirect - github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/flosch/pongo2/v4 v4.0.2 + github.com/flosch/pongo2/v5 v5.0.0 + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.0.1 github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect - golang.org/x/text v0.3.3 // indirect - gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b + golang.org/x/text v0.3.7 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c ) diff --git a/go.sum b/go.sum index 67dcc83..de72672 100644 --- a/go.sum +++ b/go.sum @@ -6,20 +6,31 @@ github.com/flosch/pongo2 v0.0.0-20200529170236-5abacdfa4915 h1:rNVrewdFbSujcoKZi github.com/flosch/pongo2 v0.0.0-20200529170236-5abacdfa4915/go.mod h1:fB4mx6dzqFinCxIf3a7Mf5yLk+18Bia9mPAnuejcvDA= github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw= github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= +github.com/flosch/pongo2/v5 v5.0.0 h1:ZauMp+iPZzh2aI1QM2UwRb0lXD4BoFcvBuWqefkIuq0= +github.com/flosch/pongo2/v5 v5.0.0/go.mod h1:6ysKu++8ANFXmc3x6uA6iVaS+PKUoDfdX3yPcv8TIzY= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/v4/filters.go b/v4/filters.go new file mode 100644 index 0000000..b8d7c3f --- /dev/null +++ b/v4/filters.go @@ -0,0 +1,310 @@ +package pongo2addons + +import ( + "bytes" + "errors" + "fmt" + "math/rand" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/extemporalgenome/slug" + "github.com/flosch/go-humanize" + "github.com/flosch/pongo2/v4" + "github.com/russross/blackfriday/v2" +) + +func init() { + rand.Seed(time.Now().UTC().UnixNano()) + + // Regulars + pongo2.RegisterFilter("slugify", filterSlugify) + pongo2.RegisterFilter("filesizeformat", filterFilesizeformat) + pongo2.RegisterFilter("truncatesentences", filterTruncatesentences) + pongo2.RegisterFilter("truncatesentences_html", filterTruncatesentencesHTML) + pongo2.RegisterFilter("random", filterRandom) + + // Markup + pongo2.RegisterFilter("markdown", filterMarkdown) + + // Humanize + pongo2.RegisterFilter("timeuntil", filterTimeuntilTimesince) + pongo2.RegisterFilter("timesince", filterTimeuntilTimesince) + pongo2.RegisterFilter("naturaltime", filterTimeuntilTimesince) + pongo2.RegisterFilter("naturalday", filterNaturalday) + pongo2.RegisterFilter("intcomma", filterIntcomma) + pongo2.RegisterFilter("ordinal", filterOrdinal) +} + +func filterMarkdown(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsSafeValue(string(blackfriday.Run([]byte(in.String())))), nil +} + +func filterSlugify(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsValue(slug.Slug(in.String())), nil +} + +func filterFilesizeformat(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsValue(humanize.IBytes(uint64(in.Integer()))), nil +} + +var filterTruncatesentencesRe = regexp.MustCompile(`(?U:.*[\w]{3,}.*([\d][\.!?][\D]|[\D][\.!?][\s]|[\n$]))`) + +func filterTruncatesentences(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + count := param.Integer() + if count <= 0 { + return pongo2.AsValue(""), nil + } + sentencens := filterTruncatesentencesRe.FindAllString(strings.TrimSpace(in.String()), -1) + return pongo2.AsValue(strings.TrimSpace(strings.Join(sentencens[:min(count, len(sentencens))], ""))), nil +} + +// Taken from pongo2/filters_builtin.go +func filterTruncateHTMLHelper(value string, newOutput *bytes.Buffer, cond func() bool, fn func(c rune, s int, idx int) int, finalize func()) { + vLen := len(value) + tagStack := make([]string, 0) + idx := 0 + + for idx < vLen && !cond() { + c, s := utf8.DecodeRuneInString(value[idx:]) + if c == utf8.RuneError { + idx += s + continue + } + + if c == '<' { + newOutput.WriteRune(c) + idx += s // consume "<" + + if idx+1 < vLen { + if value[idx] == '/' { + // Close tag + + newOutput.WriteString("/") + + tag := "" + idx++ // consume "/" + + for idx < vLen { + c2, size2 := utf8.DecodeRuneInString(value[idx:]) + if c2 == utf8.RuneError { + idx += size2 + continue + } + + // End of tag found + if c2 == '>' { + idx++ // consume ">" + break + } + tag += string(c2) + idx += size2 + } + + if len(tagStack) > 0 { + // Ideally, the close tag is TOP of tag stack + // In malformed HTML, it must not be, so iterate through the stack and remove the tag + for i := len(tagStack) - 1; i >= 0; i-- { + if tagStack[i] == tag { + // Found the tag + tagStack[i] = tagStack[len(tagStack)-1] + tagStack = tagStack[:len(tagStack)-1] + break + } + } + } + + newOutput.WriteString(tag) + newOutput.WriteString(">") + } else { + // Open tag + + tag := "" + + params := false + for idx < vLen { + c2, size2 := utf8.DecodeRuneInString(value[idx:]) + if c2 == utf8.RuneError { + idx += size2 + continue + } + + newOutput.WriteRune(c2) + + // End of tag found + if c2 == '>' { + idx++ // consume ">" + break + } + + if !params { + if c2 == ' ' { + params = true + } else { + tag += string(c2) + } + } + + idx += size2 + } + + // Add tag to stack + tagStack = append(tagStack, tag) + } + } + } else { + idx = fn(c, s, idx) + } + } + + finalize() + + for i := len(tagStack) - 1; i >= 0; i-- { + tag := tagStack[i] + // Close everything from the regular tag stack + newOutput.WriteString(fmt.Sprintf("", tag)) + } +} + +func filterTruncatesentencesHTML(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + count := param.Integer() + if count <= 0 { + return pongo2.AsValue(""), nil + } + + value := in.String() + newLen := max(param.Integer(), 0) + + newOutput := bytes.NewBuffer(nil) + + sentencefilter := 0 + + filterTruncateHTMLHelper(value, newOutput, func() bool { + return sentencefilter >= newLen + }, func(_ rune, _ int, idx int) int { + // Get next word + wordFound := false + + for idx < len(value) { + c2, size2 := utf8.DecodeRuneInString(value[idx:]) + if c2 == utf8.RuneError { + idx += size2 + continue + } + + if c2 == '<' { + // HTML tag start, don't consume it + return idx + } + + newOutput.WriteRune(c2) + idx += size2 + + if (c2 == '.' && !(idx+1 < len(value) && value[idx+1] >= '0' && value[idx+1] <= '9')) || + c2 == '!' || c2 == '?' || c2 == '\n' { + // Sentence ends here, stop capturing it now + break + } else { + wordFound = true + } + } + + if wordFound { + sentencefilter++ + } + + return idx + }, func() {}) + + return pongo2.AsSafeValue(newOutput.String()), nil +} + +func filterRandom(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + if !in.CanSlice() { + return nil, &pongo2.Error{ + Sender: "filter:random", + OrigError: errors.New("input is not sliceable"), + } + } + + if in.Len() <= 0 { + return nil, &pongo2.Error{ + Sender: "filter:random", + OrigError: errors.New("input slice is empty"), + } + } + + return in.Index(rand.Intn(in.Len())), nil +} + +func filterTimeuntilTimesince(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + basetime, isTime := in.Interface().(time.Time) + if !isTime { + return nil, &pongo2.Error{ + Sender: "filter:timeuntil/timesince", + OrigError: errors.New("time-value is not a time.Time-instance"), + } + } + var paramtime time.Time + if !param.IsNil() { + paramtime, isTime = param.Interface().(time.Time) + if !isTime { + return nil, &pongo2.Error{ + Sender: "filter:timeuntil/timesince", + OrigError: errors.New("time-parameter is not a time.Time-instance"), + } + } + } else { + paramtime = time.Now() + } + + return pongo2.AsValue(humanize.TimeDuration(basetime.Sub(paramtime))), nil +} + +func filterIntcomma(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsValue(humanize.Comma(int64(in.Integer()))), nil +} + +func filterOrdinal(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsValue(humanize.Ordinal(in.Integer())), nil +} + +func filterNaturalday(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + basetime, isTime := in.Interface().(time.Time) + if !isTime { + return nil, &pongo2.Error{ + Sender: "filter:naturalday", + OrigError: errors.New("naturalday-value is not a time.Time-instance"), + } + } + + var referenceTime time.Time + if !param.IsNil() { + referenceTime, isTime = param.Interface().(time.Time) + if !isTime { + return nil, &pongo2.Error{ + Sender: "filter:naturalday", + OrigError: errors.New("naturalday-parameter is not a time.Time-instance"), + } + } + } else { + referenceTime = time.Now() + } + + d := referenceTime.Sub(basetime) / time.Hour + + switch { + case d >= 0 && d < 24: + // Today + return pongo2.AsValue("today"), nil + case d >= 24: + return pongo2.AsValue("yesterday"), nil + case d < 0 && d >= -24: + return pongo2.AsValue("tomorrow"), nil + } + + // Default behaviour + return pongo2.ApplyFilter("naturaltime", in, param) +} diff --git a/v4/filters_test.go b/v4/filters_test.go new file mode 100644 index 0000000..fe3c799 --- /dev/null +++ b/v4/filters_test.go @@ -0,0 +1,97 @@ +package pongo2addons + +import ( + "testing" + "time" + + "github.com/flosch/pongo2/v4" + . "gopkg.in/check.v1" +) + +// Hook up gocheck into the "go test" runner. +func Test(t *testing.T) { + TestingT(t) +} + +// A wrapprt of pongo2.RenderTemplateString +func getResult(s string, ctx pongo2.Context) string { + result, _ := pongo2.RenderTemplateString(s, ctx) + return result +} + +type TestSuite1 struct{} + +var _ = Suite(&TestSuite1{}) + +func (s *TestSuite1) TestFilters(c *C) { + // Markdown + c.Assert(getResult("{{ \"**test**\"|markdown }}", nil), Equals, "

test

\n") + + // Slugify + c.Assert(getResult("{{ \"this is ä test!\"|slugify }}", nil), Equals, "this-is-a-test") + + // Filesizeformat + c.Assert(getResult("{{ 123456789|filesizeformat }}", nil), Equals, "118MiB") + + // Timesince/timeuntil + baseDate := time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + futureDate := baseDate.Add(24*7*4*time.Hour + 2*time.Hour) + c.Assert(getResult("{{ future_date|timeuntil:base_date }}", + pongo2.Context{"base_date": baseDate, "future_date": futureDate}), Equals, "4 weeks from now") + + baseDate = time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + futureDate = baseDate.Add(2 * time.Hour) + c.Assert(getResult("{{ future_date|timeuntil:base_date }}", + pongo2.Context{"base_date": baseDate, "future_date": futureDate}), Equals, "2 hours from now") + + baseDate = time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + futureDate = baseDate.Add(2 * time.Hour) + c.Assert(getResult("{{ base_date|timesince:future_date }}", + pongo2.Context{"base_date": baseDate, "future_date": futureDate}), Equals, "2 hours ago") + + // Natural time + baseDate = time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + futureDate = baseDate.Add(4 * time.Second) + c.Assert(getResult("{{ base_date|naturaltime:future_date }}", + pongo2.Context{"base_date": baseDate, "future_date": futureDate}), Equals, "4 seconds ago") + + // Naturalday + today := time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + yesterday := today.Add(-24 * time.Hour) + tomorrow := today.Add(24 * time.Hour) + todayPlus3 := today.Add(3 * 24 * time.Hour) + c.Assert(getResult("{{ date|naturalday:today }}", + pongo2.Context{"date": today, "today": today}), Equals, "today") + c.Assert(getResult("{{ date|naturalday:today }}", + pongo2.Context{"date": yesterday, "today": today}), Equals, "yesterday") + c.Assert(getResult("{{ date|naturalday:today }}", + pongo2.Context{"date": tomorrow, "today": today}), Equals, "tomorrow") + c.Assert(getResult("{{ date|naturalday:today }}", + pongo2.Context{"date": todayPlus3, "today": today}), Equals, "3 days from now") + + // Intcomma + c.Assert(getResult("{{ 123456789|intcomma }}", nil), Equals, "123,456,789") + + // Ordinal + c.Assert(getResult("{{ 1|ordinal }} {{ 2|ordinal }} {{ 3|ordinal }} {{ 18241|ordinal }}", nil), + Equals, "1st 2nd 3rd 18241st") + + // Truncatesentences + c.Assert(getResult("{{ text|truncatesentences:3|safe }}", pongo2.Context{ + "text": `This is a first sentence with a 4.50 number. The second one is even more fun! Isn't it? Last sentence, okay.`}), + Equals, "This is a first sentence with a 4.50 number. The second one is even more fun! Isn't it?") + + // Truncatesentences_html + c.Assert(getResult("{{ text|truncatesentences_html:2 }}", pongo2.Context{ + "text": `
`}), + Equals, `
`) + c.Assert(getResult("{{ text|truncatesentences_html:3 }}", pongo2.Context{ + "text": `
`}), + Equals, `
`) + + // Random + c.Assert(getResult("{{ array|random }}", + pongo2.Context{"array": []int{42}}), + Equals, "42") + +} diff --git a/v4/helpers.go b/v4/helpers.go new file mode 100644 index 0000000..008052c --- /dev/null +++ b/v4/helpers.go @@ -0,0 +1,15 @@ +package pongo2addons + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/v5/filters.go b/v5/filters.go new file mode 100644 index 0000000..105bd5c --- /dev/null +++ b/v5/filters.go @@ -0,0 +1,310 @@ +package pongo2addons + +import ( + "bytes" + "errors" + "fmt" + "math/rand" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/extemporalgenome/slug" + "github.com/flosch/go-humanize" + "github.com/flosch/pongo2/v5" + "github.com/russross/blackfriday/v2" +) + +func init() { + rand.Seed(time.Now().UTC().UnixNano()) + + // Regulars + pongo2.RegisterFilter("slugify", filterSlugify) + pongo2.RegisterFilter("filesizeformat", filterFilesizeformat) + pongo2.RegisterFilter("truncatesentences", filterTruncatesentences) + pongo2.RegisterFilter("truncatesentences_html", filterTruncatesentencesHTML) + pongo2.RegisterFilter("random", filterRandom) + + // Markup + pongo2.RegisterFilter("markdown", filterMarkdown) + + // Humanize + pongo2.RegisterFilter("timeuntil", filterTimeuntilTimesince) + pongo2.RegisterFilter("timesince", filterTimeuntilTimesince) + pongo2.RegisterFilter("naturaltime", filterTimeuntilTimesince) + pongo2.RegisterFilter("naturalday", filterNaturalday) + pongo2.RegisterFilter("intcomma", filterIntcomma) + pongo2.RegisterFilter("ordinal", filterOrdinal) +} + +func filterMarkdown(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsSafeValue(string(blackfriday.Run([]byte(in.String())))), nil +} + +func filterSlugify(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsValue(slug.Slug(in.String())), nil +} + +func filterFilesizeformat(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsValue(humanize.IBytes(uint64(in.Integer()))), nil +} + +var filterTruncatesentencesRe = regexp.MustCompile(`(?U:.*[\w]{3,}.*([\d][\.!?][\D]|[\D][\.!?][\s]|[\n$]))`) + +func filterTruncatesentences(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + count := param.Integer() + if count <= 0 { + return pongo2.AsValue(""), nil + } + sentencens := filterTruncatesentencesRe.FindAllString(strings.TrimSpace(in.String()), -1) + return pongo2.AsValue(strings.TrimSpace(strings.Join(sentencens[:min(count, len(sentencens))], ""))), nil +} + +// Taken from pongo2/filters_builtin.go +func filterTruncateHTMLHelper(value string, newOutput *bytes.Buffer, cond func() bool, fn func(c rune, s int, idx int) int, finalize func()) { + vLen := len(value) + tagStack := make([]string, 0) + idx := 0 + + for idx < vLen && !cond() { + c, s := utf8.DecodeRuneInString(value[idx:]) + if c == utf8.RuneError { + idx += s + continue + } + + if c == '<' { + newOutput.WriteRune(c) + idx += s // consume "<" + + if idx+1 < vLen { + if value[idx] == '/' { + // Close tag + + newOutput.WriteString("/") + + tag := "" + idx++ // consume "/" + + for idx < vLen { + c2, size2 := utf8.DecodeRuneInString(value[idx:]) + if c2 == utf8.RuneError { + idx += size2 + continue + } + + // End of tag found + if c2 == '>' { + idx++ // consume ">" + break + } + tag += string(c2) + idx += size2 + } + + if len(tagStack) > 0 { + // Ideally, the close tag is TOP of tag stack + // In malformed HTML, it must not be, so iterate through the stack and remove the tag + for i := len(tagStack) - 1; i >= 0; i-- { + if tagStack[i] == tag { + // Found the tag + tagStack[i] = tagStack[len(tagStack)-1] + tagStack = tagStack[:len(tagStack)-1] + break + } + } + } + + newOutput.WriteString(tag) + newOutput.WriteString(">") + } else { + // Open tag + + tag := "" + + params := false + for idx < vLen { + c2, size2 := utf8.DecodeRuneInString(value[idx:]) + if c2 == utf8.RuneError { + idx += size2 + continue + } + + newOutput.WriteRune(c2) + + // End of tag found + if c2 == '>' { + idx++ // consume ">" + break + } + + if !params { + if c2 == ' ' { + params = true + } else { + tag += string(c2) + } + } + + idx += size2 + } + + // Add tag to stack + tagStack = append(tagStack, tag) + } + } + } else { + idx = fn(c, s, idx) + } + } + + finalize() + + for i := len(tagStack) - 1; i >= 0; i-- { + tag := tagStack[i] + // Close everything from the regular tag stack + newOutput.WriteString(fmt.Sprintf("", tag)) + } +} + +func filterTruncatesentencesHTML(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + count := param.Integer() + if count <= 0 { + return pongo2.AsValue(""), nil + } + + value := in.String() + newLen := max(param.Integer(), 0) + + newOutput := bytes.NewBuffer(nil) + + sentencefilter := 0 + + filterTruncateHTMLHelper(value, newOutput, func() bool { + return sentencefilter >= newLen + }, func(_ rune, _ int, idx int) int { + // Get next word + wordFound := false + + for idx < len(value) { + c2, size2 := utf8.DecodeRuneInString(value[idx:]) + if c2 == utf8.RuneError { + idx += size2 + continue + } + + if c2 == '<' { + // HTML tag start, don't consume it + return idx + } + + newOutput.WriteRune(c2) + idx += size2 + + if (c2 == '.' && !(idx+1 < len(value) && value[idx+1] >= '0' && value[idx+1] <= '9')) || + c2 == '!' || c2 == '?' || c2 == '\n' { + // Sentence ends here, stop capturing it now + break + } else { + wordFound = true + } + } + + if wordFound { + sentencefilter++ + } + + return idx + }, func() {}) + + return pongo2.AsSafeValue(newOutput.String()), nil +} + +func filterRandom(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + if !in.CanSlice() { + return nil, &pongo2.Error{ + Sender: "filter:random", + OrigError: errors.New("input is not sliceable"), + } + } + + if in.Len() <= 0 { + return nil, &pongo2.Error{ + Sender: "filter:random", + OrigError: errors.New("input slice is empty"), + } + } + + return in.Index(rand.Intn(in.Len())), nil +} + +func filterTimeuntilTimesince(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + basetime, isTime := in.Interface().(time.Time) + if !isTime { + return nil, &pongo2.Error{ + Sender: "filter:timeuntil/timesince", + OrigError: errors.New("time-value is not a time.Time-instance"), + } + } + var paramtime time.Time + if !param.IsNil() { + paramtime, isTime = param.Interface().(time.Time) + if !isTime { + return nil, &pongo2.Error{ + Sender: "filter:timeuntil/timesince", + OrigError: errors.New("time-parameter is not a time.Time-instance"), + } + } + } else { + paramtime = time.Now() + } + + return pongo2.AsValue(humanize.TimeDuration(basetime.Sub(paramtime))), nil +} + +func filterIntcomma(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsValue(humanize.Comma(int64(in.Integer()))), nil +} + +func filterOrdinal(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + return pongo2.AsValue(humanize.Ordinal(in.Integer())), nil +} + +func filterNaturalday(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + basetime, isTime := in.Interface().(time.Time) + if !isTime { + return nil, &pongo2.Error{ + Sender: "filter:naturalday", + OrigError: errors.New("naturalday-value is not a time.Time-instance"), + } + } + + var referenceTime time.Time + if !param.IsNil() { + referenceTime, isTime = param.Interface().(time.Time) + if !isTime { + return nil, &pongo2.Error{ + Sender: "filter:naturalday", + OrigError: errors.New("naturalday-parameter is not a time.Time-instance"), + } + } + } else { + referenceTime = time.Now() + } + + d := referenceTime.Sub(basetime) / time.Hour + + switch { + case d >= 0 && d < 24: + // Today + return pongo2.AsValue("today"), nil + case d >= 24: + return pongo2.AsValue("yesterday"), nil + case d < 0 && d >= -24: + return pongo2.AsValue("tomorrow"), nil + } + + // Default behaviour + return pongo2.ApplyFilter("naturaltime", in, param) +} diff --git a/v5/filters_test.go b/v5/filters_test.go new file mode 100644 index 0000000..a227829 --- /dev/null +++ b/v5/filters_test.go @@ -0,0 +1,97 @@ +package pongo2addons + +import ( + "testing" + "time" + + "github.com/flosch/pongo2/v5" + . "gopkg.in/check.v1" +) + +// Hook up gocheck into the "go test" runner. +func Test(t *testing.T) { + TestingT(t) +} + +// A wrapprt of pongo2.RenderTemplateString +func getResult(s string, ctx pongo2.Context) string { + result, _ := pongo2.RenderTemplateString(s, ctx) + return result +} + +type TestSuite1 struct{} + +var _ = Suite(&TestSuite1{}) + +func (s *TestSuite1) TestFilters(c *C) { + // Markdown + c.Assert(getResult("{{ \"**test**\"|markdown }}", nil), Equals, "

test

\n") + + // Slugify + c.Assert(getResult("{{ \"this is ä test!\"|slugify }}", nil), Equals, "this-is-a-test") + + // Filesizeformat + c.Assert(getResult("{{ 123456789|filesizeformat }}", nil), Equals, "118MiB") + + // Timesince/timeuntil + baseDate := time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + futureDate := baseDate.Add(24*7*4*time.Hour + 2*time.Hour) + c.Assert(getResult("{{ future_date|timeuntil:base_date }}", + pongo2.Context{"base_date": baseDate, "future_date": futureDate}), Equals, "4 weeks from now") + + baseDate = time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + futureDate = baseDate.Add(2 * time.Hour) + c.Assert(getResult("{{ future_date|timeuntil:base_date }}", + pongo2.Context{"base_date": baseDate, "future_date": futureDate}), Equals, "2 hours from now") + + baseDate = time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + futureDate = baseDate.Add(2 * time.Hour) + c.Assert(getResult("{{ base_date|timesince:future_date }}", + pongo2.Context{"base_date": baseDate, "future_date": futureDate}), Equals, "2 hours ago") + + // Natural time + baseDate = time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + futureDate = baseDate.Add(4 * time.Second) + c.Assert(getResult("{{ base_date|naturaltime:future_date }}", + pongo2.Context{"base_date": baseDate, "future_date": futureDate}), Equals, "4 seconds ago") + + // Naturalday + today := time.Date(2014, time.February, 1, 8, 30, 00, 00, time.UTC) + yesterday := today.Add(-24 * time.Hour) + tomorrow := today.Add(24 * time.Hour) + todayPlus3 := today.Add(3 * 24 * time.Hour) + c.Assert(getResult("{{ date|naturalday:today }}", + pongo2.Context{"date": today, "today": today}), Equals, "today") + c.Assert(getResult("{{ date|naturalday:today }}", + pongo2.Context{"date": yesterday, "today": today}), Equals, "yesterday") + c.Assert(getResult("{{ date|naturalday:today }}", + pongo2.Context{"date": tomorrow, "today": today}), Equals, "tomorrow") + c.Assert(getResult("{{ date|naturalday:today }}", + pongo2.Context{"date": todayPlus3, "today": today}), Equals, "3 days from now") + + // Intcomma + c.Assert(getResult("{{ 123456789|intcomma }}", nil), Equals, "123,456,789") + + // Ordinal + c.Assert(getResult("{{ 1|ordinal }} {{ 2|ordinal }} {{ 3|ordinal }} {{ 18241|ordinal }}", nil), + Equals, "1st 2nd 3rd 18241st") + + // Truncatesentences + c.Assert(getResult("{{ text|truncatesentences:3|safe }}", pongo2.Context{ + "text": `This is a first sentence with a 4.50 number. The second one is even more fun! Isn't it? Last sentence, okay.`}), + Equals, "This is a first sentence with a 4.50 number. The second one is even more fun! Isn't it?") + + // Truncatesentences_html + c.Assert(getResult("{{ text|truncatesentences_html:2 }}", pongo2.Context{ + "text": `
`}), + Equals, `
`) + c.Assert(getResult("{{ text|truncatesentences_html:3 }}", pongo2.Context{ + "text": `
`}), + Equals, `
`) + + // Random + c.Assert(getResult("{{ array|random }}", + pongo2.Context{"array": []int{42}}), + Equals, "42") + +} diff --git a/v5/helpers.go b/v5/helpers.go new file mode 100644 index 0000000..008052c --- /dev/null +++ b/v5/helpers.go @@ -0,0 +1,15 @@ +package pongo2addons + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +}