It seems that we really need the "context function" soon. So we should clean up the helper functions first. Major changes: * Improve StringUtils and add JsonUtils * Remove one-time-use helper functions like CompareLink * Move other code (no change) to util_avatar/util_render/util_misc (no need to propose changes for them) I have tested the changed templates: ![image](https://user-images.githubusercontent.com/2114189/235283862-608dbf6b-2da3-4d06-8157-b523ca93edb4.png) ![image](https://user-images.githubusercontent.com/2114189/235283888-1dfc0471-e622-4d64-9d76-7859819580d3.png) ![image](https://user-images.githubusercontent.com/2114189/235283903-d559f14d-4abb-4a50-915f-2b9cbc381a7a.png) ![image](https://user-images.githubusercontent.com/2114189/235283955-b7b5adea-aca3-4758-b38a-3aae3f7c6048.png) --------- Co-authored-by: Giteabot <teabot@gitea.io>tags/v1.20.0-rc0
@@ -5,46 +5,25 @@ | |||
package templates | |||
import ( | |||
"bytes" | |||
"context" | |||
"encoding/hex" | |||
"fmt" | |||
"html" | |||
"html/template" | |||
"math" | |||
"mime" | |||
"net/url" | |||
"path/filepath" | |||
"regexp" | |||
"strings" | |||
"time" | |||
"unicode" | |||
activities_model "code.gitea.io/gitea/models/activities" | |||
"code.gitea.io/gitea/models/avatars" | |||
issues_model "code.gitea.io/gitea/models/issues" | |||
"code.gitea.io/gitea/models/organization" | |||
repo_model "code.gitea.io/gitea/models/repo" | |||
system_model "code.gitea.io/gitea/models/system" | |||
user_model "code.gitea.io/gitea/models/user" | |||
"code.gitea.io/gitea/modules/base" | |||
"code.gitea.io/gitea/modules/emoji" | |||
"code.gitea.io/gitea/modules/git" | |||
giturl "code.gitea.io/gitea/modules/git/url" | |||
gitea_html "code.gitea.io/gitea/modules/html" | |||
"code.gitea.io/gitea/modules/json" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/markup" | |||
"code.gitea.io/gitea/modules/markup/markdown" | |||
"code.gitea.io/gitea/modules/repository" | |||
"code.gitea.io/gitea/modules/setting" | |||
"code.gitea.io/gitea/modules/svg" | |||
"code.gitea.io/gitea/modules/templates/eval" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"code.gitea.io/gitea/modules/util" | |||
"code.gitea.io/gitea/services/gitdiff" | |||
"github.com/editorconfig/editorconfig-core-go/v2" | |||
) | |||
// Used from static.go && dynamic.go | |||
@@ -53,6 +32,8 @@ var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}[\s]*$`) | |||
// NewFuncMap returns functions for injecting to templates | |||
func NewFuncMap() []template.FuncMap { | |||
return []template.FuncMap{map[string]interface{}{ | |||
"DumpVar": dumpVar, | |||
// ----------------------------------------------------------------- | |||
// html/template related functions | |||
"dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names. | |||
@@ -63,6 +44,7 @@ func NewFuncMap() []template.FuncMap { | |||
"JSEscape": template.JSEscapeString, | |||
"Str2html": Str2html, // TODO: rename it to SanitizeHTML | |||
"URLJoin": util.URLJoin, | |||
"DotEscape": DotEscape, | |||
"PathEscape": url.PathEscape, | |||
"PathEscapeSegments": util.PathEscapeSegments, | |||
@@ -70,30 +52,7 @@ func NewFuncMap() []template.FuncMap { | |||
// utils | |||
"StringUtils": NewStringUtils, | |||
"SliceUtils": NewSliceUtils, | |||
// ----------------------------------------------------------------- | |||
// string / json | |||
// TODO: move string helper functions to StringUtils | |||
"Join": strings.Join, | |||
"DotEscape": DotEscape, | |||
"EllipsisString": base.EllipsisString, | |||
"DumpVar": dumpVar, | |||
"Json": func(in interface{}) string { | |||
out, err := json.Marshal(in) | |||
if err != nil { | |||
return "" | |||
} | |||
return string(out) | |||
}, | |||
"JsonPrettyPrint": func(in string) string { | |||
var out bytes.Buffer | |||
err := json.Indent(&out, []byte(in), "", " ") | |||
if err != nil { | |||
return "" | |||
} | |||
return out.String() | |||
}, | |||
"JsonUtils": NewJsonUtils, | |||
// ----------------------------------------------------------------- | |||
// svg / avatar / icon | |||
@@ -107,31 +66,7 @@ func NewFuncMap() []template.FuncMap { | |||
"MigrationIcon": MigrationIcon, | |||
"ActionIcon": ActionIcon, | |||
"SortArrow": func(normSort, revSort, urlSort string, isDefault bool) template.HTML { | |||
// if needed | |||
if len(normSort) == 0 || len(urlSort) == 0 { | |||
return "" | |||
} | |||
if len(urlSort) == 0 && isDefault { | |||
// if sort is sorted as default add arrow tho this table header | |||
if isDefault { | |||
return svg.RenderHTML("octicon-triangle-down", 16) | |||
} | |||
} else { | |||
// if sort arg is in url test if it correlates with column header sort arguments | |||
// the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev) | |||
if urlSort == normSort { | |||
// the table is sorted with this header normal | |||
return svg.RenderHTML("octicon-triangle-up", 16) | |||
} else if urlSort == revSort { | |||
// the table is sorted with this header reverse | |||
return svg.RenderHTML("octicon-triangle-down", 16) | |||
} | |||
} | |||
// the table is NOT sorted with this header | |||
return "" | |||
}, | |||
"SortArrow": SortArrow, | |||
// ----------------------------------------------------------------- | |||
// time / number / format | |||
@@ -242,32 +177,9 @@ func NewFuncMap() []template.FuncMap { | |||
"ReactionToEmoji": ReactionToEmoji, | |||
"RenderNote": RenderNote, | |||
"RenderMarkdownToHtml": func(ctx context.Context, input string) template.HTML { | |||
output, err := markdown.RenderString(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: setting.AppSubURL, | |||
}, input) | |||
if err != nil { | |||
log.Error("RenderString: %v", err) | |||
} | |||
return template.HTML(output) | |||
}, | |||
"RenderLabel": func(ctx context.Context, label *issues_model.Label) template.HTML { | |||
return template.HTML(RenderLabel(ctx, label)) | |||
}, | |||
"RenderLabels": func(ctx context.Context, labels []*issues_model.Label, repoLink string) template.HTML { | |||
htmlCode := `<span class="labels-list">` | |||
for _, label := range labels { | |||
// Protect against nil value in labels - shouldn't happen but would cause a panic if so | |||
if label == nil { | |||
continue | |||
} | |||
htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ", | |||
repoLink, label.ID, RenderLabel(ctx, label)) | |||
} | |||
htmlCode += "</span>" | |||
return template.HTML(htmlCode) | |||
}, | |||
"RenderMarkdownToHtml": RenderMarkdownToHtml, | |||
"RenderLabel": RenderLabel, | |||
"RenderLabels": RenderLabels, | |||
// ----------------------------------------------------------------- | |||
// misc | |||
@@ -278,124 +190,11 @@ func NewFuncMap() []template.FuncMap { | |||
"CommentMustAsDiff": gitdiff.CommentMustAsDiff, | |||
"MirrorRemoteAddress": mirrorRemoteAddress, | |||
"ParseDeadline": func(deadline string) []string { | |||
return strings.Split(deadline, "|") | |||
}, | |||
"FilenameIsImage": func(filename string) bool { | |||
mimeType := mime.TypeByExtension(filepath.Ext(filename)) | |||
return strings.HasPrefix(mimeType, "image/") | |||
}, | |||
"TabSizeClass": func(ec interface{}, filename string) string { | |||
var ( | |||
value *editorconfig.Editorconfig | |||
ok bool | |||
) | |||
if ec != nil { | |||
if value, ok = ec.(*editorconfig.Editorconfig); !ok || value == nil { | |||
return "tab-size-8" | |||
} | |||
def, err := value.GetDefinitionForFilename(filename) | |||
if err != nil { | |||
log.Error("tab size class: getting definition for filename: %v", err) | |||
return "tab-size-8" | |||
} | |||
if def.TabWidth > 0 { | |||
return fmt.Sprintf("tab-size-%d", def.TabWidth) | |||
} | |||
} | |||
return "tab-size-8" | |||
}, | |||
"SubJumpablePath": func(str string) []string { | |||
var path []string | |||
index := strings.LastIndex(str, "/") | |||
if index != -1 && index != len(str) { | |||
path = append(path, str[0:index+1], str[index+1:]) | |||
} else { | |||
path = append(path, str) | |||
} | |||
return path | |||
}, | |||
"CompareLink": func(baseRepo, repo *repo_model.Repository, branchName string) string { | |||
var curBranch string | |||
if repo.ID != baseRepo.ID { | |||
curBranch += fmt.Sprintf("%s/%s:", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name)) | |||
} | |||
curBranch += util.PathEscapeSegments(branchName) | |||
return fmt.Sprintf("%s/compare/%s...%s", | |||
baseRepo.Link(), | |||
util.PathEscapeSegments(baseRepo.DefaultBranch), | |||
curBranch, | |||
) | |||
}, | |||
"FilenameIsImage": FilenameIsImage, | |||
"TabSizeClass": TabSizeClass, | |||
}} | |||
} | |||
// AvatarHTML creates the HTML for an avatar | |||
func AvatarHTML(src string, size int, class, name string) template.HTML { | |||
sizeStr := fmt.Sprintf(`%d`, size) | |||
if name == "" { | |||
name = "avatar" | |||
} | |||
return template.HTML(`<img class="` + class + `" src="` + src + `" title="` + html.EscapeString(name) + `" width="` + sizeStr + `" height="` + sizeStr + `"/>`) | |||
} | |||
// Avatar renders user avatars. args: user, size (int), class (string) | |||
func Avatar(ctx context.Context, item interface{}, others ...interface{}) template.HTML { | |||
size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) | |||
switch t := item.(type) { | |||
case *user_model.User: | |||
src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) | |||
if src != "" { | |||
return AvatarHTML(src, size, class, t.DisplayName()) | |||
} | |||
case *repo_model.Collaborator: | |||
src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) | |||
if src != "" { | |||
return AvatarHTML(src, size, class, t.DisplayName()) | |||
} | |||
case *organization.Organization: | |||
src := t.AsUser().AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) | |||
if src != "" { | |||
return AvatarHTML(src, size, class, t.AsUser().DisplayName()) | |||
} | |||
} | |||
return template.HTML("") | |||
} | |||
// AvatarByAction renders user avatars from action. args: action, size (int), class (string) | |||
func AvatarByAction(ctx context.Context, action *activities_model.Action, others ...interface{}) template.HTML { | |||
action.LoadActUser(ctx) | |||
return Avatar(ctx, action.ActUser, others...) | |||
} | |||
// RepoAvatar renders repo avatars. args: repo, size(int), class (string) | |||
func RepoAvatar(repo *repo_model.Repository, others ...interface{}) template.HTML { | |||
size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) | |||
src := repo.RelAvatarLink() | |||
if src != "" { | |||
return AvatarHTML(src, size, class, repo.FullName()) | |||
} | |||
return template.HTML("") | |||
} | |||
// AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string) | |||
func AvatarByEmail(ctx context.Context, email, name string, others ...interface{}) template.HTML { | |||
size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) | |||
src := avatars.GenerateEmailAvatarFastLink(ctx, email, size*setting.Avatar.RenderedSizeFactor) | |||
if src != "" { | |||
return AvatarHTML(src, size, class, name) | |||
} | |||
return template.HTML("") | |||
} | |||
// Safe render raw as HTML | |||
func Safe(raw string) template.HTML { | |||
return template.HTML(raw) | |||
@@ -411,342 +210,6 @@ func DotEscape(raw string) string { | |||
return strings.ReplaceAll(raw, ".", "\u200d.\u200d") | |||
} | |||
// RenderCommitMessage renders commit message with XSS-safe and special links. | |||
func RenderCommitMessage(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { | |||
return RenderCommitMessageLink(ctx, msg, urlPrefix, "", metas) | |||
} | |||
// RenderCommitMessageLink renders commit message as a XXS-safe link to the provided | |||
// default url, handling for special links. | |||
func RenderCommitMessageLink(ctx context.Context, msg, urlPrefix, urlDefault string, metas map[string]string) template.HTML { | |||
cleanMsg := template.HTMLEscapeString(msg) | |||
// we can safely assume that it will not return any error, since there | |||
// shouldn't be any special HTML. | |||
fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
DefaultLink: urlDefault, | |||
Metas: metas, | |||
}, cleanMsg) | |||
if err != nil { | |||
log.Error("RenderCommitMessage: %v", err) | |||
return "" | |||
} | |||
msgLines := strings.Split(strings.TrimSpace(fullMessage), "\n") | |||
if len(msgLines) == 0 { | |||
return template.HTML("") | |||
} | |||
return template.HTML(msgLines[0]) | |||
} | |||
// RenderCommitMessageLinkSubject renders commit message as a XXS-safe link to | |||
// the provided default url, handling for special links without email to links. | |||
func RenderCommitMessageLinkSubject(ctx context.Context, msg, urlPrefix, urlDefault string, metas map[string]string) template.HTML { | |||
msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace) | |||
lineEnd := strings.IndexByte(msgLine, '\n') | |||
if lineEnd > 0 { | |||
msgLine = msgLine[:lineEnd] | |||
} | |||
msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace) | |||
if len(msgLine) == 0 { | |||
return template.HTML("") | |||
} | |||
// we can safely assume that it will not return any error, since there | |||
// shouldn't be any special HTML. | |||
renderedMessage, err := markup.RenderCommitMessageSubject(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
DefaultLink: urlDefault, | |||
Metas: metas, | |||
}, template.HTMLEscapeString(msgLine)) | |||
if err != nil { | |||
log.Error("RenderCommitMessageSubject: %v", err) | |||
return template.HTML("") | |||
} | |||
return template.HTML(renderedMessage) | |||
} | |||
// RenderCommitBody extracts the body of a commit message without its title. | |||
func RenderCommitBody(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { | |||
msgLine := strings.TrimRightFunc(msg, unicode.IsSpace) | |||
lineEnd := strings.IndexByte(msgLine, '\n') | |||
if lineEnd > 0 { | |||
msgLine = msgLine[lineEnd+1:] | |||
} else { | |||
return template.HTML("") | |||
} | |||
msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace) | |||
if len(msgLine) == 0 { | |||
return template.HTML("") | |||
} | |||
renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
Metas: metas, | |||
}, template.HTMLEscapeString(msgLine)) | |||
if err != nil { | |||
log.Error("RenderCommitMessage: %v", err) | |||
return "" | |||
} | |||
return template.HTML(renderedMessage) | |||
} | |||
// Match text that is between back ticks. | |||
var codeMatcher = regexp.MustCompile("`([^`]+)`") | |||
// RenderCodeBlock renders "`…`" as highlighted "<code>" block. | |||
// Intended for issue and PR titles, these containers should have styles for "<code>" elements | |||
func RenderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML { | |||
htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), "<code>$1</code>") // replace with HTML <code> tags | |||
return template.HTML(htmlWithCodeTags) | |||
} | |||
// RenderIssueTitle renders issue/pull title with defined post processors | |||
func RenderIssueTitle(ctx context.Context, text, urlPrefix string, metas map[string]string) template.HTML { | |||
renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
Metas: metas, | |||
}, template.HTMLEscapeString(text)) | |||
if err != nil { | |||
log.Error("RenderIssueTitle: %v", err) | |||
return template.HTML("") | |||
} | |||
return template.HTML(renderedText) | |||
} | |||
// RenderLabel renders a label | |||
func RenderLabel(ctx context.Context, label *issues_model.Label) string { | |||
labelScope := label.ExclusiveScope() | |||
textColor := "#111" | |||
if label.UseLightTextColor() { | |||
textColor = "#eee" | |||
} | |||
description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) | |||
if labelScope == "" { | |||
// Regular label | |||
return fmt.Sprintf("<div class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</div>", | |||
textColor, label.Color, description, RenderEmoji(ctx, label.Name)) | |||
} | |||
// Scoped label | |||
scopeText := RenderEmoji(ctx, labelScope) | |||
itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:]) | |||
itemColor := label.Color | |||
scopeColor := label.Color | |||
if r, g, b, err := label.ColorRGB(); err == nil { | |||
// Make scope and item background colors slightly darker and lighter respectively. | |||
// More contrast needed with higher luminance, empirically tweaked. | |||
luminance := (0.299*r + 0.587*g + 0.114*b) / 255 | |||
contrast := 0.01 + luminance*0.03 | |||
// Ensure we add the same amount of contrast also near 0 and 1. | |||
darken := contrast + math.Max(luminance+contrast-1.0, 0.0) | |||
lighten := contrast + math.Max(contrast-luminance, 0.0) | |||
// Compute factor to keep RGB values proportional. | |||
darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0) | |||
lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) | |||
scopeBytes := []byte{ | |||
uint8(math.Min(math.Round(r*darkenFactor), 255)), | |||
uint8(math.Min(math.Round(g*darkenFactor), 255)), | |||
uint8(math.Min(math.Round(b*darkenFactor), 255)), | |||
} | |||
itemBytes := []byte{ | |||
uint8(math.Min(math.Round(r*lightenFactor), 255)), | |||
uint8(math.Min(math.Round(g*lightenFactor), 255)), | |||
uint8(math.Min(math.Round(b*lightenFactor), 255)), | |||
} | |||
itemColor = "#" + hex.EncodeToString(itemBytes) | |||
scopeColor = "#" + hex.EncodeToString(scopeBytes) | |||
} | |||
return fmt.Sprintf("<span class='ui label scope-parent' title='%s'>"+ | |||
"<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+ | |||
"<div class='ui label scope-right' style='color: %s !important; background-color: %s !important''>%s</div>"+ | |||
"</span>", | |||
description, | |||
textColor, scopeColor, scopeText, | |||
textColor, itemColor, itemText) | |||
} | |||
// RenderEmoji renders html text with emoji post processors | |||
func RenderEmoji(ctx context.Context, text string) template.HTML { | |||
renderedText, err := markup.RenderEmoji(&markup.RenderContext{Ctx: ctx}, | |||
template.HTMLEscapeString(text)) | |||
if err != nil { | |||
log.Error("RenderEmoji: %v", err) | |||
return template.HTML("") | |||
} | |||
return template.HTML(renderedText) | |||
} | |||
// ReactionToEmoji renders emoji for use in reactions | |||
func ReactionToEmoji(reaction string) template.HTML { | |||
val := emoji.FromCode(reaction) | |||
if val != nil { | |||
return template.HTML(val.Emoji) | |||
} | |||
val = emoji.FromAlias(reaction) | |||
if val != nil { | |||
return template.HTML(val.Emoji) | |||
} | |||
return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction))) | |||
} | |||
// RenderNote renders the contents of a git-notes file as a commit message. | |||
func RenderNote(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { | |||
cleanMsg := template.HTMLEscapeString(msg) | |||
fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
Metas: metas, | |||
}, cleanMsg) | |||
if err != nil { | |||
log.Error("RenderNote: %v", err) | |||
return "" | |||
} | |||
return template.HTML(fullMessage) | |||
} | |||
// IsMultilineCommitMessage checks to see if a commit message contains multiple lines. | |||
func IsMultilineCommitMessage(msg string) bool { | |||
return strings.Count(strings.TrimSpace(msg), "\n") >= 1 | |||
} | |||
// Actioner describes an action | |||
type Actioner interface { | |||
GetOpType() activities_model.ActionType | |||
GetActUserName() string | |||
GetRepoUserName() string | |||
GetRepoName() string | |||
GetRepoPath() string | |||
GetRepoLink() string | |||
GetBranch() string | |||
GetContent() string | |||
GetCreate() time.Time | |||
GetIssueInfos() []string | |||
} | |||
// ActionIcon accepts an action operation type and returns an icon class name. | |||
func ActionIcon(opType activities_model.ActionType) string { | |||
switch opType { | |||
case activities_model.ActionCreateRepo, activities_model.ActionTransferRepo, activities_model.ActionRenameRepo: | |||
return "repo" | |||
case activities_model.ActionCommitRepo, activities_model.ActionPushTag, activities_model.ActionDeleteTag, activities_model.ActionDeleteBranch: | |||
return "git-commit" | |||
case activities_model.ActionCreateIssue: | |||
return "issue-opened" | |||
case activities_model.ActionCreatePullRequest: | |||
return "git-pull-request" | |||
case activities_model.ActionCommentIssue, activities_model.ActionCommentPull: | |||
return "comment-discussion" | |||
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: | |||
return "git-merge" | |||
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest: | |||
return "issue-closed" | |||
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest: | |||
return "issue-reopened" | |||
case activities_model.ActionMirrorSyncPush, activities_model.ActionMirrorSyncCreate, activities_model.ActionMirrorSyncDelete: | |||
return "mirror" | |||
case activities_model.ActionApprovePullRequest: | |||
return "check" | |||
case activities_model.ActionRejectPullRequest: | |||
return "diff" | |||
case activities_model.ActionPublishRelease: | |||
return "tag" | |||
case activities_model.ActionPullReviewDismissed: | |||
return "x" | |||
default: | |||
return "question" | |||
} | |||
} | |||
// ActionContent2Commits converts action content to push commits | |||
func ActionContent2Commits(act Actioner) *repository.PushCommits { | |||
push := repository.NewPushCommits() | |||
if act == nil || act.GetContent() == "" { | |||
return push | |||
} | |||
if err := json.Unmarshal([]byte(act.GetContent()), push); err != nil { | |||
log.Error("json.Unmarshal:\n%s\nERROR: %v", act.GetContent(), err) | |||
} | |||
if push.Len == 0 { | |||
push.Len = len(push.Commits) | |||
} | |||
return push | |||
} | |||
// DiffLineTypeToStr returns diff line type name | |||
func DiffLineTypeToStr(diffType int) string { | |||
switch diffType { | |||
case 2: | |||
return "add" | |||
case 3: | |||
return "del" | |||
case 4: | |||
return "tag" | |||
} | |||
return "same" | |||
} | |||
// MigrationIcon returns a SVG name matching the service an issue/comment was migrated from | |||
func MigrationIcon(hostname string) string { | |||
switch hostname { | |||
case "github.com": | |||
return "octicon-mark-github" | |||
default: | |||
return "gitea-git" | |||
} | |||
} | |||
type remoteAddress struct { | |||
Address string | |||
Username string | |||
Password string | |||
} | |||
func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteName string, ignoreOriginalURL bool) remoteAddress { | |||
a := remoteAddress{} | |||
remoteURL := m.OriginalURL | |||
if ignoreOriginalURL || remoteURL == "" { | |||
var err error | |||
remoteURL, err = git.GetRemoteAddress(ctx, m.RepoPath(), remoteName) | |||
if err != nil { | |||
log.Error("GetRemoteURL %v", err) | |||
return a | |||
} | |||
} | |||
u, err := giturl.Parse(remoteURL) | |||
if err != nil { | |||
log.Error("giturl.Parse %v", err) | |||
return a | |||
} | |||
if u.Scheme != "ssh" && u.Scheme != "file" { | |||
if u.User != nil { | |||
a.Username = u.User.Username() | |||
a.Password, _ = u.User.Password() | |||
} | |||
u.User = nil | |||
} | |||
a.Address = u.String() | |||
return a | |||
} | |||
// Eval the expression and return the result, see the comment of eval.Expr for details. | |||
// To use this helper function in templates, pass each token as a separate parameter. | |||
// |
@@ -0,0 +1,84 @@ | |||
// Copyright 2023 The Gitea Authors. All rights reserved. | |||
// SPDX-License-Identifier: MIT | |||
package templates | |||
import ( | |||
"context" | |||
"fmt" | |||
"html" | |||
"html/template" | |||
activities_model "code.gitea.io/gitea/models/activities" | |||
"code.gitea.io/gitea/models/avatars" | |||
"code.gitea.io/gitea/models/organization" | |||
repo_model "code.gitea.io/gitea/models/repo" | |||
user_model "code.gitea.io/gitea/models/user" | |||
gitea_html "code.gitea.io/gitea/modules/html" | |||
"code.gitea.io/gitea/modules/setting" | |||
) | |||
// AvatarHTML creates the HTML for an avatar | |||
func AvatarHTML(src string, size int, class, name string) template.HTML { | |||
sizeStr := fmt.Sprintf(`%d`, size) | |||
if name == "" { | |||
name = "avatar" | |||
} | |||
return template.HTML(`<img class="` + class + `" src="` + src + `" title="` + html.EscapeString(name) + `" width="` + sizeStr + `" height="` + sizeStr + `"/>`) | |||
} | |||
// Avatar renders user avatars. args: user, size (int), class (string) | |||
func Avatar(ctx context.Context, item interface{}, others ...interface{}) template.HTML { | |||
size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) | |||
switch t := item.(type) { | |||
case *user_model.User: | |||
src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) | |||
if src != "" { | |||
return AvatarHTML(src, size, class, t.DisplayName()) | |||
} | |||
case *repo_model.Collaborator: | |||
src := t.AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) | |||
if src != "" { | |||
return AvatarHTML(src, size, class, t.DisplayName()) | |||
} | |||
case *organization.Organization: | |||
src := t.AsUser().AvatarLinkWithSize(ctx, size*setting.Avatar.RenderedSizeFactor) | |||
if src != "" { | |||
return AvatarHTML(src, size, class, t.AsUser().DisplayName()) | |||
} | |||
} | |||
return template.HTML("") | |||
} | |||
// AvatarByAction renders user avatars from action. args: action, size (int), class (string) | |||
func AvatarByAction(ctx context.Context, action *activities_model.Action, others ...interface{}) template.HTML { | |||
action.LoadActUser(ctx) | |||
return Avatar(ctx, action.ActUser, others...) | |||
} | |||
// RepoAvatar renders repo avatars. args: repo, size(int), class (string) | |||
func RepoAvatar(repo *repo_model.Repository, others ...interface{}) template.HTML { | |||
size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) | |||
src := repo.RelAvatarLink() | |||
if src != "" { | |||
return AvatarHTML(src, size, class, repo.FullName()) | |||
} | |||
return template.HTML("") | |||
} | |||
// AvatarByEmail renders avatars by email address. args: email, name, size (int), class (string) | |||
func AvatarByEmail(ctx context.Context, email, name string, others ...interface{}) template.HTML { | |||
size, class := gitea_html.ParseSizeAndClass(avatars.DefaultAvatarPixelSize, avatars.DefaultAvatarClass, others...) | |||
src := avatars.GenerateEmailAvatarFastLink(ctx, email, size*setting.Avatar.RenderedSizeFactor) | |||
if src != "" { | |||
return AvatarHTML(src, size, class, name) | |||
} | |||
return template.HTML("") | |||
} |
@@ -0,0 +1,35 @@ | |||
// Copyright 2023 The Gitea Authors. All rights reserved. | |||
// SPDX-License-Identifier: MIT | |||
package templates | |||
import ( | |||
"bytes" | |||
"code.gitea.io/gitea/modules/json" | |||
) | |||
type JsonUtils struct{} //nolint:revive | |||
var jsonUtils = JsonUtils{} | |||
func NewJsonUtils() *JsonUtils { //nolint:revive | |||
return &jsonUtils | |||
} | |||
func (su *JsonUtils) EncodeToString(v any) string { | |||
out, err := json.Marshal(v) | |||
if err != nil { | |||
return "" | |||
} | |||
return string(out) | |||
} | |||
func (su *JsonUtils) PrettyIndent(s string) string { | |||
var out bytes.Buffer | |||
err := json.Indent(&out, []byte(s), "", " ") | |||
if err != nil { | |||
return "" | |||
} | |||
return out.String() | |||
} |
@@ -0,0 +1,209 @@ | |||
// Copyright 2023 The Gitea Authors. All rights reserved. | |||
// SPDX-License-Identifier: MIT | |||
package templates | |||
import ( | |||
"context" | |||
"fmt" | |||
"html/template" | |||
"mime" | |||
"path/filepath" | |||
"strings" | |||
"time" | |||
activities_model "code.gitea.io/gitea/models/activities" | |||
repo_model "code.gitea.io/gitea/models/repo" | |||
"code.gitea.io/gitea/modules/git" | |||
giturl "code.gitea.io/gitea/modules/git/url" | |||
"code.gitea.io/gitea/modules/json" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/repository" | |||
"code.gitea.io/gitea/modules/svg" | |||
"github.com/editorconfig/editorconfig-core-go/v2" | |||
) | |||
func SortArrow(normSort, revSort, urlSort string, isDefault bool) template.HTML { | |||
// if needed | |||
if len(normSort) == 0 || len(urlSort) == 0 { | |||
return "" | |||
} | |||
if len(urlSort) == 0 && isDefault { | |||
// if sort is sorted as default add arrow tho this table header | |||
if isDefault { | |||
return svg.RenderHTML("octicon-triangle-down", 16) | |||
} | |||
} else { | |||
// if sort arg is in url test if it correlates with column header sort arguments | |||
// the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev) | |||
if urlSort == normSort { | |||
// the table is sorted with this header normal | |||
return svg.RenderHTML("octicon-triangle-up", 16) | |||
} else if urlSort == revSort { | |||
// the table is sorted with this header reverse | |||
return svg.RenderHTML("octicon-triangle-down", 16) | |||
} | |||
} | |||
// the table is NOT sorted with this header | |||
return "" | |||
} | |||
// IsMultilineCommitMessage checks to see if a commit message contains multiple lines. | |||
func IsMultilineCommitMessage(msg string) bool { | |||
return strings.Count(strings.TrimSpace(msg), "\n") >= 1 | |||
} | |||
// Actioner describes an action | |||
type Actioner interface { | |||
GetOpType() activities_model.ActionType | |||
GetActUserName() string | |||
GetRepoUserName() string | |||
GetRepoName() string | |||
GetRepoPath() string | |||
GetRepoLink() string | |||
GetBranch() string | |||
GetContent() string | |||
GetCreate() time.Time | |||
GetIssueInfos() []string | |||
} | |||
// ActionIcon accepts an action operation type and returns an icon class name. | |||
func ActionIcon(opType activities_model.ActionType) string { | |||
switch opType { | |||
case activities_model.ActionCreateRepo, activities_model.ActionTransferRepo, activities_model.ActionRenameRepo: | |||
return "repo" | |||
case activities_model.ActionCommitRepo, activities_model.ActionPushTag, activities_model.ActionDeleteTag, activities_model.ActionDeleteBranch: | |||
return "git-commit" | |||
case activities_model.ActionCreateIssue: | |||
return "issue-opened" | |||
case activities_model.ActionCreatePullRequest: | |||
return "git-pull-request" | |||
case activities_model.ActionCommentIssue, activities_model.ActionCommentPull: | |||
return "comment-discussion" | |||
case activities_model.ActionMergePullRequest, activities_model.ActionAutoMergePullRequest: | |||
return "git-merge" | |||
case activities_model.ActionCloseIssue, activities_model.ActionClosePullRequest: | |||
return "issue-closed" | |||
case activities_model.ActionReopenIssue, activities_model.ActionReopenPullRequest: | |||
return "issue-reopened" | |||
case activities_model.ActionMirrorSyncPush, activities_model.ActionMirrorSyncCreate, activities_model.ActionMirrorSyncDelete: | |||
return "mirror" | |||
case activities_model.ActionApprovePullRequest: | |||
return "check" | |||
case activities_model.ActionRejectPullRequest: | |||
return "diff" | |||
case activities_model.ActionPublishRelease: | |||
return "tag" | |||
case activities_model.ActionPullReviewDismissed: | |||
return "x" | |||
default: | |||
return "question" | |||
} | |||
} | |||
// ActionContent2Commits converts action content to push commits | |||
func ActionContent2Commits(act Actioner) *repository.PushCommits { | |||
push := repository.NewPushCommits() | |||
if act == nil || act.GetContent() == "" { | |||
return push | |||
} | |||
if err := json.Unmarshal([]byte(act.GetContent()), push); err != nil { | |||
log.Error("json.Unmarshal:\n%s\nERROR: %v", act.GetContent(), err) | |||
} | |||
if push.Len == 0 { | |||
push.Len = len(push.Commits) | |||
} | |||
return push | |||
} | |||
// DiffLineTypeToStr returns diff line type name | |||
func DiffLineTypeToStr(diffType int) string { | |||
switch diffType { | |||
case 2: | |||
return "add" | |||
case 3: | |||
return "del" | |||
case 4: | |||
return "tag" | |||
} | |||
return "same" | |||
} | |||
// MigrationIcon returns a SVG name matching the service an issue/comment was migrated from | |||
func MigrationIcon(hostname string) string { | |||
switch hostname { | |||
case "github.com": | |||
return "octicon-mark-github" | |||
default: | |||
return "gitea-git" | |||
} | |||
} | |||
type remoteAddress struct { | |||
Address string | |||
Username string | |||
Password string | |||
} | |||
func mirrorRemoteAddress(ctx context.Context, m *repo_model.Repository, remoteName string, ignoreOriginalURL bool) remoteAddress { | |||
a := remoteAddress{} | |||
remoteURL := m.OriginalURL | |||
if ignoreOriginalURL || remoteURL == "" { | |||
var err error | |||
remoteURL, err = git.GetRemoteAddress(ctx, m.RepoPath(), remoteName) | |||
if err != nil { | |||
log.Error("GetRemoteURL %v", err) | |||
return a | |||
} | |||
} | |||
u, err := giturl.Parse(remoteURL) | |||
if err != nil { | |||
log.Error("giturl.Parse %v", err) | |||
return a | |||
} | |||
if u.Scheme != "ssh" && u.Scheme != "file" { | |||
if u.User != nil { | |||
a.Username = u.User.Username() | |||
a.Password, _ = u.User.Password() | |||
} | |||
u.User = nil | |||
} | |||
a.Address = u.String() | |||
return a | |||
} | |||
func FilenameIsImage(filename string) bool { | |||
mimeType := mime.TypeByExtension(filepath.Ext(filename)) | |||
return strings.HasPrefix(mimeType, "image/") | |||
} | |||
func TabSizeClass(ec interface{}, filename string) string { | |||
var ( | |||
value *editorconfig.Editorconfig | |||
ok bool | |||
) | |||
if ec != nil { | |||
if value, ok = ec.(*editorconfig.Editorconfig); !ok || value == nil { | |||
return "tab-size-8" | |||
} | |||
def, err := value.GetDefinitionForFilename(filename) | |||
if err != nil { | |||
log.Error("tab size class: getting definition for filename: %v", err) | |||
return "tab-size-8" | |||
} | |||
if def.TabWidth > 0 { | |||
return fmt.Sprintf("tab-size-%d", def.TabWidth) | |||
} | |||
} | |||
return "tab-size-8" | |||
} |
@@ -0,0 +1,254 @@ | |||
// Copyright 2023 The Gitea Authors. All rights reserved. | |||
// SPDX-License-Identifier: MIT | |||
package templates | |||
import ( | |||
"context" | |||
"encoding/hex" | |||
"fmt" | |||
"html/template" | |||
"math" | |||
"net/url" | |||
"regexp" | |||
"strings" | |||
"unicode" | |||
issues_model "code.gitea.io/gitea/models/issues" | |||
"code.gitea.io/gitea/modules/emoji" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/markup" | |||
"code.gitea.io/gitea/modules/markup/markdown" | |||
"code.gitea.io/gitea/modules/setting" | |||
) | |||
// RenderCommitMessage renders commit message with XSS-safe and special links. | |||
func RenderCommitMessage(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { | |||
return RenderCommitMessageLink(ctx, msg, urlPrefix, "", metas) | |||
} | |||
// RenderCommitMessageLink renders commit message as a XXS-safe link to the provided | |||
// default url, handling for special links. | |||
func RenderCommitMessageLink(ctx context.Context, msg, urlPrefix, urlDefault string, metas map[string]string) template.HTML { | |||
cleanMsg := template.HTMLEscapeString(msg) | |||
// we can safely assume that it will not return any error, since there | |||
// shouldn't be any special HTML. | |||
fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
DefaultLink: urlDefault, | |||
Metas: metas, | |||
}, cleanMsg) | |||
if err != nil { | |||
log.Error("RenderCommitMessage: %v", err) | |||
return "" | |||
} | |||
msgLines := strings.Split(strings.TrimSpace(fullMessage), "\n") | |||
if len(msgLines) == 0 { | |||
return template.HTML("") | |||
} | |||
return template.HTML(msgLines[0]) | |||
} | |||
// RenderCommitMessageLinkSubject renders commit message as a XXS-safe link to | |||
// the provided default url, handling for special links without email to links. | |||
func RenderCommitMessageLinkSubject(ctx context.Context, msg, urlPrefix, urlDefault string, metas map[string]string) template.HTML { | |||
msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace) | |||
lineEnd := strings.IndexByte(msgLine, '\n') | |||
if lineEnd > 0 { | |||
msgLine = msgLine[:lineEnd] | |||
} | |||
msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace) | |||
if len(msgLine) == 0 { | |||
return template.HTML("") | |||
} | |||
// we can safely assume that it will not return any error, since there | |||
// shouldn't be any special HTML. | |||
renderedMessage, err := markup.RenderCommitMessageSubject(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
DefaultLink: urlDefault, | |||
Metas: metas, | |||
}, template.HTMLEscapeString(msgLine)) | |||
if err != nil { | |||
log.Error("RenderCommitMessageSubject: %v", err) | |||
return template.HTML("") | |||
} | |||
return template.HTML(renderedMessage) | |||
} | |||
// RenderCommitBody extracts the body of a commit message without its title. | |||
func RenderCommitBody(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { | |||
msgLine := strings.TrimRightFunc(msg, unicode.IsSpace) | |||
lineEnd := strings.IndexByte(msgLine, '\n') | |||
if lineEnd > 0 { | |||
msgLine = msgLine[lineEnd+1:] | |||
} else { | |||
return template.HTML("") | |||
} | |||
msgLine = strings.TrimLeftFunc(msgLine, unicode.IsSpace) | |||
if len(msgLine) == 0 { | |||
return template.HTML("") | |||
} | |||
renderedMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
Metas: metas, | |||
}, template.HTMLEscapeString(msgLine)) | |||
if err != nil { | |||
log.Error("RenderCommitMessage: %v", err) | |||
return "" | |||
} | |||
return template.HTML(renderedMessage) | |||
} | |||
// Match text that is between back ticks. | |||
var codeMatcher = regexp.MustCompile("`([^`]+)`") | |||
// RenderCodeBlock renders "`…`" as highlighted "<code>" block. | |||
// Intended for issue and PR titles, these containers should have styles for "<code>" elements | |||
func RenderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML { | |||
htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), "<code>$1</code>") // replace with HTML <code> tags | |||
return template.HTML(htmlWithCodeTags) | |||
} | |||
// RenderIssueTitle renders issue/pull title with defined post processors | |||
func RenderIssueTitle(ctx context.Context, text, urlPrefix string, metas map[string]string) template.HTML { | |||
renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
Metas: metas, | |||
}, template.HTMLEscapeString(text)) | |||
if err != nil { | |||
log.Error("RenderIssueTitle: %v", err) | |||
return template.HTML("") | |||
} | |||
return template.HTML(renderedText) | |||
} | |||
// RenderLabel renders a label | |||
func RenderLabel(ctx context.Context, label *issues_model.Label) template.HTML { | |||
labelScope := label.ExclusiveScope() | |||
textColor := "#111" | |||
if label.UseLightTextColor() { | |||
textColor = "#eee" | |||
} | |||
description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) | |||
if labelScope == "" { | |||
// Regular label | |||
s := fmt.Sprintf("<div class='ui label' style='color: %s !important; background-color: %s !important' title='%s'>%s</div>", | |||
textColor, label.Color, description, RenderEmoji(ctx, label.Name)) | |||
return template.HTML(s) | |||
} | |||
// Scoped label | |||
scopeText := RenderEmoji(ctx, labelScope) | |||
itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:]) | |||
itemColor := label.Color | |||
scopeColor := label.Color | |||
if r, g, b, err := label.ColorRGB(); err == nil { | |||
// Make scope and item background colors slightly darker and lighter respectively. | |||
// More contrast needed with higher luminance, empirically tweaked. | |||
luminance := (0.299*r + 0.587*g + 0.114*b) / 255 | |||
contrast := 0.01 + luminance*0.03 | |||
// Ensure we add the same amount of contrast also near 0 and 1. | |||
darken := contrast + math.Max(luminance+contrast-1.0, 0.0) | |||
lighten := contrast + math.Max(contrast-luminance, 0.0) | |||
// Compute factor to keep RGB values proportional. | |||
darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0) | |||
lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) | |||
scopeBytes := []byte{ | |||
uint8(math.Min(math.Round(r*darkenFactor), 255)), | |||
uint8(math.Min(math.Round(g*darkenFactor), 255)), | |||
uint8(math.Min(math.Round(b*darkenFactor), 255)), | |||
} | |||
itemBytes := []byte{ | |||
uint8(math.Min(math.Round(r*lightenFactor), 255)), | |||
uint8(math.Min(math.Round(g*lightenFactor), 255)), | |||
uint8(math.Min(math.Round(b*lightenFactor), 255)), | |||
} | |||
itemColor = "#" + hex.EncodeToString(itemBytes) | |||
scopeColor = "#" + hex.EncodeToString(scopeBytes) | |||
} | |||
s := fmt.Sprintf("<span class='ui label scope-parent' title='%s'>"+ | |||
"<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+ | |||
"<div class='ui label scope-right' style='color: %s !important; background-color: %s !important''>%s</div>"+ | |||
"</span>", | |||
description, | |||
textColor, scopeColor, scopeText, | |||
textColor, itemColor, itemText) | |||
return template.HTML(s) | |||
} | |||
// RenderEmoji renders html text with emoji post processors | |||
func RenderEmoji(ctx context.Context, text string) template.HTML { | |||
renderedText, err := markup.RenderEmoji(&markup.RenderContext{Ctx: ctx}, | |||
template.HTMLEscapeString(text)) | |||
if err != nil { | |||
log.Error("RenderEmoji: %v", err) | |||
return template.HTML("") | |||
} | |||
return template.HTML(renderedText) | |||
} | |||
// ReactionToEmoji renders emoji for use in reactions | |||
func ReactionToEmoji(reaction string) template.HTML { | |||
val := emoji.FromCode(reaction) | |||
if val != nil { | |||
return template.HTML(val.Emoji) | |||
} | |||
val = emoji.FromAlias(reaction) | |||
if val != nil { | |||
return template.HTML(val.Emoji) | |||
} | |||
return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction))) | |||
} | |||
// RenderNote renders the contents of a git-notes file as a commit message. | |||
func RenderNote(ctx context.Context, msg, urlPrefix string, metas map[string]string) template.HTML { | |||
cleanMsg := template.HTMLEscapeString(msg) | |||
fullMessage, err := markup.RenderCommitMessage(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: urlPrefix, | |||
Metas: metas, | |||
}, cleanMsg) | |||
if err != nil { | |||
log.Error("RenderNote: %v", err) | |||
return "" | |||
} | |||
return template.HTML(fullMessage) | |||
} | |||
func RenderMarkdownToHtml(ctx context.Context, input string) template.HTML { //nolint:revive | |||
output, err := markdown.RenderString(&markup.RenderContext{ | |||
Ctx: ctx, | |||
URLPrefix: setting.AppSubURL, | |||
}, input) | |||
if err != nil { | |||
log.Error("RenderString: %v", err) | |||
} | |||
return template.HTML(output) | |||
} | |||
func RenderLabels(ctx context.Context, labels []*issues_model.Label, repoLink string) template.HTML { | |||
htmlCode := `<span class="labels-list">` | |||
for _, label := range labels { | |||
// Protect against nil value in labels - shouldn't happen but would cause a panic if so | |||
if label == nil { | |||
continue | |||
} | |||
htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ", | |||
repoLink, label.ID, RenderLabel(ctx, label)) | |||
} | |||
htmlCode += "</span>" | |||
return template.HTML(htmlCode) | |||
} |
@@ -3,12 +3,18 @@ | |||
package templates | |||
import "strings" | |||
import ( | |||
"strings" | |||
"code.gitea.io/gitea/modules/base" | |||
) | |||
type StringUtils struct{} | |||
var stringUtils = StringUtils{} | |||
func NewStringUtils() *StringUtils { | |||
return &StringUtils{} | |||
return &stringUtils | |||
} | |||
func (su *StringUtils) HasPrefix(s, prefix string) bool { | |||
@@ -22,3 +28,11 @@ func (su *StringUtils) Contains(s, substr string) bool { | |||
func (su *StringUtils) Split(s, sep string) []string { | |||
return strings.Split(s, sep) | |||
} | |||
func (su *StringUtils) Join(a []string, sep string) string { | |||
return strings.Join(a, sep) | |||
} | |||
func (su *StringUtils) EllipsisString(s string, max int) string { | |||
return base.EllipsisString(s, max) | |||
} |
@@ -334,7 +334,7 @@ | |||
<div class="field"> | |||
<label for="oauth2_scopes">{{.locale.Tr "admin.auths.oauth2_scopes"}}</label> | |||
<input id="oauth2_scopes" name="oauth2_scopes" value="{{if $cfg.Scopes}}{{Join $cfg.Scopes ","}}{{end}}"> | |||
<input id="oauth2_scopes" name="oauth2_scopes" value="{{if $cfg.Scopes}}{{StringUtils.Join $cfg.Scopes ","}}{{end}}"> | |||
</div> | |||
<div class="field"> | |||
<label for="oauth2_required_claim_name">{{.locale.Tr "admin.auths.oauth2_required_claim_name"}}</label> |
@@ -365,7 +365,7 @@ | |||
<dt>{{$.locale.Tr "admin.config.log_mode"}}</dt> | |||
<dd>{{.Name}} ({{.Provider}})</dd> | |||
<dt>{{$.locale.Tr "admin.config.log_config"}}</dt> | |||
<dd><pre>{{.Config | JsonPrettyPrint}}</pre></dd> | |||
<dd><pre>{{JsonUtils.PrettyIndent .Config}}</pre></dd> | |||
{{end}} | |||
<div class="ui divider"></div> | |||
<dt>{{$.locale.Tr "admin.config.router_log_mode"}}</dt> | |||
@@ -378,7 +378,7 @@ | |||
<dt>{{$.locale.Tr "admin.config.log_mode"}}</dt> | |||
<dd>{{.Name}} ({{.Provider}})</dd> | |||
<dt>{{$.locale.Tr "admin.config.log_config"}}</dt> | |||
<dd><pre>{{.Config | JsonPrettyPrint}}</pre></dd> | |||
<dd><pre>{{JsonUtils.PrettyIndent .Config}}</pre></dd> | |||
{{end}} | |||
{{else}} | |||
<dd>{{$.locale.Tr "admin.config.routes_to_default_logger"}}</dd> | |||
@@ -393,7 +393,7 @@ | |||
<dt>{{$.locale.Tr "admin.config.log_mode"}}</dt> | |||
<dd>{{.Name}} ({{.Provider}})</dd> | |||
<dt>{{$.locale.Tr "admin.config.log_config"}}</dt> | |||
<dd><pre>{{.Config | JsonPrettyPrint}}</pre></dd> | |||
<dd><pre>{{JsonUtils.PrettyIndent .Config}}</pre></dd> | |||
{{end}} | |||
{{else}} | |||
<dd>{{$.locale.Tr "admin.config.routes_to_default_logger"}}</dd> | |||
@@ -412,7 +412,7 @@ | |||
<dt>{{$.locale.Tr "admin.config.log_mode"}}</dt> | |||
<dd>{{.Name}} ({{.Provider}})</dd> | |||
<dt>{{$.locale.Tr "admin.config.log_config"}}</dt> | |||
<dd><pre>{{.Config | JsonPrettyPrint}}</pre></dd> | |||
<dd><pre>{{JsonUtils.PrettyIndent .Config}}</pre></dd> | |||
{{end}} | |||
{{else}} | |||
<dd>{{$.locale.Tr "admin.config.routes_to_default_logger"}}</dd> |
@@ -174,7 +174,7 @@ | |||
{{.locale.Tr "admin.monitor.queue.configuration"}} | |||
</h4> | |||
<div class="ui attached segment"> | |||
<pre>{{.Queue.Configuration | JsonPrettyPrint}}</pre> | |||
<pre>{{JsonUtils.PrettyIndent .Queue.Configuration}}</pre> | |||
</div> | |||
</div> | |||
@@ -22,9 +22,9 @@ | |||
<a class="item" href="{{$.Link}}/rules/{{.ID}}"><strong>{{.Type.Name}}</strong></a> | |||
<div><i>{{if .Enabled}}{{$.locale.Tr "enabled"}}{{else}}{{$.locale.Tr "disabled"}}{{end}}</i></div> | |||
{{if .KeepCount}}<div><i>{{$.locale.Tr "packages.owner.settings.cleanuprules.keep.count"}}:</i> {{if eq .KeepCount 1}}{{$.locale.Tr "packages.owner.settings.cleanuprules.keep.count.1"}}{{else}}{{$.locale.Tr "packages.owner.settings.cleanuprules.keep.count.n" .KeepCount}}{{end}}</div>{{end}} | |||
{{if .KeepPattern}}<div><i>{{$.locale.Tr "packages.owner.settings.cleanuprules.keep.pattern"}}:</i> {{EllipsisString .KeepPattern 100}}</div>{{end}} | |||
{{if .KeepPattern}}<div><i>{{$.locale.Tr "packages.owner.settings.cleanuprules.keep.pattern"}}:</i> {{StringUtils.EllipsisString .KeepPattern 100}}</div>{{end}} | |||
{{if .RemoveDays}}<div><i>{{$.locale.Tr "packages.owner.settings.cleanuprules.remove.days"}}:</i> {{$.locale.Tr "tool.days" .RemoveDays}}</div>{{end}} | |||
{{if .RemovePattern}}<div><i>{{$.locale.Tr "packages.owner.settings.cleanuprules.remove.pattern"}}:</i> {{EllipsisString .RemovePattern 100}}</div>{{end}} | |||
{{if .RemovePattern}}<div><i>{{$.locale.Tr "packages.owner.settings.cleanuprules.remove.pattern"}}:</i> {{StringUtils.EllipsisString .RemovePattern 100}}</div>{{end}} | |||
</div> | |||
</div> | |||
{{else}} |
@@ -68,7 +68,13 @@ | |||
{{$l := Eval $n "-" 1}} | |||
<!-- If home page, show new pr. If not, show breadcrumb --> | |||
{{if and (eq $n 0) .CanCompareOrPull .IsViewBranch (not .Repository.IsArchived)}} | |||
<a id="new-pull-request" role="button" class="ui compact basic button" href="{{CompareLink .BaseRepo .Repository .BranchName}}" | |||
{{$cmpBranch := ""}} | |||
{{if ne .Repository.ID .BaseRepo.ID}} | |||
{{$cmpBranch = printf "%s/%s:" (.Repository.OwnerName|PathEscape) (.Repository.Name|PathEscape)}} | |||
{{end}} | |||
{{$cmpBranch = printf "%s%s" $cmpBranch (.BranchName|PathEscapeSegments)}} | |||
{{$compareLink := printf "%s/compare/%s...%s" .BaseRepo.Link (.BaseRepo.DefaultBranch|PathEscapeSegments) $cmpBranch}} | |||
<a id="new-pull-request" role="button" class="ui compact basic button" href="{{$compareLink}}" | |||
data-tooltip-content="{{if .PullRequestCtx.Allowed}}{{.locale.Tr "repo.pulls.compare_changes"}}{{else}}{{.locale.Tr "action.compare_branch"}}{{end}}"> | |||
{{svg "octicon-git-pull-request"}} | |||
</a> | |||
@@ -103,7 +109,17 @@ | |||
</a> | |||
{{end}} | |||
{{if ne $n 0}} | |||
<span class="ui breadcrumb repo-path gt-ml-2"><a class="section" href="{{.RepoLink}}/src/{{.BranchNameSubURL}}" title="{{.Repository.Name}}">{{EllipsisString .Repository.Name 30}}</a>{{range $i, $v := .TreeNames}}<span class="divider">/</span>{{if eq $i $l}}<span class="active section" title="{{$v}}">{{EllipsisString $v 30}}</span>{{else}}{{$p := index $.Paths $i}}<span class="section"><a href="{{$.BranchLink}}/{{PathEscapeSegments $p}}" title="{{$v}}">{{EllipsisString $v 30}}</a></span>{{end}}{{end}}</span> | |||
<span class="ui breadcrumb repo-path gt-ml-2"> | |||
<a class="section" href="{{.RepoLink}}/src/{{.BranchNameSubURL}}" title="{{.Repository.Name}}">{{StringUtils.EllipsisString .Repository.Name 30}}</a> | |||
{{- range $i, $v := .TreeNames -}} | |||
<span class="divider">/</span> | |||
{{- if eq $i $l -}} | |||
<span class="active section" title="{{$v}}">{{StringUtils.EllipsisString $v 30}}</span> | |||
{{- else -}} | |||
{{$p := index $.Paths $i}}<span class="section"><a href="{{$.BranchLink}}/{{PathEscapeSegments $p}}" title="{{$v}}">{{StringUtils.EllipsisString $v 30}}</a></span> | |||
{{- end -}} | |||
{{- end -}} | |||
</span> | |||
{{end}} | |||
</div> | |||
<div class="gt-df gt-ac"> |
@@ -13,7 +13,7 @@ | |||
<div class="field"> | |||
<input name="title" id="issue_title" placeholder="{{.locale.Tr "repo.milestones.title"}}" value="{{if .TitleQuery}}{{.TitleQuery}}{{else if .IssueTemplateTitle}}{{.IssueTemplateTitle}}{{else}}{{.title}}{{end}}" tabindex="3" autofocus required maxlength="255" autocomplete="off"> | |||
{{if .PageIsComparePull}} | |||
<div class="title_wip_desc" data-wip-prefixes="{{Json .PullRequestWorkInProgressPrefixes}}">{{.locale.Tr "repo.pulls.title_wip_desc" (index .PullRequestWorkInProgressPrefixes 0| Escape) | Safe}}</div> | |||
<div class="title_wip_desc" data-wip-prefixes="{{JsonUtils.EncodeToString .PullRequestWorkInProgressPrefixes}}">{{.locale.Tr "repo.pulls.title_wip_desc" (index .PullRequestWorkInProgressPrefixes 0| Escape) | Safe}}</div> | |||
{{end}} | |||
</div> | |||
{{if .Fields}} |
@@ -304,10 +304,12 @@ | |||
{{template "shared/user/avatarlink" dict "Context" $.Context "user" .Poster}} | |||
<span class="text grey muted-links"> | |||
{{template "shared/user/authorlink" .Poster}} | |||
{{$parsedDeadline := .Content | ParseDeadline}} | |||
{{$from := DateTime "long" (index $parsedDeadline 1)}} | |||
{{$to := DateTime "long" (index $parsedDeadline 0)}} | |||
{{$.locale.Tr "repo.issues.due_date_modified" $to $from $createdStr | Safe}} | |||
{{$parsedDeadline := StringUtils.Split .Content "|"}} | |||
{{if eq (len $parsedDeadline) 2}} | |||
{{$from := DateTime "long" (index $parsedDeadline 1)}} | |||
{{$to := DateTime "long" (index $parsedDeadline 0)}} | |||
{{$.locale.Tr "repo.issues.due_date_modified" $to $from $createdStr | Safe}} | |||
{{end}} | |||
</span> | |||
</div> | |||
{{else if eq .Type 18}} |
@@ -20,7 +20,7 @@ | |||
<b>{{.tag_name}}</b><span class="at">@</span><strong>{{.tag_target}}</strong> | |||
{{else}} | |||
<input id="tag-name" name="tag_name" value="{{.tag_name}}" aria-label="{{.locale.Tr "repo.release.tag_name"}}" placeholder="{{.locale.Tr "repo.release.tag_name"}}" autofocus required maxlength="255"> | |||
<input id="tag-name-editor" type="hidden" data-existing-tags={{Json .Tags}} data-tag-helper={{.locale.Tr "repo.release.tag_helper"}} data-tag-helper-new={{.locale.Tr "repo.release.tag_helper_new"}} data-tag-helper-existing={{.locale.Tr "repo.release.tag_helper_existing"}}> | |||
<input id="tag-name-editor" type="hidden" data-existing-tags="{{JsonUtils.EncodeToString .Tags}}" data-tag-helper="{{.locale.Tr "repo.release.tag_helper"}}" data-tag-helper-new="{{.locale.Tr "repo.release.tag_helper_new"}}" data-tag-helper-existing="{{.locale.Tr "repo.release.tag_helper_existing"}}"> | |||
<div id="tag-target-selector" class="gt-dib"> | |||
<span class="at">@</span> | |||
<div class="ui selection dropdown"> |
@@ -61,13 +61,15 @@ | |||
{{else}} | |||
{{if $entry.IsDir}} | |||
{{$subJumpablePathName := $entry.GetSubJumpablePathName}} | |||
{{$subJumpablePath := SubJumpablePath $subJumpablePathName}} | |||
{{svg "octicon-file-directory-fill"}} | |||
<a class="muted" href="{{$.TreeLink}}/{{PathEscapeSegments $subJumpablePathName}}" title="{{$subJumpablePathName}}"> | |||
{{if eq (len $subJumpablePath) 2}} | |||
<span class="color-text-light-2">{{index $subJumpablePath 0}}</span>{{index $subJumpablePath 1}} | |||
{{$subJumpablePathFields := StringUtils.Split $subJumpablePathName "/"}} | |||
{{$subJumpablePathFieldLast := (Eval (len $subJumpablePathFields) "-" 1)}} | |||
{{if eq $subJumpablePathFieldLast 0}} | |||
{{$subJumpablePathName}} | |||
{{else}} | |||
{{index $subJumpablePath 0}} | |||
{{$subJumpablePathPrefixes := slice $subJumpablePathFields 0 $subJumpablePathFieldLast}} | |||
<span class="color-text-light-2">{{StringUtils.Join $subJumpablePathPrefixes "/"}}</span>/{{index $subJumpablePathFields $subJumpablePathFieldLast}} | |||
{{end}} | |||
</a> | |||
{{else}} |
@@ -37,7 +37,7 @@ | |||
</div> | |||
<div class="field" data-tooltip-content="Labels are comma-separated. Whitespace at the beginning, end, and around the commas are ignored."> | |||
<label for="custom_labels">{{.locale.Tr "actions.runners.custom_labels"}}</label> | |||
<input id="custom_labels" name="custom_labels" value="{{Join .Runner.CustomLabels `,`}}"> | |||
<input id="custom_labels" name="custom_labels" value="{{StringUtils.Join .Runner.CustomLabels `,`}}"> | |||
<p class="help">{{.locale.Tr "actions.runners.custom_labels_helper"}}</p> | |||
</div> | |||
@@ -1,6 +1,6 @@ | |||
{{if .HeatmapData}} | |||
<div id="user-heatmap" | |||
data-heatmap-data="{{Json .HeatmapData}}" | |||
data-heatmap-data="{{JsonUtils.EncodeToString .HeatmapData}}" | |||
data-locale-total-contributions="{{$.locale.Tr "heatmap.number_of_contributions_in_the_last_12_months" ($.locale.PrettyNumber .HeatmapTotalContributions)}}" | |||
data-locale-no-contributions="{{.locale.Tr "heatmap.no_contributions"}}" | |||
data-locale-more="{{.locale.Tr "heatmap.more"}}" |