This repository has no description
1package pages
2
3import (
4 "bytes"
5 "context"
6 "crypto/hmac"
7 "crypto/sha256"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "html"
12 "html/template"
13 "log"
14 "math"
15 "math/rand"
16 "net/url"
17 "path/filepath"
18 "reflect"
19 "strings"
20 "time"
21
22 "github.com/alecthomas/chroma/v2"
23 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
24 "github.com/alecthomas/chroma/v2/lexers"
25 "github.com/alecthomas/chroma/v2/styles"
26 "github.com/bluesky-social/indigo/atproto/syntax"
27 "github.com/dustin/go-humanize"
28 "github.com/dustin/go-humanize/english"
29 "github.com/go-enry/go-enry/v2"
30 "github.com/yuin/goldmark"
31 emoji "github.com/yuin/goldmark-emoji"
32 "tangled.org/core/appview/cache"
33 "tangled.org/core/appview/db"
34 "tangled.org/core/appview/models"
35 "tangled.org/core/appview/oauth"
36 "tangled.org/core/appview/pages/markup"
37 "tangled.org/core/appview/pages/markup/sanitizer"
38 "tangled.org/core/appview/pages/repoinfo"
39 "tangled.org/core/crypto"
40 "tangled.org/core/idresolver"
41 "tangled.org/core/orm"
42 "tangled.org/core/types"
43)
44
45type tab map[string]string
46
47func (p *Pages) ownerSlashRepo(repo *models.Repo) string {
48 ownerId, err := p.resolver.ResolveIdent(context.Background(), repo.Did)
49 if err != nil {
50 return repo.RepoIdentifier()
51 }
52 handle := ownerId.Handle
53 if handle != "" && !handle.IsInvalidHandle() {
54 return string(handle) + "/" + repo.Slug()
55 }
56 return repo.RepoIdentifier()
57}
58
59func (p *Pages) funcMap() template.FuncMap {
60 return template.FuncMap{
61 "split": func(s string) []string {
62 return strings.Split(s, "\n")
63 },
64 "capitalize": func(s string) string {
65 if s == "" {
66 return s
67 }
68 return strings.ToUpper(s[:1]) + s[1:]
69 },
70 "trimPrefix": func(s, prefix string) string {
71 return strings.TrimPrefix(s, prefix)
72 },
73 "join": func(elems []string, sep string) string {
74 return strings.Join(elems, sep)
75 },
76 "contains": func(s string, target string) bool {
77 return strings.Contains(s, target)
78 },
79 "stripPort": func(hostname string) string {
80 if strings.Contains(hostname, ":") {
81 return strings.Split(hostname, ":")[0]
82 }
83 return hostname
84 },
85 "mapContains": func(m any, key any) bool {
86 mapValue := reflect.ValueOf(m)
87 if mapValue.Kind() != reflect.Map {
88 return false
89 }
90 keyValue := reflect.ValueOf(key)
91 return mapValue.MapIndex(keyValue).IsValid()
92 },
93 "resolve": func(s string) string {
94 return p.DisplayHandle(context.Background(), s)
95 },
96 "resolver": func() *idresolver.Resolver {
97 return p.resolver
98 },
99 "primaryHandle": func(s string) string {
100 return primaryHandle(p.resolver, s)
101 },
102 "resolvePds": func(s string) string {
103 identity, err := p.resolver.ResolveIdent(context.Background(), s)
104 if err != nil {
105 return ""
106 }
107 return identity.PDSEndpoint()
108 },
109 "ownerSlashRepo": p.ownerSlashRepo,
110 "pipelineCommitPath": func(repoInfo repoinfo.RepoInfo, pipeline types.Pipeline) string {
111 sha := pipeline.Sha()
112 if sourceRepo := pipeline.SourceRepo(); sourceRepo != nil {
113 if repo, err := db.GetRepoByDid(p.db, *sourceRepo); err == nil && repo != nil {
114 return "/" + p.ownerSlashRepo(repo) + "/commit/" + sha
115 }
116 }
117 return "/" + repoInfo.FullName() + "/commit/" + sha
118 },
119 "pipelineSourceLabel": func(pipeline types.Pipeline) string {
120 branch := pipeline.Trigger().PRSourceBranch()
121 if branch == "" {
122 return branch
123 }
124 sourceRepo := pipeline.SourceRepo()
125 if sourceRepo == nil {
126 return branch
127 }
128 repo, err := db.GetRepoByDid(p.db, *sourceRepo)
129 if err != nil || repo == nil {
130 return branch
131 }
132 return p.ownerSlashRepo(repo) + "/" + branch
133 },
134 "pipelinePullPath": func(pipeline types.Pipeline) string {
135 pullAtStr := pipeline.Trigger().PRUri()
136 if pullAtStr == "" {
137 return ""
138 }
139 // GetPull's reverse-mapping already populates pull.Repo
140 pull, err := db.GetPull(p.db, orm.FilterEq("at_uri", pullAtStr))
141 if err != nil || pull == nil || pull.Repo == nil {
142 return ""
143 }
144 return fmt.Sprintf("/%s/pulls/%d", p.ownerSlashRepo(pull.Repo), pull.PullId)
145 },
146 "truncateAt30": func(s string) string {
147 if len(s) <= 30 {
148 return s
149 }
150 return s[:30] + "…"
151 },
152 // short prefix of a commit hash or jj change id, safe on short input
153 "shortId": func(s string) string {
154 if len(s) <= 8 {
155 return s
156 }
157 return s[:8]
158 },
159 "splitOn": func(s, sep string) []string {
160 return strings.Split(s, sep)
161 },
162 "string": func(v any) string {
163 return fmt.Sprint(v)
164 },
165 "int64": func(a int) int64 {
166 return int64(a)
167 },
168 "add": func(a, b int) int {
169 return a + b
170 },
171 "now": func() time.Time {
172 return time.Now()
173 },
174 // the absolute state of go templates
175 "add64": func(a, b int64) int64 {
176 return a + b
177 },
178 "sub": func(a, b int) int {
179 return a - b
180 },
181 "mul": func(a, b int) int {
182 return a * b
183 },
184 "div": func(a, b int) int {
185 return a / b
186 },
187 "mod": func(a, b int) int {
188 return a % b
189 },
190 "randInt": func(bound int) int {
191 return rand.Intn(bound)
192 },
193 "f64": func(a int) float64 {
194 return float64(a)
195 },
196 "addf64": func(a, b float64) float64 {
197 return a + b
198 },
199 "subf64": func(a, b float64) float64 {
200 return a - b
201 },
202 "mulf64": func(a, b float64) float64 {
203 return a * b
204 },
205 "divf64": func(a, b float64) float64 {
206 if b == 0 {
207 return 0
208 }
209 return a / b
210 },
211 "negf64": func(a float64) float64 {
212 return -a
213 },
214 "cond": func(cond any, a, b string) string {
215 if cond == nil {
216 return b
217 }
218
219 if boolean, ok := cond.(bool); boolean && ok {
220 return a
221 }
222
223 return b
224 },
225 "assoc": func(values ...string) ([][]string, error) {
226 if len(values)%2 != 0 {
227 return nil, fmt.Errorf("invalid assoc call, must have an even number of arguments")
228 }
229 pairs := make([][]string, 0)
230 for i := 0; i < len(values); i += 2 {
231 pairs = append(pairs, []string{values[i], values[i+1]})
232 }
233 return pairs, nil
234 },
235 "append": func(s []any, values ...any) []any {
236 s = append(s, values...)
237 return s
238 },
239 // scale numerics over 1000 to 1k
240 "scaleFmt": func(n any) string {
241 var v float64
242 switch x := n.(type) {
243 case int:
244 v = float64(x)
245 case int32:
246 v = float64(x)
247 case int64:
248 v = float64(x)
249 case float64:
250 v = x
251 default:
252 return fmt.Sprintf("%v", n)
253 }
254 if v < 1000 {
255 return fmt.Sprintf("%d", int(v))
256 }
257 k := v / 1000
258 if k < 10 {
259 return fmt.Sprintf("%.1fk", k)
260 }
261 return fmt.Sprintf("%dk", int(k))
262 },
263 "commaFmt": humanize.Comma,
264 "plural": english.Plural,
265 "relTimeFmt": humanize.Time,
266 "shortRelTimeFmt": func(t time.Time) string {
267 if t.Unix() == 0 {
268 return "at the beginning of time"
269 }
270 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
271 {D: time.Second, Format: "now", DivBy: time.Second},
272 {D: 2 * time.Second, Format: "1s %s", DivBy: 1},
273 {D: time.Minute, Format: "%ds %s", DivBy: time.Second},
274 {D: 2 * time.Minute, Format: "1min %s", DivBy: 1},
275 {D: time.Hour, Format: "%dmin %s", DivBy: time.Minute},
276 {D: 2 * time.Hour, Format: "1hr %s", DivBy: 1},
277 {D: humanize.Day, Format: "%dhrs %s", DivBy: time.Hour},
278 {D: 2 * humanize.Day, Format: "1d %s", DivBy: 1},
279 {D: 20 * humanize.Day, Format: "%dd %s", DivBy: humanize.Day},
280 {D: 8 * humanize.Week, Format: "%dw %s", DivBy: humanize.Week},
281 {D: humanize.Year, Format: "%dmo %s", DivBy: humanize.Month},
282 {D: 18 * humanize.Month, Format: "1y %s", DivBy: 1},
283 {D: 2 * humanize.Year, Format: "2y %s", DivBy: 1},
284 {D: humanize.LongTime, Format: "%dy %s", DivBy: humanize.Year},
285 {D: math.MaxInt64, Format: "a long while %s", DivBy: 1},
286 })
287 },
288 "isFutureTime": func(t time.Time) bool {
289 return t.After(time.Now())
290 },
291 "shortTimeFmt": func(t time.Time) string {
292 return t.Format("Jan 2, 2006")
293 },
294 "longTimeFmt": func(t time.Time) string {
295 return t.Format("Jan 2, 2006, 3:04 PM MST")
296 },
297 "iso8601DateTimeFmt": func(t time.Time) string {
298 return t.Format("2006-01-02T15:04:05-07:00")
299 },
300 "iso8601DurationFmt": func(duration time.Duration) string {
301 days := int64(duration.Hours() / 24)
302 hours := int64(math.Mod(duration.Hours(), 24))
303 minutes := int64(math.Mod(duration.Minutes(), 60))
304 seconds := int64(math.Mod(duration.Seconds(), 60))
305 return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds)
306 },
307 "durationFmt": func(duration time.Duration) string {
308 return durationFmt(duration, [4]string{"d", "h", "m", "s"})
309 },
310 "longDurationFmt": func(duration time.Duration) string {
311 return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"})
312 },
313 "byteFmt": humanize.Bytes,
314 "length": func(slice any) int {
315 v := reflect.ValueOf(slice)
316 if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
317 return v.Len()
318 }
319 return 0
320 },
321 "splitN": func(s, sep string, n int) []string {
322 return strings.SplitN(s, sep, n)
323 },
324 "escapeHtml": func(s string) template.HTML {
325 if s == "" {
326 return template.HTML("<br>")
327 }
328 return template.HTML(s)
329 },
330 "unescapeHtml": func(s string) string {
331 return html.UnescapeString(s)
332 },
333 "nl2br": func(text string) template.HTML {
334 return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>"))
335 },
336 "unwrapText": func(text string) string {
337 paragraphs := strings.Split(text, "\n\n")
338
339 for i, p := range paragraphs {
340 lines := strings.Split(p, "\n")
341 paragraphs[i] = strings.Join(lines, " ")
342 }
343
344 return strings.Join(paragraphs, "\n\n")
345 },
346 "sequence": func(n int) []struct{} {
347 return make([]struct{}, n)
348 },
349 // take atmost N items from this slice
350 "take": func(slice any, n int) any {
351 v := reflect.ValueOf(slice)
352 if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
353 return nil
354 }
355 if v.Len() == 0 {
356 return nil
357 }
358 return v.Slice(0, min(n, v.Len())).Interface()
359 },
360 "markdown": func(text string) template.HTML {
361 rctx := p.rctx.Clone()
362 rctx.RendererType = markup.RendererTypeDefault
363 htmlString := rctx.RenderMarkdown(text)
364 sanitized := sanitizer.SanitizeDefault(htmlString)
365 return template.HTML(sanitized)
366 },
367 "description": func(text string) template.HTML {
368 rctx := p.rctx.Clone()
369 rctx.RendererType = markup.RendererTypeDefault
370 htmlString := rctx.RenderMarkdownWith(text, goldmark.New(
371 goldmark.WithExtensions(
372 emoji.Emoji,
373 ),
374 ))
375 sanitized := sanitizer.SanitizeDescription(htmlString)
376 return template.HTML(sanitized)
377 },
378 "readme": func(text string) template.HTML {
379 rctx := p.rctx.Clone()
380 rctx.RendererType = markup.RendererTypeRepoMarkdown
381 htmlString := rctx.RenderMarkdown(text)
382 sanitized := sanitizer.SanitizeDefault(htmlString)
383 return template.HTML(sanitized)
384 },
385 "code": func(content, path string) string {
386 var style *chroma.Style = styles.Get("catpuccin-latte")
387 formatter := chromahtml.New(
388 chromahtml.InlineCode(false),
389 chromahtml.WithLineNumbers(true),
390 chromahtml.WithLinkableLineNumbers(true, "L"),
391 chromahtml.Standalone(false),
392 chromahtml.WithClasses(true),
393 )
394
395 lexer := lexers.Get(filepath.Base(path))
396 if lexer == nil {
397 if firstLine, _, ok := strings.Cut(content, "\n"); ok && strings.HasPrefix(firstLine, "#!") {
398 // extract interpreter from shebang (handles "#!/usr/bin/env nu", "#!/usr/bin/nu", etc.)
399 fields := strings.Fields(firstLine[2:])
400 if len(fields) > 0 {
401 interp := filepath.Base(fields[len(fields)-1])
402 lexer = lexers.Get(interp)
403 }
404 }
405 }
406 if lexer == nil {
407 lexer = lexers.Analyse(content)
408 }
409 if lexer == nil {
410 lexer = lexers.Fallback
411 }
412
413 iterator, err := lexer.Tokenise(nil, content)
414 if err != nil {
415 p.logger.Error("chroma tokenize", "err", "err")
416 return ""
417 }
418
419 var code bytes.Buffer
420 err = formatter.Format(&code, style, iterator)
421 if err != nil {
422 p.logger.Error("chroma format", "err", "err")
423 return ""
424 }
425
426 return code.String()
427 },
428 "trimUriScheme": func(text string) string {
429 text = strings.TrimPrefix(text, "https://")
430 text = strings.TrimPrefix(text, "http://")
431 return text
432 },
433 "isNil": func(t any) bool {
434 // returns false for other "zero" values
435 return t == nil
436 },
437 "hasPrefix": strings.HasPrefix,
438 "list": func(args ...any) []any {
439 return args
440 },
441 "dict": func(values ...any) (map[string]any, error) {
442 if len(values)%2 != 0 {
443 return nil, errors.New("invalid dict call")
444 }
445 dict := make(map[string]any, len(values)/2)
446 for i := 0; i < len(values); i += 2 {
447 key, ok := values[i].(string)
448 if !ok {
449 return nil, errors.New("dict keys must be strings")
450 }
451 dict[key] = values[i+1]
452 }
453 return dict, nil
454 },
455 "queryParams": func(params ...any) (url.Values, error) {
456 if len(params)%2 != 0 {
457 return nil, errors.New("invalid queryParams call")
458 }
459 vals := make(url.Values, len(params)/2)
460 for i := 0; i < len(params); i += 2 {
461 key, ok := params[i].(string)
462 if !ok {
463 return nil, errors.New("queryParams keys must be strings")
464 }
465 v, ok := params[i+1].(string)
466 if !ok {
467 return nil, errors.New("queryParams values must be strings")
468 }
469 vals.Add(key, v)
470 }
471 return vals, nil
472 },
473 "deref": func(v any) any {
474 val := reflect.ValueOf(v)
475 if val.Kind() == reflect.Pointer && !val.IsNil() {
476 return val.Elem().Interface()
477 }
478 return nil
479 },
480 "i": func(name string, classes ...string) template.HTML {
481 data, err := p.icon(name, classes)
482 if err != nil {
483 log.Printf("icon %s does not exist", name)
484 data, _ = p.icon("airplay", classes)
485 }
486 return template.HTML(data)
487 },
488 "cssContentHash": p.CssContentHash,
489 "pathEscape": func(s string) string {
490 return url.PathEscape(s)
491 },
492 "pathUnescape": func(s string) string {
493 u, _ := url.PathUnescape(s)
494 return u
495 },
496 "safeUrl": func(s string) template.URL {
497 return template.URL(s)
498 },
499 "tinyAvatar": func(handle string) string {
500 return p.AvatarUrl(handle, "tiny")
501 },
502 "fullAvatar": func(handle string) string {
503 return p.AvatarUrl(handle, "")
504 },
505 "placeholderAvatar": func(size string) template.HTML {
506 sizeClass := "size-6"
507 iconSize := "size-4"
508 switch size {
509 case "tiny":
510 sizeClass = "size-6"
511 iconSize = "size-4"
512 case "small":
513 sizeClass = "size-8"
514 iconSize = "size-5"
515 default:
516 sizeClass = "size-12"
517 iconSize = "size-8"
518 }
519 icon, _ := p.icon("user-round", []string{iconSize, "text-gray-400", "dark:text-gray-500"})
520 return template.HTML(fmt.Sprintf(`<div class="%s rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">%s</div>`, sizeClass, icon))
521 },
522 "profileAvatarUrl": func(profile *models.Profile, size string) string {
523 if profile != nil {
524 return p.AvatarUrl(profile.Did, size)
525 }
526 return ""
527 },
528 "langColor": enry.GetColor,
529 "reverse": func(s any) any {
530 if s == nil {
531 return nil
532 }
533
534 v := reflect.ValueOf(s)
535
536 if v.Kind() != reflect.Slice {
537 return s
538 }
539
540 length := v.Len()
541 reversed := reflect.MakeSlice(v.Type(), length, length)
542
543 for i := range length {
544 reversed.Index(i).Set(v.Index(length - 1 - i))
545 }
546
547 return reversed.Interface()
548 },
549 "normalizeForHtmlId": func(s string) string {
550 normalized := strings.ReplaceAll(s, ":", "_")
551 normalized = strings.ReplaceAll(normalized, ".", "_")
552 return normalized
553 },
554 "sshFingerprint": func(pubKey string) string {
555 fp, err := crypto.SSHFingerprint(pubKey)
556 if err != nil {
557 return "error"
558 }
559 return fp
560 },
561 "otherAccounts": func(activeDid string, accounts []oauth.AccountInfo) []oauth.AccountInfo {
562 result := make([]oauth.AccountInfo, 0, len(accounts))
563 for _, acc := range accounts {
564 if acc.Did != activeDid {
565 result = append(result, acc)
566 }
567 }
568 return result
569 },
570 "isGenerated": func(path string) bool {
571 return enry.IsGenerated(path, nil)
572 },
573 // NOTE(boltless): I know... I hate doing this too
574 "asReactionMapMap": func(dict any) map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData {
575 if dict == nil {
576 return make(map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData)
577 }
578 m, _ := dict.(map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData)
579 return m
580 },
581 "asReactionStatusMapMap": func(dict any) map[syntax.ATURI]map[models.ReactionKind]bool {
582 if dict == nil {
583 log.Println("returning empty map")
584 return make(map[syntax.ATURI]map[models.ReactionKind]bool)
585 }
586 m, _ := dict.(map[syntax.ATURI]map[models.ReactionKind]bool)
587 return m
588 },
589 // constant values used to define a template
590 "const": func() map[string]any {
591 return map[string]any{
592 "OrderedReactionKinds": models.OrderedReactionKinds,
593 // would be great to have ordered maps right about now
594 "UserSettingsTabs": []tab{
595 {"Name": "profile", "Label": "Profile", "Icon": "user"},
596 {"Name": "keys", "Label": "Keys", "Icon": "key"},
597 {"Name": "emails", "Label": "Emails", "Icon": "mail"},
598 {"Name": "notifications", "Label": "Notifications", "Icon": "bell"},
599 {"Name": "knots", "Label": "Knots", "Icon": "volleyball"},
600 {"Name": "spindles", "Label": "Spindles", "Icon": "spool"},
601 {"Name": "sites", "Label": "Sites", "Icon": "globe"},
602 },
603 "RepoSettingsTabs": []tab{
604 {"Name": "general", "Label": "General", "Icon": "sliders-horizontal"},
605 {"Name": "access", "Label": "Access", "Icon": "users"},
606 {"Name": "pipelines", "Label": "Pipelines", "Icon": "layers-2"},
607 {"Name": "hooks", "Label": "Hooks", "Icon": "webhook"},
608 {"Name": "sites", "Label": "Sites", "Icon": "globe"},
609 },
610 "PdsUserDomain": p.pdsCfg.UserDomain,
611 }
612 },
613 "did": func(s string) syntax.DID {
614 // cast to DID
615 return syntax.DID(s)
616 },
617 }
618}
619
620func primaryHandle(r *idresolver.Resolver, s string) string {
621 identity, err := r.ResolveIdent(context.Background(), s)
622 if err != nil || identity.Handle.IsInvalidHandle() {
623 return "handle.invalid"
624 }
625 return identity.Handle.String()
626}
627
628func (p *Pages) DisplayHandle(ctx context.Context, did string) string {
629 if p.db != nil {
630 if h := cache.LookupPreferredHandle(ctx, p.rdb, p.db, did); h != "" {
631 return h
632 }
633 }
634 if id, err := p.resolver.ResolveIdent(ctx, did); err == nil && !id.Handle.IsInvalidHandle() {
635 return id.Handle.String()
636 }
637 return did
638}
639
640func (p *Pages) AvatarUrl(actor, size string) string {
641 actor = strings.TrimPrefix(actor, "@")
642
643 identity, err := p.resolver.ResolveIdent(context.Background(), actor)
644 var did string
645 if err != nil {
646 did = actor
647 } else {
648 did = identity.DID.String()
649 }
650
651 secret := p.avatar.SharedSecret
652 if secret == "" {
653 return ""
654 }
655 h := hmac.New(sha256.New, []byte(secret))
656 h.Write([]byte(did))
657 signature := hex.EncodeToString(h.Sum(nil))
658
659 // Get avatar CID for cache busting
660 version := ""
661 if p.db != nil {
662 profile, err := db.GetProfile(p.db, did)
663 if err == nil && profile != nil && profile.Avatar != "" {
664 // Use first 8 chars of avatar CID as version
665 if len(profile.Avatar) > 8 {
666 version = profile.Avatar[:8]
667 } else {
668 version = profile.Avatar
669 }
670 }
671 }
672
673 baseUrl := fmt.Sprintf("%s/%s/%s", p.avatar.Host, signature, did)
674 if size != "" {
675 if version != "" {
676 return fmt.Sprintf("%s?size=%s&v=%s", baseUrl, size, version)
677 }
678 return fmt.Sprintf("%s?size=%s", baseUrl, size)
679 }
680 if version != "" {
681 return fmt.Sprintf("%s?v=%s", baseUrl, version)
682 }
683
684 return baseUrl
685}
686
687func (p *Pages) icon(name string, classes []string) (template.HTML, error) {
688 iconPath := filepath.Join("static", "icons", name)
689
690 if filepath.Ext(name) == "" {
691 iconPath += ".svg"
692 }
693
694 data, err := Files.ReadFile(iconPath)
695 if err != nil {
696 return "", fmt.Errorf("icon %s not found: %w", name, err)
697 }
698
699 // Convert SVG data to string
700 svgStr := string(data)
701
702 svgTagEnd := strings.Index(svgStr, ">")
703 if svgTagEnd == -1 {
704 return "", fmt.Errorf("invalid SVG format for icon %s", name)
705 }
706
707 classTag := ` class="` + strings.Join(classes, " ") + `"`
708
709 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:]
710 return template.HTML(modifiedSVG), nil
711}
712
713func durationFmt(duration time.Duration, names [4]string) string {
714 days := int64(duration.Hours() / 24)
715 hours := int64(math.Mod(duration.Hours(), 24))
716 minutes := int64(math.Mod(duration.Minutes(), 60))
717 seconds := int64(math.Mod(duration.Seconds(), 60))
718
719 chunks := []struct {
720 name string
721 amount int64
722 }{
723 {names[0], days},
724 {names[1], hours},
725 {names[2], minutes},
726 {names[3], seconds},
727 }
728
729 parts := []string{}
730
731 for _, chunk := range chunks {
732 if chunk.amount != 0 {
733 parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name))
734 }
735 }
736
737 return strings.Join(parts, " ")
738}